From 1ddb5cb1b299cef3bb1a4adf1b70d85f5ce74d35 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 29 Oct 2026 14:45:10 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20=E2=80=94=20=EC=8B=9C?= =?UTF-8?q?=EC=A0=90=20=C2=B7=20=EC=A2=85=EC=A0=90=20=EC=97=AC=EB=9F=BF=20?= =?UTF-8?q?=C2=B7=20=EB=93=A4=EC=96=B4=EC=98=AC=20=EB=95=8C=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20[=EA=B8=8B=EA=B8=B0]?= =?UTF-8?q?=20=EC=96=91=EB=81=9D=20=C2=B7=20=EB=B6=80=EB=B6=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20=C2=B7=20=EA=B8=B0=EC=9A=B8=EA=B8=B0=20%=20=C2=B7?= =?UTF-8?q?=20=C2=B0=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B0=A9=ED=96=A5=20=EB=B0=94=EA=BE=B8=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EC=84=A0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EC=A7=80?= =?UTF-8?q?=EC=9A=B0=EA=B8=B0=20=C2=B7=20=EC=97=B0=EB=8F=84=20(PLAN=20579-?= =?UTF-8?q?3=20=C2=B7=20580-1~3=20=C2=B7=20=EC=9A=94=EA=B5=AC=20440=20?= =?UTF-8?q?=C2=B7=20441)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 579-3: D02 시점 · 종점 목록 받음 · 점 그림 · 맞춤 · 등고선 범위 · 「범위 밖」 판정에 시점 · 종점 · 들어올 때 받은 범위가 못 덮으면 바로 받기(진행 막대 · 직전 실패면 안 함) - 선 이름 = 갈래 없는 선 「본선」(둘 이상 「본선 N」) · 갈래 「갈래 N」 — TS lineNames ↔ D04 line_names 짝 · 거울 시험 - 580-1: [긋기] 하나 — 시점 · 종점(끝낼 때 뒤집어 No.0 = 반대쪽) · 선 꼬리 · 선 머리 · 선 위(갈래)에서 시작 · 머리가 다른 선 꼬리에 닿으면 한 선 · 꺾임점이면 갈래 · 꺾임점 끌기 · 더하기 · 지우기(마크는 구간 비율) - 580-2: 미리보기 글 = 거리 + 가상 종단 기울기 % · ° · 기울기 고정 칸 옆 ° 환산 · 고른 선 [방향 바꾸기] - 580-3: 지도에서 선 · 조각 고름(좌측 줄 밝힘) · Delete · [선 지움] · 선 통째 연도 · 긋는 해(긋는 중 바꾸면 다음 점부터) · [연도 구분] 뒤 연도 고름 바로 · 마크 끌기 - 새 파일: D03_PlanAnalysis_UI_Edit.ts(고르기 · 고치기) · D03_PlanAnalysis_UI_DrawPaint.ts(그림 · 집기 — UI_Draw 700 줄 넘어 나눔) - UI_Map = 겹 꼴에 release · pointerup · 끌기 중 포인터 잡기 · [?] 안내 줄(그리기 본체 draw() 는 sub2 몫이라 손 안 댐) - 시험: 530-3 새 꼴 함수 · 서버 derive 대조 · 선 이름 거울 / 553-4 그림 자리 옮김 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 6 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 558 ++++++++---------- .../D03_PlanAnalysis_UI_DrawPaint.ts | 258 ++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts | 321 ++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 19 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 152 ++++- .../D03_PlanAnalysis_UI_Style.css | 6 + .../logic/D03_PlanAnalysis_Plan.ts | 306 +++++++++- D04_PlanConfirm/D04_PlanConfirm_Repository.py | 26 +- resources/tester/test_530_3_plan_logic.py | 131 +++- resources/tester/test_553_4_de_parts.py | 3 +- 11 files changed, 1406 insertions(+), 380 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 4cdd47984..08c906429 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -19,11 +19,11 @@ export interface BasePoint { y: number; } -/** D02 정본 — 전체 시점 · 종점(작업 좌표계 m + 경위도). */ +/** D02 정본 — 시점 목록 · 종점 목록(작업 좌표계 m + 경위도 · 579-1). */ export interface PlanBaseView { epsg: number | null; - start: BasePoint | null; - end: BasePoint | null; + starts: BasePoint[]; + ends: BasePoint[]; } export interface PlanJob { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index ec52b41e3..7cd7b839c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -1,12 +1,17 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Draw.ts - * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 요구서 389 3장 8~10) — 시점에서 점을 찍어 직선으로 이음. + * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음. * - * 본선 = D02 시점에서 · D02 종점 둘레(SNAP_PX)에 오면 종점에 붙고 그 점에서 끝. - * 미리보기 = 마지막 점 → 마우스 길이(m) · 원지반 기울기(%, + 오르막) — 제약 칸 넷: - * 자유 · 길이 고정 · 기울기 고정(마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 마우스에 가까운 것) · - * 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈) · 미리보기 값은 + 오르막 / − 내리막. - * 갈래 = 기존 선 꺾임점(선 위면 꺾임점 끼움)을 눌러 시작 · 구역 마크 = 선 위 누름(오른쪽 = 지움). + * [긋기] 하나 — 첫 누름 자리로 시작: 시점 = 새 본선 · 종점 = 새 본선(끝낼 때 뒤집어 종점이 선 끝 · + * 측점 No.0 = 반대쪽) · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 되돌림 · + * 선 위 · 꺾임점 = 갈래. 시점 · 종점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 — + * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래. + * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: + * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 + * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). + * 긋는 해 = 새 조각 처음 해 · 긋는 중 해를 바꾸면 다음 점부터 그 해(지금 끝 자리에 연도 구분 마크). + * 연도 구분 = 선 위 누름(마크 · 뒤 조각 고름 → 페이지가 연도 고름을 엶) · 오른쪽 = 지움. + * 고르기 · 고치기(쉴 때) = `D03_PlanAnalysis_UI_Edit`. * 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄). * 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` · * 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지). @@ -16,50 +21,49 @@ import { createButton, showToast } from "@ui/ui_template_elements"; import { createInputField } from "@ui/ui_template_input"; import { createSegment } from "@ui/ui_template_segment"; import { createText } from "@ui/ui_template_text"; -import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; import { compassCandidates, compassPoint, - distanceAlong, fixedLengthPoint, fixedSlopePoint, groundSlopePct, lineLength, - pointAtDistance, segmentLength, - stationChainages, } from "./logic/D03_PlanAnalysis_Draw"; import { addBranch, appendVertex, - dropLastLine, + attachHead, dropLastVertex, insertVertex, - reachedEnd, + newLine, + normalizePieces, + removeLine, removeMark, + reverseLine, + samePoint, + setPieceYear, splitPiece, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; import { - planColors, - PLAN_STATION_INTERVAL_M, - type PlanMapLayer, - type ScreenMap, -} from "./D03_PlanAnalysis_UI_Map"; + fmt, + lineAt, + paintNetwork, + paintPreview, + screenGap, + slopeDeg, + SNAP_PX, +} from "./D03_PlanAnalysis_UI_DrawPaint"; +import type { PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -/** 종점 · 꺾임점에 붙는 반경(px). */ -const SNAP_PX = 12; -/** 선 위 누름으로 볼 반경(px). */ -const LINE_HIT_PX = 8; -/** 구간 길이 라벨을 보일 화면 길이(px). */ -const LABEL_MIN_PX = 70; /** 제약 칸 처음 값 — 길이 = 측점 간격(지식DB 설계제원_총괄) · 기울기 = 간선 20 km/h 일반지형 상한(종단선형). */ export const DRAW_DEFAULT_LENGTH_M = 20; export const DRAW_DEFAULT_SLOPE_PCT = 9; export type DrawConstraint = "free" | "length" | "slope" | "both"; -export type DrawMode = "idle" | "draw" | "branch" | "mark"; +export type DrawMode = "idle" | "draw" | "mark"; export interface DrawPreview { point: Vertex; @@ -69,15 +73,21 @@ export interface DrawPreview { note: string | null; blocked: boolean; snapped: boolean; + /** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */ + onLine: { line: number; segment: number } | null; } export interface PlanDrawParams { - base: () => { start: Vertex; end: Vertex }; + base: () => { starts: Vertex[]; ends: Vertex[] }; network: () => PlanNetwork; heightAt: () => (x: number, y: number) => number; commit: (next: PlanNetwork) => void; + /** 긋는 해 — 새 조각 처음 해 · 긋는 중 바꾸면 다음 점부터. */ + year: () => number; /** 모드 · 그리는 선이 바뀜 — 페이지가 단추 · 안내를 다시. */ onMode: () => void; + /** 연도 구분 마크를 놓음 — 뒤 조각(id) 고름 · 연도 고름. */ + onMark: (piece: string) => void; } export interface PlanDraw { @@ -91,21 +101,18 @@ export interface PlanDraw { key: (event: KeyboardEvent) => boolean; /** 되돌리기 뒤 — 그리던 선이 사라졌으면 그리기를 멈춤. */ sync: () => void; - /** 검증 창구 — 지금 미리보기 · 제약. */ + /** 검증 창구 — 지금 미리보기 · 제약 · 그리는 선(차례 · 끝에서 긋는 중). */ preview: () => DrawPreview | null; + stroke: () => { line: number; flip: boolean } | null; constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; } -const fmt = (value: number): string => value.toFixed(1); -const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; -const near = (a: [number, number], b: [number, number], px: number): boolean => - Math.hypot(a[0] - b[0], a[1] - b[1]) <= px; - export function createPlanDraw(params: PlanDrawParams): PlanDraw { let mode: DrawMode = "idle"; let editable = false; - let active: number | null = null; // 그리는 선 차례 + let active: number | null = null; // 그리는 선 차례(첫 누름 전 = null) + let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음 let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴 let preview: DrawPreview | null = null; let candidates: Vertex[] = []; @@ -114,27 +121,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let slopePct = DRAW_DEFAULT_SLOPE_PCT; // ── 좌측 칸 ───────────────────────────────────────────── - const mainButton = createButton({ - label: "본선 긋기", + const drawButton = createButton({ + label: "긋기", variant: "filled", size: 28, - onClick: () => setMode(mode === "draw" && active === 0 ? "idle" : "draw"), - }); - const branchButton = createButton({ - label: "갈래", - variant: "ghost", - size: 28, - onClick: () => setMode(mode === "branch" ? "idle" : "branch"), + onClick: () => setMode(mode === "draw" ? "idle" : "draw"), }); const markButton = createButton({ - label: "구역 마크", + label: "연도 구분", variant: "ghost", size: 28, onClick: () => setMode(mode === "mark" ? "idle" : "mark"), }); const modeRow = document.createElement("div"); modeRow.className = "d03-plan__row"; - modeRow.append(mainButton, branchButton, markButton); + modeRow.append(drawButton, markButton); const kindSegment = createSegment({ options: [ { value: "free", label: "자유" }, @@ -173,14 +174,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { size: 24, onInput: (value) => { const next = Number(value); - if (value.trim() !== "" && Number.isFinite(next)) slopePct = next; - slopeField.setError(value.trim() !== "" && Number.isFinite(next) ? undefined : "숫자"); + const ok = value.trim() !== "" && Number.isFinite(next); + if (ok) slopePct = next; + slopeField.setError(ok ? undefined : "숫자"); + degText(); }, }); slopeField.input.dataset.role = "draw-slope"; + const slopeDegText = createText("", "muted"); + slopeDegText.dataset.role = "draw-slope-deg"; + const degText = (): void => { + slopeDegText.textContent = `≈ ${fmt(slopeDeg(Math.abs(slopePct)))}°`; + }; + degText(); const fieldRow = document.createElement("div"); fieldRow.className = "d03-plan__row"; - fieldRow.append(lengthField.root, slopeField.root); + fieldRow.append(lengthField.root, slopeField.root, slopeDegText); const hint = createText("", "muted"); hint.classList.add("d03-plan__hint"); const panel = document.createElement("div"); @@ -194,26 +203,21 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { function panelState(): void { const network = params.network(); - const done = reachedEnd(network, params.base().end); - mainButton.querySelector(".ui-btn__label")!.textContent = - mode === "draw" && active === 0 ? "긋기 끝" : done ? "본선 끝남" : "본선 긋기"; - mainButton.disabled = !editable || (done && !(mode === "draw" && active === 0)); - branchButton.disabled = !editable || lineLength(network.lines[0]?.vertices ?? []) <= 0; - markButton.disabled = branchButton.disabled; - branchButton.setAttribute("aria-pressed", String(mode === "branch")); + drawButton.querySelector(".ui-btn__label")!.textContent = mode === "draw" ? "긋기 끝" : "긋기"; + drawButton.disabled = !editable; + markButton.disabled = !editable || !network.lines.some((line) => lineLength(line.vertices) > 0); + drawButton.setAttribute("aria-pressed", String(mode === "draw")); markButton.setAttribute("aria-pressed", String(mode === "mark")); panel.dataset.mode = mode; hint.textContent = !editable ? "보기만 — 산림청 계정에서 긋습니다." : mode === "draw" - ? "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" - : mode === "branch" - ? "갈래를 시작할 선 위 점을 누르세요." - : mode === "mark" - ? "선 위를 눌러 구역을 나눕니다 · 오른쪽 누름 = 마크 지움" - : done - ? "본선이 종점에 닿았습니다 — 갈래 · 구역 마크 · 연도를 정하세요." - : "[본선 긋기]로 시점에서 점을 찍어 갑니다."; + ? active === null + ? "시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다." + : "누름 = 점 · Enter 끝 · Esc 취소 · Backspace 마지막 점 빼기" + : mode === "mark" + ? "선 위를 눌러 연도를 나눕니다 · 오른쪽 누름 = 마크 지움" + : "[긋기]로 시점 · 종점 · 선에서 긋습니다 · 선을 누르면 고릅니다."; } fieldsState(); @@ -223,20 +227,46 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return vertices[vertices.length - 1] ?? null; }; - /** 마우스 자리 → 찍을 점 · 값 — 종점 붙기가 제약보다 먼저(본선만). */ + /** 붙을 자리 — 앞에서 = 종점 · 끝에서 = 시점 · 다른 선 꺾임점 · 다른 선 위(그리는 선 빼고). */ + function snapAt( + px: number, + py: number, + map: ScreenMap, + ): { point: Vertex; note: string; onLine: DrawPreview["onLine"] } | null { + const { starts, ends } = params.base(); + const network = params.network(); + const offers = (flip ? starts : ends).map((point) => ({ + point, + note: flip ? "시점에 붙음" : "종점에 붙음", + })); + network.lines.forEach((line, i) => { + if (i !== active) + for (const point of line.vertices) offers.push({ point, note: "선 꺾임점에 붙음" }); + }); + const best = offers + .map((offer) => ({ ...offer, gap: screenGap(map, offer.point, px, py) })) + .filter((offer) => offer.gap <= SNAP_PX) + .sort((a, b) => a.gap - b.gap)[0]; + if (best) return { point: best.point, note: best.note, onLine: null }; + const hit = lineAt(network, px, py, map, active); + return hit + ? { point: hit.point, note: "선 위에 붙음", onLine: { line: hit.line, segment: hit.segment } } + : null; + } + + /** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */ function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { if (active === null) return null; const from = lastOf(active); if (!from) return null; const z = params.heightAt(); const cursor = map.toMetric(px, py); - const end = params.base().end; candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; + const snap = snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; - let snapped = false; - if (active === 0 && near(map.toScreen(end), [px, py], SNAP_PX)) { - [point, note, snapped] = [end, "종점에 붙음", true]; + if (snap) { + [point, note] = [snap.point, snap.note]; } else if (kind === "length") { point = fixedLengthPoint(from, cursor, lengthM); } else if (kind === "slope") { @@ -254,56 +284,39 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { slopePct: groundSlopePct(from, at, z), note, blocked: !point || length <= 0, - snapped, + snapped: !!snap, + onLine: snap?.onLine ?? null, }; } - /** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간. */ - function lineAt( - px: number, - py: number, - map: ScreenMap, - ): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { - const cursor = map.toMetric(px, py); - let best: ReturnType = null; - let bestPx = LINE_HIT_PX; - params.network().lines.forEach((line, index) => { - if (line.vertices.length < 2) return; - // 붙는 반경 안 꺾임점 중 가장 가까운 것 - let vertex = -1; - let vertexPx = SNAP_PX; - line.vertices.forEach((v, i) => { - const [vx, vy] = map.toScreen(v); - const d = Math.hypot(vx - px, vy - py); - if (d <= vertexPx) [vertex, vertexPx] = [i, d]; - }); - const atM = - vertex >= 0 - ? lineLength(line.vertices.slice(0, vertex + 1)) - : distanceAlong(line.vertices, cursor); - const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); - const [sx, sy] = map.toScreen(point); - const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); - if (gap > bestPx || (best && gap === bestPx)) return; - // 누가가 든 구간(꺾임점 끼울 자리) - let segment = 0; - for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { - walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); - if (walked >= atM) break; - } - best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; - bestPx = gap; - }); - return best; - } - // ── 모드 ──────────────────────────────────────────────── + /** 이번 긋기 마침 — 점 하나뿐인 선 버림 · 끝에서 그은 선은 뒤집고 머리 잇기 · 꼬리가 다른 선 머리면 잇기. */ function stop(): void { - // 점 하나뿐인 갈래는 버림(본선 시점은 남김) - const network = params.network(); - if (active !== null && active > 0 && (network.lines[active]?.vertices.length ?? 0) < 2) - params.commit(dropLastLine(network)); + let network = params.network(); + const line = active; + const entry = line === null ? null : network.lines[line]; + if (line !== null && entry) { + if (entry.vertices.length < 2) { + const dropped = removeLine(network, line); + if (typeof dropped !== "string") network = dropped; + } else if (flip) { + const turned = reverseLine(network, line); + if (typeof turned !== "string") network = attachHead(turned, line); + } else { + const tail = entry.vertices[entry.vertices.length - 1]; + const next = network.lines.findIndex( + (other, i) => + i !== line && + !other.branch && + other.vertices.length > 1 && + samePoint(other.vertices[0], tail), + ); + if (next >= 0) network = attachHead(network, next); + } + if (network !== params.network()) params.commit(network); + } active = null; + flip = false; strokeStart = null; preview = null; candidates = []; @@ -312,20 +325,93 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { function setMode(next: DrawMode): void { if (mode === "draw") stop(); mode = editable ? next : "idle"; - if (mode === "draw") { - const network = params.network(); - if (reachedEnd(network, params.base().end)) { - showToast("본선이 이미 종점에 닿았습니다 — 갈래는 [갈래]로 긋습니다.", "info"); - mode = "idle"; - } else { - active = 0; - strokeStart = network; - } - } panelState(); params.onMode(); } + /** [긋기] 첫 누름 — 선 끝 이어 긋기 · 종점(뒤집어 긋기) · 시점 · 선 위 갈래. 참 = 시작함. */ + function begin(px: number, py: number, map: ScreenMap): boolean { + const network = params.network(); + const { starts, ends } = params.base(); + const gap = (point: Vertex): number => screenGap(map, point, px, py); + const nearest = (items: T[]): T | undefined => + items.filter((item) => item.gap <= SNAP_PX).sort((a, b) => a.gap - b.gap)[0]; + const onBase = (list: Vertex[], point: Vertex): boolean => + list.some((p) => samePoint(p, point)); + strokeStart = network; + // 1) 선 끝 — 꼬리(종점에 닿은 꼬리 빼고) · 갈래 없는 선 머리(시점에 놓인 머리 빼고) + const tips: Array<{ line: number; head: boolean; gap: number }> = []; + network.lines.forEach((line, i) => { + const tail = line.vertices[line.vertices.length - 1]; + const head = line.vertices[0]; + if (tail && !onBase(ends, tail)) tips.push({ line: i, head: false, gap: gap(tail) }); + if (line.vertices.length > 1 && !line.branch && !onBase(starts, head)) + tips.push({ line: i, head: true, gap: gap(head) }); + }); + const tip = nearest(tips); + if (tip) { + if (tip.head) { + const turned = reverseLine(network, tip.line); + if (typeof turned === "string") { + showToast(turned, "info"); + return false; + } + params.commit(turned); + } + [active, flip] = [tip.line, tip.head]; + return true; + } + // 2) 시점 · 종점 + const base = nearest([ + ...starts.map((point) => ({ point, fromEnd: false, gap: gap(point) })), + ...ends.map((point) => ({ point, fromEnd: true, gap: gap(point) })), + ]); + if (base) { + const { point, fromEnd } = base; + const owner = fromEnd + ? -1 + : network.lines.findIndex((line) => line.vertices.some((v) => samePoint(v, point))); + if (owner >= 0) { + // 이미 선이 놓인 시점 = 그 점의 갈래 + const vertex = network.lines[owner].vertices.findIndex((v) => samePoint(v, point)); + params.commit(addBranch(network, owner, vertex)); + } else params.commit(newLine(network, point)); + [active, flip] = [params.network().lines.length - 1, fromEnd]; + return true; + } + // 3) 선 위 · 꺾임점 = 갈래 + const hit = lineAt(network, px, py, map); + if (hit) { + let next = network; + let vertex = hit.vertex; + if (vertex === null) { + next = insertVertex(next, hit.line, hit.segment, hit.point); + vertex = hit.segment + 1; + } + params.commit(addBranch(next, hit.line, vertex)); + [active, flip] = [params.network().lines.length - 1, false]; + return true; + } + strokeStart = null; + showToast("시점 · 종점 · 선 끝 · 선 위를 눌러 시작합니다.", "info"); + return false; + } + + /** 긋는 해가 끝 조각 해와 다르면 `fromM`(이번 점 앞 길이)에서 나눠 새 조각 = 긋는 해. */ + function yearSplit(network: PlanNetwork, line: number, fromM: number): PlanNetwork { + const year = params.year(); + if (!(fromM > 0)) return network; + let next = normalizePieces(network, year); + const id = next.lines[line].id; + const tail = next.pieces.filter((piece) => piece.line === id).at(-1); + if (!tail || tail.year === year) return next; + next = splitPiece(next, id, fromM); + const index = next.pieces.findIndex( + (piece) => piece.line === id && Math.abs(piece.from_m - fromM) < 1e-3, + ); + return index >= 0 ? setPieceYear(next, index, year) : next; + } + function addPoint(): boolean { if (active === null || !preview) return false; if (preview.blocked) { @@ -334,9 +420,14 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { } const line = active; const ended = preview.snapped; - params.commit(appendVertex(params.network(), line, preview.point)); + let next = params.network(); + const before = Math.round(lineLength(next.lines[line].vertices) * 1000) / 1000; + if (preview.onLine) + next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point); + next = yearSplit(appendVertex(next, line, preview.point), line, before); + params.commit(next); if (ended) { - showToast("본선이 종점에 닿았습니다.", "success"); + showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success"); setMode("idle"); } return true; @@ -344,95 +435,44 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { const layer: PlanMapLayer = { draw(context, map) { - const colors = planColors(); - const { start, end } = params.base(); const network = params.network(); - context.lineCap = "round"; - context.lineJoin = "round"; - network.lines.forEach((line, index) => { - if (line.vertices.length < 2) return; - context.strokeStyle = index === active ? colors.active : colors.line; - context.lineWidth = index === 0 ? 3 : 2.5; - context.beginPath(); - line.vertices.forEach((v, i) => { - const [sx, sy] = map.toScreen(v); - if (i) context.lineTo(sx, sy); - else context.moveTo(sx, sy); - }); - context.stroke(); - if (map.stations) - drawStationTicks( + paintNetwork(context, map, network, params.base(), active, params.heightAt(), () => { + const from = active === null ? null : lastOf(active); + if (from && preview) + paintPreview( context, - line.vertices.map(([x, y]) => ({ x, y })), - { - intervalM: PLAN_STATION_INTERVAL_M, - pxPerMeter: map.pxPerMeter, - toScreen: (x, y) => map.toScreen([x, y]), - uprightRad: map.uprightRad, - chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), - }, + map, + from, + preview, + kind === "both" ? { lengthM, candidates } : null, ); - if (map.slopes) segmentLabels(context, map, line.vertices); }); - // 꺾임점 - context.fillStyle = "#ffffff"; - for (const line of network.lines) - for (const v of line.vertices.slice(1)) { - const [sx, sy] = map.toScreen(v); - context.beginPath(); - context.arc(sx, sy, 3, 0, Math.PI * 2); - context.fill(); - } - drawPieces(context, map, network); - drawPreview(context, map, colors); - // 시점(초록) · 종점(빨강) - for (const [point, fill] of [ - [start, "#16a34a"], - [end, "#dc2626"], - ] as const) { - const [sx, sy] = map.toScreen(point); - context.beginPath(); - context.arc(sx, sy, 7, 0, Math.PI * 2); - context.fillStyle = fill; - context.fill(); - context.lineWidth = 2; - context.strokeStyle = "#ffffff"; - context.stroke(); - } }, press(px, py, map, button) { if (!editable) return false; if (mode === "draw" && button === 0) { + if (active === null) { + const started = begin(px, py, map); + panelState(); + params.onMode(); + return started; + } preview = previewAt(px, py, map); return addPoint(); } - if (mode === "branch" && button === 0) { - const hit = lineAt(px, py, map); - if (!hit) return false; - let network = params.network(); - let vertex = hit.vertex; - if (vertex === null) { - network = insertVertex(network, hit.line, hit.segment, hit.point); - vertex = hit.segment + 1; - } - strokeStart = params.network(); - params.commit(addBranch(network, hit.line, vertex)); - mode = "draw"; - active = params.network().lines.length - 1; - panelState(); - params.onMode(); - return true; - } if (mode === "mark") { - const hit = lineAt(px, py, map); - if (!hit) return false; const network = params.network(); + const hit = lineAt(network, px, py, map); + if (!hit) return false; const id = network.lines[hit.line].id; const next = button === 2 ? removeMark(network, id, hit.atM, SNAP_PX / map.pxPerMeter) : splitPiece(network, id, hit.atM); - if (next !== network) params.commit(next); + if (next === network) return true; + const added = next.pieces.find((piece) => !network.pieces.some((p) => p.id === piece.id)); + params.commit(next); + if (added) params.onMark(added.id); return true; } return false; @@ -445,120 +485,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { }, }; - function segmentLabels( - context: CanvasRenderingContext2D, - map: ScreenMap, - vertices: Vertex[], - ): void { - const z = params.heightAt(); - for (let i = 1; i < vertices.length; i += 1) { - const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; - if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; - const slope = groundSlopePct(vertices[i - 1], vertices[i], z); - const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; - label(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); - } - } - - function drawPieces( - context: CanvasRenderingContext2D, - map: ScreenMap, - network: PlanNetwork, - ): void { - const colors = planColors(); - network.pieces.forEach((piece) => { - const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; - if (!vertices || vertices.length < 2) return; - if (piece.from_m > 0) { - const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); - context.beginPath(); - context.moveTo(sx, sy - 8); - context.lineTo(sx + 6, sy); - context.lineTo(sx, sy + 8); - context.lineTo(sx - 6, sy); - context.closePath(); - context.fillStyle = colors.active; - context.fill(); - context.lineWidth = 1.5; - context.strokeStyle = "#ffffff"; - context.stroke(); - } - const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); - label(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); - }); - } - - function drawPreview( - context: CanvasRenderingContext2D, - map: ScreenMap, - colors: ReturnType, - ): void { - if (active === null || !preview) return; - const from = lastOf(active); - if (!from) return; - const [ax, ay] = map.toScreen(from); - const [bx, by] = map.toScreen(preview.point); - if (kind === "both") { - context.strokeStyle = colors.active; - context.lineWidth = 1; - context.setLineDash([3, 4]); - context.beginPath(); - context.arc(ax, ay, lengthM * map.pxPerMeter, 0, Math.PI * 2); - context.stroke(); - context.fillStyle = colors.active; - for (const c of candidates) { - const [cx, cy] = map.toScreen(c); - context.beginPath(); - context.arc(cx, cy, 3, 0, Math.PI * 2); - context.fill(); - } - } - context.strokeStyle = preview.blocked ? colors.warn : colors.active; - context.lineWidth = 2; - context.setLineDash([6, 4]); - context.beginPath(); - context.moveTo(ax, ay); - context.lineTo(bx, by); - context.stroke(); - context.setLineDash([]); - context.beginPath(); - context.arc(bx, by, 5, 0, Math.PI * 2); - context.stroke(); - const value = - `${fmt(preview.lengthM)} m · ` + - (preview.slopePct === null ? "지형 밖" : `${signed(preview.slopePct)} %`); - label( - context, - map, - bx + 14, - by - 14, - preview.note ? `${value} · ${preview.note}` : value, - "12px", - "left", - ); - } - - function label( - context: CanvasRenderingContext2D, - map: ScreenMap, - x: number, - y: number, - text: string, - font: string, - align: CanvasTextAlign = "center", - ): void { - map.upright(context, x, y, () => { - context.font = `${font} system-ui, sans-serif`; - context.textAlign = align; - context.textBaseline = "middle"; - context.lineWidth = 3; - context.strokeStyle = planColors().halo; - context.strokeText(text, x, y); - context.fillStyle = "#ffffff"; - context.fillText(text, x, y); - }); - } - panelState(); return { @@ -579,19 +505,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (mode !== "idle" && event.key === "Escape") { if (mode === "draw" && strokeStart && strokeStart !== params.network()) params.commit(strokeStart); - active = null; // 되돌린 꼴이라 stop 의 갈래 버림은 안 함 + active = null; // 되돌린 꼴이라 stop 의 버림 · 뒤집기는 안 함 setMode("idle"); return true; } if (mode === "draw" && active !== null && event.key === "Backspace") { const network = params.network(); const step = - active > 0 && network.lines[active].vertices.length < 2 - ? dropLastLine(network) + network.lines[active].vertices.length < 2 + ? removeLine(network, active) : dropLastVertex(network, active); if (typeof step === "string") showToast(step, "info"); else if (step !== network) params.commit(step); - if (active >= params.network().lines.length) setMode("idle"); + if (active >= params.network().lines.length) { + active = null; + setMode("idle"); + } return true; } return false; @@ -599,17 +528,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { sync() { if (active !== null && active >= params.network().lines.length) { active = null; + flip = false; mode = "idle"; } panelState(); }, preview: () => preview, + stroke: () => (active === null ? null : { line: active, flip }), constraint: () => ({ kind, lengthM, slopePct }), setConstraint(next, length, slope) { kind = next; kindSegment.set(next); if (length !== undefined) [lengthM, lengthField.input.value] = [length, String(length)]; - if (slope !== undefined) [slopePct, slopeField.input.value] = [slope, String(slope)]; + if (slope !== undefined) { + [slopePct, slopeField.input.value] = [slope, String(slope)]; + degText(); + } fieldsState(); }, }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts new file mode 100644 index 000000000..e5f54324d --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -0,0 +1,258 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_DrawPaint.ts + * D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) · + * `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · + * 시점 · 종점 · 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리. + * ========================================================================== */ + +import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import { + distanceAlong, + groundSlopePct, + lineLength, + pointAtDistance, + segmentLength, + stationChainages, + type HeightAt, +} from "./logic/D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { planColors, PLAN_STATION_INTERVAL_M, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import type { DrawPreview } from "./D03_PlanAnalysis_UI_Draw"; + +/** 시점 · 종점 · 꺾임점에 붙는 반경(px). */ +export const SNAP_PX = 12; +/** 선 위 누름으로 볼 반경(px). */ +export const LINE_HIT_PX = 8; +/** 구간 길이 라벨을 보일 화면 길이(px). */ +const LABEL_MIN_PX = 70; + +export const fmt = (value: number): string => value.toFixed(1); +const signed = (value: number): string => `${value >= 0 ? "+" : ""}${value.toFixed(1)}`; +/** 기울기 % → 각(°) — 가상 종단 기울기를 두 단위로. */ +export const slopeDeg = (pct: number): number => (Math.atan(pct / 100) * 180) / Math.PI; +const slopeText = (pct: number): string => `${signed(pct)} % (${signed(slopeDeg(pct))}°)`; +/** 화면 px 거리(작업 좌표계 점 ↔ 마우스). */ +export const screenGap = (map: ScreenMap, point: Vertex, px: number, py: number): number => { + const [sx, sy] = map.toScreen(point); + return Math.hypot(sx - px, sy - py); +}; + +/** 화면 px 둘레 선 위 자리 — 가장 가까운 선 · 누가 · 꺾임점(가까우면) · 구간 · `skip` 선 빼고. */ +export function lineAt( + network: PlanNetwork, + px: number, + py: number, + map: ScreenMap, + skip: number | null = null, +): { line: number; atM: number; point: Vertex; vertex: number | null; segment: number } | null { + const cursor = map.toMetric(px, py); + let best: ReturnType = null; + let bestPx = LINE_HIT_PX; + network.lines.forEach((line, index) => { + if (line.vertices.length < 2 || index === skip) return; + // 붙는 반경 안 꺾임점 중 가장 가까운 것 + let vertex = -1; + let vertexPx = SNAP_PX; + line.vertices.forEach((v, i) => { + const [vx, vy] = map.toScreen(v); + const d = Math.hypot(vx - px, vy - py); + if (d <= vertexPx) [vertex, vertexPx] = [i, d]; + }); + const atM = + vertex >= 0 + ? lineLength(line.vertices.slice(0, vertex + 1)) + : distanceAlong(line.vertices, cursor); + const point = vertex >= 0 ? line.vertices[vertex] : pointAtDistance(line.vertices, atM); + const [sx, sy] = map.toScreen(point); + const gap = vertex >= 0 ? 0 : Math.hypot(sx - px, sy - py); + if (gap > bestPx || (best && gap === bestPx)) return; + // 누가가 든 구간(꺾임점 끼울 자리) + let segment = 0; + for (let walked = 0; segment < line.vertices.length - 2; segment += 1) { + walked += segmentLength(line.vertices[segment], line.vertices[segment + 1]); + if (walked >= atM) break; + } + best = { line: index, atM, point, vertex: vertex >= 0 ? vertex : null, segment }; + bestPx = gap; + }); + return best; +} + +/** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기) · 시점(초록) · 종점(빨강). + * `active` = 그리는 선(굵은 보라). */ +export function paintNetwork( + context: CanvasRenderingContext2D, + map: ScreenMap, + network: PlanNetwork, + base: { starts: Vertex[]; ends: Vertex[] }, + active: number | null, + z: HeightAt, + extra: () => void, +): void { + const { starts, ends } = base; + const colors = planColors(); + context.lineCap = "round"; + context.lineJoin = "round"; + network.lines.forEach((line, index) => { + if (line.vertices.length < 2) return; + context.strokeStyle = index === active ? colors.active : colors.line; + context.lineWidth = line.branch ? 2.5 : 3; + context.beginPath(); + line.vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + if (map.stations) + drawStationTicks( + context, + line.vertices.map(([x, y]) => ({ x, y })), + { + intervalM: PLAN_STATION_INTERVAL_M, + pxPerMeter: map.pxPerMeter, + toScreen: (x, y) => map.toScreen([x, y]), + uprightRad: map.uprightRad, + chainages: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), + }, + ); + if (map.slopes) segmentLabels(context, map, line.vertices, z); + }); + // 꺾임점 + context.fillStyle = "#ffffff"; + for (const line of network.lines) + for (const v of line.vertices.slice(1)) { + const [sx, sy] = map.toScreen(v); + context.beginPath(); + context.arc(sx, sy, 3, 0, Math.PI * 2); + context.fill(); + } + drawPieces(context, map, network); + extra(); + // 시점(초록) · 종점(빨강) + for (const [points, fill] of [ + [starts, "#16a34a"], + [ends, "#dc2626"], + ] as const) + for (const point of points) { + const [sx, sy] = map.toScreen(point); + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.fillStyle = fill; + context.fill(); + context.lineWidth = 2; + context.strokeStyle = "#ffffff"; + context.stroke(); + } +} + +function segmentLabels( + context: CanvasRenderingContext2D, + map: ScreenMap, + vertices: Vertex[], + z: HeightAt, +): void { + for (let i = 1; i < vertices.length; i += 1) { + const [a, b] = [map.toScreen(vertices[i - 1]), map.toScreen(vertices[i])]; + if (Math.hypot(b[0] - a[0], b[1] - a[1]) < LABEL_MIN_PX) continue; + const slope = groundSlopePct(vertices[i - 1], vertices[i], z); + const text = `${fmt(segmentLength(vertices[i - 1], vertices[i]))} m · ${slope === null ? "—" : `${signed(slope)} %`}`; + planLabel(context, map, (a[0] + b[0]) / 2, (a[1] + b[1]) / 2 - 12, text, "11px"); + } +} + +function drawPieces(context: CanvasRenderingContext2D, map: ScreenMap, network: PlanNetwork): void { + const colors = planColors(); + network.pieces.forEach((piece) => { + const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; + if (!vertices || vertices.length < 2) return; + if (piece.from_m > 0) { + const [sx, sy] = map.toScreen(pointAtDistance(vertices, piece.from_m)); + context.beginPath(); + context.moveTo(sx, sy - 8); + context.lineTo(sx + 6, sy); + context.lineTo(sx, sy + 8); + context.lineTo(sx - 6, sy); + context.closePath(); + context.fillStyle = colors.active; + context.fill(); + context.lineWidth = 1.5; + context.strokeStyle = "#ffffff"; + context.stroke(); + } + const [mx, my] = map.toScreen(pointAtDistance(vertices, (piece.from_m + piece.to_m) / 2)); + planLabel(context, map, mx, my + 16, `${piece.year}년`, "bold 12px"); + }); +} + +/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */ +export function paintPreview( + context: CanvasRenderingContext2D, + map: ScreenMap, + from: Vertex, + preview: DrawPreview, + compass: { lengthM: number; candidates: Vertex[] } | null, +): void { + const colors = planColors(); + const [ax, ay] = map.toScreen(from); + const [bx, by] = map.toScreen(preview.point); + if (compass) { + context.strokeStyle = colors.active; + context.lineWidth = 1; + context.setLineDash([3, 4]); + context.beginPath(); + context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2); + context.stroke(); + context.fillStyle = colors.active; + for (const c of compass.candidates) { + const [cx, cy] = map.toScreen(c); + context.beginPath(); + context.arc(cx, cy, 3, 0, Math.PI * 2); + context.fill(); + } + } + context.strokeStyle = preview.blocked ? colors.warn : colors.active; + context.lineWidth = 2; + context.setLineDash([6, 4]); + context.beginPath(); + context.moveTo(ax, ay); + context.lineTo(bx, by); + context.stroke(); + context.setLineDash([]); + context.beginPath(); + context.arc(bx, by, 5, 0, Math.PI * 2); + context.stroke(); + const value = + `${fmt(preview.lengthM)} m · ` + + (preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct)); + planLabel( + context, + map, + bx + 14, + by - 14, + preview.note ? `${value} · ${preview.note}` : value, + "12px", + "left", + ); +} + +export function planLabel( + context: CanvasRenderingContext2D, + map: ScreenMap, + x: number, + y: number, + text: string, + font: string, + align: CanvasTextAlign = "center", +): void { + map.upright(context, x, y, () => { + context.font = `${font} system-ui, sans-serif`; + context.textAlign = align; + context.textBaseline = "middle"; + context.lineWidth = 3; + context.strokeStyle = planColors().halo; + context.strokeText(text, x, y); + context.fillStyle = "#ffffff"; + context.fillText(text, x, y); + }); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts new file mode 100644 index 000000000..170a20e14 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Edit.ts @@ -0,0 +1,321 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_Edit.ts + * D03 고르기 · 고치기 겹(PLAN 580 · 요구서 440 3 · 6 · 7) — 쉴 때(긋기 · 연도 구분 · 재기 아님)만. + * + * 선 누름 = 그 선 · 조각 고름(굵게 · 좌측 「구역 · 연도」 그 줄 밝힘) · 빈 곳 = 고름 풂. + * 고른 선 위 누름 = 꺾임점 더함(그대로 끌면 옮김) · 꺾임점 끌기 = 옮김(붙은 갈래 첫 점 같이) · + * 마크 끌기 = 연도 구분 옮김 · 오른쪽 누름 = 꺾임점 · 마크 지움 · Delete = 고른 선 지움. + * 잠금 = 시점 · 종점에 놓인 점(1단계 몫) · 갈래 첫 점(붙은 선 점을 끌면 같이 감). + * 끌기 중 = 화면만(`show`) · 놓을 때 `commit` 한 번 = 되돌리기 한 걸음. + * 좌측 「고른 선」 칸 = 이름 · 길이 · [방향 바꾸기](측점 No.0 쪽 바꿈) · [선 지움]. + * ========================================================================== */ + +import { createButton, showToast } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { + distanceAlong, + lineLength, + pointAtDistance, + sliceLine, +} from "./logic/D03_PlanAnalysis_Draw"; +import { + insertVertex, + lineNames, + moveMark, + moveVertex, + removeLine, + removeMark, + removeVertex, + reverseLine, + samePoint, + type PlanNetwork, + type Vertex, +} from "./logic/D03_PlanAnalysis_Plan"; +import { lineAt, screenGap, SNAP_PX } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; + +export interface PlanEditParams { + network: () => PlanNetwork; + base: () => { starts: Vertex[]; ends: Vertex[] }; + commit: (next: PlanNetwork) => void; + /** 끌기 중 — 화면만 바꿈(되돌리기 · 캐시 없음). */ + show: (next: PlanNetwork) => void; + /** 고르기 받는 때(쉴 때 · 재기 아님). */ + enabled: () => boolean; + /** 고치기 됨(산림청). */ + editable: () => boolean; + /** 고름이 바뀜 — 페이지가 좌측 줄 밝힘. */ + onSelect: () => void; +} + +export interface PlanSelection { + line: string; + piece: string | null; +} + +export interface PlanEdit { + layer: PlanMapLayer; + /** 좌측 「고른 선」 칸. */ + panel: HTMLElement; + selected: () => PlanSelection | null; + select: (line: string | null, piece?: string | null) => void; + /** 키 — 받았으면 참(Delete · Esc). */ + key: (event: KeyboardEvent) => boolean; + /** 되돌리기 · 저장 뒤 — 고른 선이 사라졌으면 풂 · 칸 다시. */ + sync: () => void; +} + +type Drag = + | { kind: "vertex"; line: number; vertex: number; before: PlanNetwork; last: PlanNetwork | null } + | { kind: "mark"; index: number; before: PlanNetwork; last: PlanNetwork | null } + | { kind: "locked"; note: string; told: boolean }; + +export function createPlanEdit(params: PlanEditParams): PlanEdit { + let selected: PlanSelection | null = null; + let drag: Drag | null = null; + + const info = createText("", "muted"); + const flipButton = createButton({ + label: "방향 바꾸기", + variant: "ghost", + size: 24, + onClick: () => apply((network, index) => reverseLine(network, index)), + }); + flipButton.title = "측점 No.0 쪽(시점 쪽)을 반대 끝으로 바꿉니다."; + const dropButton = createButton({ + label: "선 지움", + variant: "ghost", + size: 24, + onClick: () => apply((network, index) => removeLine(network, index)), + }); + const row = document.createElement("div"); + row.className = "d03-plan__row"; + row.append(flipButton, dropButton); + const panel = document.createElement("div"); + panel.className = "d03-plan__pick"; + panel.dataset.role = "plan-pick"; + panel.append(info, row); + + const indexOf = (network: PlanNetwork): number => + selected ? network.lines.findIndex((line) => line.id === selected!.line) : -1; + + /** 고른 선에 꼴 바꿈 하나 — 까닭 글이면 알림. */ + function apply(step: (network: PlanNetwork, index: number) => PlanNetwork | string): void { + const network = params.network(); + const index = indexOf(network); + if (index < 0 || !params.editable()) return; + const next = step(network, index); + if (typeof next === "string") return void showToast(next, "info"); + params.commit(next); + sync(); + } + + function panelState(): void { + const network = params.network(); + const index = indexOf(network); + panel.hidden = index < 0; + if (index < 0) return; + const line = network.lines[index]; + info.textContent = `고른 선 — ${lineNames(network.lines)[index]} · ${lineLength(line.vertices).toFixed(1)} m`; + flipButton.hidden = dropButton.hidden = !params.editable(); + flipButton.disabled = !!line.branch; + } + + function select(line: string | null, piece: string | null = null): void { + selected = line ? { line, piece } : null; + panelState(); + params.onSelect(); + } + + function sync(): void { + if (selected && indexOf(params.network()) < 0) selected = null; + if (selected?.piece && !params.network().pieces.some((p) => p.id === selected!.piece)) + selected = { ...selected, piece: null }; + panelState(); + } + + const pieceAt = (network: PlanNetwork, line: string, atM: number): string | null => + network.pieces.find((p) => p.line === line && atM >= p.from_m - 1e-6 && atM <= p.to_m + 1e-6) + ?.id ?? null; + + /** 화면 px 둘레 마크(조각 시작) — 조각 차례. */ + function markAt(network: PlanNetwork, px: number, py: number, map: ScreenMap): number | null { + let best: number | null = null; + let bestPx = SNAP_PX; + network.pieces.forEach((piece, index) => { + const vertices = network.lines.find((line) => line.id === piece.line)?.vertices; + if (!vertices || !(piece.from_m > 0)) return; + const gap = screenGap(map, pointAtDistance(vertices, piece.from_m), px, py); + if (gap <= bestPx) [best, bestPx] = [index, gap]; + }); + return best; + } + + /** 화면 px 둘레 꺾임점 — 갈래 첫 점은 붙은 선 점이 대신 집힘. */ + function vertexAt( + network: PlanNetwork, + px: number, + py: number, + map: ScreenMap, + ): { line: number; vertex: number } | null { + let best: { line: number; vertex: number } | null = null; + let bestPx = SNAP_PX; + network.lines.forEach((line, i) => + line.vertices.forEach((point, v) => { + if (v === 0 && line.branch) return; + const gap = screenGap(map, point, px, py); + if (gap <= bestPx) [best, bestPx] = [{ line: i, vertex: v }, gap]; + }), + ); + return best; + } + + /** 잠금 까닭 — 시점 · 종점에 놓인 점. */ + function lockOf(network: PlanNetwork, line: number, vertex: number): string | null { + const point = network.lines[line].vertices[vertex]; + const { starts, ends } = params.base(); + return [...starts, ...ends].some((p) => samePoint(p, point)) + ? "시점 · 종점 자리 점은 1단계(기준 지정)에서 옮깁니다." + : null; + } + + const layer: PlanMapLayer = { + draw(context, map) { + const network = params.network(); + const index = indexOf(network); + if (index < 0) return; + const colors = planColors(); + const line = network.lines[index]; + const path = (points: Vertex[]): void => { + context.beginPath(); + points.forEach((point, i) => { + const [sx, sy] = map.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + }; + context.lineCap = "round"; + context.lineJoin = "round"; + context.strokeStyle = colors.active; + context.globalAlpha = 0.45; + context.lineWidth = 9; + path(line.vertices); + const piece = network.pieces.find((p) => p.id === selected?.piece); + if (piece) { + context.globalAlpha = 0.85; + context.lineWidth = 6; + path(sliceLine(line.vertices, piece.from_m, piece.to_m)); + } + context.globalAlpha = 1; + // 꺾임점 손잡이 + context.lineWidth = 2; + context.fillStyle = "#ffffff"; + for (const point of line.vertices) { + const [sx, sy] = map.toScreen(point); + context.fillRect(sx - 4, sy - 4, 8, 8); + context.strokeRect(sx - 4, sy - 4, 8, 8); + } + }, + press(px, py, map, button) { + if (!params.enabled()) return false; + const network = params.network(); + const editable = params.editable(); + const mark = markAt(network, px, py, map); + const vertex = vertexAt(network, px, py, map); + if (button === 2) { + if (!editable) return false; + if (mark !== null) { + const piece = network.pieces[mark]; + params.commit(removeMark(network, piece.line, piece.from_m, 1e-3)); + sync(); + return true; + } + if (!vertex) return false; + const next = removeVertex(network, vertex.line, vertex.vertex); + if (typeof next === "string") showToast(next, "info"); + else params.commit(next); + sync(); + return true; + } + if (mark !== null) { + const piece = network.pieces[mark]; + select(piece.line, piece.id); + if (editable) drag = { kind: "mark", index: mark, before: network, last: null }; + return true; + } + if (vertex) { + const id = network.lines[vertex.line].id; + const atM = lineLength(network.lines[vertex.line].vertices.slice(0, vertex.vertex + 1)); + select(id, pieceAt(network, id, atM)); + const lock = lockOf(network, vertex.line, vertex.vertex); + if (editable) + drag = lock + ? { kind: "locked", note: lock, told: false } + : { kind: "vertex", ...vertex, before: network, last: null }; + return true; + } + const hit = lineAt(network, px, py, map); + if (hit) { + const id = network.lines[hit.line].id; + if (editable && selected?.line === id) { + params.commit(insertVertex(network, hit.line, hit.segment, hit.point)); + const before = params.network(); + drag = { kind: "vertex", line: hit.line, vertex: hit.segment + 1, before, last: null }; + } + select(id, pieceAt(params.network(), id, hit.atM)); + return true; + } + if (!selected) return false; + select(null); + return true; + }, + hover(px, py, map) { + if (!drag) return; + if (drag.kind === "locked") { + if (!drag.told) showToast(drag.note, "info"); + drag.told = true; + return; + } + const cursor = map.toMetric(px, py); + if (drag.kind === "vertex") { + drag.last = moveVertex(drag.before, drag.line, drag.vertex, cursor); + } else { + const piece = drag.before.pieces[drag.index]; + const vertices = drag.before.lines.find((line) => line.id === piece.line)!.vertices; + drag.last = moveMark(drag.before, drag.index, distanceAlong(vertices, cursor)); + } + params.show(drag.last); + }, + release() { + const done = drag; + drag = null; + if (!done || done.kind === "locked" || !done.last) return; + params.show(done.before); + params.commit(done.last); + sync(); + }, + }; + + panelState(); + + return { + layer, + panel, + selected: () => selected, + select, + key(event) { + if (!selected || !params.enabled()) return false; + if (event.key === "Delete" && params.editable()) { + apply((network, index) => removeLine(network, index)); + return true; + } + if (event.key === "Escape") { + select(null); + return true; + } + return false; + }, + sync, + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index a4d0de066..642827276 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -64,11 +64,12 @@ export interface ScreenMap { upright: (context: CanvasRenderingContext2D, x: number, y: number, paint: () => void) => void; } -/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁 · 나감. */ +/** 평면 위 겹 — 그리기 · 누름(참 = 받음 · 0 왼쪽 · 2 오른쪽) · 마우스 곁(왼쪽 끌기 중도) · 놓음 · 나감. */ export interface PlanMapLayer { draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void; press?: (px: number, py: number, map: ScreenMap, button: 0 | 2) => boolean; hover?: (px: number, py: number, map: ScreenMap) => void; + release?: () => void; leave?: () => void; } @@ -152,11 +153,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { label: "평면 도구", hint: { lines: [ + "[긋기] 뒤 시점 · 종점 · 선 끝 · 선 위 누름 = 시작", "누름 = 점 찍기", "Enter = 긋기 끝", "Esc = 긋기 취소", "Backspace = 마지막 점 빼기", - "오른쪽 누름 = 마크 지움", + "선 누름 = 고름 · 고른 선 위 누름 = 꺾임점 더함", + "꺾임점 · 마크 끌기 = 옮김", + "오른쪽 누름 = 꺾임점 · 마크 지움", + "Delete = 고른 선 지움", "가운데 끌기 = 옮기기", "휠 = 확대 · 축소", "Ctrl+휠 = 돌리기", @@ -270,7 +275,15 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (!meta || event.button !== 0) return; const [px, py] = at(event); const map = screenMap(); - if (layers.some((layer) => layer.press?.(px, py, map, 0))) draw(); + if (layers.some((layer) => layer.press?.(px, py, map, 0))) { + canvas.setPointerCapture(event.pointerId); // 끌기가 캔버스 밖으로 나가도 놓음을 받음 + draw(); + } + }); + canvas.addEventListener("pointerup", (event) => { + if (event.button !== 0) return; + for (const layer of layers) layer.release?.(); + draw(); }); canvas.addEventListener("pointermove", (event) => { if (!meta || event.buttons & 4) return; // 가운데 단추 옮기는 중 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index dfb9b71b3..7d3f0670b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -1,10 +1,12 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Page.ts - * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · PLAN 562-3 · 563-1) — B05~B09 틀(`mountGroupStep`). + * D 예정노선 2단계 노선분석(요구서 389 3장 6~13 · 440 · PLAN 562-3 · 563-1 · 579-3 · 580) — B05~B09 틀(`mountGroupStep`). * - * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 구역 마크) + 도구 여섯. - * 좌 = 지형(범위를 카카오 지도에 먼저 보임 → [이 범위 받기] · [범위 밖 더 받기]는 선이 받은 범위 밖일 때만) · - * 그리기(본선 · 갈래 · 구역 마크 · 제약 칸) · 구역 · 연도(조각마다 연도) · [저장] [초기화]. + * 메인 = 평면(바탕 겹 켜고 끔 — 위성 · 등고선 · 세류 · 지적 · 도로 · 급경사 + 그은 선 · 측점 · 연도 구분) + 도구 여섯. + * 좌 = 지형(들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기 · [범위 밖 더 받기]는 시점 · 종점 · 선이 + * 받은 범위 밖일 때만) · 그리기([긋기] · [연도 구분] · 제약 칸 · 긋는 해 · 고른 선) · + * 구역 · 연도(선 통째 해 · 조각마다 해 · 고른 줄 밝힘) · [저장] [초기화]. + * 시점 · 종점 = D02 목록(여럿) · 고르기 · 고치기 = `D03_PlanAnalysis_UI_Edit`. * * 데이터 3층(CLAUDE.md 5장) — 긋기 · 마크 · 연도 = 캐시(`d03-plan` {version: 2, network}) · [저장] = * PUT plan/network(서버가 길이 · 측점 · 조각 · 연도 행을 다시 셈 · 응답으로 바꿈) · [초기화] = 빈 선 PUT. @@ -46,6 +48,7 @@ import { } from "./D03_PlanAnalysis_Api"; import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; +import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; import { createPlanMap, PLAN_STATION_INTERVAL_M } from "./D03_PlanAnalysis_UI_Map"; import { createPlanMeasure } from "./D03_PlanAnalysis_UI_Measure"; @@ -53,9 +56,10 @@ import { lineLength, stationChainages } from "./logic/D03_PlanAnalysis_Draw"; import { emptyNetwork, fromServer, - lineName, + lineNames, marksOf, normalizePieces, + setLineYear, setPieceYear, type PlanNetwork, type Vertex, @@ -82,11 +86,12 @@ const station = (m: number): string => stationLabel(m, PLAN_STATION_INTERVAL_M, export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const projectId = currentProjectId(); let editable = false; - let start: Vertex = [0, 0]; - let end: Vertex = [0, 0]; + let starts: Vertex[] = []; + let ends: Vertex[] = []; let epsg = 0; let yearStart = new Date().getFullYear(); - let network: PlanNetwork = emptyNetwork(start); + let drawYear = yearStart; // 긋는 해 — 새 조각 처음 해 + let network: PlanNetwork = emptyNetwork(); let savedJson = JSON.stringify(network); // 못 읽은 채 떠남 = 고침 없음 let undoStack: PlanNetwork[] = []; let redoStack: PlanNetwork[] = []; @@ -99,14 +104,31 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN); const draw = createPlanDraw({ - base: () => ({ start, end }), + base: () => ({ starts, ends }), network: () => network, heightAt: () => z, commit, + year: () => drawYear, onMode: () => { if (draw.mode() !== "idle") setMeasure(false); refresh(); }, + onMark: (piece) => { + edit.select(network.pieces.find((p) => p.id === piece)?.line ?? null, piece); + openYear(piece); + }, + }); + const edit = createPlanEdit({ + network: () => network, + base: () => ({ starts, ends }), + commit, + show: (next) => { + network = normalizePieces(next, drawYear); + map.redraw(); + }, + enabled: () => draw.mode() === "idle" && !measuring, + editable: () => editable, + onSelect: () => refresh(), }); const measure = createPlanMeasure(() => z); const map = createPlanMap({ @@ -118,7 +140,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const backdrop = createPlanLayers({ projectId, map, - points: () => [start, end, ...allVertices()], + points: () => [...starts, ...ends, ...allVertices()], }); map.root.append(backdrop.bar, backdrop.legend); const main = document.createElement("div"); @@ -143,8 +165,16 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { }); const terrainCard = createPanelCard("지형", false); terrainCard.append(terrainText, bar.root, extentButton, moreButton); + const drawYearPick = createSelectField({ + label: "긋는 해", + options: [], + size: 24, + ariaLabel: "긋는 해", + onChange: (value) => (drawYear = Number(value)), + }); + drawYearPick.select.dataset.role = "draw-year"; const drawCard = createPanelCard("그리기", false); - drawCard.append(draw.panel); + drawCard.append(draw.panel, drawYearPick.root, edit.panel); const pieceList = document.createElement("div"); pieceList.className = "d03-plan__pieces"; const pieceCard = createPanelCard("구역 · 연도", false); @@ -183,7 +213,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function commit(next: PlanNetwork): void { undoStack = [...undoStack.slice(1 - UNDO_MAX), network]; redoStack = []; - network = normalizePieces(next, yearStart); + network = normalizePieces(next, drawYear); keepDraft(); refresh(); } @@ -211,17 +241,25 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { measuring = on; measure.clear(); if (on) draw.setMode("idle"); - map.setLayers([...backdrop.layers, draw.layer, ...(measuring ? [measure.layer] : [])]); + map.setLayers([ + ...backdrop.layers, + draw.layer, + edit.layer, + ...(measuring ? [measure.layer] : []), + ]); refresh(); } - /** 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */ + /** 시점 · 종점 · 꺾임점이 받은 범위 밖인가(받은 범위 없음 = 밖 아님 — 그땐 [지형 범위 보기]). */ const outside = (): boolean => !!have && - allVertices().some(([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3]); + [...starts, ...ends, ...allVertices()].some( + ([x, y]) => x < have![0] || x > have![2] || y < have![1] || y > have![3], + ); function refresh(): void { draw.sync(); + edit.sync(); map.setTools({ undo: editable && undoStack.length > 0, redo: editable && redoStack.length > 0, @@ -238,7 +276,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { : !surface ? "받은 지형 없음 — 범위를 보고 받으세요(미리보기 길이 · 기울기는 지형을 받은 뒤)." : outside() - ? "그은 선이 받은 지형 밖으로 나갔습니다 — 더 받아야 기울기를 셉니다." + ? "시점 · 종점 또는 그은 선이 받은 지형 밖입니다 — 더 받아야 기울기를 셉니다." : "받은 지형 안"; if (failed && editable && !busy) extentButton.hidden = false; extentButton.querySelector(".ui-btn__label")!.textContent = failed @@ -248,23 +286,59 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { saveButton.disabled = busy || !dirty(); } - function renderPieces(): void { + /** 고를 해 — 사업연도 시작부터 열 해 + 조각에 있는 해. */ + function yearOptions(): Array<{ value: string; text: string }> { const years = new Set(Array.from({ length: YEAR_SPAN }, (_, i) => yearStart + i)); network.pieces.forEach((piece) => years.add(piece.year)); - const options = [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + return [...years].sort().map((year) => ({ value: String(year), text: `${year}년` })); + } + + /** 연도 구분 마크를 놓은 뒤 — 뒤 조각 해 고름을 엶. */ + function openYear(piece: string): void { + // 지도 누름의 기본 동작(초점 걷음)이 끝난 뒤 + setTimeout(() => { + const pick = pieceList.querySelector(`[data-piece="${piece}"] select`); + if (!pick) return; + pick.scrollIntoView({ block: "nearest" }); + pick.focus(); + try { + pick.showPicker(); + } catch { + // 사용자 누름 밖이면 못 엶 — 초점만 + } + }); + } + + function renderPieces(): void { + const options = yearOptions(); + const names = lineNames(network.lines); + const picked = edit.selected(); const rows: HTMLElement[] = []; network.lines.forEach((line, index) => { const pieces = network.pieces.filter((piece) => piece.line === line.id); if (!pieces.length) return; - const head = createText( - `${lineName(index)} · ${lineLength(line.vertices).toFixed(1)} m`, - "muted", + const years = new Set(pieces.map((piece) => piece.year)); + const whole = createSelectField({ + options: years.size > 1 ? [{ value: "", text: "여러 해" }, ...options] : options, + value: years.size > 1 ? "" : String(pieces[0].year), + size: 24, + ariaLabel: "선 통째 연도", + disabled: !editable, + onChange: (value) => value && commit(setLineYear(network, line.id, Number(value))), + }); + const head = document.createElement("div"); + head.className = "d03-plan__piece d03-plan__piece-head"; + head.classList.toggle("is-selected", picked?.line === line.id && !picked.piece); + head.dataset.line = line.id; + head.append( + createText(`${names[index]} · ${lineLength(line.vertices).toFixed(1)} m`, "muted"), + whole.root, ); - head.classList.add("d03-plan__piece-head"); rows.push(head); for (const piece of pieces) { const row = document.createElement("div"); row.className = "d03-plan__piece"; + row.classList.toggle("is-selected", picked?.piece === piece.id); row.dataset.piece = piece.id; const span = createText( `${station(piece.from_m)} ~ ${station(piece.to_m)} (${(piece.to_m - piece.from_m).toFixed(1)} m)`, @@ -425,7 +499,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { network = server && server.lines.length ? fromServer(server.lines, server.marks, server.pieces, yearStart) - : emptyNetwork(start); + : emptyNetwork(); if (server?.epsg) epsg = server.epsg; savedJson = JSON.stringify(network); } @@ -484,9 +558,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { function open3d(): void { if (!surface) return; + const names = lineNames(network.lines); const lines = network.lines .map((line, index) => ({ - name: lineName(index), + name: names[index], line: line.vertices, stations: stationChainages(lineLength(line.vertices), PLAN_STATION_INTERVAL_M), })) @@ -497,9 +572,14 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { // 검증 창구 — 꼴 · 미리보기 · 제약 · 화면 자리 · 재기 · 회전 · 저장 시간(ORCA 판정) (window as unknown as { __d03Plan?: unknown }).__d03Plan = { network: () => network, - base: () => ({ start, end }), + base: () => ({ starts, ends }), + lineNames: () => lineNames(network.lines), screenOf: map.screenOf, preview: draw.preview, + stroke: draw.stroke, + selected: edit.selected, + select: edit.select, + drawYear: () => drawYear, constraint: draw.constraint, setConstraint: draw.setConstraint, mode: draw.mode, @@ -525,36 +605,44 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { ? (redo(), true) : measuring && event.key === "Escape" ? (setMeasure(false), true) - : !ctrl && draw.key(event); + : !ctrl && (draw.key(event) || edit.key(event)); if (handled) { event.preventDefault(); map.redraw(); } }; window.addEventListener("keydown", onKey); - map.setLayers([...backdrop.layers, draw.layer]); + map.setLayers([...backdrop.layers, draw.layer, edit.layer]); try { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); const view = await fetchPlanNetwork(projectId); const base = view.base; - if (!base.start || !base.end || !base.epsg) { + if (!base.starts.length || !base.ends.length || !base.epsg) { terrainText.textContent = "시점 · 종점이 비었습니다 — 1단계(기준 지정)에서 정하세요."; return; } - start = [base.start.x, base.start.y]; - end = [base.end.x, base.end.y]; + starts = base.starts.map((p): Vertex => [p.x, p.y]); + ends = base.ends.map((p): Vertex => [p.x, p.y]); epsg = base.epsg; takeServer(view); + drawYear = yearStart; + drawYearPick.setOptions(yearOptions(), String(drawYear)); const draft = readState("d03-plan", projectId); if (draft?.version === DRAFT_VERSION && draft.network?.lines?.length) network = draft.network; - map.update({ fit: [start, end, ...allVertices()] }); + map.update({ fit: [...starts, ...ends, ...allVertices()] }); refresh(); await loadSurface(); refresh(); await backdrop.load(); - if ((await fetchPlanProgress(projectId)).terrain.running) await follow(); + const job = (await fetchPlanProgress(projectId)).terrain; + if (job.running) await follow(); + else if (editable && !job.error) { + // 들어올 때 받은 범위가 시점 · 종점을 못 덮으면 바로 받기(요구 441 1 · 실패한 뒤엔 [다시 받기]) + const extent = await fetchTerrainExtent(projectId, null, []); + if (!extent.covered) await download(extent.margin_m, []); + } } catch (error) { terrainText.textContent = `불러오지 못했습니다 — ${errorText(error)}`; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 77484c73f..1b73113d8 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -124,3 +124,9 @@ justify-content: flex-end; margin-bottom: 6px; } + +/* 580-3 고른 선 · 조각 줄 밝힘 */ +.d03-plan__piece.is-selected { + background: var(--color-accent-soft); + border-radius: var(--radius-md); +} diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index 7f48c26bc..36f4ae731 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -1,15 +1,17 @@ /* ============================================================================= * D03_PlanAnalysis_Plan.ts - * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기. + * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 · + * 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김. * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ -import { lineLength } from "./D03_PlanAnalysis_Draw"; +import { lineLength, segmentLength } from "./D03_PlanAnalysis_Draw"; export type Vertex = [number, number]; -/** 그은 선 하나 — 첫 선 = 본선(D02 시점에서) · 둘째부터 = 갈래(`branch` = 붙은 선 · 꺾임점 차례). */ +/** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) · + * 꼭짓점 차례 = 측점 차례(첫 점 = No.0). */ export interface PlanLine { id: string; vertices: Vertex[]; @@ -32,8 +34,11 @@ export interface PlanNetwork { /** 마크가 끝 · 다른 마크와 이보다 가까우면 안 놓음(m). */ export const MARK_GAP_M = 1; +/** 같은 점(m) — 서버 1 mm 와 같음. */ +const SAME_M = 1e-3; const round3 = (value: number): number => Math.round(value * 1000) / 1000; +export const samePoint = (a: Vertex, b: Vertex): boolean => segmentLength(a, b) <= SAME_M; /** 새 조각 id — `p`(있는 것 중 가장 큰 수 + 1). */ function nextPieceId(pieces: ReadonlyArray): string { @@ -41,9 +46,28 @@ function nextPieceId(pieces: ReadonlyArray): string { return `p${Math.max(0, ...used) + 1}`; } -/** 빈 선 — 본선 시점만. */ -export function emptyNetwork(start: Vertex): PlanNetwork { - return { lines: [{ id: "main", vertices: [start], branch: null }], pieces: [] }; +/** 빈 꼴 — 선 없음(긋기 = 시점 · 종점 · 선에서 시작). */ +export function emptyNetwork(): PlanNetwork { + return { lines: [], pieces: [] }; +} + +/** 새 선 id — 처음 본선 = `main` · 그 밖 `-`. */ +function nextLineId(lines: ReadonlyArray, prefix: "line" | "branch"): string { + const used = new Set(lines.map((entry) => entry.id)); + if (prefix === "line" && !used.has("main")) return "main"; + let n = lines.length; + while (used.has(`${prefix}-${n}`)) n += 1; + return `${prefix}-${n}`; +} + +/** 새 본선 — `point` 한 점(끝에 꺾임점을 더해 감). */ +export function newLine(network: PlanNetwork, point: Vertex): PlanNetwork { + const line: PlanLine = { + id: nextLineId(network.lines, "line"), + vertices: [[point[0], point[1]]], + branch: null, + }; + return { ...network, lines: [...network.lines, line] }; } /** 선마다 조각이 0 ~ 길이를 빈틈없이 덮게 맞춤 — 경계 = 조각 시작(0 · 끝 근처 빼고) · 연도 = 그 자리 조각 · @@ -186,14 +210,12 @@ export function dropLastLine(network: PlanNetwork): PlanNetwork { export function addBranch(network: PlanNetwork, line: number, vertex: number): PlanNetwork { const parent = network.lines[line]; const from = parent.vertices[vertex]; - const used = new Set(network.lines.map((entry) => entry.id)); - let n = network.lines.length; - while (used.has(`branch-${n}`)) n += 1; + const id = nextLineId(network.lines, "branch"); return { ...network, lines: [ ...network.lines, - { id: `branch-${n}`, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } }, + { id, vertices: [[from[0], from[1]]], branch: { line: parent.id, vertex } }, ], }; } @@ -223,14 +245,262 @@ export function marksOf(network: PlanNetwork): Array<{ line: string; at_m: numbe .map((piece) => ({ line: piece.line, at_m: piece.from_m })); } -/** 본선이 종점에 닿았나(1 mm). */ -export function reachedEnd(network: PlanNetwork, end: Vertex): boolean { - const main = network.lines[0]?.vertices ?? []; - const last = main[main.length - 1]; - return !!last && Math.hypot(last[0] - end[0], last[1] - end[1]) < 1e-3; +/** 선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례). + * ⚠ 짝 = `D04_PlanConfirm_Repository.line_names`(조각 · 내보내기 이름) — 고치면 같이(거울 시험 530-3). */ +export function lineNames(lines: ReadonlyArray>): string[] { + const roots = lines.filter((line) => !line.branch).length; + let root = 0; + let branch = 0; + return lines.map((line) => + line.branch ? `갈래 ${++branch}` : roots > 1 ? `본선 ${++root}` : "본선", + ); } -/** 선 이름 — 본선 · 갈래 1 · 갈래 2 …(선 차례). */ -export function lineName(index: number): string { - return index === 0 ? "본선" : `갈래 ${index}`; +/** 선 차례 — 갈래는 부모 뒤(서버 셈이 앞에서부터 부모를 찾음) · 이미 맞으면 그대로. */ +export function orderLines(lines: ReadonlyArray): PlanLine[] { + const done = new Set(); + const out: PlanLine[] = []; + let rest = [...lines]; + while (rest.length) { + const left: PlanLine[] = []; + for (const line of rest) { + if (!line.branch || done.has(line.branch.line)) { + out.push(line); + done.add(line.id); + } else left.push(line); + } + if (left.length === rest.length) return [...out, ...left]; + rest = left; + } + return out; +} + +/** `id` 선과 그 아래 갈래 모두. */ +function family(network: PlanNetwork, id: string): Set { + const out = new Set([id]); + for (let grew = true; grew;) { + grew = false; + for (const line of network.lines) + if (line.branch && out.has(line.branch.line) && !out.has(line.id)) { + out.add(line.id); + grew = true; + } + } + return out; +} + +/** 꺾임점마다 선 위 거리. */ +function chainages(vertices: ReadonlyArray): number[] { + const out = [0]; + for (let i = 1; i < vertices.length; i += 1) + out.push(out[i - 1] + segmentLength(vertices[i - 1], vertices[i])); + return out; +} + +/** 선 모양이 바뀜 — 그 선 마크(조각 시작)를 `carry(옛 거리)` 로 옮겨 실음 · 끝은 `normalizePieces` 가 맞춤. */ +function carryMarks( + pieces: ReadonlyArray, + line: string, + carry: (m: number) => number, +): PlanPiece[] { + return pieces.map((piece) => + piece.line === line && piece.from_m > 0 + ? { ...piece, from_m: round3(carry(piece.from_m)) } + : piece, + ); +} + +/** 옛 모양 위 거리 → 같은 구간 · 같은 비율의 새 모양 위 거리(꺾임점 수 같음). */ +function sameShare( + before: ReadonlyArray, + after: ReadonlyArray, +): (m: number) => number { + const [a, b] = [chainages(before), chainages(after)]; + return (m) => { + const found = a.findIndex((s, k) => k > 0 && m <= s); + const i = found < 0 ? a.length - 2 : found - 1; + const span = a[i + 1] - a[i]; + return b[i] + (span > 0 ? (m - a[i]) / span : 0) * (b[i + 1] - b[i]); + }; +} + +/** 꺾임점 옮김 — 이 점에 붙은 갈래 첫 점도 같이 · 마크는 구간 안 비율 그대로. */ +export function moveVertex( + network: PlanNetwork, + line: number, + vertex: number, + point: Vertex, +): PlanNetwork { + const moved = new Map(); // 선 id → 옮길 꺾임점 + const visit = (id: string, v: number): void => { + moved.set(id, v); + for (const other of network.lines) + if (other.branch?.line === id && other.branch.vertex === v) visit(other.id, 0); + }; + visit(network.lines[line].id, vertex); + let pieces = network.pieces; + const lines = network.lines.map((entry) => { + const v = moved.get(entry.id); + if (v === undefined) return entry; + const vertices = entry.vertices.map((p, i): Vertex => (i === v ? [point[0], point[1]] : p)); + pieces = carryMarks(pieces, entry.id, sameShare(entry.vertices, vertices)); + return { ...entry, vertices }; + }); + return { lines, pieces }; +} + +/** 꺾임점 지움 — 갈래 첫 점 · 갈래가 붙은 점 · 점 둘뿐인 선은 못 지움(까닭 글) · 뒤 갈래 차례 −1 · + * 마크는 합친 구간 안 비율. */ +export function removeVertex( + network: PlanNetwork, + line: number, + vertex: number, +): PlanNetwork | string { + const entry = network.lines[line]; + if (entry.vertices.length <= 2) return "점이 둘뿐인 선입니다 — 선을 지우세요."; + if (vertex === 0 && entry.branch) return "갈래 첫 점은 붙은 선 위라 지울 수 없습니다."; + if (network.lines.some((o) => o.branch?.line === entry.id && o.branch.vertex === vertex)) + return "갈래가 붙은 점은 지울 수 없습니다 — 갈래를 먼저 지우세요."; + const vertices = entry.vertices.filter((_, i) => i !== vertex); + const [a, b] = [chainages(entry.vertices), chainages(vertices)]; + const last = entry.vertices.length - 1; + const carry = (m: number): number => { + if (vertex === 0) return Math.max(0, m - a[1]); + if (vertex === last) return Math.min(m, b[b.length - 1]); + if (m <= a[vertex - 1]) return m; + if (m >= a[vertex + 1]) return m - a[vertex + 1] + b[vertex]; + const share = (m - a[vertex - 1]) / (a[vertex + 1] - a[vertex - 1]); + return b[vertex - 1] + share * (b[vertex] - b[vertex - 1]); + }; + return { + lines: network.lines.map((other, i) => { + if (i === line) return { ...other, vertices }; + const branch = other.branch; + return branch?.line === entry.id && branch.vertex > vertex + ? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } } + : other; + }), + pieces: carryMarks(network.pieces, entry.id, carry), + }; +} + +/** 선 방향 바꿈(측점 No.0 쪽 바꿈) — 조각 · 붙은 갈래 차례 따라감 · 갈래는 못 바꿈(까닭 글). */ +export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | string { + const entry = network.lines[line]; + if (entry.branch) return "갈래는 방향을 바꿀 수 없습니다 — 첫 점이 붙은 선 위입니다."; + const n = entry.vertices.length; + const length = round3(lineLength(entry.vertices)); + return { + lines: network.lines.map((other, i) => { + if (i === line) return { ...other, vertices: [...other.vertices].reverse() }; + const branch = other.branch; + return branch?.line === entry.id + ? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } } + : other; + }), + pieces: network.pieces.map((piece) => + piece.line === entry.id + ? { ...piece, from_m: round3(length - piece.to_m), to_m: round3(length - piece.from_m) } + : piece, + ), + }; +} + +/** 선 지움 — 그 선 조각도 · 갈래가 붙어 있으면 못 지움(까닭 글). */ +export function removeLine(network: PlanNetwork, line: number): PlanNetwork | string { + const id = network.lines[line]?.id; + if (network.lines.some((other) => other.branch?.line === id)) + return "갈래가 붙은 선은 지울 수 없습니다 — 갈래를 먼저 지우세요."; + return { + lines: network.lines.filter((_, i) => i !== line), + pieces: network.pieces.filter((piece) => piece.line !== id), + }; +} + +/** `a` 선 꼬리에 `b` 선(머리 = a 꼬리)을 이어 한 선 — 조각 id 그대로 · 이음 자리 두 조각이 같은 해면 하나로. */ +function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork { + const [head, tail] = [network.lines[a], network.lines[b]]; + const offset = round3(lineLength(head.vertices)); + const shift = head.vertices.length - 1; + const last = network.pieces + .filter((piece) => piece.line === head.id) + .sort((x, y) => y.from_m - x.from_m)[0]; + const pieces = network.pieces + .map((piece) => + piece.line === tail.id + ? { + ...piece, + line: head.id, + from_m: round3(piece.from_m + offset), + to_m: round3(piece.to_m + offset), + } + : piece, + ) + .filter( + (piece) => + !( + piece.line === head.id && + Math.abs(piece.from_m - offset) < SAME_M && + last?.year === piece.year + ), + ); + const lines = network.lines + .map((line, i) => { + if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] }; + const branch = line.branch; + return branch?.line === tail.id + ? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } } + : line; + }) + .filter((_, i) => i !== b); + return { lines: orderLines(lines), pieces }; +} + +/** 갈래 없는 선 머리가 다른 선 꼬리에 닿으면 이어 붙이고 · 꺾임점에 닿으면 그 점의 갈래로(제 갈래 빼고). */ +export function attachHead(network: PlanNetwork, line: number): PlanNetwork { + const entry = network.lines[line]; + if (!entry || entry.branch || entry.vertices.length < 2) return network; + const head = entry.vertices[0]; + const own = family(network, entry.id); + const others = network.lines + .map((other, index) => ({ other, index })) + .filter(({ other }) => !own.has(other.id) && other.vertices.length > 0); + const tail = others.find(({ other }) => + samePoint(other.vertices[other.vertices.length - 1], head), + ); + if (tail) return joinLines(network, tail.index, line); + for (const { other } of others) { + const vertex = other.vertices.findIndex((v) => samePoint(v, head)); + if (vertex < 0) continue; + const lines = network.lines.map((entry, i) => + i === line ? { ...entry, branch: { line: other.id, vertex } } : entry, + ); + return { ...network, lines: orderLines(lines) }; + } + return network; +} + +/** 선 통째 연도 — 그 선 조각 모두. */ +export function setLineYear(network: PlanNetwork, line: string, year: number): PlanNetwork { + return { + ...network, + pieces: network.pieces.map((piece) => (piece.line === line ? { ...piece, year } : piece)), + }; +} + +/** 마크 옮김 — `index` 조각의 시작(= 앞 조각 끝)을 `atM` 으로 · 양쪽 조각 안에서 `MARK_GAP_M` 남김. */ +export function moveMark(network: PlanNetwork, index: number, atM: number): PlanNetwork { + const piece = network.pieces[index]; + if (!piece || !(piece.from_m > 0)) return network; + const before = network.pieces.find( + (p) => p !== piece && p.line === piece.line && Math.abs(p.to_m - piece.from_m) < SAME_M, + ); + if (!before) return network; + const at = round3(Math.min(piece.to_m - MARK_GAP_M, Math.max(before.from_m + MARK_GAP_M, atM))); + return { + ...network, + pieces: network.pieces.map((p) => + p === piece ? { ...p, from_m: at } : p === before ? { ...p, to_m: at } : p, + ), + }; } diff --git a/D04_PlanConfirm/D04_PlanConfirm_Repository.py b/D04_PlanConfirm/D04_PlanConfirm_Repository.py index 91b34d073..bff78aaa7 100644 --- a/D04_PlanConfirm/D04_PlanConfirm_Repository.py +++ b/D04_PlanConfirm/D04_PlanConfirm_Repository.py @@ -41,8 +41,24 @@ async def list_year_routes(project_id: str) -> list[dict[str, Any]]: return rows +def line_names(lines: list[dict[str, Any]]) -> list[str]: + """선 이름 — 갈래 없는 선 = 본선(둘 이상이면 본선 1 · 2 …) · 갈래 = 갈래 1 · 2 …(선 차례). + + ⚠ 짝 = TS `D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts` `lineNames` — 거울 시험 530-3.""" + roots = sum(1 for line in lines if not line.get("branch")) + out, root, branch = [], 0, 0 + for line in lines: + if line.get("branch"): + branch += 1 + out.append(f"갈래 {branch}") + else: + root += 1 + out.append(f"본선 {root}" if roots > 1 else "본선") + return out + + async def list_pieces(project_id: str) -> list[dict[str, Any]]: - """조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(본선 · 갈래 N = 정본 선 차례).""" + """조각 행(연도 · 선 차례 · 선 위 거리 차례) + `line_name`(`line_names` = 정본 선 차례).""" async with ( get_db_pool().acquire() as connection, connection.cursor(aiomysql.DictCursor) as cursor, @@ -61,13 +77,13 @@ async def list_pieces(project_id: str) -> list[dict[str, Any]]: if network: raw = network["network_json"] body = json.loads(raw) if isinstance(raw, str | bytes) else raw - lines = [line["id"] for line in (body or {}).get("lines", [])] - order = {line: i for i, line in enumerate(lines)} + lines = (body or {}).get("lines", []) + order = {line["id"]: i for i, line in enumerate(lines)} + names = dict(zip(order, line_names(lines))) for row in rows: raw = row["route_json"] row["route_json"] = json.loads(raw) if isinstance(raw, str | bytes) else raw - index = order.get(row["line_id"], len(order)) - row["line_name"] = "본선" if index == 0 else f"갈래 {index}" + row["line_name"] = names.get(row["line_id"], "갈래") rows.sort(key=lambda r: (r["year"], order.get(r["line_id"], len(order)), r["from_m"])) return rows diff --git a/resources/tester/test_530_3_plan_logic.py b/resources/tester/test_530_3_plan_logic.py index 727ea3077..6cd187ea7 100644 --- a/resources/tester/test_530_3_plan_logic.py +++ b/resources/tester/test_530_3_plan_logic.py @@ -1,7 +1,9 @@ -"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1) — TS `logic/D03_PlanAnalysis_Plan.ts`. +"""D03 손 긋기 꼴 다루기(PLAN 562-3 · 563-1 · 580) — TS `logic/D03_PlanAnalysis_Plan.ts`. -조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · 서버 꼴 읽기 · -화면이 보내는 꼴(선 · 마크 · 조각)을 서버 셈(`D03_PlanAnalysis_Network.derive`)이 그대로 받음. +조각이 선을 빈틈없이 덮음 · 마크 놓기 · 지우기 · 갈래 차례 밀기 · 갈래 붙은 점 못 뺌 · +서버 꼴 읽기 · 화면 꼴(선 · 마크 · 조각)을 서버 셈(`derive`)이 그대로 받음 · +580 = 종점에서 그은 선 뒤집어 잇기 · 갈래로 붙음 · 꺾임점 옮김 · 지움(마크 비율) · +선 지움 · 방향 바꿈 · 선 통째 연도 · 마크 옮김 · 선 이름 거울(D04 `line_names`). ./venv/Scripts/python.exe -m pytest resources/tester/test_530_3_plan_logic.py -q """ @@ -19,6 +21,7 @@ if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) from D03_PlanAnalysis import D03_PlanAnalysis_Network as server # noqa: E402 +from D04_PlanConfirm.D04_PlanConfirm_Repository import line_names # noqa: E402 TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Plan.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" @@ -27,7 +30,8 @@ _RUNNER = """ import { writeFileSync } from "node:fs"; import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; const Y = 2026; -let n = plan.emptyNetwork([0, 0]); +const empty = plan.emptyNetwork(); +let n = plan.newLine(empty, [0, 0]); n = plan.appendVertex(n, 0, [100, 0]); n = plan.appendVertex(n, 0, [100, 100]); n = plan.normalizePieces(n, Y); @@ -52,10 +56,57 @@ const read = plan.fromServer( [{ id: "p9", line: "main", from_m: 0, to_m: 50, year: 2028 }], Y, ); +// 580 — 본선(0,0)→(100,0) 반쯤 · 종점(200,0)에서 거꾸로 그은 선이 본선 꼬리에 닿음 → 한 선 +let j = plan.normalizePieces(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), Y); +j = plan.newLine(j, [200, 0]); +j = plan.appendVertex(j, 1, [150, 50]); +j = plan.appendVertex(j, 1, [100, 0]); +j = plan.normalizePieces(j, 2027); +const joined = plan.normalizePieces(plan.attachHead(plan.reverseLine(j, 1), 1), Y); +const sameYear = plan.normalizePieces( + plan.attachHead(plan.reverseLine(plan.setLineYear(j, j.lines[1].id, Y), 1), 1), + Y, +); +// 종점에서 그은 선이 본선 꺾임점에 닿음 → 그 점의 갈래(선 차례 = 부모 뒤) +let b = plan.normalizePieces( + plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [200, 0]), + Y, +); +b = plan.newLine(b, [100, 80]); +b = plan.appendVertex(b, 1, [100, 0]); +const asBranch = plan.attachHead(plan.reverseLine(b, 1), 1); +// 꺾임점 옮김 · 지움 — 마크 비율 · 붙은 갈래 첫 점 +let m = plan.normalizePieces( + plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]), + Y, +); +m = plan.normalizePieces(plan.splitPiece(m, "main", 150), Y); +m = plan.normalizePieces(plan.appendVertex(plan.addBranch(m, 0, 1), 1, [160, 0]), Y); +const movedV = plan.normalizePieces(plan.moveVertex(m, 0, 1, [100, -100]), Y); +let r = plan.normalizePieces( + plan.appendVertex(plan.appendVertex(plan.newLine(empty, [0, 0]), 0, [100, 0]), 0, [100, 100]), + Y, +); +r = plan.normalizePieces(plan.splitPiece(r, "main", 150), Y); +const removedV = plan.normalizePieces(plan.removeVertex(r, 0, 1), Y); +const removeBlocked = plan.removeVertex(m, 0, 1); +const lineBlocked = plan.removeLine(m, 0); +const lineGone = plan.normalizePieces(plan.removeLine(m, 1), Y); +const reversed = plan.normalizePieces(plan.reverseLine(m, 0), Y); +const branchFlip = plan.reverseLine(m, 1); +const lineYear = plan.setLineYear(m, "main", 2029); +const markMoved = plan.normalizePieces(plan.moveMark(m, 1, 120), Y); +const markClamp = plan.normalizePieces(plan.moveMark(m, 1, 500), Y); +const names = [ + plan.lineNames([{ branch: null }]), + plan.lineNames([{ branch: null }, { branch: null }, { branch: { line: "main", vertex: 1 } }]), + plan.lineNames([{ branch: null }, { branch: { line: "main", vertex: 1 } }]), +]; writeFileSync(process.argv[2], JSON.stringify({ one, split, tooClose, branched, inserted, blocked, dropped, guard, removed, grown, read, marks: plan.marksOf(branched), - reached: plan.reachedEnd(n, [100, 100]), + empty, joined, sameYear, asBranch, m, movedV, removedV, removeBlocked, lineBlocked, lineGone, + reversed, branchFlip, lineYear, markMoved, markClamp, names, })); """ @@ -111,7 +162,7 @@ def test_pieces_cover_and_split(tmp_path: Path) -> None: assert got["tooClose"] == got["split"] # 마크 1 m 안 = 안 놓음 assert _spans(got["removed"]) == [(0, 200, 2026)] # 마크 지움 = 앞 조각 연도 assert _spans(got["grown"]) == [(0, 50, 2026), (50, 250, 2027)] # 선이 늘면 끝 조각이 늚 - assert got["reached"] is True + assert got["empty"] == {"lines": [], "pieces": []} # 빈 꼴 = 선 없음(시점 · 종점에서 시작) def test_branch_order_and_guard(tmp_path: Path) -> None: @@ -144,3 +195,71 @@ def test_server_accepts_screen_body(tmp_path: Path) -> None: ("main", 50.0, 200.0, 2027), ("branch-1", 0.0, 60.0, 2026), ] + + +def test_reversed_stroke_joins_or_branches(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + joined = got["joined"] + # 종점에서 그은 선 = 뒤집혀 본선 꼬리에 이어 한 선 · No.0 = 시점 쪽 · 다른 해면 이음 자리 마크 + assert len(joined["lines"]) == 1 + assert joined["lines"][0]["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0]] + total = 100 + 2 * (50**2 * 2) ** 0.5 + spans = _spans(joined) + assert spans[0] == (0, 100, 2026) and spans[1][0] == 100 and spans[1][2] == 2027 + assert abs(spans[1][1] - total) < 1e-3 + assert len(got["sameYear"]["pieces"]) == 1 # 같은 해면 이음 자리 마크 없음 + branch = got["asBranch"]["lines"][1] + assert branch["branch"] == {"line": "main", "vertex": 1} + assert branch["vertices"] == [[100, 0], [100, 80]] + body = {**got["asBranch"], "marks": [], "pieces": [], "epsg": 5186} + server.derive(body, 2026) # 서버가 그대로 받음(갈래 첫 점 = 부모 점) + body = {**joined, "marks": [], "epsg": 5186} + body["marks"] = [ + {"line": p["line"], "at_m": p["from_m"]} for p in joined["pieces"] if p["from_m"] > 0 + ] + assert len(server.derive(body, 2026)["pieces"]) == 2 + + +def test_vertex_move_remove_keeps_marks(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + # 본선 (0,0)-(100,0)-(100,100) · 마크 150 m(둘째 구간 반) · 갈래 = 꺾임점 1 에서 (160,0) + moved = got["movedV"] + assert moved["lines"][0]["vertices"][1] == [100, -100] + assert moved["lines"][1]["vertices"][0] == [100, -100] # 붙은 갈래 첫 점 같이 + first = (100**2 + 100**2) ** 0.5 + mark = [p for p in moved["pieces"] if p["line"] == "main"][1]["from_m"] + assert abs(mark - (first + 100)) < 1e-3 # 둘째 구간(길이 200) 반 + removed = got["removedV"] + assert removed["lines"][0]["vertices"] == [[0, 0], [100, 100]] + share = 0.75 * (2 * 100**2) ** 0.5 # 합친 구간(200 m) 안 150 m = 3/4 + assert abs(_spans(removed)[1][0] - share) < 1e-3 + assert "갈래가 붙은 점" in got["removeBlocked"] + assert "갈래가 붙은 선" in got["lineBlocked"] + assert [line["id"] for line in got["lineGone"]["lines"]] == ["main"] + assert all(p["line"] == "main" for p in got["lineGone"]["pieces"]) + assert "갈래는 방향을" in got["branchFlip"] + + +def test_reverse_year_mark_move(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + rev = got["reversed"] + assert rev["lines"][0]["vertices"] == [[100, 100], [100, 0], [0, 0]] + assert rev["lines"][1]["branch"] == {"line": "main", "vertex": 1} # 3 − 1 − 1 + assert _spans(rev) == [(0, 50, 2026), (50, 200, 2026)] + assert {p["year"] for p in got["lineYear"]["pieces"] if p["line"] == "main"} == {2029} + assert _spans(got["markMoved"]) == [(0, 120, 2026), (120, 200, 2026)] + assert _spans(got["markClamp"]) == [(0, 199, 2026), (199, 200, 2026)] # 끝에서 1 m 남김 + body = {**rev, "marks": [{"line": "main", "at_m": 50}], "epsg": 5186} + server.derive(body, 2026) + + +def test_line_names_mirror(tmp_path: Path) -> None: + got = _run_ts(tmp_path) + cases = [ + [{"branch": None}], + [{"branch": None}, {"branch": None}, {"branch": {"line": "main", "vertex": 1}}], + [{"branch": None}, {"branch": {"line": "main", "vertex": 1}}], + ] + assert got["names"] == [line_names(c) for c in cases] + assert got["names"][1] == ["본선 1", "본선 2", "갈래 1"] # 시점 둘 = 둘째도 본선 + assert got["names"][2] == ["본선", "갈래 1"] # 옛 꼴 이름 그대로 diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 667385454..66b7473d8 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -47,7 +47,8 @@ def test_d03_parts(): assert '"rotate-ccw", "rotate-cw"' in tools for b05_only in ("footprint", "station-snap", "contour-minor"): assert f'"{b05_only}"' not in tools, b05_only - assert "map.stations" in draw and "map.slopes" in draw + paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔 + assert "map.stations" in paint and "map.slopes" in paint # 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억 assert "createToolbar({" in layers and "createIconButton({" in layers assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers