diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 99284c25b..6284663e6 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -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, points: PlanPoints | null, + edits: Record = {}, ): 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 { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts new file mode 100644 index 000000000..4200793a9 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -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; + } + }, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index e5e59f21e..29975cf1b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -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(), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index 87741a4b8..dbad0a984 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -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 = { 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; + /** 꺾임점 고침(연도 → 고침) — 있으면 이것이 지금 값(서버 저장 고침보다 먼저). */ + edits?: Record; } +type Snapshot = { edits: Record; choices: Record }; + const sleep = (ms: number): Promise => 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 { 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 { }); 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 { let editable = false; let busy: "terrain" | "analyze" | null = null; let lastCross: StationGround | null = null; + // 꺾임점 고침 — 지금 값 · 서버 저장 고침 · 캐시에서 읽은 값(없으면 서버 값으로) · 되돌리기 + let edits: Record = {}; + let serverEdits: Record = {}; + let draftEdits: Record | 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 { }), ); 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, drop: number[]): Record => + 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 { ); // 고름 = 저장된 갈래(서버 이은 누가) 위에 캐시 고름 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 = {}; + 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 { ? "모든 연도에 노선이 있어야 저장합니다 — [분석]을 먼저 하세요." : ""; 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 { `${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 { 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 { ); } + /** 고친 연도 한 줄 — 「꺾임점 고침」 · [원래 노선](고침 버림 · 되돌리기 됨). */ + 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 { } 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 { 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 { 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 { const draft = readState("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;