feat(D03): 평면 꺾임점 고침 겹 — 마디 끌기 · 두 번 누름 넣기 · 오른쪽 누름 지우기 · 곡선 손잡이 R · 캐시 d03-plan edits · 되돌리기(Ctrl+Z) · 원래 노선 · 카드 연장 즉시 (PLAN 543-2)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 13:32:13 +09:00
co-authored by Claude Opus 5.5
parent 084df5137c
commit 7683b74524
4 changed files with 456 additions and 23 deletions
+17 -4
View File
@@ -1,14 +1,19 @@
/* =============================================================================
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 530-3) — 지점 정본(D02) · 지형 받기 · 분석 · 진행 · 저장본 · [저장] ·
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄.
* 연도별 지형면(모델 행 · 격자). 끈 지점은 `points`(작업 좌표계 m)로 실어 보냄 · 꺾임점 고침은
* `edits`(마디 · 켬끔 · R — 선은 안 보냄 · 서버가 다시 그음).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { ChainRow, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
import type { PlanEdit } from "./logic/D03_PlanAnalysis_Edit";
import type { ChainRow, RouteMetrics, Vertex, YearAnalysis } from "./logic/D03_PlanAnalysis_Plan";
/** 저장된 꺾임점 고침 — 입력 + 그때 서버가 잰 값. */
export type SavedEdit = PlanEdit & { metrics?: RouteMetrics | null };
export interface PlanPointsView {
year_start: number;
@@ -29,7 +34,14 @@ export interface PlanJob {
}
export interface PlanAnalysisView {
years: Array<{ year: number; start: Vertex; end: Vertex; analysis: YearAnalysis | null }>;
years: Array<{
year: number;
start: Vertex;
end: Vertex;
analysis: YearAnalysis | null;
/** 저장된 꺾임점 고침(분석 지문이 같을 때만). */
edit?: SavedEdit | null;
}>;
chain: ChainRow[] | null;
/** 등록 노선 시작 누가(m). */
start_chainage_m: number;
@@ -83,10 +95,11 @@ export const savePlanAnalysis = (
projectId: string,
choices: Record<number, string>,
points: PlanPoints | null,
edits: Record<number, PlanEdit> = {},
): Promise<{ chain: ChainRow[] }> =>
request(`${base(projectId)}/analysis`, {
method: "PUT",
body: JSON.stringify(points ? { choices, points } : { choices }),
body: JSON.stringify(points ? { choices, points, edits } : { choices, edits }),
});
export interface YearSurface {
@@ -0,0 +1,186 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Edit.ts
* D03 평면 꺾임점 고침 겹(요구 363-1 · PLAN 543-2) — 고른 갈래 노선의 마디(네모) · 곡선 손잡이(동그라미) ·
* R 글을 그리고, 끌기 · 두 번 누름(넣기) · 오른쪽 누름(지우기)을 순수 셈(`logic/D03_PlanAnalysis_Edit`)
* 으로 넘김. 처음 · 끝 마디(연도 경계)는 지점 끌기 몫이라 안 그림 · 안 집음.
*
* 값은 페이지가 쥠(캐시 `d03-plan` · 되돌리기) — 여기는 고친 값을 `change` 로 올리기만.
* 집기는 B05 편집기 순수 부품 그대로(`nodeAtScreen` · `handleAtScreen` · `segmentAtScreen`).
* ========================================================================== */
import {
handleAtScreen,
nodeAtScreen,
segmentAtScreen,
} from "../B05_Profile/B05_Profile_UI_RouteEdit_Input";
import type { EditedPolyline } from "../B05_Profile/logic/B05_Profile_UI_RouteEdit_Curve";
import type { PlanMapOverlay, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import {
insertNode,
moveHandle,
moveNode,
removeNode,
type EditStep,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
/** 마디 · 손잡이 집는 반경 · 선 위 두 번 누름 거리(px). */
const NODE_HIT_PX = 10;
const HANDLE_HIT_PX = 8;
const SEGMENT_HIT_PX = 10;
/** 연도 하나 — 지금 고침(없으면 엔진 노선을 편 것) · 그 선 · 법정 최소 R. */
export interface EditTarget {
year: number;
edit: PlanEdit;
line: EditedPolyline;
minRadiusM: number;
}
export interface PlanEditEvents {
/** 고치기 직전 한 번(되돌리기 쌓기). */
begin: () => void;
/** 고친 값 — `final` = 끌기 끝 · 넣기 · 지우기(캐시 · 횡단 다시). */
change: (year: number, edit: PlanEdit, final: boolean) => void;
/** 막힌 까닭(하한 · 끝 잠금). */
blocked: (message: string) => void;
}
export interface PlanEditLayer extends PlanMapOverlay {
setTargets: (targets: EditTarget[]) => void;
targets: () => EditTarget[];
}
type Grab = { year: number; index: number; which: "node" | "start" | "end" };
export function createPlanEditLayer(events: PlanEditEvents): PlanEditLayer {
let targets: EditTarget[] = [];
let grab: Grab | null = null;
let moved = false;
let stop = "";
const interior = (edit: PlanEdit): Vertex[] => edit.nodes.slice(1, -1);
function apply(target: EditTarget, step: EditStep, final: boolean): void {
if ("blocked" in step) {
stop = step.blocked;
if (final) events.blocked(stop);
return;
}
target.edit = step.edit;
target.line = step.line;
events.change(target.year, step.edit, final);
}
return {
setTargets(next) {
targets = next;
},
targets: () => targets,
draw(context, map) {
context.save();
context.font = "11px system-ui, sans-serif";
context.textAlign = "left";
context.textBaseline = "bottom";
for (const target of targets) {
for (const curve of target.line.curves) {
for (const end of [curve.start, curve.end]) {
const [x, y] = map.toScreen(end);
context.beginPath();
context.arc(x, y, 4, 0, Math.PI * 2);
context.fillStyle = "#f59e0b";
context.fill();
context.lineWidth = 1.5;
context.strokeStyle = "#ffffff";
context.stroke();
}
const [ax, ay] = map.toScreen(curve.apex);
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.strokeText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
context.fillStyle = curve.violations.length ? "#fca5a5" : "#ffffff";
context.fillText(`R ${curve.radius_m.toFixed(0)}`, ax + 7, ay - 5);
}
interior(target.edit).forEach((node, i) => {
const [x, y] = map.toScreen(node);
context.fillStyle = "#ffffff";
context.fillRect(x - 4.5, y - 4.5, 9, 9);
context.lineWidth = 2;
const bad = target.line.nodes[i + 1]?.violations.length;
context.strokeStyle = bad ? "#dc2626" : "#2563eb";
context.strokeRect(x - 4.5, y - 4.5, 9, 9);
});
}
context.restore();
},
grab(px, py, map) {
grab = null;
moved = false;
stop = "";
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node >= 0) {
grab = { year: target.year, index: node + 1, which: "node" };
return true;
}
}
for (const target of targets) {
const hit = handleAtScreen(target.line.curves, map.toScreen, px, py, HANDLE_HIT_PX);
if (hit) {
grab = { year: target.year, index: hit.node, which: hit.end };
return true;
}
}
return false;
},
move(point) {
const target = grab && targets.find((entry) => entry.year === grab!.year);
if (!grab || !target) return;
if (!moved) events.begin();
moved = true;
const step =
grab.which === "node"
? moveNode(target.edit, grab.index, point, target.minRadiusM)
: moveHandle(target.edit, grab.index, grab.which, point, target.minRadiusM);
if (!("blocked" in step)) stop = "";
apply(target, step, false);
},
release() {
const target = grab && targets.find((entry) => entry.year === grab!.year);
grab = null;
if (!moved || !target) return;
moved = false;
events.change(target.year, target.edit, true);
if (stop) events.blocked(stop);
},
insert(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const segment = segmentAtScreen(target.edit.nodes, map.toScreen, px, py, SEGMENT_HIT_PX);
if (segment < 0) continue;
events.begin();
apply(
target,
insertNode(target.edit, segment, map.toMetric(px, py), target.minRadiusM),
true,
);
return;
}
},
remove(px: number, py: number, map: ScreenMap) {
for (const target of targets) {
const node = nodeAtScreen(interior(target.edit), map.toScreen, px, py, NODE_HIT_PX);
if (node < 0) continue;
events.begin();
apply(target, removeNode(target.edit, node + 1, target.minRadiusM), true);
return;
}
},
};
}
+45 -6
View File
@@ -7,6 +7,7 @@
* `computeRouteView` · 등고선 `drawPreparedLayer` · 측점 `drawStationTicks` · 집기 `stationAtScreen` ·
* 휠 확대 · 끌어 옮기기 `bindRouteEditNavigation`. 측점 누가 = 등록 시작 누가부터(앞붙임 선
* `leadInLine`). 지점 끌기 = 왼쪽 단추로 지점을 집으면 지도 옮기기보다 먼저 받음(끈 값은 페이지가 캐시).
* 꺾임점 고침 겹(543 · `overlay`) = 지점 다음 차례로 집음 · 두 번 누름 = 넣기 · 오른쪽 누름 = 지우기.
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
@@ -60,10 +61,27 @@ export interface PlanMapScene {
editable: boolean;
}
/** 화면 px ↔ 작업 좌표계 m. */
export interface ScreenMap {
toScreen: (point: Vertex) => [number, number];
toMetric: (px: number, py: number) => Vertex;
}
/** 평면 위 고침 겹 — 그리기 · 집기(참 = 잡음) · 끌기 · 놓기 · 넣기 · 지우기. */
export interface PlanMapOverlay {
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
grab: (px: number, py: number, map: ScreenMap) => boolean;
move: (point: Vertex) => void;
release: () => void;
insert: (px: number, py: number, map: ScreenMap) => void;
remove: (px: number, py: number, map: ScreenMap) => void;
}
export interface PlanMapEvents {
onDrag: (index: number, point: Vertex) => void;
onDragEnd: (index: number) => void;
onStation: (chainage: number) => void;
overlay?: PlanMapOverlay;
}
export interface PlanMap {
@@ -73,6 +91,8 @@ export interface PlanMap {
fit: () => void;
/** 검증 창구 — 측점 누가 → 화면 px(그림 좌표). */
stationScreen: (chainage: number) => [number, number] | null;
/** 검증 창구 — 작업 좌표계 점 → 화면 px(그림 좌표 · 그리기 전이면 null). */
screenOf: (point: Vertex) => [number, number] | null;
dispose: () => void;
}
@@ -133,6 +153,10 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
const toScreen = (point: Vertex): [number, number] =>
createMetricProjector(meta!, view).toScreen(point[0], point[1]);
const screenMap: ScreenMap = {
toScreen,
toMetric: (px, py) => screenToMetric(meta!, view, px, py),
};
function refit(): void {
const all = [...scene.nodes, ...scene.line];
@@ -228,6 +252,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
context.stroke();
}
}
if (scene.editable) events.overlay?.draw(context, screenMap);
scene.nodes.forEach((node, i) => {
const [sx, sy] = toScreen(node);
const last = i === scene.nodes.length - 1;
@@ -295,11 +320,12 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
canvas.addEventListener("pointerdown", (event) => {
press = [event.clientX, event.clientY];
if (!meta || !scene.editable || event.button !== 0) return;
const index = nodeAt(...at(event));
if (index < 0) return;
event.stopImmediatePropagation();
const [px, py] = at(event);
const [sx, sy] = toScreen(scene.nodes[index]);
// 지점 먼저 · 다음 고침 겹(index -1)
const index = nodeAt(px, py);
if (index < 0 && !events.overlay?.grab(px, py, screenMap)) return;
event.stopImmediatePropagation();
const [sx, sy] = index < 0 ? [px, py] : toScreen(scene.nodes[index]);
dragging = { index, id: event.pointerId, dx: sx - px, dy: sy - py, moved: false };
canvas.setPointerCapture(event.pointerId);
canvas.style.cursor = "grabbing";
@@ -315,14 +341,17 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (!dragging.moved && !far) return;
dragging.moved = true;
const [px, py] = at(event);
events.onDrag(dragging.index, screenToMetric(meta, view, px + dragging.dx, py + dragging.dy));
const point = screenToMetric(meta, view, px + dragging.dx, py + dragging.dy);
if (dragging.index < 0) events.overlay?.move(point);
else events.onDrag(dragging.index, point);
});
const endDrag = (event: PointerEvent): void => {
if (!dragging || event.pointerId !== dragging.id) return;
const { index, moved } = dragging;
dragging = null;
canvas.style.cursor = "";
if (moved) events.onDragEnd(index);
if (index < 0) events.overlay?.release();
else if (moved) events.onDragEnd(index);
};
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
@@ -353,6 +382,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
if (hit) events.onStation(hit.chainage);
});
canvas.addEventListener("dblclick", (event) => {
if (meta && scene.editable) events.overlay?.insert(...at(event), screenMap);
});
canvas.addEventListener("contextmenu", (event) => {
if (!meta || !scene.editable) return;
event.preventDefault();
events.overlay?.remove(...at(event), screenMap);
});
const observer = new ResizeObserver(() => resize());
observer.observe(root);
@@ -369,6 +407,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
draw();
},
stationScreen,
screenOf: (point) => (meta ? toScreen(point) : null),
dispose: () => observer.disconnect(),
};
}
+208 -13
View File
@@ -6,8 +6,10 @@
* 좌 = 지형 받기 진행 · 연도별 갈래 넷 카드 · [분석] [저장] [3D].
* 열리면 지형 받기(뒤에서 · 받은 지형 안이면 건너뜀) · 끈 지점이 받은 범위 밖 = 「범위 밖 — 더 받기」.
*
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 = 캐시(`d03-plan`) · [저장] = PUT plan/analysis
* (끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 다시 셈). 화면 전체 잠금 = [저장]만.
* 데이터 3층(CLAUDE.md 5장) — 지점 끌기 · 갈래 고름 · 꺾임점 고침(543) = 캐시(`d03-plan`) · [저장] =
* PUT plan/analysis(끈 지점은 서버가 D02 규칙으로 정본에 씀 · 노선은 서버 저장본으로 · 고친 노선은
* 마디로 서버가 다시 셈). 꺾임점 고침 = 브라우저 즉시 다시 그음 → 길이 · 측점 · 누른 측점 횡단 ·
* 되돌리기(Ctrl+Z). 화면 전체 잠금 = [저장]만.
* 공유 회사(산림청 아님) = 보기만 — 끌기 · [분석] · [저장] · 받기 없음.
* ========================================================================== */
@@ -45,11 +47,13 @@ import {
savePlanAnalysis,
startPlanJob,
type PlanAnalysisView,
type SavedEdit,
type PlanJob,
type YearSurface,
} from "./D03_PlanAnalysis_Api";
import { openPlan3d } from "./D03_PlanAnalysis_UI_3D";
import { createPlanCross } from "./D03_PlanAnalysis_UI_Cross";
import { createPlanEditLayer, type EditTarget } from "./D03_PlanAnalysis_UI_Edit";
import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map";
import {
chainRows,
@@ -61,10 +65,19 @@ import {
rowAt,
sameNodes,
stationChainages,
withEdits,
type ChainRow,
type RouteEdit,
type Vertex,
type YearAnalysis,
} from "./logic/D03_PlanAnalysis_Plan";
import {
buildEdit,
editFromRoute,
moveNode,
sameEdit,
type PlanEdit,
} from "./logic/D03_PlanAnalysis_Edit";
const MODE_LABEL: Record<string, string> = {
gradeline: "영선",
@@ -76,18 +89,30 @@ const POLL_MS = 1000;
/** 등고선을 받아 올 지점 둘레(m). */
const CONTOUR_PAD_M = 600;
/** 되돌리기 쌓는 수. */
const UNDO_MAX = 50;
interface Draft {
nodes: Vertex[] | null;
choices: Record<number, string>;
/** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */
edits?: Record<number, PlanEdit>;
}
type Snapshot = { edits: Record<number, PlanEdit>; choices: Record<number, string> };
const sleep = (ms: number): Promise<void> => new Promise((done) => setTimeout(done, ms));
const errorText = (error: unknown): string =>
error instanceof Error ? error.message : String(error);
export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY) ?? "";
const map = createPlanMap({ onDrag, onDragEnd, onStation });
const editLayer = createPlanEditLayer({
begin: remember,
change: onEdit,
blocked: (message) => showToast(message, "info"),
});
const map = createPlanMap({ onDrag, onDragEnd, onStation, overlay: editLayer });
const cross = createPlanCross();
const main = document.createElement("div");
main.className = "d03-plan";
@@ -114,9 +139,11 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
});
const saveButton = createButton({ label: "저장", variant: "filled", onClick: () => void save() });
const viewButton = createButton({ label: "3D", variant: "ghost", onClick: () => open3d() });
const undoButton = createButton({ label: "되돌리기", variant: "ghost", onClick: () => undo() });
undoButton.title = "꺾임점 고침 · 갈래 고름 한 걸음 되돌림(Ctrl+Z)";
const actions = document.createElement("div");
actions.className = "ui-sidebar-actions";
actions.append(analyzeButton, saveButton, viewButton);
actions.append(analyzeButton, saveButton, viewButton, undoButton);
const terrainCard = createPanelCard("지형", false);
terrainCard.append(terrainText, bar, more);
const routeCard = createPanelCard("연도별 노선", false);
@@ -142,6 +169,12 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
let editable = false;
let busy: "terrain" | "analyze" | null = null;
let lastCross: StationGround | null = null;
// 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기
let edits: Record<number, PlanEdit> = {};
let serverEdits: Record<number, SavedEdit> = {};
let draftEdits: Record<number, PlanEdit> | undefined;
let history: Snapshot[] = [];
let lastSaveMs: number | null = null;
const moved = (): boolean => !sameNodes(nodes, savedNodes);
const sentPoints = () => (moved() ? pointsOf(years, nodes) : null);
@@ -161,11 +194,54 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}),
);
const keepDraft = (): void =>
writeState("d03-plan", { nodes: moved() ? nodes : null, choices } satisfies Draft, projectId);
writeState(
"d03-plan",
{ nodes: moved() ? nodes : null, choices, edits } satisfies Draft,
projectId,
);
/** 고치기 직전 — 되돌리기에 지금 값을 쌓음. */
function remember(): void {
history = [...history.slice(1 - UNDO_MAX), structuredClone({ edits, choices })];
}
function undo(): void {
const last = history.pop();
if (!last) return;
({ edits, choices } = last);
keepDraft();
refresh();
repick();
}
/** 누른 측점 횡단을 지금 노선으로 다시. */
function repick(): void {
if (selected !== null && rowAt(rows, selected)) onStation(selected);
}
function onEdit(year: number, edit: PlanEdit, final: boolean): void {
edits = { ...edits, [year]: edit };
refresh();
if (!final) return;
keepDraft();
repick();
}
/** 연도의 지금 고침 — 고른 갈래 · 분석 지문이 같을 때만. */
const editOf = (result: YearAnalysis, mode: string | null): PlanEdit | null => {
const own = edits[result.year];
return own && own.base_mode === mode && own.base_fingerprint === result.fingerprint
? own
: null;
};
/** 연도 목록에서 뺌. */
const without = (from: Record<number, PlanEdit>, drop: number[]): Record<number, PlanEdit> =>
Object.fromEntries(Object.entries(from).filter(([year]) => !drop.includes(Number(year))));
/** 저장본 · 고름 → 이은 누가 · 중심선 · 측점 → 평면 · 카드 · 단추. */
function refresh(): void {
results = years.map(
const raw: YearAnalysis[] = years.map(
(year, i) =>
analysis?.years.find((entry) => entry.year === year)?.analysis ?? {
year,
@@ -176,7 +252,33 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
);
// 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름
const saved = Object.fromEntries((analysis?.chain ?? []).map((row) => [row.year, row.mode]));
rows = chainRows(results, { ...saved, ...choices }, startM);
const picks = { ...saved, ...choices };
// 꺾임점 고침 — 고른 갈래 노선의 마디(고침 · 없으면 엔진 노선) → 선 → 그 갈래 자리에 갈아 끼움
const targets: EditTarget[] = [];
const routed: Record<number, RouteEdit> = {};
chainRows(raw, picks, startM).forEach((row, i) => {
const result = raw[i];
const route = row.mode ? result.routes[row.mode] : undefined;
if (!row.mode || !route?.nodes || !result.fingerprint) return;
const minRadiusM = result.criteria?.min_radius_m ?? 0;
const own = editOf(result, row.mode);
const built = buildEdit(
own ?? editFromRoute(route, row.mode, result.fingerprint),
minRadiusM,
);
targets.push({ year: result.year, ...built, minRadiusM });
if (own)
routed[result.year] = {
mode: row.mode,
planned: built.line.vertices,
metrics: sameEdit(own, serverEdits[result.year])
? serverEdits[result.year].metrics
: null,
};
});
editLayer.setTargets(editable ? targets : []);
results = withEdits(raw, routed);
rows = chainRows(results, picks, startM);
const line = joinedLine(results, rows);
const others: Vertex[][] = results.flatMap((result, i) => {
const routes = PLAN_MODES.filter((mode) => mode !== rows[i].mode)
@@ -210,6 +312,8 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요."
: "";
viewButton.disabled = !rows.some((row) => row.mode && surfaces.has(row.year));
undoButton.hidden = !editable;
undoButton.disabled = !history.length;
}
function renderYears(): void {
@@ -226,6 +330,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
`${stationLabel(row.end_chainage_m, PLAN_STATION_INTERVAL_M, 1)} (${row.length_m.toFixed(1)} m)`
: `${result.year}년 · 분석 전 — [분석]을 누르세요`;
box.append(head);
if (row.mode && editOf(result, row.mode)) box.append(editLine(result.year));
for (const mode of PLAN_MODES) {
const route = result.routes[mode];
if (!route) continue;
@@ -239,11 +344,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const name = document.createElement("strong");
name.textContent = MODE_LABEL[mode];
const values = document.createElement("span");
values.textContent = metrics
? `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${(metrics.max_grade_pct ?? 0).toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`
: "노선 없음";
values.textContent = !metrics
? "노선 없음"
: metrics.max_grade_pct === undefined
? `연장 ${metrics.length_m.toFixed(1)} m · 기울기 · 위반 = 저장 때 셈`
: `연장 ${metrics.length_m.toFixed(1)} m · 최대 ${metrics.max_grade_pct.toFixed(1)} % · 위반 ${metrics.over_limit?.total ?? 0}`;
card.append(name, values);
card.addEventListener("click", () => {
if (row.mode === mode) return;
remember();
choices = { ...choices, [result.year]: mode };
keepDraft();
refresh();
@@ -256,6 +365,30 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
);
}
/** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */
function editLine(year: number): HTMLElement {
const line = document.createElement("div");
line.className = "d03-plan__year-head";
line.textContent = sameEdit(edits[year], serverEdits[year])
? "꺾임점 고침(저장됨) "
: "꺾임점 고침 — [저장] 전 ";
const reset = createButton({
label: "원래 노선",
variant: "ghost",
size: "sm",
onClick: () => {
remember();
edits = without(edits, [year]);
keepDraft();
refresh();
repick();
},
});
reset.hidden = !editable;
line.append(reset);
return line;
}
/** 이은 끝 누가 대 등록 목표(요구서 3-1) — 모든 연도에 노선이 있을 때만 */
function targetLine(): HTMLElement[] {
const last = rows[rows.length - 1];
@@ -276,7 +409,15 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
}
function onDragEnd(index: number): void {
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석]
// 끈 지점의 앞 · 뒤 연도 노선은 지점이 달라 빔 — 다시 [분석] · 그 연도 꺾임점 고침도 버림
const lost = [years[index - 1], years[index]].filter(
(year) => year !== undefined && edits[year],
);
if (lost.length) {
edits = without(edits, lost);
history = [];
showToast(`지점을 옮겨 ${lost.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
if (analysis)
analysis = {
...analysis,
@@ -357,6 +498,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
analysis = await fetchPlanAnalysis(projectId, sentPoints());
startM = analysis.start_chainage_m;
targetM = analysis.end_chainage_target_m ?? null;
serverEdits = Object.fromEntries(
analysis.years.flatMap((entry) => (entry.edit ? [[entry.year, entry.edit]] : [])),
);
edits =
draftEdits ??
Object.fromEntries(
Object.entries(serverEdits).map(([year, { metrics: _m, ...edit }]) => [year, edit]),
);
// 분석이 바뀐 연도(지문 다름)의 고침은 버림
const prints = new Map(analysis.years.map((e) => [e.year, e.analysis?.fingerprint]));
const stale = Object.keys(edits)
.map(Number)
.filter((year) => prints.get(year) !== edits[year].base_fingerprint);
if (stale.length) {
edits = without(edits, stale);
if (draftEdits)
showToast(`분석이 바뀌어 ${stale.join(" · ")}년 꺾임점 고침을 버렸습니다.`, "info");
}
draftEdits = edits;
} catch (error) {
analysis = null;
showToast(`분석 저장본을 읽지 못했습니다 — ${errorText(error)}`, "error");
@@ -422,10 +582,20 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const picked = Object.fromEntries(
rows.filter((row) => row.mode).map((row) => [row.year, row.mode as string]),
);
const sent = Object.fromEntries(
results.flatMap((result, i) => {
const edit = editOf(result, rows[i].mode);
return edit ? [[result.year, edit]] : [];
}),
);
showLoadingOverlay();
try {
await savePlanAnalysis(projectId, picked, sentPoints());
const began = performance.now();
await savePlanAnalysis(projectId, picked, sentPoints(), sent);
lastSaveMs = Math.round(performance.now() - began);
clearState("d03-plan", projectId);
draftEdits = undefined;
history = [];
await loadPoints();
await loadAnalysis();
refresh();
@@ -469,17 +639,42 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
const draft = readState<Draft>("d03-plan", projectId);
nodes = draft?.nodes?.length === savedNodes.length ? draft.nodes : savedNodes;
choices = draft?.choices ?? {};
draftEdits = draft?.edits;
return true;
}
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디(ORCA 판정)
// 검증 창구 — 측점 화면 자리 · 지금 누가 · 마디 · 꺾임점 고침 · 저장 시간(ORCA 판정)
(window as unknown as { __d03Plan?: unknown }).__d03Plan = {
stationScreen: map.stationScreen,
screenOf: map.screenOf,
rows: () => rows,
nodes: () => nodes,
pick: onStation,
cross: () => lastCross,
edits: () => edits,
targets: () => editLayer.targets(),
results: () => results,
saveMs: () => lastSaveMs,
// 가이드 그림 · 검증 — 첫 고침 연도 마디 하나를 (dx, dy) m 옮김(끌기와 같은 길)
nudge: (index: number, dx: number, dy: number) => {
const target = editLayer.targets()[0];
const node = target?.edit.nodes[index];
if (!node) return;
const step = moveNode(target.edit, index, [node[0] + dx, node[1] + dy], target.minRadiusM);
if ("blocked" in step) return showToast(step.blocked, "info");
remember();
onEdit(target.year, step.edit, true);
},
};
const onKey = (event: KeyboardEvent): void => {
if (!root.isConnected) return window.removeEventListener("keydown", onKey);
const typing = (event.target as HTMLElement | null)?.closest?.("input, textarea, select");
if (editable && !typing && (event.ctrlKey || event.metaKey) && event.key === "z") {
event.preventDefault();
undo();
}
};
window.addEventListener("keydown", onKey);
try {
editable = !!(await fetchSessionUser())?.is_forest_service;