From 5b41cfaf010549267d6af122e1d4195ce3280d4f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:20:52 +0900 Subject: [PATCH 1/5] =?UTF-8?q?refactor(B05):=20=EA=B3=84=ED=9A=8D?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20=EB=88=84?= =?UTF-8?q?=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=81=8C=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=86=93=EA=B8=B0=EB=A5=BC=20=5FRouteEdit=5FPointer.ts=20?= =?UTF-8?q?=EB=A1=9C=20=EB=96=BC=EC=96=B4=EB=83=84(697=20=E2=86=92=20529?= =?UTF-8?q?=EC=A4=84=20=C2=B7=20=EB=8F=99=EC=9E=91=20=EA=B7=B8=EB=8C=80?= =?UTF-8?q?=EB=A1=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BU9gbNE3aWFUvE61pS2WGt --- B05_Profile/B05_Profile_UI_RouteEdit.ts | 266 +++------------ .../B05_Profile_UI_RouteEdit_Pointer.ts | 310 ++++++++++++++++++ 2 files changed, 359 insertions(+), 217 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index dde74057..90c2c7ce 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -15,7 +15,6 @@ import { computeMapRect, - hitPreparedLayer, metricToScreen, pickLevelStep, type PreparedLayer, @@ -33,19 +32,12 @@ import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; import { buildEditedPolyline, - dragHandleTo as curveDragTo, type EditedCurve, type EditedNode, } from "./B05_Profile_UI_RouteEdit_Curve"; import { drawRouteEditScene, polylineLengthM } from "./B05_Profile_UI_RouteEdit_Render"; -import { - bindNodeSplice, - bindRouteEditNavigation, - handleAtScreen, - nodeAtScreen, - segmentAtScreen, - stationAtScreen, -} from "./B05_Profile_UI_RouteEdit_Input"; +import { bindNodeSplice, bindRouteEditNavigation } from "./B05_Profile_UI_RouteEdit_Input"; +import { bindRouteEditPointer } from "./B05_Profile_UI_RouteEdit_Pointer"; import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; @@ -64,11 +56,8 @@ import { createInitialPanel } from "./B05_Profile_UI_RouteEdit_Initial"; import { applyArcLocks, applyCurveLimits, - curveShortfalls, - radiusFloorM, curveSummary, flattenServerPlan, - shortfallCrossed, spliceNode, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; @@ -80,14 +69,6 @@ import { } from "./B05_Profile_UI_RouteEdit_History"; import "./B05_Profile_UI_Style_RouteEdit.css"; -/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ -const NODE_HIT_PX = 9; -/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ -const SEGMENT_HIT_PX = 12; -/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ -const CONTOUR_HIT_PX = 6; -/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ -const STATION_HIT_PX = 11; /** 등고선 눈금을 고를 때 쓸 라벨 예산 — B04 지도 기본값(350)보다 **넉넉히** 둔다 * (2026-09-12 사용자 지시 ⑦ 「좀더 촘촘히」). 편집 창은 노선 둘레 300m 띠만 보이므로 * 같은 예산으로도 화면에 드는 줄이 적어 눈금이 필요 이상으로 성겼다. */ @@ -284,7 +265,7 @@ export async function openRouteEditModal( pxPerMeter: pxPerMeter(), toScreen, uprightRad: rotation.uprightRad(), - dragging: dragNode >= 0 || dragHandle !== null, + dragging: pointer.dragging(), }), measure: measure.marks(), crossWidth: crossPreview.watched(), @@ -302,38 +283,6 @@ export async function openRouteEditModal( viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다. } - /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ - const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => - handleAtScreen( - // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). - curveInfo.filter( - (entry) => - (curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true, - ), - toScreen, - px, - py, - NODE_HIT_PX + 2, - ); - const nodeAt = (px: number, py: number): number => - nodeAtScreen(planned, toScreen, px, py, NODE_HIT_PX); - const segmentAt = (px: number, py: number): number => - segmentAtScreen(planned, toScreen, px, py, SEGMENT_HIT_PX); - - /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ - function dragHandleTo( - node: number, - which: "start" | "end", - to: Vertex, - ): { apex: Vertex; radius: number } | null { - const curve = curveInfo.find((entry) => entry.node_first === node); - if (!curve) return null; - const before = planned[node - 1]; - const after = planned[node + 1]; - if (!before || !after) return null; - return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); - } - /** 노드를 고쳤다 — **곡선을 그 자리에서 다시 그린다**(2026-09-07 사용자 지적 ①②). * * 예전에는 그려 둔 선과 손잡이를 통째로 비웠다. 그러면 노드 하나만 건드려도 **곡선이 전부 @@ -434,176 +383,59 @@ export async function openRouteEditModal( restore: (snapshot, message) => restoreSnapshot(snapshot, message, false), }); - // ── 조작 — 노드 끌기 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── + // ── 조작 — 노드 끌기 · 집기는 `_Pointer` 몫 / 두 번 클릭 삽입 / 오른쪽 클릭 삭제 ── // 지도 이동·확대는 `bindRouteEditNavigation`(가운데 버튼 팬 · 휠) 몫이다. - let dragNode = -1; - /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ - let dragHandle: { node: number; end: "start" | "end" } | null = null; - /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 - * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ - let dragMoved = false; - - canvas.addEventListener("pointerdown", (event) => { - if (event.button !== 0) return; - const rect = canvas.getBoundingClientRect(); - const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - if (event.shiftKey || measureControls.mode()) { - // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). - void measure.pick(px, py); - return; - } - // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 - // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 - // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. - const hitNode = nodeAt(px, py); - // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). - dragNode = hitNode >= 0 && apexLock[hitNode] !== true ? hitNode : -1; - dragHandle = hitNode >= 0 ? null : handleAt(px, py); - dragMoved = false; - if (hitNode >= 0) { - picked = hitNode; // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. - measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). - syncCurveBar(); - draw(); - } else if (dragHandle) { - picked = dragHandle.node; - measure.clear(); - syncCurveBar(); - draw(); - } else if ( - // 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나 - // 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다. - ((): boolean => { - if (picked >= 0) { - picked = -1; - syncCurveBar(); - } - return false; - })() - ) { - /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ - } else if ( - // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. - (() => { - const chainage = stationAtScreen( - plannedLine.length ? plannedLine : planned, - toScreen, - stationIntervalM, - px, - py, - STATION_HIT_PX, - ); - if (chainage === null) return false; - void crossPreview.open(chainage); - return true; - })() - ) { - /* 횡단 창이 떴다 — 더 집지 않는다. */ - } else if (contours) { - // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). - // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다. - const hit = hitPreparedLayer(contours.layer, view, px, py, CONTOUR_HIT_PX, contourStepM()); - if (hit !== pickedContour) { - pickedContour = hit; - status.textContent = `${routeHead()} — ${contourHint()} ${curveHint()}`; - draw(); - } - } - canvas.setPointerCapture(event.pointerId); - }); - - canvas.addEventListener("pointermove", (event) => { - const rect = canvas.getBoundingClientRect(); - const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); - if (dragHandle) { - // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). - const node = dragHandle.node; - const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); - if (moved) { - planned[node] = moved.apex; - // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). - // 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다. - curveRadius[node] = Math.max( - radiusFloorM(nodeInfo[node]?.inner_angle_deg, limitRadiusM, limitArcM), - Math.round(moved.radius * 100) / 100, - ); - curveOn[node] = true; - picked = node; - // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 - // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. - dragMoved = true; - markEdited(); - syncCurveBar(); - status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`; - draw(); - } - return; - } - if (dragNode >= 0) { - // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 - // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). - const before = curveShortfalls(nodeInfo, limitRadiusM, limitArcM); - const previous = planned[dragNode]; - dragMoved = true; - planned[dragNode] = toMetric(px, py); - markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. - if (shortfallCrossed(before, curveShortfalls(nodeInfo, limitRadiusM, limitArcM))) { - // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. - planned[dragNode] = previous; - markEdited(); - status.textContent = - `${routeHead()} — 하한에 걸려 더 못 옮깁니다` + - `(곡선반지름 ${limitRadiusM}m${limitArcM > 0 ? ` · 곡선 길이 ${limitArcM}m` : ""}).`; - draw(); - return; - } - // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 - // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). - status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; - draw(); - return; - } - const overNode = nodeAt(px, py); - // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. - canvas.style.cursor = - overNode >= 0 - ? apexLock[overNode] - ? "not-allowed" - : "grab" - : handleAt(px, py) - ? "grab" - : "default"; - }); - - const endDrag = (event: PointerEvent): void => { - if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); - // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 - // 눌러야 한다. 놓는 순간에만 쌓는다. - if (dragMoved) { + const pointer = bindRouteEditPointer({ + canvas, + status, + stationIntervalM, + state: () => ({ + planned, + plannedLine, + nodeInfo, + curveInfo, + curveOn, + curveRadius, + curveLock, + apexLock, + limitRadiusM, + limitArcM, + picked, + pickedContour, + contours, + view, + }), + setPicked: (index) => { + picked = index; + }, + setPickedContour: (index) => { + pickedContour = index; + }, + toScreen, + toMetric, + unrotate: rotation.unrotate, + contourStepM, + measure, + measureMode: () => measureControls.mode(), + crossPreview, + slopeRefresh: () => slope.refresh(), + markEdited, + syncCurveBar: () => syncCurveBar(), + draw, + routeHead, + curveHint, + contourHint, + commit: () => { history?.commit(snapshotNow()); historyControls.sync(); - // 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲). - // 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초). - void crossPreview.refresh(); - } - const moved = dragMoved; - dragNode = -1; - dragHandle = null; - dragMoved = false; - // 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①). - if (moved) { - slope.refresh(); - draw(); - } - }; - canvas.addEventListener("pointerup", endDrag); - canvas.addEventListener("pointercancel", endDrag); + }, + }); bindNodeSplice({ canvas, unrotate: rotation.unrotate, - segmentAt, - nodeAt, + segmentAt: pointer.segmentAt, + nodeAt: pointer.nodeAt, insert: (segment, px, py) => { spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. picked = segment + 1; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts new file mode 100644 index 00000000..173e4ec4 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Pointer.ts @@ -0,0 +1,310 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Pointer.ts + * 계획노선 편집 모달의 **캔버스 누르기 · 끌기 · 놓기** — 노드 · 곡선 손잡이 · 측점 눈금 · + * 등고선을 집고, 노드와 손잡이를 끌어 옮긴다. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄에 닿아 떼어낸 조각이다. 본문 로직과 수치는 + * 그대로이고, 모달 클로저가 쥐고 있던 값만 `state()` 로 받는다. 배열은 모달이 쥔 것을 + * 그대로 건네받아 **제자리에서** 고친다(되돌리기 사진첩이 같은 배열을 본다). + * ========================================================================== */ + +import { hitPreparedLayer, type ViewState } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; +import { + dragHandleTo as curveDragTo, + type EditedCurve, + type EditedNode, + type Vertex, +} from "./B05_Profile_UI_RouteEdit_Curve"; +import { + handleAtScreen, + nodeAtScreen, + segmentAtScreen, + stationAtScreen, +} from "./B05_Profile_UI_RouteEdit_Input"; +import { + curveShortfalls, + radiusFloorM, + shortfallCrossed, + type CurveLock, +} from "./B05_Profile_UI_RouteEdit_Edits"; + +/** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ +const NODE_HIT_PX = 9; +/** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ +const SEGMENT_HIT_PX = 12; +/** 등고선을 집었다고 볼 거리(px) — 노드·손잡이보다 **좁게** 둔다(노선 편집이 먼저). */ +const CONTOUR_HIT_PX = 6; +/** 측점 눈금을 집었다고 볼 거리(px) — 눈금이 보이는 자리를 누르면 잡히게 넉넉히. */ +const STATION_HIT_PX = 11; + +/** 누르기·끌기가 보는 편집값 — 모달이 쥔 배열을 그대로 건네받는다. */ +export interface RouteEditPointerState { + planned: Vertex[]; + plannedLine: Vertex[]; + nodeInfo: EditedNode[]; + curveInfo: EditedCurve[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + /** 자리를 못 박은 노드 — 켜진 자리는 고르기만 되고 안 끌린다. */ + apexLock: boolean[]; + limitRadiusM: number; + limitArcM: number; + picked: number; + pickedContour: number; + contours: RouteEditContours | null; + view: ViewState; +} + +export interface RouteEditPointerParams { + canvas: HTMLCanvasElement; + status: HTMLElement; + stationIntervalM: number; + state: () => RouteEditPointerState; + setPicked: (index: number) => void; + setPickedContour: (index: number) => void; + toScreen: (vertex: Vertex) => [number, number]; + toMetric: (px: number, py: number) => Vertex; + /** 돌린 지도의 화면 좌표를 돌리기 전 자리로. */ + unrotate: (x: number, y: number) => [number, number]; + contourStepM: () => number; + measure: { pick: (px: number, py: number) => unknown; clear: () => void }; + /** 재기 단추가 켜져 있나 — 켜져 있으면 노드 위에서도 잰다. */ + measureMode: () => boolean; + crossPreview: { open: (chainageM: number) => unknown; refresh: () => unknown }; + slopeRefresh: () => void; + markEdited: () => void; + syncCurveBar: () => void; + draw: () => void; + routeHead: () => string; + curveHint: () => string; + contourHint: () => string; + /** 끌기 한 번을 되돌리기에 쌓는다. */ + commit: () => void; +} + +export interface RouteEditPointer { + /** 노드나 손잡이를 끌고 있나 — 끄는 동안 기울기 라벨을 숨긴다. */ + dragging: () => boolean; + nodeAt: (px: number, py: number) => number; + segmentAt: (px: number, py: number) => number; +} + +export function bindRouteEditPointer(params: RouteEditPointerParams): RouteEditPointer { + const { canvas, status, toScreen, toMetric } = params; + + /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ + const handleAt = (px: number, py: number): { node: number; end: "start" | "end" } | null => { + const { curveInfo, curveLock, apexLock } = params.state(); + return handleAtScreen( + // 붙들어 둔 곡선은 손잡이로도 안 바뀐다 — 끌면 R 이 바뀌기 때문(사용자 지시 5). + curveInfo.filter( + (entry) => + (curveLock[entry.node_first] ?? null) === null && apexLock[entry.node_first] !== true, + ), + toScreen, + px, + py, + NODE_HIT_PX + 2, + ); + }; + const nodeAt = (px: number, py: number): number => + nodeAtScreen(params.state().planned, toScreen, px, py, NODE_HIT_PX); + const segmentAt = (px: number, py: number): number => + segmentAtScreen(params.state().planned, toScreen, px, py, SEGMENT_HIT_PX); + + /** 끈 접선점으로 새 교각점·새 반지름을 구한다 — 셈은 `_RouteEdit_Curve` 몫. */ + function dragHandleTo( + node: number, + which: "start" | "end", + to: Vertex, + ): { apex: Vertex; radius: number } | null { + const { curveInfo, planned } = params.state(); + const curve = curveInfo.find((entry) => entry.node_first === node); + if (!curve) return null; + const before = planned[node - 1]; + const after = planned[node + 1]; + if (!before || !after) return null; + return curveDragTo(before, [curve.apex[0], curve.apex[1]], after, which, to); + } + + let dragNode = -1; + /** 끌고 있는 곡선 손잡이(시작·끝점). **고른 곡선에만** 있다. */ + let dragHandle: { node: number; end: "start" | "end" } | null = null; + /** 이번 끌기에서 **실제로 움직였나** — 그냥 눌러 고르기만 한 것은 되돌릴 걸음이 아니다 + * (2026-09-07 실화면: 노드를 클릭만 해도 [되돌리기]가 켜졌다). */ + let dragMoved = false; + + canvas.addEventListener("pointerdown", (event) => { + if (event.button !== 0) return; + const rect = canvas.getBoundingClientRect(); + const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + if (event.shiftKey || params.measureMode()) { + // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). + void params.measure.pick(px, py); + return; + } + const state = params.state(); + // **노드가 손잡이보다 먼저다**(2026-09-07 사용자 지적 ④). 반대로 두었더니 헤어핀처럼 + // 곡선이 몰린 데서는 손잡이가 늘 먼저 잡혀 **노드를 아예 못 집었다**(실화면에서 격자로 + // 훑어 보니 잡히는 것이 전부 손잡이였음). 손잡이는 고른 곡선에만 나오므로 겹침도 적다. + const hitNode = nodeAt(px, py); + // 교각점을 고정한 자리는 **고르기만** 되고 안 끌린다(2026-09-12 사용자 지시 ①). + dragNode = hitNode >= 0 && state.apexLock[hitNode] !== true ? hitNode : -1; + dragHandle = hitNode >= 0 ? null : handleAt(px, py); + dragMoved = false; + if (hitNode >= 0) { + params.setPicked(hitNode); // 누른 자리를 고른다 — R 라벨이 그 곡선을 만진다. + params.measure.clear(); // 잰 창과 곡선 패널은 같이 뜨지 않는다(㉔). + params.syncCurveBar(); + params.draw(); + } else if (dragHandle) { + params.setPicked(dragHandle.node); + params.measure.clear(); + params.syncCurveBar(); + params.draw(); + } else if ( + // 노드도 손잡이도 아니면 **고른 꺾임점을 푼다**(계획서 0-9 ㉖) — 고른 자리를 벗어나 + // 눌렀는데 패널이 그대로 떠 있으면 무엇을 만지고 있는지 헷갈린다. + ((): boolean => { + if (state.picked >= 0) { + params.setPicked(-1); + params.syncCurveBar(); + } + return false; + })() + ) { + /* 여기로는 안 온다 — 위 갈래는 선택만 풀고 다음 갈래로 넘긴다. */ + } else if ( + // 측점 눈금을 누르면 그 측점 횡단을 따로 띄운다(계획서 0-9 ⑧). 노드·손잡이 다음이다. + (() => { + const chainage = stationAtScreen( + state.plannedLine.length ? state.plannedLine : state.planned, + toScreen, + params.stationIntervalM, + px, + py, + STATION_HIT_PX, + ); + if (chainage === null) return false; + void params.crossPreview.open(chainage); + return true; + })() + ) { + /* 횡단 창이 떴다 — 더 집지 않는다. */ + } else if (state.contours) { + // 노드도 손잡이도 아니면 **등고선**을 집는다 — 노선 편집이 늘 먼저다(계획서 0-9 ⑦). + // 빈 자리를 누르면 -1 이 되어 고른 것이 풀린다. + const hit = hitPreparedLayer( + state.contours.layer, + state.view, + px, + py, + CONTOUR_HIT_PX, + params.contourStepM(), + ); + if (hit !== state.pickedContour) { + params.setPickedContour(hit); + status.textContent = `${params.routeHead()} — ${params.contourHint()} ${params.curveHint()}`; + params.draw(); + } + } + canvas.setPointerCapture(event.pointerId); + }); + + canvas.addEventListener("pointermove", (event) => { + const rect = canvas.getBoundingClientRect(); + const [px, py] = params.unrotate(event.clientX - rect.left, event.clientY - rect.top); + const state = params.state(); + if (dragHandle) { + // 곡선 시작·끝점을 끈다 — 그쪽 직선 각도와 반지름이 함께 바뀐다(2026-09-07 사용자 확정). + const node = dragHandle.node; + const moved = dragHandleTo(node, dragHandle.end, toMetric(px, py)); + if (moved) { + state.planned[node] = moved.apex; + // 손으로 끌어도 하한 아래로는 안 내려간다 — 거기서 멈춘다(계획서 0-9 ④). + // 하한은 그 자리의 교각까지 본 값이라 L 하한도 함께 지켜진다. + state.curveRadius[node] = Math.max( + radiusFloorM(state.nodeInfo[node]?.inner_angle_deg, state.limitRadiusM, state.limitArcM), + Math.round(moved.radius * 100) / 100, + ); + state.curveOn[node] = true; + params.setPicked(node); + // 손잡이 자리는 다시 셈한 곡선에서 나온다 — 접선 자리가 모자라 R 이 눌리면 손이 + // 끄는 자리보다 덜 따라오고, 그 눌림이 그 자리에서 눈에 보인다. + dragMoved = true; + params.markEdited(); + params.syncCurveBar(); + status.textContent = `${params.routeHead()} — 곡선을 잡는 중. ${params.curveHint()}`; + params.draw(); + } + return; + } + if (dragNode >= 0) { + // 옮기기 **전**에 하한을 지키던 자리 — 이미 밑돌던 자리는 그대로 고칠 수 있어야 하므로 + // **지키던 자리가 넘어가는 것만** 막는다(계획서 0-9 ④). + const before = curveShortfalls(state.nodeInfo, state.limitRadiusM, state.limitArcM); + const previous = state.planned[dragNode]; + dragMoved = true; + state.planned[dragNode] = toMetric(px, py); + params.markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. + const after = params.state(); + if ( + shortfallCrossed( + before, + curveShortfalls(after.nodeInfo, after.limitRadiusM, after.limitArcM), + ) + ) { + // 접선 자리가 모자라 R 이 하한 아래로 눌리는 자리다 — 그 걸음만 되돌린다. + state.planned[dragNode] = previous; + params.markEdited(); + status.textContent = + `${params.routeHead()} — 하한에 걸려 더 못 옮깁니다` + + `(곡선반지름 ${state.limitRadiusM}m${state.limitArcM > 0 ? ` · 곡선 길이 ${state.limitArcM}m` : ""}).`; + params.draw(); + return; + } + // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 + // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). + status.textContent = `${params.routeHead()} — 옮기는 중. ${params.curveHint()}`; + params.draw(); + return; + } + const overNode = nodeAt(px, py); + // 고정한 교각점 위에서는 **못 끈다**고 커서로 먼저 알린다. + canvas.style.cursor = + overNode >= 0 + ? state.apexLock[overNode] + ? "not-allowed" + : "grab" + : handleAt(px, py) + ? "grab" + : "default"; + }); + + const endDrag = (event: PointerEvent): void => { + if (canvas.hasPointerCapture(event.pointerId)) canvas.releasePointerCapture(event.pointerId); + // 끌기 **한 번**이 되돌리기 한 걸음이다 — 프레임마다 쌓으면 한 번 물리는 데 수십 번 + // 눌러야 한다. 놓는 순간에만 쌓는다. + if (dragMoved) { + params.commit(); + // 노선이 바뀌었다 — 보던 측점 횡단을 다시 셈해 **전후로** 늘어놓는다(계획서 0-9 ⑲). + // 끄는 동안에는 한 번도 안 부른다(한 장에 0.7초). + void params.crossPreview.refresh(); + } + const moved = dragMoved; + dragNode = -1; + dragHandle = null; + dragMoved = false; + // 끄는 동안 숨겨 둔 기울기 라벨 — 놓은 노선 높이를 한 번 묻게 다시 그린다(계획서 18 ①). + if (moved) { + params.slopeRefresh(); + params.draw(); + } + }; + canvas.addEventListener("pointerup", endDrag); + canvas.addEventListener("pointercancel", endDrag); + + return { dragging: () => dragNode >= 0 || dragHandle !== null, nodeAt, segmentAt }; +} From 074d9aec1316934fac6d5f8b2351f043cc01b5a4 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 29 Sep 2026 19:28:20 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(B05):=20=EA=B3=84=ED=9A=8D=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=ED=8E=B8=EC=A7=91=20=EC=B0=BD=20=E2=80=94=20[?= =?UTF-8?q?=EB=85=B8=EC=84=A0=20=EC=B6=94=EC=B2=9C]=20=EB=AA=A8=EB=8B=AC(?= =?UTF-8?q?=EC=95=8C=EC=95=BD=20=EB=84=B7=20=C2=B7=20=EB=AF=B8=EB=A6=AC=20?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=20=EC=A7=80=EB=8F=84=20=C2=B7=20=EB=B9=84?= =?UTF-8?q?=EC=A4=91=20=C2=B7=20=EC=A0=9C=EC=95=BD=20=EC=B9=B8)=20=C2=B7?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94=20=3D=20=EC=A0=84=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EC=B4=88=EA=B8=B0=EA=B0=92=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=C2=B7=20=EC=97=B0=20=EC=83=81=ED=83=9C=EB=A1=9C=20=EB=8B=A8?= =?UTF-8?q?=EC=B6=94=20=EB=BA=8C=20(PLAN=2023-2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 도구 막대: 추천 묶음을 빼고 「1m 등고선」 왼쪽에 구분선 + [노선 추천] 하나 · 「연 상태로」 뺌 · 「현재 추천: ○○」 표시 - 모달(_Recommend): 알약 넷(예상노선 추적 기본) · 비중 슬라이더 · 제약 칸 · 미리 보기 지도 · 비교값 · 못 지킨 구간 · [적용] · [닫기] - [초기화]: 재계산 없이 GET /route/plan 의 initial_route(전처리 초기값 노선)를 놓음 - 서버: GET /route/plan 에 initial_route(노드 · 곡선) 추가 - 시험: 헬퍼 · 왕복 시험 갱신(추적 표기 · initial_route · 지도 선) Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_019BVZd1M6WUf1YEy9gbAE1G --- B05_Profile/B05_Profile_Api_Replan.ts | 2 + B05_Profile/B05_Profile_Api_RouteInitial.ts | 2 +- B05_Profile/B05_Profile_Router_Replan.py | 19 + .../B05_Profile_UI_RouteEdit_Chrome.ts | 8 +- .../B05_Profile_UI_RouteEdit_History.ts | 16 +- .../B05_Profile_UI_RouteEdit_Initial.ts | 337 +++++------------ .../B05_Profile_UI_RouteEdit_Initial_Model.ts | 52 ++- .../B05_Profile_UI_RouteEdit_Recommend.ts | 353 ++++++++++++++++++ .../B05_Profile_UI_Style_RouteEdit.css | 107 ------ .../B05_Profile_UI_Style_RouteRecommend.css | 223 +++++++++++ .../tester/helper_b05_routeedit_initial.cjs | 27 ++ .../tester/test_b05_routeedit_initial.py | 23 +- .../test_b05_routeedit_initial_roundtrip.py | 33 +- 13 files changed, 823 insertions(+), 379 deletions(-) create mode 100644 B05_Profile/B05_Profile_UI_RouteEdit_Recommend.ts create mode 100644 B05_Profile/B05_Profile_UI_Style_RouteRecommend.css diff --git a/B05_Profile/B05_Profile_Api_Replan.ts b/B05_Profile/B05_Profile_Api_Replan.ts index b1690721..1b49d5d6 100644 --- a/B05_Profile/B05_Profile_Api_Replan.ts +++ b/B05_Profile/B05_Profile_Api_Replan.ts @@ -68,6 +68,8 @@ export interface RoutePlanResponse { edited: boolean; /** 현재 초기값 — 서버가 [확인] 때 프로젝트에 적어 둔 초기노선 갈래(계획서 20). */ current_initial?: { logic: string; weights: Record | null }; + /** 전처리가 만든 초기 노선(예상노선 추적)의 노드 · 곡선 — [초기화]가 재계산 없이 놓는다. */ + initial_route?: { nodes: RoutePlanNode[]; curves: RoutePlanCurve[] }; } export interface RouteReplanResponse { diff --git a/B05_Profile/B05_Profile_Api_RouteInitial.ts b/B05_Profile/B05_Profile_Api_RouteInitial.ts index 7f8b6653..b846d9e9 100644 --- a/B05_Profile/B05_Profile_Api_RouteInitial.ts +++ b/B05_Profile/B05_Profile_Api_RouteInitial.ts @@ -14,7 +14,7 @@ import { requestJson } from "./B05_Profile_Api_Replan"; /** 갈래 셈 대기 상한 — 노선 여러 벌을 셈해 견주므로 넉넉히 잡는다. */ const INITIAL_TIMEOUT_MS = 5 * 60 * 1000; -/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추종(처음 초기값). */ +/** 로직 이름 — 서버와 같은 낱말을 쓴다. `follow` 는 예상노선 추적(처음 초기값). */ export type RouteInitialLogic = "follow" | "earthwork" | "gentle" | "weighted"; /** 비중 계산의 세 비중(%) — 합 100. */ diff --git a/B05_Profile/B05_Profile_Router_Replan.py b/B05_Profile/B05_Profile_Router_Replan.py index 42edf351..1a3e0ac8 100644 --- a/B05_Profile/B05_Profile_Router_Replan.py +++ b/B05_Profile/B05_Profile_Router_Replan.py @@ -267,6 +267,24 @@ def _read_curves(path: Path) -> list[dict]: return curves if isinstance(curves, list) else [] +def _initial_shape(project_root: Path, fallback: list, radius_m: float) -> dict[str, list]: + """전처리가 만든 **초기 노선**의 노드 · 곡선 — 화면 [초기화]가 재계산 없이 놓는 자리. + + 수정본이 있어도 초기 폴리라인 파일은 그대로라 항상 같은 노선이 나온다. + """ + path = planned_route_initial_path(project_root) + saved_nodes = _vertices_of(_nodes_path(path)) + outline = build_planned_polyline( + [(x, y) for x, y in (saved_nodes or _vertices_of(path) or fallback)], + min_radius_m=radius_m, + simplify=not saved_nodes, + ) + return { + "nodes": [node.as_dict() for node in outline.nodes], + "curves": _read_curves(path) or [curve.as_dict() for curve in outline.curves], + } + + def _write_planned_polyline( path: Path, points: list[tuple[float, float]], @@ -516,6 +534,7 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse: "violation_count": outline.violation_count, "edited": bool(working), "current_initial": await asyncio.to_thread(read_initial_choice, project_root), + "initial_route": await asyncio.to_thread(_initial_shape, project_root, expected, radius_m), } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index c830b3bb..cbeeee06 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -40,7 +40,11 @@ export function createRouteEditChrome(): RouteEditChrome {