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
+19 -4
View File
@@ -52,6 +52,12 @@ import {
viewPxPerMeter,
} from "./B05_Profile_UI_RouteEdit_View";
import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar";
import {
lockColumn,
readRouteLocks,
routeLocksOf,
writeRouteLocks,
} from "./B05_Profile_UI_RouteEdit_Lock";
import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial";
import {
applyArcLocks,
@@ -118,8 +124,9 @@ export async function openRouteEditModal(
* 노드를 옮기면 교각이 바뀌어 R 과 길이 중 하나는 반드시 따라 움직인다(`_Edits.ts`). */
let curveLock: CurveLock[] = [];
let curveArc: Array<number | null> = [];
/** 교각점 자리를 못 박은 꺾임점(2026-09-12 사용자 지시 ①) — 켜진 자리는 노드도 손잡이도
* 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다. */
/** 자리를 못 박은 노드(2026-09-12 사용자 지시 ① · 계획서 22-5) — 0번 = 시점, 끝 칸 = 종점
* (기본 고정), 사이 = 교각점. 켜진 자리는 안 끌리고 반지름·곡선 길이 칸으로만 바뀐다.
* 고정 한 벌의 저장 · 읽기는 `_Lock` 몫. */
let apexLock: boolean[] = [];
/** 지금 고른 꺾임점 — 곡선 편집줄이 이 자리를 만진다. 없으면 -1. */
let picked = -1;
@@ -275,6 +282,7 @@ export async function openRouteEditModal(
nodeInfo,
curveInfo,
curveOn,
locked: apexLock,
picked,
stationIntervalM,
});
@@ -322,7 +330,7 @@ export async function openRouteEditModal(
curveOn,
curveRadius,
curveLock,
apexLock,
apexLock: apexLock.slice(1, -1), // 시점 · 종점은 기본 고정이라 「고정 N곳」에 안 센다.
curveCount: curveInfo.length,
violationCount: nodeInfo.filter((node) => node.violations.length).length,
minRadiusM,
@@ -359,6 +367,7 @@ export async function openRouteEditModal(
/** 한 번의 편집을 마무리한다 — 다시 그리고, 라벨·상태줄을 맞추고, 되돌리기에 쌓는다. */
function applyEdit(message: string, record = true): void {
markEdited();
writeRouteLocks(projectId, planned, apexLock); // 고정은 [취소] · 다시 열기에도 남는다(22-5).
syncCurveBar();
status.textContent = `${routeHead()} — ${message} ${curveHint()}`;
draw();
@@ -373,7 +382,10 @@ export async function openRouteEditModal(
/** 사진 한 벌을 편집값으로 되살린다. 되돌리기는 새 걸음이 아니고, 초기값 싣기는 새 걸음이다. */
function restoreSnapshot(snapshot: RouteEditSnapshot, message: string, record: boolean): void {
// 초기노선 싣기는 새 노선이다 — 고정은 좌표로 다시 짚는다. 되돌리기는 그 사진의 고정 그대로.
const locks = record ? routeLocksOf(planned, apexLock) : null;
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock, picked } = snapshot);
if (locks) apexLock = lockColumn(planned, locks);
applyEdit(message, record);
}
@@ -443,6 +455,8 @@ export async function openRouteEditModal(
},
remove: (index) => {
if (planned.length <= 2) return showToast("노선은 노드가 2개 이상이어야 합니다.", "error");
if (apexLock[index])
return showToast("고정한 점은 못 지웁니다 — 고정을 먼저 푸세요.", "error");
spliceNode(snapshotNow(), index);
picked = -1;
applyEdit("노드를 지웠습니다(직선 삭제).");
@@ -490,7 +504,8 @@ export async function openRouteEditModal(
limitArcM = plan.limit_curve_length_m ?? 0;
// 곡선 성분을 편집할 수 있는 꼴로 편다 — 셈은 `_Edits` 몫(까닭도 그쪽에 적었다).
const flat = flattenServerPlan(nodes, plan.curves ?? []);
({ planned, curveOn, curveRadius, curveLock, curveArc, apexLock } = flat);
({ planned, curveOn, curveRadius, curveLock, curveArc } = flat);
apexLock = lockColumn(planned, readRouteLocks(projectId)); // 닫기 전 고정을 좌표로 되살린다.
({ nodes: nodeInfo, curves: curveInfo } = flat);
picked = -1;
// 여기가 [연 상태로]가 돌아갈 자리다 — 창을 연 그대로.
@@ -100,11 +100,12 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
);
},
onApexLock: (locked) => {
const { picked, apexLock } = params.state();
const { picked, apexLock, planned } = params.state();
if (picked < 0) return;
apexLock[picked] = locked;
const name = picked === 0 ? "시점" : picked === planned.length - 1 ? "종점" : "교각점";
// 자리는 안 바뀌었지만 잡히는 것이 달라졌다 — 상태줄과 되돌리기에 한 걸음으로 남긴다.
params.applyEdit(locked ? "교각점을 고정했습니다." : "교각점 고정을 풀었습니다.");
params.applyEdit(locked ? `${name}을 고정했습니다.` : `${name} 고정을 풀었습니다.`);
},
onCurveOn: (on) => {
const { picked, curveOn } = params.state();
@@ -114,7 +115,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
},
});
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없으므로 라벨을 숨긴다. */
/** 고른 자리에 맞춰 라벨을 옮겨 그린다. 끝점은 곡선이 없어 **고정 단추만** 낸다(계획서 22-5). */
function sync(): void {
const {
picked,
@@ -128,16 +129,43 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
limitRadiusM,
limitArcM,
} = params.state();
if (!(picked > 0 && picked < planned.length - 1)) {
if (!(picked >= 0 && picked < planned.length)) {
label.hide();
return;
}
const rect = params.canvas.getBoundingClientRect();
const bounds = {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
};
const last = planned.length - 1;
if (picked === 0 || picked === last) {
const [ex, ey] = params.toScreen(planned[picked]);
const [nx, ny] = params.toScreen(planned[picked === 0 ? 1 : last - 1]);
label.show({
seat: picked,
endpoint: picked === 0 ? "start" : "end",
at: [ex + rect.left, ey + rect.top],
bounds,
// 노선에 직각으로 비켜 붙인다 — 노선 바깥쪽에는 시점 · 종점 이름표가 있다.
centerDirection: [-(ny - ey), nx - ex],
curveOn: false,
radiusShown: null,
arcLengthShown: null,
lock: null,
apexLocked: apexLock[picked] === true,
innerAngleDeg: null,
curveOptional: false,
});
return;
}
const pickedCurve = curveInfo.find((entry) => entry.node_first === picked);
const shown = curveRadius[picked] ?? pickedCurve?.radius_m ?? null;
const innerAngle = nodeInfo[picked]?.inner_angle_deg ?? null;
const optional = curveOptionalAt(innerAngle);
const deflection = deflectionRad(innerAngle);
const rect = params.canvas.getBoundingClientRect();
const [screenX, screenY] = params.toScreen(planned[picked]);
label.show({
seat: picked,
@@ -145,12 +173,7 @@ export function createCurveBar(params: CurveBarParams): CurveBar {
at: [screenX + rect.left, screenY + rect.top],
// 넘어가도 되는 테두리 = **지도 칸**(하단 정보행 위까지). 밖으로 나가면 지금 무엇을
// 고치는지 모달 안에서 안 보인다(2026-09-12 사용자 지적 ⑨).
bounds: {
left: rect.left + 8,
top: rect.top + 8,
right: rect.right - 8,
bottom: rect.bottom - 8,
},
bounds,
centerDirection: pickedCurve
? centerDirectionOf(
params.toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]),
+15 -4
View File
@@ -57,6 +57,8 @@ const COMPASS_STEPS = 16;
export interface CurveLabelState {
/** 몇 번째 꺾임점인지 — 0부터 센 자리. 표시는 +1 해서 낸다. */
seat: number;
/** 시점 · 종점을 골랐나 — 곡선 칸을 숨기고 **고정 단추만** 낸다(계획서 22-5). */
endpoint?: "start" | "end" | null;
/** 그 꺾임점의 **화면(viewport) 좌표** px — 패널이 `position: fixed` 라서. */
at: [number, number];
/** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */
@@ -135,9 +137,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
<button type="button" class="b05-routeedit__lock" data-act="lock-arc"
title="곡선 길이 고정 — 노드를 옮겨도 안 바뀝니다">고정</button>
</label>
<div class="b05-routeedit__curve-field">교각점
<button type="button" class="b05-routeedit__lock" data-act="lock-apex"
title="교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다">고정</button>
<div class="b05-routeedit__curve-field"><span class="b05-routeedit__lock-name">교각점</span>
<button type="button" class="b05-routeedit__lock" data-act="lock-apex">고정</button>
</div>
<span class="b05-routeedit__curve-info"></span>`;
document.body.append(root);
@@ -151,6 +152,9 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
const lockArc = root.querySelector<HTMLButtonElement>('[data-act="lock-arc"]')!;
const lockApex = root.querySelector<HTMLButtonElement>('[data-act="lock-apex"]')!;
const info = root.querySelector<HTMLElement>(".b05-routeedit__curve-info")!;
const lockName = root.querySelector<HTMLElement>(".b05-routeedit__lock-name")!;
/** 끝점에서는 숨길 곡선 칸 — 곡선 지우기 단추 · 반지름 줄 · 곡선 길이 줄 · 내각 글. */
const curveOnly = [toggle, radius.closest("label")!, arc.closest("label")!, info];
// 패널 위에서 누른 것이 캔버스로 새어 나가면 노드가 딸려 움직인다.
for (const type of ["pointerdown", "dblclick", "contextmenu", "wheel"] as const) {
@@ -280,7 +284,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel {
radius.min = limitRadius > 0 ? String(limitRadius) : "1";
arc.min = limitArc > 0 ? String(limitArc) : "1";
root.hidden = false;
seatText.textContent = `${state.seat + 1}번째 꺾임점`;
const end = state.endpoint ?? null;
const name = end === "start" ? "시점" : end === "end" ? "종점" : "교각점";
curveOnly.forEach((element) => (element.hidden = end !== null));
seatText.textContent = end ? name : `${state.seat + 1}번째 꺾임점`;
lockName.textContent = name;
lockApex.title = end
? `${name} 고정 — 켜면 끌어 옮길 수 없습니다. 풀면 끌어 옮깁니다`
: "교각점 고정 — 이 꺾임점의 자리를 못 박습니다. 노드도 손잡이도 안 끌리고 반지름·곡선 길이 칸으로만 바뀝니다";
toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기";
radius.disabled = !state.curveOn;
arc.disabled = !state.curveOn;
@@ -0,0 +1,84 @@
/* =============================================================================
* B05_Profile_UI_RouteEdit_Lock.ts
* 계획노선 편집 창의 **고정 한 벌** — 시점 · 종점과 교각점 자리(계획서 22-4 · 22-5).
*
* 모달은 노드마다 한 칸씩 `apexLock` 을 쥔다 — 0번 = 시점, 끝 칸 = 종점, 사이 = 교각점.
* 시점 · 종점은 **기본 고정**이다. 켜진 자리는 고르기만 되고 안 끌린다(`_Pointer`).
*
* 창을 닫아도([취소] · [확인] 포함) 고정은 남는다 — 화면 상태 보관소의 pref 통(프로젝트 칸)에
* 바로 적고, 다시 열 때 서버 노드에 **좌표로** 다시 짚는다(노드 번호는 편집마다 밀린다).
* draft 통에 두지 않는다 — [저장] 대상이 아닌데 미저장 표시가 켜지고 [저장] 뒤 지워진다.
* 초기노선 계산(POST /route/initial)에 넘길 때도 `readRouteLocks` 한 곳만 읽는다.
* DOM 을 안 만진다 — 시험이 Node 로 돈다.
* ========================================================================== */
import { readState, writeState } from "../A00_Common/b_page_state";
type Vertex = [number, number];
/** 고정 한 벌. `apex` 는 고정한 교각점 좌표(사업지 좌표계 m). */
export interface RouteLocks {
start: boolean;
end: boolean;
apex: Vertex[];
}
/** 같은 교각점으로 볼 거리(m) — 서버가 되돌려 준 좌표의 끝자리 어긋남만 흡수한다. */
const SAME_POINT_M = 0.05;
const isVertex = (value: unknown): value is Vertex =>
Array.isArray(value) && value.length >= 2 && value.every((n) => Number.isFinite(n));
/** 저장된 고정 — 없거나 깨졌으면 시점 · 종점만 고정. */
export function readRouteLocks(projectId: string): RouteLocks {
const saved = readState<Partial<RouteLocks>>("routeedit-locks", projectId);
return {
start: saved?.start !== false,
end: saved?.end !== false,
apex: Array.isArray(saved?.apex) ? saved.apex.filter(isVertex) : [],
};
}
/** 노드 칸 → 고정 한 벌. */
export function routeLocksOf(
planned: ReadonlyArray<Vertex>,
locked: ReadonlyArray<boolean>,
): RouteLocks {
const last = planned.length - 1;
return {
start: locked[0] === true,
end: locked[last] === true,
apex: planned
.filter((_, index) => index > 0 && index < last && locked[index] === true)
.map(([x, y]) => [x, y]),
};
}
export function writeRouteLocks(
projectId: string,
planned: ReadonlyArray<Vertex>,
locked: ReadonlyArray<boolean>,
): void {
if (planned.length < 2) return; // 노선을 못 읽은 채 닫으면 적어 둔 고정을 지우지 않는다.
writeState("routeedit-locks", routeLocksOf(planned, locked), projectId);
}
/** 고정 한 벌 → 노드 칸. 교각점은 좌표가 가까운 자리만 켠다. */
export function lockColumn(planned: ReadonlyArray<Vertex>, locks: RouteLocks): boolean[] {
const last = planned.length - 1;
return planned.map(([x, y], index) =>
index === 0
? locks.start
: index === last
? locks.end
: locks.apex.some(([ax, ay]) => Math.hypot(ax - x, ay - y) <= SAME_POINT_M),
);
}
/** 곧은 선(노드 `index` → `index + 1`)은 **양 끝이 모두** 고정일 때 고정. */
export const straightLocked = (locked: ReadonlyArray<boolean>, index: number): boolean =>
locked[index] === true && locked[index + 1] === true;
/** 호(교각점 `index`)는 **그 교각점과 앞뒤 점이 모두** 고정일 때 고정. */
export const arcLocked = (locked: ReadonlyArray<boolean>, index: number): boolean =>
locked[index - 1] === true && locked[index] === true && locked[index + 1] === true;
+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],
@@ -435,6 +435,11 @@
font-size: var(--text-caption);
}
/* 시점 · 종점을 고르면 곡선 칸을 숨긴다 — 줄마다 `display: flex` 라 `hidden` 이 안 먹는다. */
.b05-routeedit__label [hidden] {
display: none;
}
/* 머리는 **잡아 옮기는 자리**다 — 패널이 곡선을 가리면 손으로 치울 수 있어야 한다. */
.b05-routeedit__label-head {
display: flex;