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.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_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 {