feat(B05): 계획노선 편집 창 — 측점 라벨 · 호 고르기 판정 함수(22-2 · 22-3 1차)

- 측점 라벨 글자 상자를 그릴 때 기록(drawStationTicks 의 onLabel · B04 지도 · 배수유역도 동작 그대로)
- stationAtScreen 이 눈금 다음 라벨 상자를 봄 · 솎여 안 보이는 눈금은 안 잡음
- 곡선 호 점열(curveArcVertices)과 점-호 판정(arcAtScreen) 더함
- 시험 resources/tester/test_b05_routeedit_pick.py 8건
- RouteEdit.ts 배선은 sub6 나누기 뒤 새 파일에 잇는 다음 커밋

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr
This commit is contained in:
2026-09-29 19:32:57 +09:00
co-authored by Claude Sonnet 5.5
parent eb387d5f84
commit 90194b2849
6 changed files with 369 additions and 2 deletions
@@ -208,6 +208,18 @@ export interface StationTickOptions {
/** 돌린 지도에서 **글자만 되돌려 세울** 각(라디안). 0이면 그림과 함께 돈다.
* 눈금 막대는 노선에 직각이라 함께 돌아야 맞고, 숫자만 눈높이로 세운다. */
uprightRad?: number;
/** 라벨을 그릴 때마다 그 글자 상자를 알린다 — 누름 판정이 쓴다. 그림 좌표(회전 전)이고,
* 상자는 `uprightRad` 만큼 (x, y) 를 축으로 돌아 있다. 안 주면 아무 일도 안 한다. */
onLabel?: (box: StationLabelBox) => void;
}
/** 그려진 측점 라벨 글자 상자 — 가운데 (x, y) · 가로 반폭 `half` · 세로 반폭 8 · 돌린 각. */
export interface StationLabelBox {
chainage: number;
x: number;
y: number;
half: number;
uprightRad: number;
}
export function drawStationTicks(
@@ -299,6 +311,7 @@ export function drawStationTicks(
context.stroke();
}
drawn.push({ x: lx, y: ly, half });
options.onLabel?.({ chainage, x: lx, y: ly, half, uprightRad: options.uprightRad ?? 0 });
context.save();
if (options.uprightRad) {
context.translate(lx, ly);
@@ -172,6 +172,30 @@ function arcPoints(center: Vertex, start: Vertex, end: Vertex, clockwise: boolea
return points;
}
/** 곡선의 호를 점열로 — 시작점 · 원호 위 점 · 끝점. 그려지는 선(`buildEditedPolyline`)과 같은
* 5° 걸음이라 「호를 눌렀나」 판정이 눈에 보이는 선과 어긋나지 않는다. 호는 늘 180° 미만이라
* 두 접선점 사이 **짧은 쪽**으로 돈다(도는 방향을 따로 안 받는다). */
export function curveArcVertices(curve: EditedCurve): Vertex[] {
const toStart = unit(curve.apex, curve.start);
const toEnd = unit(curve.apex, curve.end);
const bisector: Vertex = [toStart[0] + toEnd[0], toStart[1] + toEnd[1]];
const bisectorLength = Math.hypot(bisector[0], bisector[1]);
const half = Math.sin((curve.inner_angle_deg * RAD_PER_DEG) / 2);
if (bisectorLength <= 1e-9 || !(half > 1e-9)) return [curve.start, curve.end];
const centerDistance = curve.radius_m / half;
const center: Vertex = [
curve.apex[0] + (bisector[0] / bisectorLength) * centerDistance,
curve.apex[1] + (bisector[1] / bisectorLength) * centerDistance,
];
const sweep = Math.atan2(
(curve.start[0] - center[0]) * (curve.end[1] - center[1]) -
(curve.start[1] - center[1]) * (curve.end[0] - center[0]),
(curve.start[0] - center[0]) * (curve.end[0] - center[0]) +
(curve.start[1] - center[1]) * (curve.end[1] - center[1]),
);
return [curve.start, ...arcPoints(center, curve.start, curve.end, sweep < 0), curve.end];
}
/** 노드 하나의 요약 — 화면이 붉은 점·내각·R 을 그리는 재료(서버 `RouteNode` 와 같은 꼴). */
export interface EditedNode {
inner_angle_deg: number | null;
@@ -15,6 +15,8 @@
import { computeMaxScale, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { StationLabelBox } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import { curveArcVertices, type EditedCurve } from "./B05_Profile_UI_RouteEdit_Curve";
/** 배수유역도와 같은 값 — 한 번에 얼마나 확대·축소할지. */
const ZOOM_IN_FACTOR = 1.15;
@@ -189,6 +191,41 @@ export function handleAtScreen(
return best;
}
/** 호(곡선 부분)를 눌렀다고 볼 거리(px) — 노선 선 굵기보다 넉넉히. */
export const ARC_HIT_PX = 8;
/** 화면 좌표에 가장 가까운 **호**를 가진 곡선의 노드 번호(`node_first`). 문턱 밖이면 -1.
*
* 호는 그려지는 선과 같은 점열(`curveArcVertices`)을 화면으로 옮겨 선분 거리로 잰다.
* 손잡이(접선점)는 호의 양 끝이라 이 판정에도 걸린다 — 손잡이를 먼저 보는 것은 부르는 쪽 몫. */
export function arcAtScreen(
curves: ReadonlyArray<EditedCurve>,
toScreen: ScreenOf,
px: number,
py: number,
hitPx: number = ARC_HIT_PX,
): number {
let best = -1;
let bestDistance = hitPx;
for (const curve of curves) {
const arc = curveArcVertices(curve).map(([x, y]) => toScreen([x, y]));
for (let index = 0; index < arc.length - 1; index += 1) {
const [ax, ay] = arc[index];
const [bx, by] = arc[index + 1];
const dx = bx - ax;
const dy = by - ay;
const lengthSquared = dx * dx + dy * dy || 1;
const t = Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / lengthSquared));
const distance = Math.hypot(ax + t * dx - px, ay + t * dy - py);
if (distance <= bestDistance) {
bestDistance = distance;
best = curve.node_first;
}
}
}
return best;
}
/** 두 노드 사이 선분 중 클릭에 가장 가까운 것 — 새 노드를 끼울 자리. 없으면 -1. */
export function segmentAtScreen(
points: Array<[number, number]>,
@@ -275,6 +312,59 @@ export function stationAtScreen(
px: number,
py: number,
maxPx: number,
labels: ReadonlyArray<StationLabelBox> = [],
): number | null {
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
const shown = labels.length
? new Set(labels.map((box) => Math.round(box.chainage / intervalM)))
: null;
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown);
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
return tick ?? stationLabelAtScreen(labels, px, py);
}
/** 그려 둔 측점 라벨 글자 상자 — 그릴 때마다 `Render` 가 새로 적는다. 창(캔버스)마다 따로. */
const labelBoxes = new WeakMap<HTMLCanvasElement, ReadonlyArray<StationLabelBox>>();
export const setStationLabelBoxes = (
canvas: HTMLCanvasElement,
boxes: ReadonlyArray<StationLabelBox>,
): void => void labelBoxes.set(canvas, boxes);
export const stationLabelBoxes = (canvas: HTMLCanvasElement): ReadonlyArray<StationLabelBox> =>
labelBoxes.get(canvas) ?? [];
/** 라벨 글자 상자 안을 눌렀나 — 상자는 가운데를 축으로 `uprightRad` 만큼 돌아 있어, 눌린 자리를
* 거꾸로 돌려 세운 뒤 가로 반폭 · 세로 반폭(8) 안인지 본다. 여럿이면 가운데가 가까운 것. */
export function stationLabelAtScreen(
labels: ReadonlyArray<StationLabelBox>,
px: number,
py: number,
): number | null {
let best: number | null = null;
let bestDistance = Infinity;
for (const box of labels) {
const dx = px - box.x;
const dy = py - box.y;
const cos = Math.cos(-box.uprightRad);
const sin = Math.sin(-box.uprightRad);
if (Math.abs(dx * cos - dy * sin) > box.half || Math.abs(dx * sin + dy * cos) > 8) continue;
const distance = Math.hypot(dx, dy);
if (distance < bestDistance) {
bestDistance = distance;
best = box.chainage;
}
}
return best;
}
function stationTickAtScreen(
line: Array<[number, number]>,
toScreen: ScreenOf,
intervalM: number,
px: number,
py: number,
maxPx: number,
shown: ReadonlySet<number> | null,
): number | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
@@ -289,6 +379,7 @@ export function stationAtScreen(
let bestDistance = maxPx;
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += intervalM) {
if (shown && !shown.has(Math.round(chainage / intervalM))) continue;
while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1;
const back = line[cursor - 1];
const front = line[cursor];
@@ -20,9 +20,12 @@ import {
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
drawStationTicks,
type StationLabelBox,
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input";
import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./B05_Profile_UI_RouteEdit_Frame";
import type { MeasureMark } from "./B05_Profile_UI_RouteEdit_Measure";
@@ -442,6 +445,9 @@ function drawStationMarks(
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
): void {
// 라벨 글자 상자는 그릴 때마다 새로 적는다 — 누름 판정(`stationAtScreen`)이 이것을 본다.
const labels: StationLabelBox[] = [];
setStationLabelBoxes(context.canvas, labels);
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
drawStationTicks(
@@ -452,6 +458,7 @@ function drawStationMarks(
pxPerMeter: scene.pxPerMeter,
toScreen: (x, y) => scene.toScreen([x, y]),
uprightRad: scene.uprightRad,
onLabel: (box) => labels.push(box),
},
);
const total = polylineLengthM(line);
@@ -0,0 +1,161 @@
/* 계획노선 편집 창 **측점 라벨 · 호 고르기**(계획서 22-2 · 22-3) 단위검증 헬퍼 —
* `_RouteEdit_Input.ts` · `_RouteEdit_Curve.ts` · B04 `drawStationTicks` 를 트랜스파일해 Node 로 돌린다.
* 브라우저 전용 팔레트·표기 함수는 가짜로 채운다 — 셈만 본다.
*/
const fs = require("fs");
const path = require("path");
const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript"));
const ROOT = path.join(__dirname, "..", "..");
const cache = new Map();
const STUBS = {
"@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback },
"@util/common_util_svg": {
stationLabel: (chainage, interval) => `No.${Math.round(chainage / interval)}`,
},
};
function loadTs(file) {
const full = path.resolve(file);
if (cache.has(full)) return cache.get(full);
const js = ts.transpileModule(fs.readFileSync(full, "utf8"), {
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
}).outputText;
const box = { exports: {} };
cache.set(full, box.exports);
const localRequire = (request) => {
if (STUBS[request]) return STUBS[request];
if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`));
return require(request);
};
new Function("exports", "module", "require", js)(box.exports, box, localRequire);
cache.set(full, box.exports);
return box.exports;
}
const input = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Input.ts"));
const curve = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Curve.ts"));
const overlays = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapOverlays.ts"));
// 화면 = 사업지 m × 2, y 뒤집음(실제 `metricToScreen` 처럼 y 가 화면에서 뒤집힌다).
const toScreen = ([x, y]) => [x * 2, 400 - y * 2];
const near = (a, b, tol) => Math.hypot(a[0] - b[0], a[1] - b[1]) <= tol;
// 노드 세 개 · 가운데 R=30 곡선 하나.
const nodes = [
[0, 0],
[100, 0],
[100, 100],
];
const built = curve.buildEditedPolyline(nodes, [true, true, true], [null, 30, null], 12);
const c = built.curves[0];
// ① 호 점열 == 그려지는 선에서 시작~끝점 사이 조각(거울).
const drawn = built.vertices;
const iStart = drawn.findIndex((v) => near(v, c.start, 1e-9));
const iEnd = drawn.findIndex((v) => near(v, c.end, 1e-9));
const arc = curve.curveArcVertices(c);
const arcMatchesDrawn =
arc.length === iEnd - iStart + 1 && arc.every((v, i) => near(v, drawn[iStart + i], 1e-9));
// ② 호 누름 — 호 가운데 · 호에서 5px 비킨 곳(잡힘) · 곧은 선 · 빈 곳.
const mid = drawn[Math.floor((iStart + iEnd) / 2)];
const [mx, my] = toScreen(mid);
const arcAt = (px, py) => input.arcAtScreen(built.curves, toScreen, px, py);
const pick = {
onArc: arcAt(mx, my),
nearArc: arcAt(mx + 3, my + 3),
farFromArc: arcAt(mx - 60, my + 60),
onStraight: arcAt(...toScreen([50, 0])),
empty: arcAt(5, 5),
noCurves: input.arcAtScreen([], toScreen, mx, my),
};
// ③ 반대로 도는(오른쪽 꺾임) 곡선도 같은 판정.
const built2 = curve.buildEditedPolyline(
[
[0, 0],
[100, 0],
[100, -100],
],
[true, true, true],
[null, 30, null],
12,
);
const mid2 = built2.vertices[Math.floor(built2.vertices.length / 2)];
const pick2 = input.arcAtScreen(built2.curves, toScreen, ...toScreen(mid2));
// ④ 측점 라벨 — 가짜 캔버스로 `drawStationTicks` 를 돌려 상자를 받고, 그린 사각형과 같은지 본다.
const rects = [];
const fakeContext = {
save() {},
restore() {},
beginPath() {},
moveTo() {},
lineTo() {},
stroke() {},
translate() {},
rotate() {},
fillText() {},
fillRect: (x, y, w, h) => rects.push([x, y, w, h]),
measureText: (text) => ({ width: text.length * 6 }),
};
const line = [
{ x: 0, y: 0 },
{ x: 100, y: 0 },
];
const boxes = [];
overlays.drawStationTicks(fakeContext, line, {
intervalM: 20,
pxPerMeter: 4, // 간격 80px → 두 칸에 하나(step 2)
toScreen: (x, y) => toScreen([x, y]),
uprightRad: 0.5,
onLabel: (box) => boxes.push(box),
});
const labelsMatchRects =
boxes.length === rects.length &&
boxes.every(
(box, i) =>
Math.abs(rects[i][0] - (box.x - box.half)) < 1e-9 &&
Math.abs(rects[i][1] - (box.y - 8)) < 1e-9 &&
Math.abs(rects[i][2] - box.half * 2) < 1e-9,
);
// ⑤ 라벨 누름 — 라벨 가운데 · 라벨 밖 · 돌린 상자 모서리 안팎 · 눈금 우선.
const target = boxes[1];
const stationLine = [
[0, 0],
[100, 0],
];
const stationAt = (px, py, labels) =>
input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels);
const rot = (dx, dy, rad) => [
dx * Math.cos(rad) - dy * Math.sin(rad),
dx * Math.sin(rad) + dy * Math.cos(rad),
];
// 돌린 상자의 오른쪽 위 모서리 안쪽(-1px) · 바깥쪽(+3px) — 세워 본 좌표(half, 8)를 상자 각만큼 돌린 자리.
const insideCorner = rot(target.half - 1, 7, target.uprightRad);
const outsideCorner = rot(target.half + 3, 11, target.uprightRad);
const tickScreen = toScreen([40, 0]); // 라벨이 그려진 측점(0 · 40 · 80)의 눈금
const hiddenTickScreen = toScreen([20, 0]); // 솎여 라벨이 없는 측점의 눈금
const label = {
count: boxes.length,
chainages: boxes.map((b) => b.chainage),
onLabel: stationAt(target.x, target.y, boxes),
insideCorner: stationAt(target.x + insideCorner[0], target.y + insideCorner[1], boxes),
outsideCorner: stationAt(target.x + outsideCorner[0], target.y + outsideCorner[1], boxes),
// 눈금 자리를 누르면 다른 측점 라벨 상자와 겹쳐도 눈금 몫이다.
tickBeatsLabel: stationAt(tickScreen[0], tickScreen[1], [
target,
{ chainage: 80, x: tickScreen[0], y: tickScreen[1], half: 30, uprightRad: 0 },
]),
// 안 보이는(솎인) 눈금은 누름을 가로채지 않는다 — 라벨 정보가 없을 때만 전부 본다.
hiddenTick: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], boxes),
hiddenTickNoInfo: stationAt(hiddenTickScreen[0], hiddenTickScreen[1], []),
emptyNoLabels: stationAt(target.x, target.y, []),
emptyElsewhere: stationAt(5, 5, boxes),
};
process.stdout.write(
JSON.stringify({ arcMatchesDrawn, pick, pick2, node: c.node_first, labelsMatchRects, label }),
);
@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
"""계획노선 편집 창 — **측점 라벨 고르기**(계획서 22-2) · **호 고르기**(22-3) 회귀검증.
Node 헬퍼(`helper_b05_routeedit_pick.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다.
"""
import json
import os
import subprocess
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
@pytest.fixture(scope="module")
def results():
proc = subprocess.run(
["node", os.path.join(HERE, "helper_b05_routeedit_pick.cjs")],
capture_output=True,
text=True,
timeout=60,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout)
def test_호_점열은_그려지는_선의_시작_끝_조각과_같다(results):
assert results["arcMatchesDrawn"] is True
def test_호를_누르면_그_곡선_노드_번호(results):
pick = results["pick"]
assert pick["onArc"] == results["node"]
assert pick["nearArc"] == results["node"]
assert results["pick2"] == results["node"] # 반대로 도는 곡선도
def test_곧은_선_빈_곳_곡선_없음은_안_고름(results):
pick = results["pick"]
assert pick["farFromArc"] == -1
assert pick["onStraight"] == -1
assert pick["empty"] == -1
assert pick["noCurves"] == -1
def test_라벨_상자는_실제로_그린_사각형과_같다(results):
assert results["labelsMatchRects"] is True
assert results["label"]["chainages"] == [0, 40, 80]
def test_라벨_글자를_누르면_그_측점(results):
label = results["label"]
assert label["onLabel"] == 40
assert label["insideCorner"] == 40 # 돌린 상자 모서리 안쪽
assert label["outsideCorner"] is None # 모서리 바깥
def test_눈금이_라벨보다_먼저(results):
assert results["label"]["tickBeatsLabel"] == 40
def test_솎여_안_보이는_눈금은_안_잡힘(results):
assert results["label"]["hiddenTick"] is None
assert results["label"]["hiddenTickNoInfo"] == 20 # 라벨 정보가 없으면 예전처럼 전부
def test_라벨이_없거나_빈_곳은_안_고름(results):
label = results["label"]
assert label["emptyNoLabels"] is None
assert label["emptyElsewhere"] is None