diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index c6bb83bee..66db04074 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -3,12 +3,15 @@ * D03 손 긋기 겹 · 그리기 칸(PLAN 562-3 · 580 · 요구서 389 3장 8~10 · 440 3~7) — 점을 찍어 직선으로 이음. * * 우클릭 메뉴(591-2 · `D03_PlanAnalysis_UI_Points`) = 이 자리에서 시작 — 빈 땅도 새 본선(지점 없이 긋기). - * [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점 · 반대는 [방향 바꾸기]) · + * [긋기] 하나 — 첫 누름 자리로 시작: 지점 = 새 본선(측점 No.0 = 긋기 시작한 지점) · * 선이 이미 놓인 지점 = 그 점의 갈래 · 선 꼬리 = 이어 긋기 · 갈래 없는 선 머리 = 뒤집어 이어 긋고 끝낼 때 * 되돌림 · 선 위 · 꺾임점 = 갈래. 다른 지점 · 다른 선 둘레(SNAP_PX)에 오면 거기 붙고 그 점에서 끝 — - * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래. + * 머리가 다른 선 꼬리에 닿으면 한 선으로 잇고 · 꺾임점에 닿으면 그 점의 갈래 · 본선 꼬리가 다른 구역에 + * 닿아 끝나면 저절로 그 점의 갈래(No.0 = 닿은 점 · 599 · 요구서 463 3장 10). + * 다시 긋기(599-3 · `respan`) = 고른 구간 앞 노드부터 긋고 원래 뒤 노드에 붙으면 끝(속 구간 흐리게 · + * Enter 로 끝내면 취소) — 끝나면 `replaceSpan` 한 번 = 되돌리기 한 걸음. * 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷: - * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 둘 다(양각기 원 위 후보 중 + * 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중 * 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈). * 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 · * 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름. @@ -41,6 +44,7 @@ import { type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; +import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; import { fmt, lineAt, @@ -103,6 +107,8 @@ export interface PlanDraw { stroke: () => { line: number; flip: boolean } | null; constraint: () => { kind: DrawConstraint; lengthM: number; slopePct: number }; setConstraint: (kind: DrawConstraint, lengthM?: number, slopePct?: number) => void; + /** 다시 긋기 시작(599-3 · 598 판 [다시 긋기]) — `lineId` 선 `fromVertex`~`toVertex` · 참 = 시작. */ + respan: (lineId: string, fromVertex: number, toVertex: number) => boolean; } export function createPlanDraw(params: PlanDrawParams): PlanDraw { @@ -116,6 +122,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let kind: DrawConstraint = "free"; let lengthM = DRAW_DEFAULT_LENGTH_M; let slopePct = DRAW_DEFAULT_SLOPE_PCT; + // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 + let span: { line: string; from: number; to: number; through: Vertex[] } | null = null; + const spanLine = (): number => + span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1; // ── 좌측 칸 ───────────────────────────────────────────── const kindFields = createDrawKindFields({ @@ -171,13 +181,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { /** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */ function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null { - if (active === null) return null; - const from = lastOf(active); + const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined; + if (active === null && !spanVertices) return null; + const from = spanVertices + ? (span!.through[span!.through.length - 1] ?? spanVertices[span!.from]) + : lastOf(active!); if (!from) return null; const z = params.heightAt(); const cursor = map.toMetric(px, py); candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : []; - const snap = snapAt(px, py, map); + // 다시 긋기 = 원래 뒤 노드에만 붙음 + const end = spanVertices?.[span!.to]; + const snap = end + ? screenGap(map, end, px, py) <= SNAP_PX + ? { point: end, note: "뒤 노드에 붙음", onLine: null } + : null + : snapAt(px, py, map); let point: Vertex | null = cursor; let note: string | null = null; if (snap) { @@ -227,6 +246,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { samePoint(other.vertices[0], tail), ); if (next >= 0) network = attachHead(network, next); + else if (!entry.branch) { + // 꼬리가 다른 구역 꺾임점에 닿음 = 그 점의 갈래(No.0 = 닿은 점) + const turned = reverseLine(network, line); + const joined = typeof turned === "string" ? turned : attachHead(turned, line); + if (typeof joined !== "string" && joined !== turned) network = joined; + } } if (network !== params.network()) params.commit(network); } @@ -235,6 +260,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { strokeStart = null; preview = null; candidates = []; + span = null; + } + + function respan(lineId: string, from: number, to: number): boolean { + if (!editable) return false; + const index = params.network().lines.findIndex((line) => line.id === lineId); + const blocked = respanBlocked(params.network(), index, from, to); + if (blocked) { + showToast(blocked, "info"); + return false; + } + setMode("draw"); + span = { line: lineId, from, to, through: [] }; + panelState(); + params.onMode(); + return true; } function setMode(next: DrawMode): void { @@ -335,12 +376,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { } function addPoint(): boolean { - if (active === null || !preview) return false; + if ((active === null && !span) || !preview) return false; if (preview.blocked) { showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); return false; } + if (span) { + if (!preview.snapped) return (span.through.push(preview.point), true); + const next = replaceSpan(params.network(), spanLine(), span.from, span.to, span.through); + if (typeof next === "string") showToast(next, "info"); + else params.commit(next); + span = null; + setMode("idle"); + return true; + } const line = active; + if (line === null) return false; const ended = preview.snapped; let next = params.network(); if (preview.onLine) @@ -355,23 +406,41 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { const layer: PlanMapLayer = { draw(context, map) { - const network = params.network(); - paintNetwork(context, map, network, active, params.heightAt(), () => { - const from = active === null ? null : lastOf(active); - if (from && preview) - paintPreview( - context, - map, - from, - preview, - kind === "both" ? { lengthM, candidates } : null, - ); - }); + let network = params.network(); + let stroke = active; + const index = spanLine(); + const dim = span && index >= 0 ? { line: index, from: span.from, to: span.to } : null; + if (span && index >= 0) { + // 다시 긋는 선 = 앞 노드 + 찍은 점(그리는 선 꼴) + const head = network.lines[index].vertices[span.from]; + const temp = { id: "", vertices: [head, ...span.through], branch: null }; + network = { ...network, lines: [...network.lines, temp] }; + stroke = network.lines.length - 1; + } + paintNetwork( + context, + map, + network, + stroke, + params.heightAt(), + () => { + const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1); + if (from && preview) + paintPreview( + context, + map, + from, + preview, + kind === "both" ? { lengthM, candidates } : null, + ); + }, + dim, + ); }, press(px, py, map, button) { if (!editable) return false; if (mode === "draw" && button === 0) { - if (active === null) { + if (active === null && !span) { const started = begin(px, py, map); panelState(); params.onMode(); @@ -413,6 +482,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { }, key(event) { if (mode === "draw" && keyIs(event, "plan.drawFinish")) { + if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info"); setMode("idle"); return true; } @@ -423,6 +493,10 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { setMode("idle"); return true; } + if (mode === "draw" && span && keyIs(event, "plan.drawBack")) { + if (!span.through.pop()) setMode("idle"); + return true; + } if (mode === "draw" && active !== null && keyIs(event, "plan.drawBack")) { const network = params.network(); const step = @@ -440,9 +514,13 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; }, sync() { - if (active !== null && active >= params.network().lines.length) { + if ( + (active !== null && active >= params.network().lines.length) || + (span && spanLine() < 0) + ) { active = null; flip = false; + span = null; mode = "idle"; } panelState(); @@ -456,5 +534,6 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { if (slope !== undefined) slopePct = slope; kindFields.set({ kind, lengthM, slopePct }); }, + respan, }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 0e81c63f5..5332eb23b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -112,7 +112,7 @@ export function lineAt( } /** 그은 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 · (extra = 미리보기). - * `active` = 그리는 선(굵은 보라). */ + * `active` = 그리는 선(굵은 보라) · `dim` = 다시 긋는 구간(599-3 · 흐리게). */ export function paintNetwork( context: CanvasRenderingContext2D, map: ScreenMap, @@ -120,6 +120,7 @@ export function paintNetwork( active: number | null, z: HeightAt, extra: () => void, + dim: { line: number; from: number; to: number } | null = null, ): void { const colors = planColors(); context.lineCap = "round"; @@ -128,13 +129,22 @@ export function paintNetwork( 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(); + const path = (vertices: ReadonlyArray): void => { + context.beginPath(); + vertices.forEach((v, i) => { + const [sx, sy] = map.toScreen(v); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + context.stroke(); + }; + if (dim?.line === index) { + path(line.vertices.slice(0, dim.from + 1)); + path(line.vertices.slice(dim.to)); + context.globalAlpha = 0.3; + path(line.vertices.slice(dim.from, dim.to + 1)); + context.globalAlpha = 1; + } else path(line.vertices); if (map.stations) drawStationTicks( context, diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts new file mode 100644 index 000000000..06b7fd896 --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -0,0 +1,73 @@ +/* ============================================================================= + * D03_PlanAnalysis_Span.ts + * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. + * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · + * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * ========================================================================== */ + +import { lineLength } from "./D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; + +const round3 = (value: number): number => Math.round(value * 1000) / 1000; + +/** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */ +export function respanBlocked( + network: PlanNetwork, + line: number, + from: number, + to: number, +): string | null { + const entry = network.lines[line]; + if (!entry || !(from >= 0 && to > from && to < entry.vertices.length)) + return "다시 그을 구간을 고르세요."; + if ( + network.lines.some( + (o) => o.branch?.line === entry.id && o.branch.vertex > from && o.branch.vertex < to, + ) + ) + return "구간 속 점에 갈래가 붙어 있습니다 — 갈래를 먼저 지우세요."; + return null; +} + +/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */ +export function replaceSpan( + network: PlanNetwork, + line: number, + from: number, + to: number, + through: ReadonlyArray, +): PlanNetwork | string { + const blocked = respanBlocked(network, line, from, to); + if (blocked) return blocked; + const entry = network.lines[line]; + const old = entry.vertices; + const vertices: Vertex[] = [ + ...old.slice(0, from + 1), + ...through.map(([x, y]): Vertex => [x, y]), + ...old.slice(to), + ]; + const shift = through.length - (to - from - 1); + const at = (list: ReadonlyArray, i: number): number => lineLength(list.slice(0, i + 1)); + const [startM, oldEndM, newEndM] = [at(old, from), at(old, to), at(vertices, to + shift)]; + const carry = (m: number): number => + m <= startM + ? m + : m >= oldEndM + ? m - oldEndM + newEndM + : startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM); + return { + lines: network.lines.map((other, i) => { + if (i === line) return { ...other, vertices }; + const branch = other.branch; + return branch?.line === entry.id && branch.vertex >= to + ? { ...other, branch: { ...branch, vertex: branch.vertex + shift } } + : other; + }), + pieces: network.pieces.map((piece) => + piece.line === entry.id + ? { ...piece, from_m: round3(carry(piece.from_m)), to_m: round3(carry(piece.to_m)) } + : piece, + ), + }; +} diff --git a/resources/tester/test_599_3_plan_span.py b/resources/tester/test_599_3_plan_span.py new file mode 100644 index 000000000..2a7a3a83c --- /dev/null +++ b/resources/tester/test_599_3_plan_span.py @@ -0,0 +1,101 @@ +"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`. + +두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 · +마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로. + +./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q +""" + +from __future__ import annotations + +import json +import math +import re +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Span.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +_RUNNER = """ +import { writeFileSync } from "node:fs"; +import * as plan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.js"; +import * as span from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.js"; +const Y = 2026; +let n = plan.newLine(plan.emptyNetwork(), [0, 0]); +for (const p of [[100, 0], [200, 0], [300, 0]]) n = plan.appendVertex(n, 0, p); +n = plan.normalizePieces(n, Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 150), Y); +n = plan.normalizePieces(plan.splitPiece(n, "main", 250), Y); +n = plan.addBranch(n, 0, 2); +n = plan.normalizePieces(plan.appendVertex(n, 1, [200, 80]), Y); +const done = span.replaceSpan(n, 0, 1, 2, [[150, 50]]); +const wide = span.replaceSpan(n, 0, 0, 3, [[150, 50]]); +const bad = span.replaceSpan(n, 0, 2, 2, []); +const straight = span.replaceSpan(n, 0, 0, 2, []); +writeFileSync(process.argv[2], JSON.stringify({ before: n, done, wide, bad, straight })); +""" + + +def _run_ts(tmp_path: Path) -> dict: + out = tmp_path / "js" + subprocess.run( # noqa: S603 — 고정 실행 파일 + [ + "node", + str(TSC), + str(TS_FILE), + "--outDir", + str(out), + "--rootDir", + str(ROOT), + "--module", + "esnext", + "--target", + "es2022", + "--moduleResolution", + "bundler", + "--ignoreConfig", + ], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + for emitted in out.rglob("*.js"): + emitted.write_text( + re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")), + encoding="utf-8", + ) + (out / "runner.mjs").write_text(_RUNNER, encoding="utf-8") + result = tmp_path / "out.json" + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_replace_span(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + done = r["done"] + main = done["lines"][0] + assert main["vertices"] == [[0, 0], [100, 0], [150, 50], [200, 0], [300, 0]] + assert done["lines"][1]["branch"] == {"line": "main", "vertex": 3} # 뒤 갈래 한 칸 밈 + new_span = 2 * math.hypot(50, 50) + pieces = [(p["id"], p["from_m"], p["to_m"]) for p in done["pieces"] if p["line"] == "main"] + before = [p["id"] for p in r["before"]["pieces"] if p["line"] == "main"] + assert [p[0] for p in pieces] == before # 조각 id 그대로 + assert pieces[1][1] == round(100 + new_span / 2, 3) # 구간 속 마크 = 길이 비율 + assert pieces[2][1] == round(250 - 200 + 100 + new_span, 3) # 뒤 마크 = 길이 차 + assert pieces[2][2] == round(300 - 200 + 100 + new_span, 3) + + +def test_blocked_and_straight(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + assert "갈래" in r["wide"] # 속 점(2)에 갈래 + assert "고르세요" in r["bad"] + straight = r["straight"]["lines"][0]["vertices"] + assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게 + assert r["straight"]["lines"][1]["branch"]["vertex"] == 1