feat(D 예정노선): 거리재기 첫 점이 노선 위면 노선 따라 길이 (PLAN 600-2 · 요구 463-23)

- 새 순수 셈 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 14:40:29 +09:00
co-authored by Claude Sonnet 5.5
parent 26c5c0343d
commit 7ae441be67
4 changed files with 308 additions and 18 deletions
+69 -17
View File
@@ -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),
};
+4 -1
View File
@@ -146,7 +146,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise<void> {
yearStart: () => yearStart,
commit,
});
const measure = createPlanMeasure(() => z);
const measure = createPlanMeasure(
() => z,
() => network,
);
const marks = createPlanPoints({
points: () => points,
network: () => network,
@@ -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;
}
+138
View File
@@ -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"