feat(B05): 계획노선 편집 — 예상노선 거리 재기 · 측점 눈금 라벨 비켜 놓기 · 횡단 반폭 선

- 거리·기울기 재기가 예상노선(점선)에서도 됨 — 두 점은 한 노선 위 · 다른 노선을 짚으면 새로 시작
- 측점 눈금 라벨이 겹치면 빼지 않고 비켜 놓음(반대쪽 → 한 칸 더 멀리 · 가는 선으로 이음) — 공용 drawStationTicks
- 오른쪽에 횡단이 뜬 측점에 좌·우 설계 반폭 선 — 노드를 끄는 동안 브라우저 셈으로 바로 따라감
- 측점 틀 파이썬·TS 짝(_interpolate_xy·_tangent_at ↔ stationFrameAt) + 거울 시험

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014WeXB1zh5hbYLgoeGS5kQu
This commit is contained in:
2026-09-29 09:41:39 +09:00
co-authored by Claude Opus 5.5
parent 071676e0b8
commit b2cbd852a0
9 changed files with 424 additions and 56 deletions
@@ -194,6 +194,9 @@ export function drawRidgeRing(
* 으로 밀어 마커를 가리지 않게 한다. B04 지도와 B05 배수유역도가 이 한 곳을 함께 쓴다.
* -------------------------------------------------------------------------- */
/** 측점 라벨을 눈금에서 띄우는 거리(px) — 앞이 기본 자리, 뒤로 갈수록 비켜 놓는 자리. */
const LABEL_REACH_PX = [16, 32, 48, 64, 80];
export interface StationTickOptions {
/** 규칙 측점 간격(m). */
intervalM: number;
@@ -234,8 +237,8 @@ export function drawStationTicks(
context.font = "11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "middle";
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다 — 이미 그린 라벨과
// 겹치는 자리는 건너뛴다(2026-09-04 실측에서 4px 간격까지 붙었다).
// 노선이 되꺾이면 멀쩡한 배율에서도 두 측점이 화면에서 붙는다(2026-09-04 실측 4px) —
// 이미 그린 라벨과 겹치는 자리는 비켜 놓는다.
const drawn: Array<{ x: number; y: number; half: number }> = [];
let cursor = 1;
for (let chainage = 0; chainage <= total; chainage += interval) {
@@ -268,14 +271,33 @@ export function drawStationTicks(
context.stroke();
const label = stationLabel(chainage, interval);
const lx = sx + ux * side * 16;
const ly = sy + uy * side * 16;
const width = context.measureText(label).width + 6;
const half = width / 2;
const collides = drawn.some(
(item) => Math.abs(item.x - lx) < item.half + half && Math.abs(item.y - ly) < 16,
);
if (collides) continue;
// 겹치면 빼지 않고 **비켜 놓는다** — 직각 방향으로 반대쪽 → 한 칸 더 멀리 순서로 빈자리를
// 찾는다. 끝내 못 찾으면 첫 자리에 그대로 둔다(눈금마다 이름이 있어야 한다).
const free = (x: number, y: number): boolean =>
!drawn.some((item) => Math.abs(item.x - x) < item.half + half && Math.abs(item.y - y) < 16);
let reach = LABEL_REACH_PX[0] * side;
for (const distance of LABEL_REACH_PX) {
const found = [side, -side].find((way) =>
free(sx + ux * way * distance, sy + uy * way * distance),
);
if (found !== undefined) {
reach = distance * found;
break;
}
}
const lx = sx + ux * reach;
const ly = sy + uy * reach;
if (Math.abs(reach) > LABEL_REACH_PX[0]) {
// 멀리 비켜 선 라벨은 어느 눈금 것인지 가는 선으로 잇는다.
context.beginPath();
context.moveTo(sx + ux * Math.sign(reach) * 6, sy + uy * Math.sign(reach) * 6);
context.lineTo(lx - ux * Math.sign(reach) * 8, ly - uy * Math.sign(reach) * 8);
context.lineWidth = 0.8;
context.strokeStyle = "rgba(40, 40, 40, 0.55)";
context.stroke();
}
drawn.push({ x: lx, y: ly, half });
context.save();
if (options.uprightRad) {