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_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts index 8740fff3d..7117cc376 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -21,9 +21,13 @@ 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 { @@ -33,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) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ @@ -121,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)); @@ -156,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); @@ -189,20 +230,8 @@ export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditP } else if ( // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. (() => { - // [측점] 끔(381-2) — 라벨 0개면 눈금이 전부 집히므로 아예 안 봄. - if (!markShown("stations")) return false; - 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; })() ) { @@ -299,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 @@ -309,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 7e4d3d596..2f317ef30 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -26,6 +26,7 @@ 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"; @@ -34,8 +35,8 @@ 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). @@ -247,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)"; @@ -305,14 +314,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou }); }); context.restore(); - - 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); + drawPickHover(context, scene, line, style, NODE_R); context.restore(); // 회전 끝 } 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_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_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}