diff --git a/B05_Profile/B05_Profile_UI_Profile_Tools.ts b/B05_Profile/B05_Profile_UI_Profile_Tools.ts
index f14a0b087..b82790a57 100644
--- a/B05_Profile/B05_Profile_UI_Profile_Tools.ts
+++ b/B05_Profile/B05_Profile_UI_Profile_Tools.ts
@@ -16,6 +16,7 @@
import type { StraightRun } from "./logic/B05_Profile_UI_Profile_Straighten";
import { sameRun } from "./logic/B05_Profile_UI_Profile_Straighten";
+import { HISTORY_ICON } from "./B05_Profile_UI_RouteEdit_Chrome";
import type { FreeChartGeom } from "../A00_Common/design/design_profile_free_view";
export type ProfileToolMode = "none" | "straighten" | "shift" | "free";
@@ -174,6 +175,11 @@ export function createProfileTools(callbacks: ProfileToolsCallbacks): ProfileToo
undo.disabled = !callbacks.canUndo();
const redo = toolButton("↷", "다시하기 (Ctrl+Shift+Z)", callbacks.onRedo);
redo.disabled = !callbacks.canRedo();
+ // 평면 편집과 같은 그림(요구 241-9) — 글은 aria-label 로.
+ undo.innerHTML = HISTORY_ICON.undo;
+ undo.setAttribute("aria-label", "되돌리기");
+ redo.innerHTML = HISTORY_ICON.redo;
+ redo.setAttribute("aria-label", "다시하기");
const straighten = toolButton(
"직선화",
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
index 13021c9e8..1556d97a1 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts
@@ -1,5 +1,6 @@
import { createButton } from "@ui/ui_template_elements";
import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
+import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks";
/* =============================================================================
* B05_Profile_UI_RouteEdit_Chrome.ts
@@ -11,7 +12,8 @@ import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint";
* **배치**(352-1) — 지도가 칸 전체 · 머리 줄 · 테두리 없음. 도구는 3D 와 같은 떠 있는 유리 단추
* (지도 왼쪽 위 · ☰ 비킴), [?] 조작 설명은 도구줄 아래 2열, 상태·범례는 왼쪽 아래, 잰 값은 오른쪽
* 아래. 횡단 두 판은 지도 오른쪽을 덮는 판(아래 종단 판이 그 위).
- * 도구줄 차례(366-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 절/성토선 · 거리 재기 | 측점 스냅 · 분석 지형.
+ * 도구줄 차례(366-2 · 381-2) — [?] | ↶ ↷ | 3D | ⟲ ⟳ · 1m 등고선 · 측점 · 기울기 · 절/성토선 · 거리 재기 |
+ * 측점 스냅 · 분석 지형.
*
* 자리(345-3) — 모달이 아니라 종단 보기 가운데 위 본문(옛 3D 자리)에 붙는다. 덮개 · 전체 화면 ·
* 닫기 단추는 걷었다 · [취소] · [확인] · [노선 반영] · [저장 전으로]는 없앰(346-3 · 352-2 — 노선은 페이지
@@ -31,6 +33,17 @@ export const HALF_TURN_ICON = {
`,
};
+/** 되돌리기 · 다시하기 아이콘 — **곧은 꼬리 + 갈고리**(↩ ↪ · 요구 241-9). 둥근 화살표(`↶`·`↷`)는
+ * 회전 단추와 헷갈렸다. 종단 도구줄도 같은 그림. */
+export const HISTORY_ICON = {
+ undo: ``,
+ redo: ``,
+};
+
export interface RouteEditChrome {
overlay: HTMLElement;
canvas: HTMLCanvasElement;
@@ -65,9 +78,9 @@ export function createRouteEditChrome(
data-act="hint-toggle" aria-expanded="false" title="조작 도움말 펼치기 · 접기">?
+ title="되돌리기 (Ctrl+Z)" aria-label="되돌리기" disabled>${HISTORY_ICON.undo}
+ title="다시하기 (Ctrl+Y)" aria-label="다시하기" disabled>${HISTORY_ICON.redo}
@@ -78,8 +91,12 @@ export function createRouteEditChrome(
+
+
+ title="도로 끝 · 비탈 끝(깎기 붉음 · 쌓기 청록)을 평면에 겹칩니다">절/성토선
@@ -188,6 +205,8 @@ export function createRouteEditChrome(
const terrainSelect = overlay.querySelector('[data-act="terrain"]')!;
const stationSnapButton = overlay.querySelector('[data-act="station-snap"]')!;
bindFootprintToggle(overlay.querySelector('[data-act="footprint"]')!);
+ bindMarkToggle(overlay.querySelector('[data-act="stations"]')!, "stations");
+ bindMarkToggle(overlay.querySelector('[data-act="slopes"]')!, "slopes");
return {
overlay,
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts
index 82faeb7d3..1ce234f82 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Footprint.ts
@@ -17,21 +17,21 @@ import {
} from "./logic/B05_Profile_Footprint_Strokes";
import "./B05_Profile_UI_Style_RouteEdit_Footprint.css";
-/** 갈래 → 색 토큰 · 대체 색. */
-const STROKE_STYLE: Record = {
- road: ["--b05-footprint-road", "#475569"],
- cut: ["--b05-footprint-cut", "#dc2626"],
- fill: ["--b05-footprint-fill", "#2563eb"],
- slope: ["--b05-footprint-road", "#475569"],
+/** 갈래 → 색 토큰 · 대체 색 · 굵기(px · 비탈 끝이 더 굵음 · 요구 241-10). */
+const STROKE_STYLE: Record = {
+ road: ["--b05-footprint-road", "#475569", 1.8],
+ cut: ["--b05-footprint-cut", "#e11d48", 2.6],
+ fill: ["--b05-footprint-fill", "#0d9488", 2.6],
+ slope: ["--b05-footprint-road", "#475569", 2.6],
};
-const STROKE_WIDTH = 1.4;
let shown = true;
let redraw: (() => void) | null = null;
let frame = 0;
let listening: { projectId: string; stop: () => void } | null = null;
-function scheduleRedraw(): void {
+/** 마지막 장면을 다음 프레임에 한 번 다시 그림 — [측점] · [기울기] 켬끔(`_Marks`)도 씀. */
+export function scheduleRedraw(): void {
if (frame) return;
frame = requestAnimationFrame(() => {
frame = 0;
@@ -77,9 +77,9 @@ export function drawFootprint(
if (!strokes.length) return;
context.save();
context.setLineDash([]);
- context.lineWidth = STROKE_WIDTH;
for (const { kind, points } of strokes) {
- const [token, fallback] = STROKE_STYLE[kind];
+ const [token, fallback, width] = STROKE_STYLE[kind];
+ context.lineWidth = width;
context.strokeStyle = style.getPropertyValue(token).trim() || fallback;
context.beginPath();
points.forEach((vertex, index) => {
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts
new file mode 100644
index 000000000..6fabb93f3
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Hover.ts
@@ -0,0 +1,67 @@
+/* =============================================================================
+ * B05_Profile_UI_RouteEdit_Hover.ts
+ * 노선 편집 평면 **맴돔 밝힘**(요구 241-14 · PLAN 381-3).
+ *
+ * 맴도는 자리는 `_Pointer` 가 `_Input.pickAtScreen`(집기와 같은 셈)으로 적고, 여기서 이길 쪽만
+ * 그린다 — 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. `_Render` 가 노드 그린 뒤 부름.
+ * ========================================================================== */
+
+import { pickHover } from "./B05_Profile_UI_RouteEdit_Input";
+import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render";
+import { stationFrameAt } from "./logic/B05_Profile_UI_RouteEdit_Frame";
+import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
+
+/** 횡단 눈금 맴돔 색 — 횡단 반폭 선(`_Render`)과 같은 하늘색. */
+const STATION_HOVER_COLOR = "#0ea5e9";
+
+/** 맴도는 자리 밝힘(381-3) — 누르면 집힐 쪽만: 노드 = 바깥 고리 · 횡단 눈금 = 파란 짧은 직각선. */
+export function drawPickHover(
+ context: CanvasRenderingContext2D,
+ scene: RouteEditScene,
+ line: ReadonlyArray,
+ style: CSSStyleDeclaration,
+ nodeR: number,
+): void {
+ const hover = pickHover(context.canvas);
+ if (!hover) return;
+ context.save();
+ context.setLineDash([]);
+ if ("node" in hover) {
+ const vertex = scene.planned[hover.node];
+ if (vertex) {
+ const [x, y] = scene.toScreen(vertex);
+ const radius = (hover.node === scene.picked ? nodeR + 2 : nodeR) + 4;
+ context.beginPath();
+ context.arc(x, y, radius, 0, Math.PI * 2);
+ context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
+ context.lineWidth = 4;
+ context.stroke();
+ context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316";
+ context.lineWidth = 2;
+ context.stroke();
+ }
+ } else {
+ const frame = stationFrameAt(line, hover.chainage);
+ if (frame) {
+ const [ax, ay] = scene.toScreen(frame.origin);
+ const [bx, by] = scene.toScreen([
+ frame.origin[0] + frame.left[0],
+ frame.origin[1] + frame.left[1],
+ ]);
+ const length = Math.hypot(bx - ax, by - ay) || 1;
+ const ux = ((bx - ax) / length) * 12;
+ const uy = ((by - ay) / length) * 12;
+ context.strokeStyle = STATION_HOVER_COLOR;
+ context.fillStyle = STATION_HOVER_COLOR;
+ context.lineWidth = 3;
+ context.beginPath();
+ context.moveTo(ax - ux, ay - uy);
+ context.lineTo(ax + ux, ay + uy);
+ context.stroke();
+ context.beginPath();
+ context.arc(ax, ay, 4, 0, Math.PI * 2);
+ context.fill();
+ }
+ }
+ context.restore();
+}
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
index 910775aef..e2f8cd278 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Input.ts
@@ -323,7 +323,7 @@ export function stationAtScreen(
labels: ReadonlyArray = [],
/** 측점 누가거리 — 안 주면 간격마다(측점 스냅 · 요구 224-2). */
chainages: ReadonlyArray | null = null,
-): number | null {
+): StationHit | null {
// 라벨이 그려진 측점만 눈금도 보인다(`drawStationTicks` 가 성기게 솎는다) — 안 보이는 눈금이
// 호·등고선 누름을 가로채지 않게 한다. 라벨 정보가 없으면 전부 본다.
const shown = labels.length
@@ -331,9 +331,50 @@ export function stationAtScreen(
: null;
const tick = stationTickAtScreen(line, toScreen, intervalM, px, py, maxPx, shown, chainages);
// 눈금과 라벨이 겹치면 눈금이 먼저다 — 라벨은 눈금을 못 잡았을 때만 본다.
- return tick ?? stationLabelAtScreen(labels, px, py);
+ if (tick) return tick;
+ const chainage = stationLabelAtScreen(labels, px, py);
+ if (chainage === null) return null;
+ const box = labels.find((entry) => entry.chainage === chainage)!;
+ return { chainage, distance: Math.hypot(px - box.x, py - box.y) };
}
+/** 집은 측점과 화면 거리(px) — 노드와 견줄 때 씀(381-3). */
+export interface StationHit {
+ chainage: number;
+ distance: number;
+}
+
+/** 집기 · 맴돔 결과 — 노드 번호 또는 횡단 측점(381-3). */
+export type RouteEditPick = { node: number } | { chainage: number } | null;
+
+/** 노드 · 횡단 눈금 집기 · 맴돔을 한 길로(요구 241-14) — 노드 가운데 `corePx` 안 = 노드,
+ * 밖 = 노드(`nodePx` 안)와 눈금 중 가까운 쪽. `station` 이 null 이면 눈금은 안 봄([측점] 끔). */
+export function pickAtScreen(
+ points: Array<[number, number]>,
+ toScreen: ScreenOf,
+ px: number,
+ py: number,
+ nodePx: number,
+ corePx: number,
+ station: (() => StationHit | null) | null,
+): RouteEditPick {
+ const node = nodeAtScreen(points, toScreen, px, py, nodePx);
+ const nodeDistance =
+ node >= 0
+ ? Math.hypot(toScreen(points[node])[0] - px, toScreen(points[node])[1] - py)
+ : Infinity;
+ if (nodeDistance <= corePx) return { node };
+ const tick = station?.() ?? null;
+ if (tick && tick.distance < nodeDistance) return { chainage: tick.chainage };
+ return node >= 0 ? { node } : null;
+}
+
+/** 맴도는 자리(381-3) — `Pointer` 가 적고 `Render` 가 고리 · 파랑으로 밝힘. 창(캔버스)마다 따로. */
+const hovers = new WeakMap();
+export const setPickHover = (canvas: HTMLCanvasElement, pick: RouteEditPick): void =>
+ void hovers.set(canvas, pick);
+export const pickHover = (canvas: HTMLCanvasElement): RouteEditPick => hovers.get(canvas) ?? null;
+
/** 그려 둔 측점 라벨 글자 상자 — 그릴 때마다 `Render` 가 새로 적는다. 창(캔버스)마다 따로. */
const labelBoxes = new WeakMap>();
export const setStationLabelBoxes = (
@@ -376,7 +417,7 @@ function stationTickAtScreen(
maxPx: number,
shown: ReadonlySet | null,
chainages: ReadonlyArray | null,
-): number | null {
+): StationHit | null {
if (line.length < 2 || !(intervalM > 0)) return null;
const cumulative: number[] = [0];
for (let index = 1; index < line.length; index += 1) {
@@ -386,7 +427,7 @@ function stationTickAtScreen(
);
}
const total = cumulative[cumulative.length - 1];
- let best: number | null = null;
+ let best: StationHit | null = null;
let bestDistance = maxPx;
let cursor = 1;
const marks: number[] = [];
@@ -406,7 +447,7 @@ function stationTickAtScreen(
const distance = Math.hypot(x - px, y - py);
if (distance < bestDistance) {
bestDistance = distance;
- best = chainage;
+ best = { chainage, distance };
}
}
return best;
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Marks.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Marks.ts
new file mode 100644
index 000000000..e61fa9988
--- /dev/null
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Marks.ts
@@ -0,0 +1,30 @@
+/* =============================================================================
+ * B05_Profile_UI_RouteEdit_Marks.ts
+ * 노선 편집 평면 [측점] [기울기] 켬끔(요구 241-11).
+ *
+ * [측점] 끔 = 측점 눈금 · 번호 안 그림 · 눈금 집기 안 함(시점 · 종점 이름표는 남음).
+ * [기울기] 끔 = 측점 사이 기울기 라벨 안 그림 → 높이도 안 물음.
+ * 켬끔은 이 페이지 동안만(기본 켬 · 계정 설정 아님) — `_Footprint` 와 같은 꼴.
+ * ========================================================================== */
+
+import { scheduleRedraw } from "./B05_Profile_UI_RouteEdit_Footprint";
+
+export type RouteEditMark = "stations" | "slopes";
+
+const shown: Record = { stations: true, slopes: true };
+
+export const markShown = (mark: RouteEditMark): boolean => shown[mark];
+
+/** [측점] · [기울기] 단추 — 누르면 켬끔 · 다시 그림. */
+export function bindMarkToggle(button: HTMLButtonElement, mark: RouteEditMark): void {
+ const paint = (): void => {
+ button.classList.toggle("is-active", shown[mark]);
+ button.setAttribute("aria-pressed", String(shown[mark]));
+ };
+ paint();
+ button.addEventListener("click", () => {
+ shown[mark] = !shown[mark];
+ paint();
+ scheduleRedraw();
+ });
+}
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts
index 1f9dc33de..7117cc376 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts
@@ -21,10 +21,15 @@ import {
arcAtScreen,
handleAtScreen,
nodeAtScreen,
+ pickAtScreen,
+ pickHover,
segmentAtScreen,
+ setPickHover,
stationAtScreen,
stationLabelBoxes,
+ type RouteEditPick,
} from "./B05_Profile_UI_RouteEdit_Input";
+import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
import {
curveShortfalls,
radiusFloorM,
@@ -32,8 +37,10 @@ import {
type CurveLock,
} from "./logic/B05_Profile_UI_RouteEdit_Edits";
-/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */
-const NODE_HIT_PX = 9;
+/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기(381-3 — 9 → 12). */
+const NODE_HIT_PX = 12;
+/** 노드 가운데 이 안(px)은 횡단 눈금이 가까워도 노드(381-3). */
+const NODE_CORE_PX = 6;
/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */
const SEGMENT_HIT_PX = 12;
/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */
@@ -120,6 +127,38 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
};
const nodeAt = (px: number, py: number): number =>
nodeAtScreen(params.state().planned, toScreen, px, py, pickPx(NODE_HIT_PX));
+ /** 노드 · 횡단 눈금 집기 · 맴돔 한 길(381-3) — 셈은 `_Input.pickAtScreen` · [측점] 끔이면 눈금 안 봄. */
+ const pickAt = (px: number, py: number): RouteEditPick => {
+ const state = params.state();
+ return pickAtScreen(
+ state.planned,
+ toScreen,
+ px,
+ py,
+ pickPx(NODE_HIT_PX),
+ NODE_CORE_PX,
+ // [측점] 끔(381-2) — 라벨 0개면 눈금이 전부 집히므로 아예 안 봄.
+ markShown("stations")
+ ? () =>
+ stationAtScreen(
+ state.plannedLine.length ? state.plannedLine : state.planned,
+ toScreen,
+ params.stationIntervalM,
+ px,
+ py,
+ pickPx(STATION_HIT_PX),
+ stationLabelBoxes(canvas),
+ params.stations(),
+ )
+ : null,
+ );
+ };
+ /** 맴돔이 바뀌었을 때만 적고 다시 그림 — 고리 · 파랑은 `Render` 몫. */
+ const hover = (pick: RouteEditPick): void => {
+ if (JSON.stringify(pick) === JSON.stringify(pickHover(canvas))) return;
+ setPickHover(canvas, pick);
+ params.draw();
+ };
const segmentAt = (px: number, py: number): number =>
segmentAtScreen(params.state().planned, toScreen, px, py, pickPx(SEGMENT_HIT_PX));
@@ -155,10 +194,13 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return;
}
const state = params.state();
+ hover(null); // 누르는 동안 · 끄는 동안 맴돔 끔(381-3)
+ const pick = pickAt(px, py);
// **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼
// 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로
// 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다.
- const hitNode = nodeAt(px, py);
+ // 노드 가운데(6px)는 노드 · 그 밖은 횡단 눈금과 가까운 쪽(381-3 `pickAt`).
+ const hitNode = pick && "node" in pick ? pick.node : -1;
// 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①).
dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1;
dragHandle = hitNode >= 0 ? null : handleAt(px, py);
@@ -188,18 +230,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
} else if (
// 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다.
(() => {
- const chainage = stationAtScreen(
- state.plannedLine.length ? state.plannedLine : state.planned,
- toScreen,
- params.stationIntervalM,
- px,
- py,
- pickPx(STATION_HIT_PX),
- stationLabelBoxes(canvas),
- params.stations(),
- );
- if (chainage === null) return false;
- void params.crossPreview.open(chainage);
+ if (!pick || !("chainage" in pick)) return false;
+ void params.crossPreview.open(pick.chainage);
return true;
})()
) {
@@ -296,8 +328,12 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
return;
}
// 재는 중 — 둘째 점 미리보기는 첫 점 노선 위에만 붙는다.
- if ((event.shiftKey || params.measureMode()) && params.measure.preview(px, py)) params.draw();
- const overNode = nodeAt(px, py);
+ const measuring = event.shiftKey || params.measureMode();
+ if (measuring && params.measure.preview(px, py)) params.draw();
+ // 맴돌 때 이길 쪽만 밝힘(381-3) — 누르면 집힐 것과 같은 셈 · 재는 중엔 끔.
+ const over = measuring ? null : pickAt(px, py);
+ hover(over);
+ const overNode = over && "node" in over ? over.node : -1;
// 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다.
canvas.style.cursor =
overNode >= 0
@@ -306,7 +342,12 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP
: "grab"
: handleAt(px, py)
? "grab"
- : "default";
+ : over
+ ? "pointer"
+ : "default";
+ });
+ canvas.addEventListener("pointerleave", () => {
+ if (dragNode < 0 && !dragHandle) hover(null);
});
const endDrag = (event: PointerEvent): void => {
diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
index 22b7631ff..2f317ef30 100644
--- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
+++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts
@@ -26,15 +26,17 @@ import {
} from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import type { EditedCurve, EditedNode, Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve";
import { contourBandRect, setStationLabelBoxes } from "./B05_Profile_UI_RouteEdit_Input";
+import { drawPickHover } from "./B05_Profile_UI_RouteEdit_Hover";
import type { CrossWatch } from "./B05_Profile_UI_RouteEdit_Cross";
import { stationFrameAt } from "./logic/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";
import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint";
+import { markShown } from "./B05_Profile_UI_RouteEdit_Marks";
-/** 노드 반지름(px). */
-const NODE_R = 4;
+/** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */
+const NODE_R = 6;
/** 고정한 선 · 호 굵기(px) — 노선(2.4)보다 조금 굵게 덧그려 밑의 주황이 비치지 않게. */
const LOCKED_WIDTH = 3;
/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07).
@@ -246,6 +248,14 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
const lockedColor = style.getPropertyValue("--map-route-locked") || "#db2777";
strokeLockedRoute(context, scene, line, lockedColor);
+ drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
+ drawStationMarks(context, scene, line);
+ if (markShown("slopes")) scene.overStations?.(context); // 끄면 높이도 안 물음(381-2)
+ drawMeasureMarks(context, scene, line);
+ // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
+ // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
+ drawContourLabels(context, scene, band, style);
+ // 노드 · 손잡이는 라벨 뒤 맨 위(381-3) — 측점 · 기울기 · 높이 글자에 묻혀 안 집히던 자리.
context.save();
context.fillStyle = style.getPropertyValue("--map-route") || "#f97316";
context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)";
@@ -304,14 +314,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou
});
});
context.restore();
-
- drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다.
- drawStationMarks(context, scene, line);
- scene.overStations?.(context);
- drawMeasureMarks(context, scene, line);
- // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저
- // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다.
- drawContourLabels(context, scene, band, style);
+ drawPickHover(context, scene, line, style, NODE_R);
context.restore(); // 회전 끝
}
@@ -494,18 +497,21 @@ function drawStationMarks(
setStationLabelBoxes(context.canvas, labels);
if (line.length < 2) return;
// 눈금은 B04 지도·배수유역도와 같은 한 곳이 그린다 — 표기가 화면마다 갈리지 않게.
- drawStationTicks(
- context,
- line.map(([x, y]) => ({ x, y })),
- {
- intervalM: scene.stationIntervalM,
- pxPerMeter: scene.pxPerMeter,
- toScreen: (x, y) => scene.toScreen([x, y]),
- uprightRad: scene.uprightRad,
- onLabel: (box) => labels.push(box),
- chainages: scene.stations ?? undefined,
- },
- );
+ // [측점] 끔(381-2)이면 눈금 · 번호만 빼고 시점 · 종점 이름표는 남김.
+ if (markShown("stations")) {
+ drawStationTicks(
+ context,
+ line.map(([x, y]) => ({ x, y })),
+ {
+ intervalM: scene.stationIntervalM,
+ pxPerMeter: scene.pxPerMeter,
+ toScreen: (x, y) => scene.toScreen([x, y]),
+ uprightRad: scene.uprightRad,
+ onLabel: (box) => labels.push(box),
+ chainages: scene.stations ?? undefined,
+ },
+ );
+ }
const total = polylineLengthM(line);
const last = line.length - 1;
endLabel(context, scene, line[0], line[1], `시점 ${formatStation(0, scene.stationIntervalM)}`);
diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css
index f2e5fa3c0..18781a952 100644
--- a/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css
+++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit_Footprint.css
@@ -1,20 +1,20 @@
-/* 노선 편집 평면 발자국(PLAN 346-2) — 도로 끝 · 비탈 끝 깎기(붉음) · 쌓기(파랑) */
+/* 노선 편집 평면 발자국(PLAN 346-2) — 도로 끝 · 비탈 끝 깎기(붉음) · 쌓기(청록) */
:root {
--b05-footprint-road: #475569;
- --b05-footprint-cut: #dc2626;
- --b05-footprint-fill: #2563eb;
+ --b05-footprint-cut: #e11d48;
+ --b05-footprint-fill: #0d9488;
}
:root[data-theme="dark"] {
--b05-footprint-road: #cbd5e1;
- --b05-footprint-cut: #f87171;
- --b05-footprint-fill: #60a5fa;
+ --b05-footprint-cut: #ff6b6b;
+ --b05-footprint-fill: #2dd4bf;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--b05-footprint-road: #cbd5e1;
- --b05-footprint-cut: #f87171;
- --b05-footprint-fill: #60a5fa;
+ --b05-footprint-cut: #ff6b6b;
+ --b05-footprint-fill: #2dd4bf;
}
}
diff --git a/resources/tester/helper_282_1_profile_toolbar.cjs b/resources/tester/helper_282_1_profile_toolbar.cjs
index 1fe0671b6..932c18225 100644
--- a/resources/tester/helper_282_1_profile_toolbar.cjs
+++ b/resources/tester/helper_282_1_profile_toolbar.cjs
@@ -103,7 +103,7 @@ const popup = {
// ---- 진짜 도구 묶음 ----
const { createProfileTools } = load(read("B05_Profile", "B05_Profile_UI_Profile_Tools.ts"), {
- require: () => ({ sameRun: () => false }),
+ require: () => ({ sameRun: () => false, HISTORY_ICON: { undo: "", redo: "" } }),
document,
});
const tools = createProfileTools({
diff --git a/resources/tester/helper_b05_routeedit_pick.cjs b/resources/tester/helper_b05_routeedit_pick.cjs
index 35bf9b5d3..1859f0238 100644
--- a/resources/tester/helper_b05_routeedit_pick.cjs
+++ b/resources/tester/helper_b05_routeedit_pick.cjs
@@ -128,7 +128,7 @@ const stationLine = [
[100, 0],
];
const stationAt = (px, py, labels) =>
- input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels);
+ input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, labels)?.chainage ?? null;
const rot = (dx, dy, rad) => [
dx * Math.cos(rad) - dy * Math.sin(rad),
dx * Math.sin(rad) + dy * Math.cos(rad),
@@ -156,6 +156,42 @@ const label = {
emptyElsewhere: stationAt(5, 5, boxes),
};
+// ⑥ 381-3 노드 · 횡단 눈금 겹침 — 노드 [41,0] = 화면 (82,400) · 눈금 40 = 화면 (80,400).
+const overlapNodes = [
+ [0, 0],
+ [41, 0],
+ [100, 100],
+];
+const overlapAt = (px, py, stationOn = true) =>
+ input.pickAtScreen(
+ overlapNodes,
+ toScreen,
+ px,
+ py,
+ 12,
+ 6,
+ stationOn ? () => input.stationAtScreen(stationLine, toScreen, 20, px, py, 11, []) : null,
+ );
+const overlap = {
+ center: overlapAt(82, 400),
+ coreNearTick: overlapAt(79, 400), // 노드 3px · 눈금 1px — 가운데 6px 안이라 노드
+ tickCloser: overlapAt(74, 400), // 노드 8px · 눈금 6px — 눈금
+ nodeCloser: overlapAt(90, 400), // 노드 8px · 눈금 10px — 노드
+ edgeTick: overlapAt(70, 400), // 노드 12px(문턱 끝) · 눈금 10px — 눈금
+ stationsOff: overlapAt(74, 400, false), // [측점] 끔 — 노드
+ far: overlapAt(82, 430),
+ tickOnly: overlapAt(160, 400), // 눈금 80 위 · 노드 멀리
+ distance: input.stationAtScreen(stationLine, toScreen, 20, 74, 400, 11, []),
+};
+
process.stdout.write(
- JSON.stringify({ arcMatchesDrawn, pick, pick2, node: c.node_first, labelsMatchRects, label }),
+ JSON.stringify({
+ arcMatchesDrawn,
+ pick,
+ pick2,
+ node: c.node_first,
+ labelsMatchRects,
+ label,
+ overlap,
+ }),
);
diff --git a/resources/tester/test_346_2_footprint_draw.py b/resources/tester/test_346_2_footprint_draw.py
index 26642f4bf..c5c105ba4 100644
--- a/resources/tester/test_346_2_footprint_draw.py
+++ b/resources/tester/test_346_2_footprint_draw.py
@@ -111,3 +111,8 @@ def test_편집기가_발자국을_그리고_단추로_켬끔() -> None:
for token in ("--b05-footprint-road", "--b05-footprint-cut", "--b05-footprint-fill"):
assert token in footprint
assert css.count(f"{token}:") == 3 # 밝음 · 어두움 · 시스템 어두움
+ # 381-1 — 절토 · 성토 테마 색 · 굵게(도로 끝 1.8 · 비탈 끝 2.6)
+ for color in ("#e11d48", "#ff6b6b", "#0d9488", "#2dd4bf"):
+ assert color in css
+ assert '"#e11d48", 2.6]' in footprint and '"#0d9488", 2.6]' in footprint
+ assert 'road: ["--b05-footprint-road", "#475569", 1.8]' in footprint
diff --git a/resources/tester/test_381_2_routeedit_marks.py b/resources/tester/test_381_2_routeedit_marks.py
new file mode 100644
index 000000000..0ed430cd4
--- /dev/null
+++ b/resources/tester/test_381_2_routeedit_marks.py
@@ -0,0 +1,58 @@
+"""381-2 B05 평면 [측점] [기울기] 켬끔(요구 241-11).
+
+- 단추 = 절/성토선 왼쪽 · 켬끔 모듈(기본 켬 · 페이지 동안)
+- [측점] 끔 = 눈금 · 번호 안 그림 · 눈금 집기 안 함(라벨 0개 = 전부 집힘 함정) · 시점 · 종점 이름표 남김
+- [기울기] 끔 = 기울기 라벨(높이 묻기) 안 부름
+"""
+
+from pathlib import Path
+
+B05 = Path(__file__).resolve().parents[2] / "B05_Profile"
+
+
+def _read(name: str) -> str:
+ return (B05 / name).read_text(encoding="utf-8")
+
+
+def test_켬끔_모듈_기본_켬():
+ marks = _read("B05_Profile_UI_RouteEdit_Marks.ts")
+ assert "{ stations: true, slopes: true }" in marks
+ assert "scheduleRedraw();" in marks
+ assert "export function scheduleRedraw" in _read("B05_Profile_UI_RouteEdit_Footprint.ts")
+
+
+def test_단추는_절성토선_왼쪽_배선():
+ chrome = _read("B05_Profile_UI_RouteEdit_Chrome.ts")
+ stations = chrome.index('data-act="stations"')
+ slopes = chrome.index('data-act="slopes"')
+ assert stations < slopes < chrome.index('data-act="footprint"')
+ assert ">측점" in chrome and ">기울기" in chrome
+ assert '[data-act="stations"]\')!, "stations")' in chrome
+ assert '[data-act="slopes"]\')!, "slopes")' in chrome
+
+
+def test_측점_끄면_눈금만_빠지고_이름표_남음():
+ render = _read("B05_Profile_UI_RouteEdit_Render.ts")
+ body = render[render.index("function drawStationMarks(") : render.index("function endLabel(")]
+ # 라벨 상자를 먼저 비우고(누름 판정) · 눈금은 켬일 때만 · 이름표는 밖
+ assert body.index("setStationLabelBoxes(") < body.index('if (markShown("stations"))')
+ guard = body.index('if (markShown("stations"))')
+ assert guard < body.index("drawStationTicks(") < body.index("`시점 ")
+ assert body.count("endLabel(") == 2
+
+
+def test_기울기_끄면_라벨도_높이_요청도_없음():
+ render = _read("B05_Profile_UI_RouteEdit_Render.ts")
+ assert 'if (markShown("slopes")) scene.overStations?.(context);' in render
+ assert render.count("scene.overStations?.(context)") == 1
+
+
+def test_측점_끄면_눈금_집기_없음():
+ pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
+ # 집기에 쓰는 눈금 찾기는 켬일 때만 — 라벨 0개면 전부 집히는 함정
+ at = 0
+ calls = 0
+ while (at := pointer.find("stationAtScreen(", at + 1)) >= 0:
+ calls += 1
+ assert 'markShown("stations")' in pointer[at - 300 : at], at
+ assert calls >= 1
diff --git a/resources/tester/test_381_3_routeedit_node_pick.py b/resources/tester/test_381_3_routeedit_node_pick.py
new file mode 100644
index 000000000..2cf8264f1
--- /dev/null
+++ b/resources/tester/test_381_3_routeedit_node_pick.py
@@ -0,0 +1,50 @@
+"""381-3 B05 평면 교각점(요구 241-14) — 크기 · 그리는 차례 · 맴돔 강조 · 집기 한 길.
+
+겹침 셈(`pickAtScreen`)은 test_b05_routeedit_pick 이 돌려 봄 — 여기는 배선만.
+"""
+
+from pathlib import Path
+
+B05 = Path(__file__).resolve().parents[2] / "B05_Profile"
+
+
+def _read(name: str) -> str:
+ return (B05 / name).read_text(encoding="utf-8")
+
+
+def test_노드_크기_문턱():
+ assert "const NODE_R = 6;" in _read("B05_Profile_UI_RouteEdit_Render.ts")
+ pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
+ assert "const NODE_HIT_PX = 12;" in pointer and "const NODE_CORE_PX = 6;" in pointer
+
+
+def test_노드는_라벨_뒤_맨_위_회전_안():
+ render = _read("B05_Profile_UI_RouteEdit_Render.ts")
+ scene = render[render.index("export function drawRouteEditScene(") :]
+ scene = scene[: scene.index("context.restore(); // 회전 끝")]
+ labels = scene.index("drawContourLabels(context, scene, band, style);")
+ for later in ("scene.planned.forEach(", "scene.curveInfo.forEach(", "drawPickHover("):
+ assert labels < scene.index(later), later
+ for earlier in ("drawStationMarks(", "scene.overStations?.(", "drawMeasureMarks("):
+ assert scene.index(earlier) < labels, earlier
+
+
+def test_집기_맴돔_한_함수():
+ pointer = _read("B05_Profile_UI_RouteEdit_Pointer.ts")
+ assert "pickAtScreen(" in pointer
+ down = pointer[pointer.index('addEventListener("pointerdown"') :]
+ down = down[: down.index('addEventListener("pointermove"')]
+ assert "hover(null);" in down and "const pick = pickAt(px, py);" in down
+ assert "nodeAt(px, py)" not in down and "stationAtScreen(" not in down
+ move = pointer[pointer.index('addEventListener("pointermove"') :]
+ move = move[: move.index("const endDrag")]
+ # 끄는 동안은 앞에서 돌아감 — 맴돔은 끌기 갈래 뒤
+ assert move.index("if (dragNode >= 0)") < move.index("pickAt(px, py)")
+ assert "hover(over);" in move
+ assert 'addEventListener("pointerleave"' in pointer
+
+
+def test_맴돔_그림():
+ hover = _read("B05_Profile_UI_RouteEdit_Hover.ts")
+ assert "pickHover(context.canvas)" in hover
+ assert '"node" in hover' in hover and "stationFrameAt(line, hover.chainage)" in hover
diff --git a/resources/tester/test_b05_routeedit_chrome_modal.py b/resources/tester/test_b05_routeedit_chrome_modal.py
index 0214f08ef..8cfe246f3 100644
--- a/resources/tester/test_b05_routeedit_chrome_modal.py
+++ b/resources/tester/test_b05_routeedit_chrome_modal.py
@@ -38,8 +38,13 @@ def test_겉틀은_본문_칸():
assert ">저장 전으로" not in chrome and "노선 반영" not in chrome
assert ">취소" not in chrome and ">확인" not in chrome
# 352-2 — 되돌리기 · 다시하기 = 글 없이 기호 + aria-label · title(단축키)
- assert 'aria-label="되돌리기" disabled>↶' in chrome
- assert 'aria-label="다시하기" disabled>↷' in chrome
+ # 381-1 — 곧은 꼬리 + 갈고리 SVG(회전 반 바퀴 그림과 헷갈리지 않게)
+ assert 'aria-label="되돌리기" disabled>${HISTORY_ICON.undo}' in chrome
+ assert 'aria-label="다시하기" disabled>${HISTORY_ICON.redo}' in chrome
+ assert ">↶" not in chrome and ">↷" not in chrome
+ tools = _read("B05_Profile_UI_Profile_Tools.ts")
+ assert "undo.innerHTML = HISTORY_ICON.undo;" in tools
+ assert "redo.innerHTML = HISTORY_ICON.redo;" in tools
assert 'title="되돌리기 (Ctrl+Z)"' in chrome and 'title="다시하기 (Ctrl+Y)"' in chrome
# 시험 · 배선이 집는 자리는 남김
assert "b05-routeedit__measure-close" in chrome
diff --git a/resources/tester/test_b05_routeedit_initial.py b/resources/tester/test_b05_routeedit_initial.py
index 43647248b..9b1b1429a 100644
--- a/resources/tester/test_b05_routeedit_initial.py
+++ b/resources/tester/test_b05_routeedit_initial.py
@@ -223,6 +223,8 @@ def test_노선_추천_다듬기_23_3():
'data-act="rotate-ccw"',
'data-act="rotate-cw"',
'data-act="contour-minor"',
+ 'data-act="stations"', # 381-2 [측점] [기울기] = 절/성토선 왼쪽
+ 'data-act="slopes"',
'data-act="footprint"',
'data-act="measure"',
"|",
diff --git a/resources/tester/test_b05_routeedit_pick.py b/resources/tester/test_b05_routeedit_pick.py
index e6bf32b03..250687035 100644
--- a/resources/tester/test_b05_routeedit_pick.py
+++ b/resources/tester/test_b05_routeedit_pick.py
@@ -69,3 +69,16 @@ def test_라벨이_없거나_빈_곳은_안_고름(results):
label = results["label"]
assert label["emptyNoLabels"] is None
assert label["emptyElsewhere"] is None
+
+
+def test_381_3_노드_눈금_겹침은_가까운_쪽(results):
+ o = results["overlap"]
+ assert o["center"] == {"node": 1}
+ assert o["coreNearTick"] == {"node": 1} # 가운데 6px 안 = 노드
+ assert o["tickCloser"] == {"chainage": 40}
+ assert o["nodeCloser"] == {"node": 1}
+ assert o["edgeTick"] == {"chainage": 40}
+ assert o["stationsOff"] == {"node": 1} # [측점] 끔 = 눈금 안 봄
+ assert o["far"] is None
+ assert o["tickOnly"] == {"chainage": 80}
+ assert o["distance"] == {"chainage": 40, "distance": 6}