feat(B05): 계획노선 편집 창 고정 표시 · 시점 · 종점 고정(PLAN 22-4 · 22-5)

- 고정 한 벌 — 시점 · 종점(기본 고정) · 교각점을 한 칸 줄로 봄 · 저장 · 읽기는 _RouteEdit_Lock.ts(readRouteLocks = 초기노선 계산에 넘길 한 곳)
- 곧은 선은 양 끝 · 호는 교각점과 앞뒤 점이 모두 고정일 때 고정 색(--map-route-locked · 밝음 #db2777 · 어두움 #f472b6) · 고정 점도 같은 색
- 시점 · 종점을 누르면 고정 단추만 뜸 · 풀면 끌어 옮김 · 고정이면 끌기 · 지우기 막힘
- 되돌리기 · [취소] · 다시 열기 · [확인] 뒤에도 고정 유지(pref 통 · 좌표로 되짚음) · [확인] 몸통은 그대로
- 시험 test_b05_routeedit_lock 5건

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt
This commit is contained in:
2026-09-29 19:46:46 +09:00
co-authored by Claude Opus 5.5
parent 5b41cfaf01
commit ea4cb043cd
10 changed files with 387 additions and 20 deletions
+63 -1
View File
@@ -27,9 +27,12 @@ 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";
import { stationLabel as formatStation } from "../common_util/common_util_station";
import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock";
/** 노드 반지름(px). */
const NODE_R = 4;
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */
const LOCKED_WIDTH = 3;
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
*
* 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나
@@ -82,6 +85,8 @@ export interface RouteEditScene {
nodeInfo: ReadonlyArray<EditedNode>;
curveInfo: ReadonlyArray<EditedCurve>;
curveOn: ReadonlyArray<boolean>;
/** 자리를 못 박은 노드 — 0번 = 시점, 끝 칸 = 종점(계획서 22-4 · 22-5). */
locked: ReadonlyArray<boolean>;
/** 지금 고른 꺾임점. 없으면 -1. */
picked: number;
/** 규칙 측점 간격(m). */
@@ -203,6 +208,8 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
style.getPropertyValue("--map-route") || "#f97316",
2.4,
);
const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777";
strokeLockedRoute(context, scene, line, lockedColor);
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
@@ -212,9 +219,12 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
const [x, y] = toScreen(vertex);
// 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정).
const bad = (scene.nodeInfo[index]?.violations?.length ?? 0) > 0;
// 고정한 점은 고정 색 — 기준 미달(붉음)이 먼저다.
context.fillStyle = bad
? style.getPropertyValue("--color-danger") || "#dc2626"
: style.getPropertyValue("--map-route") || "#f97316";
: scene.locked[index]
? lockedColor
: style.getPropertyValue("--map-route") || "#f97316";
context.beginPath();
context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2);
context.fill();
@@ -503,6 +513,58 @@ function endLabel(
context.restore();
}
/**
* 고정한 선 · 호를 고정 색으로 덧그린다(계획서 22-4). 곧은 선은 양 끝 노드가, 호는 그 교각점과
* 앞뒤 노드가 모두 고정일 때다. 곧은 선은 앞뒤 곡선의 접선점 사이, 호는 그려진 노선에서
* 접선점 사이 정점을 잘라 쓴다 — 노드를 끄는 동안에도 네트워크 없이 따라간다.
*/
function strokeLockedRoute(
context: CanvasRenderingContext2D,
scene: RouteEditScene,
line: ReadonlyArray<Vertex>,
color: string,
): void {
const { planned, locked } = scene;
if (!locked.some(Boolean)) return;
const curveAt = new Map(
scene.curveInfo
.filter((curve) => scene.curveOn[curve.node_first] !== false)
.map((curve) => [curve.node_first, curve]),
);
const pieces: Vertex[][] = [];
for (let index = 0; index + 1 < planned.length; index += 1) {
if (!straightLocked(locked, index)) continue;
pieces.push([
curveAt.get(index)?.end ?? planned[index],
curveAt.get(index + 1)?.start ?? planned[index + 1],
]);
}
curveAt.forEach((curve, index) => {
if (arcLocked(locked, index)) pieces.push(sliceBetween(line, curve.start, curve.end));
});
pieces.forEach((piece) =>
strokePolyline(context, scene.toScreen, piece, [], color, LOCKED_WIDTH),
);
}
/** 노선에서 두 점(정점 위)에 가장 가까운 정점 사이를 잘라 낸다. */
function sliceBetween(line: ReadonlyArray<Vertex>, from: Vertex, to: Vertex): Vertex[] {
const nearest = (point: Vertex): number => {
let best = 0;
line.forEach((vertex, index) => {
if (
Math.hypot(vertex[0] - point[0], vertex[1] - point[1]) <
Math.hypot(line[best][0] - point[0], line[best][1] - point[1])
) {
best = index;
}
});
return best;
};
const [low, high] = [nearest(from), nearest(to)].sort((a, b) => a - b);
return line.slice(low, high + 1);
}
function strokePolyline(
context: CanvasRenderingContext2D,
toScreen: (vertex: Vertex) => [number, number],