From 7ae441be67d89dda5c2ceb92ca1e0768ad1a0cbb Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:40:29 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EA=B1=B0=EB=A6=AC=EC=9E=AC=EA=B8=B0=20=EC=B2=AB?= =?UTF-8?q?=20=EC=A0=90=EC=9D=B4=20=EB=85=B8=EC=84=A0=20=EC=9C=84=EB=A9=B4?= =?UTF-8?q?=20=EB=85=B8=EC=84=A0=20=EB=94=B0=EB=9D=BC=20=EA=B8=B8=EC=9D=B4?= =?UTF-8?q?=20(PLAN=20600-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-23)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 순수 셈 logic/D03_PlanAnalysis_Measure — 같은 선 누가 차 · 갈래 고리 따라 올라가 만남 · 이어지지 않으면 곧은 거리 - 재기 겹 — 첫 누름 선 8 px · 꺾임점 12 px 안이면 노선 따라 · 굵게 그림 · 글 「노선 따라 L m · 측점 · Δh」 - Page.ts 한 줄(노선 넘김) · 새 시험 test_600_2_plan_measure Co-Authored-By: Claude Sonnet 5.5 --- .../D03_PlanAnalysis_UI_Measure.ts | 86 ++++++++--- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 5 +- .../logic/D03_PlanAnalysis_Measure.ts | 97 ++++++++++++ resources/tester/test_600_2_plan_measure.py | 138 ++++++++++++++++++ 4 files changed, 308 insertions(+), 18 deletions(-) create mode 100644 D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts create mode 100644 resources/tester/test_600_2_plan_measure.py diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts index 0102c9d31..fc656d204 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Measure.ts @@ -2,40 +2,72 @@ * D03_PlanAnalysis_UI_Measure.ts * D03 거리재기 겹(PLAN 562-3) — 두 점을 누르면 그 사이 길이(m) · 원지반 기울기(%) · 높이 차(m). * 셋째 누름 = 새로 잼 · Esc = 지움. 지반고 = 받은 지형 `heightAt` · 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`). + * 600-2 — 첫 누름이 노선 위면 「노선 따라」: 둘째 점 = 이어진 노선 위 가장 가까운 자리 · 길이 = 노선 따라 + * (`logic/D03_PlanAnalysis_Measure`) · 첫 누름이 선 밖이면 곧은 거리 그대로. * ========================================================================== */ import { groundSlopePct, segmentLength } from "./logic/D03_PlanAnalysis_Draw"; -import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { + nearestOnRoute, + routeSpan, + type RoutePos, + type RouteSpan, +} from "./logic/D03_PlanAnalysis_Measure"; +import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { planColors, type PlanMapLayer, type ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import { strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; +import { lineAt, strokeCased } from "./D03_PlanAnalysis_UI_DrawPaint"; export interface PlanMeasure { layer: PlanMapLayer; /** 지움(Esc · 끔). */ clear: () => void; - /** 검증 창구 — 잰 값(두 점이 다 있으면). */ - result: () => { lengthM: number; slopePct: number | null; riseM: number | null } | null; + /** 검증 창구 — 잰 값(두 점이 다 있으면) · `alongRoute` = 노선 따라 쟀나. */ + result: () => { + lengthM: number; + slopePct: number | null; + riseM: number | null; + alongRoute: boolean; + } | null; } const fmt = (value: number, digits = 1): string => value.toFixed(digits); const signed = (value: number, digits = 1): string => `${value >= 0 ? "+" : ""}${fmt(value, digits)}`; -export function createPlanMeasure(heightAt: () => (x: number, y: number) => number): PlanMeasure { +export function createPlanMeasure( + heightAt: () => (x: number, y: number) => number, + network: () => PlanNetwork, +): PlanMeasure { let from: Vertex | null = null; let to: Vertex | null = null; let fixed = false; + /** 첫 누름이 노선 위였던 자리 · 둘째 점이 노선 위에 놓인 길. */ + let fromPos: RoutePos | null = null; + let span: RouteSpan | null = null; const measure = (a: Vertex, b: Vertex) => { const z = heightAt(); const rise = z(b[0], b[1]) - z(a[0], a[1]); + const lengthM = span ? span.lengthM : segmentLength(a, b); return { - lengthM: segmentLength(a, b), - slopePct: groundSlopePct(a, b, z), + lengthM, + slopePct: span + ? Number.isFinite(rise) && lengthM > 0 + ? (rise / lengthM) * 100 + : null + : groundSlopePct(a, b, z), riseM: Number.isFinite(rise) ? rise : null, + alongRoute: !!span, }; }; + /** 둘째 점 놓기 — 노선 위 첫 점이면 이어진 노선 위 가까운 자리 · 아니면 누른 자리. */ + const place = (point: Vertex): void => { + const near = fromPos ? nearestOnRoute(network(), point, fromPos) : null; + span = near && fromPos ? routeSpan(network(), fromPos, near) : null; + to = near && span ? near.point : point; + }; + const layer: PlanMapLayer = { draw(context, map: ScreenMap) { if (!from) return; @@ -50,18 +82,33 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb context.strokeStyle = colors.casing; context.stroke(); }; + context.lineCap = "round"; + context.lineJoin = "round"; if (to) { const [bx, by] = map.toScreen(to); context.beginPath(); - context.moveTo(ax, ay); - context.lineTo(bx, by); - strokeCased(context, colors.measure, 2.5, [6, 4]); + if (span) { + span.path.forEach((point, i) => { + const [sx, sy] = map.toScreen(point); + if (i) context.lineTo(sx, sy); + else context.moveTo(sx, sy); + }); + strokeCased(context, colors.measure, 4); + } else { + context.moveTo(ax, ay); + context.lineTo(bx, by); + strokeCased(context, colors.measure, 2.5, [6, 4]); + } dot(ax, ay); dot(bx, by); const { lengthM, slopePct, riseM } = measure(from, to); - const text = - `${fmt(lengthM)} m · ` + - (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · Δh ${signed(riseM ?? 0)} m`); + const rise = riseM === null ? "지형 밖" : `Δh ${signed(riseM)} m`; + const text = span + ? `노선 따라 ${fmt(lengthM)} m · ` + + (span.stations ? `측점 ${fmt(span.stations[0])} ~ ${fmt(span.stations[1])} m · ` : "") + + rise + : `${fmt(lengthM)} m · ` + + (slopePct === null ? "지형 밖" : `${signed(slopePct)} % · ${rise}`); const [mx, my] = [(ax + bx) / 2, (ay + by) / 2 - 14]; map.upright(context, mx, my, () => { context.font = "12px system-ui, sans-serif"; @@ -81,21 +128,26 @@ export function createPlanMeasure(heightAt: () => (x: number, y: number) => numb if (button !== 0) return false; const point = map.toMetric(px, py); if (!from || fixed) { - [from, to, fixed] = [point, null, false]; + // 첫 누름 — 노선 위(선 8 px · 꺾임점 12 px)면 노선 따라 · 아니면 곧은 거리 + const net = network(); + const hit = lineAt(net, px, py, map); + fromPos = hit ? { line: net.lines[hit.line].id, atM: hit.atM } : null; + [from, to, fixed, span] = [hit ? hit.point : point, null, false, null]; } else { - [to, fixed] = [point, true]; + place(point); + fixed = true; } return true; }, hover(px, py, map) { - if (from && !fixed) to = map.toMetric(px, py); + if (from && !fixed) place(map.toMetric(px, py)); }, }; return { layer, clear: () => { - [from, to, fixed] = [null, null, false]; + [from, to, fixed, fromPos, span] = [null, null, false, null, null]; }, result: () => (from && to && fixed ? measure(from, to) : null), }; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index a3c8746c4..51d9328c1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -146,7 +146,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { yearStart: () => yearStart, commit, }); - const measure = createPlanMeasure(() => z); + const measure = createPlanMeasure( + () => z, + () => network, + ); const marks = createPlanPoints({ points: () => points, network: () => network, diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts new file mode 100644 index 000000000..1a017648c --- /dev/null +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.ts @@ -0,0 +1,97 @@ +/* ============================================================================= + * D03_PlanAnalysis_Measure.ts + * D 예정노선 거리재기 「노선 따라」(PLAN 600-2 · 요구 463-23) — 첫 누름이 노선 위면 둘째 누름까지 + * 노선(망)을 따라간 길 · 같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리(`branch`)를 따라 올라가 만나는 점까지. + * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). + * 서로 이어지지 않은 본선끼리 = 길 없음(null) → 부르는 쪽이 곧은 거리로. + * ========================================================================== */ + +import { + distanceAlong, + lineLength, + pointAtDistance, + segmentLength, + sliceLine, +} from "./D03_PlanAnalysis_Draw"; +import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; + +/** 노선 위 한 자리 — 선 id · 그 선 위 누가(m). */ +export interface RoutePos { + line: string; + atM: number; +} + +export interface RouteSpan { + lengthM: number; + /** 두 자리 사이 노선 따라 꺾은선(앞 자리 → 뒤 자리). */ + path: Vertex[]; + /** 같은 선 위일 때만 — 두 누가(m). */ + stations: [number, number] | null; +} + +/** 자리에서 시작해 갈래 고리를 따라 올라간 (선 · 그 선에 들어선 누가) 목록 — 첫 항목 = 자리 자신. */ +function upChain(network: PlanNetwork, pos: RoutePos): RoutePos[] { + const chain: RoutePos[] = [pos]; + const seen = new Set([pos.line]); + let line = network.lines.find((entry) => entry.id === pos.line); + while (line?.branch) { + const parent = network.lines.find((entry) => entry.id === line!.branch!.line); + if (!parent || seen.has(parent.id)) break; + seen.add(parent.id); + chain.push({ + line: parent.id, + atM: lineLength(parent.vertices.slice(0, line.branch.vertex + 1)), + }); + line = parent; + } + return chain; +} + +/** 두 자리 사이 노선 따라 길 — 이어지지 않으면 null. */ +export function routeSpan(network: PlanNetwork, a: RoutePos, b: RoutePos): RouteSpan | null { + const up = upChain(network, a); + const down = upChain(network, b); + const ia = up.findIndex((step) => down.some((other) => other.line === step.line)); + if (ia < 0) return null; + const ib = down.findIndex((step) => step.line === up[ia].line); + const legs: Array<[string, number, number]> = []; + // a → 갈래 첫 점(0 m) → 붙은 선 … 만나는 선 + for (let k = 0; k < ia; k += 1) legs.push([up[k].line, up[k].atM, 0]); + legs.push([up[ia].line, up[ia].atM, down[ib].atM]); + // 만나는 선 → … → b + for (let k = ib - 1; k >= 0; k -= 1) legs.push([down[k].line, 0, down[k].atM]); + const path: Vertex[] = []; + let lengthM = 0; + for (const [id, from, to] of legs) { + const vertices = network.lines.find((entry) => entry.id === id)?.vertices ?? []; + if (vertices.length < 2) continue; + lengthM += Math.abs(to - from); + const piece = sliceLine(vertices, from, to); + if (from > to) piece.reverse(); + for (const point of piece) { + const last = path[path.length - 1]; + if (!last || segmentLength(last, point) > 1e-9) path.push(point); + } + } + const same = ia === 0 && ib === 0; + return { lengthM, path, stations: same ? [a.atM, b.atM] : null }; +} + +/** 점에서 가장 가까운 노선 위 자리 — `from` 과 이어진 선 안에서만. */ +export function nearestOnRoute( + network: PlanNetwork, + point: Vertex, + from: RoutePos, +): (RoutePos & { point: Vertex }) | null { + let best: (RoutePos & { point: Vertex }) | null = null; + let bestGap = Infinity; + for (const line of network.lines) { + if (line.vertices.length < 2) continue; + const atM = distanceAlong(line.vertices, point); + const gap = segmentLength(pointAtDistance(line.vertices, atM), point); + if (gap >= bestGap || !routeSpan(network, from, { line: line.id, atM })) continue; + best = { line: line.id, atM, point: pointAtDistance(line.vertices, atM) }; + bestGap = gap; + } + return best; +} diff --git a/resources/tester/test_600_2_plan_measure.py b/resources/tester/test_600_2_plan_measure.py new file mode 100644 index 000000000..846b5d58c --- /dev/null +++ b/resources/tester/test_600_2_plan_measure.py @@ -0,0 +1,138 @@ +"""D03 거리재기 「노선 따라」 셈(PLAN 600-2 · 요구 463-23) — `logic/D03_PlanAnalysis_Measure.ts`. + +같은 선 = 누가 차 · 갈래 ↔ 본선 = 갈래 고리를 따라 올라가 만나는 점까지 · 이어지지 않은 본선끼리 = null. +TS 를 tsc 로 옮겨 node 로 돌림(서버 · 화면 없이). + +./venv/Scripts/python.exe -m pytest resources/tester/test_600_2_plan_measure.py -q +""" + +from __future__ import annotations + +import json +import re +import subprocess +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Measure.ts" +TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" + +# main 200 m (0,0)→(100,0)→(100,100) · b1 = main 꼭짓점 1 에서 60 m · b2 = b1 끝에서 40 m · other = 이어지지 않은 본선 +NETWORK = { + "pieces": [], + "lines": [ + {"id": "main", "vertices": [[0, 0], [100, 0], [100, 100]], "branch": None}, + {"id": "b1", "vertices": [[100, 0], [160, 0]], "branch": {"line": "main", "vertex": 1}}, + {"id": "b2", "vertices": [[160, 0], [160, 40]], "branch": {"line": "b1", "vertex": 1}}, + {"id": "other", "vertices": [[0, 500], [50, 500]], "branch": None}, + ], +} + +CASES = { + "same": ["routeSpan", NETWORK, {"line": "main", "atM": 40}, {"line": "main", "atM": 150}], + "same_back": ["routeSpan", NETWORK, {"line": "main", "atM": 150}, {"line": "main", "atM": 40}], + "branch_main_after": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 150}, + ], + "branch_main_before": [ + "routeSpan", + NETWORK, + {"line": "b1", "atM": 30}, + {"line": "main", "atM": 60}, + ], + "twig_root": ["routeSpan", NETWORK, {"line": "b2", "atM": 40}, {"line": "main", "atM": 0}], + "twig_twig": ["routeSpan", NETWORK, {"line": "b2", "atM": 10}, {"line": "b1", "atM": 20}], + "apart": ["routeSpan", NETWORK, {"line": "main", "atM": 10}, {"line": "other", "atM": 10}], + "near_branch": ["nearestOnRoute", NETWORK, [130, 5], {"line": "main", "atM": 10}], + "near_other_excluded": ["nearestOnRoute", NETWORK, [20, 490], {"line": "main", "atM": 10}], +} + +_RUNNER = """ +import { readFileSync, writeFileSync } from "node:fs"; +import * as measure from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Measure.js"; +const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); +const out = {}; +for (const [key, [name, ...args]] of Object.entries(cases)) out[key] = measure[name](...args); +writeFileSync(process.argv[3], JSON.stringify(out)); +""" + + +@pytest.fixture(scope="module") +def r(tmp_path_factory: pytest.TempPathFactory) -> dict: + tmp = tmp_path_factory.mktemp("measure") + out = tmp / "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") + job, result = tmp / "job.json", tmp / "out.json" + job.write_text(json.dumps(CASES), encoding="utf-8") + subprocess.run( # noqa: S603 + ["node", str(out / "runner.mjs"), str(job), str(result)], + cwd=str(ROOT), + check=True, + capture_output=True, + ) + return json.loads(result.read_text(encoding="utf-8")) + + +def test_같은_선은_누가_차(r): + assert r["same"]["lengthM"] == pytest.approx(110) + assert r["same"]["stations"] == [40, 150] + assert r["same"]["path"][0] == [40, 0] + assert r["same"]["path"][-1] == [100, 50] + assert r["same_back"]["lengthM"] == pytest.approx(110) + assert r["same_back"]["path"][0] == [100, 50] + + +def test_갈래에서_본선으로(r): + """갈래 30 m 지점 → 갈래 첫 점(30) → 본선 100 m → 150 m(50) = 80 · 본선 60 m 쪽 = 30 + 40 = 70.""" + assert r["branch_main_after"]["lengthM"] == pytest.approx(80) + assert r["branch_main_after"]["stations"] is None + assert r["branch_main_after"]["path"][0] == [130, 0] + assert r["branch_main_before"]["lengthM"] == pytest.approx(70) + + +def test_갈래의_갈래는_고리를_따라_올라감(r): + """b2 끝(40) → b1 끝(160,0)까지 → b1 첫 점(60) → 본선 0 m(100) = 200 m.""" + assert r["twig_root"]["lengthM"] == pytest.approx(200) + # b2 10 m(160,10) → b2 첫 점 10 → b1 20 m(120,0) 까지 40 = 50 + assert r["twig_twig"]["lengthM"] == pytest.approx(50) + + +def test_이어지지_않은_본선은_길_없음(r): + assert r["apart"] is None + + +def test_가까운_자리는_이어진_선_안에서만(r): + assert r["near_branch"]["line"] == "b1" + assert r["near_branch"]["atM"] == pytest.approx(30) + assert r["near_other_excluded"]["line"] == "main" From 9febb63e131c077af274de8faba12cd05b63f15a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Fri, 30 Oct 2026 14:49:21 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(D=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0):=20=EB=85=B8=EB=93=9C=EB=A7=88=EB=8B=A4=20=EA=B8=8B?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=A9=EC=8B=9D=20=C2=B7=20=EA=B0=92=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=E2=80=94=20=EA=BC=AD=EC=A7=93=EC=A0=90=EA=B3=BC=20?= =?UTF-8?q?=EA=B0=99=EC=9D=B4=20=EC=9B=80=EC=A7=81=EC=9E=84=20=C2=B7=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B4=20=EB=85=B8=EB=93=9C=20=EA=B8=B0=EB=A1=9D=20?= =?UTF-8?q?=EB=82=A8=EA=B8=B0=EA=B3=A0=20=EA=B2=BD=EA=B3=A0=20=EA=B3=A0?= =?UTF-8?q?=EB=A6=AC=20(PLAN=20599-2=20=C2=B7=20=EC=9A=94=EA=B5=AC=20463-1?= =?UTF-8?q?1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 점 찍을 때 그때 방식 · 값을 nodes 칸에(594-1 꼴 · 붙은 점 = 자유 · 처음 기록 = 앞 노드 기록 없음) - 끼움 · 더함 · 뺌 · 지움 · 뒤집기(토막 끝 노드 몫 한 칸 밀림) · 잇기 · 다시 긋기(뒤 노드 = 자유)가 기록 길이를 꼭짓점과 맞춤 — [저장] 400 막음 - nodeRecord · nodeOff(길이 1 cm · 기울기 0.1 % 넘게 어긋남) — Span.ts(Plan 700 줄 넘지 않게) · 598 노드 판이 씀 - 시험 599_3 에 기록 따라감 · 어긋남 Co-Authored-By: Claude Opus 5.5 --- D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts | 35 ++++++++--- .../D03_PlanAnalysis_UI_DrawPaint.ts | 14 ++++- .../logic/D03_PlanAnalysis_Plan.ts | 52 +++++++++++++---- .../logic/D03_PlanAnalysis_Span.ts | 48 ++++++++++++--- resources/tester/test_599_3_plan_span.py | 58 ++++++++++++++++++- 5 files changed, 175 insertions(+), 32 deletions(-) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts index 66db04074..7d18054d1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Draw.ts @@ -41,10 +41,11 @@ import { removeLine, reverseLine, samePoint, + type NodeRecord, type PlanNetwork, type Vertex, } from "./logic/D03_PlanAnalysis_Plan"; -import { replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; +import { nodeRecord, replaceSpan, respanBlocked } from "./logic/D03_PlanAnalysis_Span"; import { fmt, lineAt, @@ -122,8 +123,14 @@ 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; + // 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록 + let span: { + line: string; + from: number; + to: number; + through: Vertex[]; + records: NodeRecord[]; + } | null = null; const spanLine = (): number => span ? params.network().lines.findIndex((line) => line.id === span!.line) : -1; @@ -272,7 +279,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return false; } setMode("draw"); - span = { line: lineId, from, to, through: [] }; + span = { line: lineId, from, to, through: [], records: [] }; panelState(); params.onMode(); return true; @@ -381,9 +388,22 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { showToast(preview.note ?? "여기에는 점을 찍을 수 없습니다.", "info"); return false; } + // 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2) + const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct); 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 (!preview.snapped) { + span.through.push(preview.point); + span.records.push(record); + return true; + } + const next = replaceSpan( + params.network(), + spanLine(), + span.from, + span.to, + span.through, + span.records, + ); if (typeof next === "string") showToast(next, "info"); else params.commit(next); span = null; @@ -396,7 +416,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { let next = params.network(); if (preview.onLine) next = insertVertex(next, preview.onLine.line, preview.onLine.segment, preview.point); - params.commit(appendVertex(next, line, preview.point)); + params.commit(appendVertex(next, line, preview.point, record)); if (ended) { showToast(`${preview.note ?? "붙음"} — 긋기 끝`, "success"); setMode("idle"); @@ -494,6 +514,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw { return true; } if (mode === "draw" && span && keyIs(event, "plan.drawBack")) { + span.records.pop(); if (!span.through.pop()) setMode("idle"); return true; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts index 5332eb23b..2e3a0a29a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts @@ -18,6 +18,7 @@ import { type HeightAt, } from "./logic/D03_PlanAnalysis_Draw"; import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan"; +import { nodeOff } from "./logic/D03_PlanAnalysis_Span"; import { planColors, PLAN_STATION_INTERVAL_M, @@ -159,15 +160,22 @@ export function paintNetwork( ); if (map.slopes) segmentLabels(context, map, line.vertices, z); }); - // 꺾임점 + // 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2) context.fillStyle = "#ffffff"; + context.strokeStyle = colors.warn; + context.lineWidth = 2; for (const line of network.lines) - for (const v of line.vertices.slice(1)) { + line.vertices.forEach((v, i) => { + if (!i) return; const [sx, sy] = map.toScreen(v); context.beginPath(); context.arc(sx, sy, 3, 0, Math.PI * 2); context.fill(); - } + if (!nodeOff(line, i, z)) return; + context.beginPath(); + context.arc(sx, sy, 7, 0, Math.PI * 2); + context.stroke(); + }); drawPieces(context, map, network); extra(); } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts index a56e04d06..07266668f 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Plan.ts @@ -2,7 +2,8 @@ * D03_PlanAnalysis_Plan.ts * D 예정노선 2단계 손 긋기 꼴(PLAN 562-3 · 580) — 그은 선 · 갈래 · 조각(구역 × 연도) 다루기 · * 선 뒤집기 · 잇기 · 꺾임점 옮김 · 지움 · 선 지움 · 선 통째 연도 · 마크 옮김 · 여러 조각 연도 · 끌어 고르기 판정(585-2) · - * 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1). + * 갈래 시점 지정 · 갈래 처음 해 = 붙은 자리 구간 해(585-3) · 선 이름 · 노드 기록 꼴 · 이름 검사(594-1) · + * 노드 기록이 꼭짓점과 같이 움직임(599-2 · 기록 만들기 · 어긋남 = `D03_PlanAnalysis_Span`). * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * [저장] 은 서버가 선 · 조각으로 연도 행을 다시 셈(562-2) — 여기 값을 받아 적지 않음. * ========================================================================== */ @@ -21,6 +22,15 @@ export interface NodeRecord { slope_pct: number | null; } +/** 꼭짓점이 바뀐 선 — 기록 있는 선만 `edit` 로 기록도 같이(없는 선 = 그대로 없음). */ +function withNodes( + line: PlanLine, + vertices: Vertex[], + edit: (nodes: Array) => Array, +): PlanLine { + return line.nodes ? { ...line, vertices, nodes: edit([...line.nodes]) } : { ...line, vertices }; +} + /** 그은 선 하나 — `branch` 없음 = 본선(시점 · 종점에서) · 있음 = 갈래(붙은 선 · 꺾임점 차례) · * 꼭짓점 차례 = 측점 차례(첫 점 = No.0) · `name` 없음 = 차례 이름(`lineNames`) · * `nodes` = 꼭짓점마다 기록(같은 길이 · 칸 없음 = 기록 없음 · 594-1). */ @@ -201,7 +211,7 @@ export function insertVertex( if (i === line) { const vertices = [...entry.vertices]; vertices.splice(segment + 1, 0, point); - return { ...entry, vertices }; + return withNodes(entry, vertices, (nodes) => (nodes.splice(segment + 1, 0, null), nodes)); } const branch = entry.branch; return branch && branch.line === id && branch.vertex > segment @@ -211,13 +221,21 @@ export function insertVertex( }; } -/** 선 끝에 점을 더함. */ -export function appendVertex(network: PlanNetwork, line: number, point: Vertex): PlanNetwork { +/** 선 끝에 점을 더함 — `record` = 그 노드 긋기 방식 · 값(599-2 · 처음 기록이면 앞 노드 = 기록 없음). */ +export function appendVertex( + network: PlanNetwork, + line: number, + point: Vertex, + record: NodeRecord | null = null, +): PlanNetwork { return { ...network, - lines: network.lines.map((entry, i) => - i === line ? { ...entry, vertices: [...entry.vertices, point] } : entry, - ), + lines: network.lines.map((entry, i) => { + if (i !== line) return entry; + const vertices = [...entry.vertices, point]; + const nodes = entry.nodes ?? (record ? entry.vertices.map(() => null) : null); + return nodes ? { ...entry, vertices, nodes: [...nodes, record] } : { ...entry, vertices }; + }), }; } @@ -233,7 +251,9 @@ export function dropLastVertex(network: PlanNetwork, line: number): PlanNetwork return { ...network, lines: network.lines.map((other, i) => - i === line ? { ...other, vertices: other.vertices.slice(0, last) } : other, + i === line + ? withNodes(other, other.vertices.slice(0, last), (nodes) => nodes.slice(0, last)) + : other, ), }; } @@ -430,7 +450,8 @@ export function removeVertex( }; return { lines: network.lines.map((other, i) => { - if (i === line) return { ...other, vertices }; + if (i === line) + return withNodes(other, vertices, (nodes) => nodes.filter((_, k) => k !== vertex)); const branch = other.branch; return branch?.line === entry.id && branch.vertex > vertex ? { ...other, branch: { ...branch, vertex: branch.vertex - 1 } } @@ -448,7 +469,10 @@ export function reverseLine(network: PlanNetwork, line: number): PlanNetwork | s const length = round3(lineLength(entry.vertices)); return { lines: network.lines.map((other, i) => { - if (i === line) return { ...other, vertices: [...other.vertices].reverse() }; + if (i === line) + return withNodes(other, [...other.vertices].reverse(), (nodes) => + nodes.map((_, k) => (k ? nodes[n - k] : null)), + ); const branch = other.branch; return branch?.line === entry.id ? { ...other, branch: { ...branch, vertex: n - 1 - branch.vertex } } @@ -502,7 +526,13 @@ function joinLines(network: PlanNetwork, a: number, b: number): PlanNetwork { ); const lines = network.lines .map((line, i) => { - if (i === a) return { ...line, vertices: [...line.vertices, ...tail.vertices.slice(1)] }; + if (i === a) { + const vertices = [...line.vertices, ...tail.vertices.slice(1)]; + if (!line.nodes && !tail.nodes) return { ...line, vertices }; + const nodesOf = (entry: PlanLine): Array => + entry.nodes ?? entry.vertices.map(() => null); + return { ...line, vertices, nodes: [...nodesOf(line), ...nodesOf(tail).slice(1)] }; + } const branch = line.branch; return branch?.line === tail.id ? { ...line, branch: { line: head.id, vertex: branch.vertex + shift } } diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts index 06b7fd896..ad88e9d36 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts +++ b/D03_PlanAnalysis/logic/D03_PlanAnalysis_Span.ts @@ -3,14 +3,35 @@ * D 예정노선 다시 긋기(PLAN 599-3 · 요구 463-12) — 선 하나의 두 꺾임점 사이를 새로 그은 점들로 바꿈. * 끝 두 점(앞 노드 · 뒤 노드) 그대로 · 속 꺾임점에 갈래가 붙었으면 막음(까닭 글) · 뒤 갈래 차례 밈 · * 마크(조각 경계) = 바뀐 구간 안은 길이 비율 · 뒤는 길이 차만큼 · 조각 id 그대로(업체 열쇠). + * 노드 기록(599-2) — 그리기 칸 값 → 기록 · 옮긴 노드 어긋남(⚠) · 다시 그은 속 노드 = 그때 기록 · 뒤 노드 = 자유. * DOM · fetch · sessionStorage 없음 · 길이 셈 = 562-1 짝(`D03_PlanAnalysis_Draw`). * ========================================================================== */ -import { lineLength } from "./D03_PlanAnalysis_Draw"; -import type { PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; +import { groundSlopePct, lineLength, segmentLength, type HeightAt } from "./D03_PlanAnalysis_Draw"; +import type { NodeKind, NodeRecord, PlanLine, PlanNetwork, Vertex } from "./D03_PlanAnalysis_Plan"; const round3 = (value: number): number => Math.round(value * 1000) / 1000; +/** 그리기 칸 방식 · 값 → 노드 기록(안 쓴 값 = null · 599-2). */ +export function nodeRecord(kind: NodeKind, lengthM: number, slopePct: number): NodeRecord { + return { + kind, + length_m: kind === "length" || kind === "both" ? lengthM : null, + slope_pct: kind === "slope" || kind === "both" ? slopePct : null, + }; +} + +/** 노드 기록과 지금 앞 토막이 어긋남(옮긴 노드 ⚠ · 599-2) — 길이 1 cm · |기울기| 0.1 % 넘게 · 지형 밖 = 안 봄. */ +export function nodeOff(line: PlanLine, vertex: number, z: HeightAt): boolean { + const record = line.nodes?.[vertex]; + if (!record || vertex < 1) return false; + const [a, b] = [line.vertices[vertex - 1], line.vertices[vertex]]; + if (record.length_m !== null && Math.abs(segmentLength(a, b) - record.length_m) > 0.01) + return true; + const slope = record.slope_pct === null ? null : groundSlopePct(a, b, z); + return slope !== null && Math.abs(Math.abs(slope) - Math.abs(record.slope_pct!)) > 0.1; +} + /** `line` 째 선 `from`~`to` 꺾임점 사이를 다시 그을 수 있나 — 못 하면 까닭 글. */ export function respanBlocked( network: PlanNetwork, @@ -30,13 +51,15 @@ export function respanBlocked( return null; } -/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. */ +/** `line` 째 선 `from`~`to` 꺾임점 사이 속 점을 `through` 로 바꿈(끝 두 점 그대로) — 막히면 까닭 글. + * `records` = 속 점 기록(`through` 와 같은 길이) · 뒤 노드 기록 = 자유 · 기록 없는 선 + 기록 없음 = 그대로 없음. */ export function replaceSpan( network: PlanNetwork, line: number, from: number, to: number, through: ReadonlyArray, + records: ReadonlyArray = [], ): PlanNetwork | string { const blocked = respanBlocked(network, line, from, to); if (blocked) return blocked; @@ -56,13 +79,22 @@ export function replaceSpan( : m >= oldEndM ? m - oldEndM + newEndM : startM + ((m - startM) / (oldEndM - startM)) * (newEndM - startM); + const branched = (other: PlanLine): PlanLine => + other.branch?.line === entry.id && other.branch.vertex >= to + ? { ...other, branch: { ...other.branch, vertex: other.branch.vertex + shift } } + : other; 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; + if (i !== line) return branched(other); + if (!other.nodes && !records.some(Boolean)) return { ...other, vertices }; + const nodes = other.nodes ?? old.map(() => null); + const inner = through.map((_, k) => records[k] ?? null); + const end = nodeRecord("free", 0, 0); + return { + ...other, + vertices, + nodes: [...nodes.slice(0, from + 1), ...inner, end, ...nodes.slice(to + 1)], + }; }), pieces: network.pieces.map((piece) => piece.line === entry.id diff --git a/resources/tester/test_599_3_plan_span.py b/resources/tester/test_599_3_plan_span.py index 2a7a3a83c..1138265e8 100644 --- a/resources/tester/test_599_3_plan_span.py +++ b/resources/tester/test_599_3_plan_span.py @@ -1,7 +1,8 @@ -"""D03 다시 긋기(PLAN 599-3 · 요구 463-12) — TS `logic/D03_PlanAnalysis_Span.ts`. +"""D03 다시 긋기 · 노드 기록(PLAN 599-2 · 599-3 · 요구 463-11 · 12) — TS `logic/D03_PlanAnalysis_Span.ts` · `_Plan.ts`. 두 꺾임점 사이를 새 점으로 바꿈 · 끝 두 점 그대로 · 뒤 갈래 차례 밈 · 속 점 갈래 = 막음 · -마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로. +마크 = 바뀐 구간 안 길이 비율 · 뒤는 길이 차 · 조각 id 그대로 · 노드 기록 = 꼭짓점과 같은 길이로 따라감 +(더함 · 끼움 · 뺌 · 지움 · 뒤집기 · 잇기 · 다시 긋기) · 옮긴 노드 = 기록 남고 어긋남. ./venv/Scripts/python.exe -m pytest resources/tester/test_599_3_plan_span.py -q """ @@ -34,7 +35,32 @@ 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 })); +// 599-2 노드 기록 +const L = span.nodeRecord("length", 20, 9); +const S = span.nodeRecord("slope", 20, 9); +let r = plan.newLine(plan.emptyNetwork(), [0, 0]); +r = plan.appendVertex(r, 0, [10, 0]); +r = plan.appendVertex(r, 0, [30, 0], L); +r = plan.appendVertex(r, 0, [30, 5], S); +const recorded = r; +const inserted = plan.insertVertex(r, 0, 0, [5, 0]); +const dropped = plan.dropLastVertex(r, 0); +const removed = plan.removeVertex(r, 0, 1); +const reversed = plan.reverseLine(r, 0); +let t = plan.appendVertex(plan.newLine(r, [30, 5]), 1, [40, 5], L); +const joined = plan.attachHead(t, 1); +const moved = plan.moveVertex(r, 0, 2, [33, 0]); +const flat = () => 0; +const off = [1, 2, 3].map((v) => span.nodeOff(moved.lines[0], v, flat)); +const respanned = span.replaceSpan(r, 0, 1, 3, [[20, 9]], [L]); +const plain = span.replaceSpan(n, 0, 1, 2, [[150, 50]]); +writeFileSync( + process.argv[2], + JSON.stringify({ + before: n, done, wide, bad, straight, + recorded, inserted, dropped, removed, reversed, joined, moved, off, respanned, plain, + }), +); """ @@ -99,3 +125,29 @@ def test_blocked_and_straight(tmp_path: Path) -> None: straight = r["straight"]["lines"][0]["vertices"] assert straight == [[0, 0], [200, 0], [300, 0]] # 속 점 없이 곧게 assert r["straight"]["lines"][1]["branch"]["vertex"] == 1 + + +L = {"kind": "length", "length_m": 20, "slope_pct": None} +S = {"kind": "slope", "length_m": None, "slope_pct": 9} +F = {"kind": "free", "length_m": None, "slope_pct": None} + + +def _nodes(network: dict, i: int = 0) -> list: + line = network["lines"][i] + assert len(line.get("nodes") or line["vertices"]) == len(line["vertices"]) # 서버 400 막음 + return line.get("nodes") + + +def test_node_records_follow_vertices(tmp_path: Path) -> None: + r = _run_ts(tmp_path) + assert _nodes(r["recorded"]) == [None, None, L, S] # 첫 기록 = 앞 노드 기록 없음 + assert _nodes(r["inserted"]) == [None, None, None, L, S] + assert _nodes(r["dropped"]) == [None, None, L] + assert _nodes(r["removed"]) == [None, L, S] + assert _nodes(r["reversed"]) == [None, S, L, None] # 토막 끝 노드 몫 = 한 칸 밀림 + assert len(r["joined"]["lines"]) == 1 and _nodes(r["joined"]) == [None, None, L, S, L] + assert _nodes(r["moved"]) == [None, None, L, S] # 옮겨도 기록 남음 + # 2 = 길이 20 기록 · 지금 23 · 3 = 기울기 9 % 기록 · 평지 0 % → 둘 다 어긋남 · 1 = 기록 없음 + assert r["off"] == [False, True, True] + assert _nodes(r["respanned"]) == [None, None, L, F] # 속 점 = 그때 기록 · 뒤 노드 = 자유 + assert "nodes" not in r["plain"]["lines"][0] # 기록 없는 선 = 그대로 없음