diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index bdc2df3b..862ac1bc 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -281,9 +281,14 @@ export async function renderB05Route(root: HTMLElement): Promise { // 계획노선 편집 — 모달 [확인]에서 서버가 배수유역부터 다시 계산하므로, 끝나면 // 옛 노선 기준 캐시를 버리고 페이지를 새로 세운다([초기화]와 같은 뒷정리). onEditPlannedRoute: () => - void openRouteEditModal(activeProjectId, () => { - navigateTo(ROUTES.B05_PROFILE); - }), + void openRouteEditModal( + activeProjectId, + () => { + navigateTo(ROUTES.B05_PROFILE); + }, + // 측점 눈금 간격은 좌측 패널이 쥔 값을 그대로 넘긴다 — 모달이 따로 굳히지 않는다. + panel.values().stationInterval ?? undefined, + ), onTempSave: () => void tempSaveAction(actionContext), onGoCross: () => { // 페이지 이동 = 코리도 영구저장 시점(2026-08-23 사용자 확정) — 이동은 막지 않는다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 23f76c1e..a9524df5 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -16,7 +16,6 @@ import { computeMapRect, computeRouteView, - drawPreparedLayer, createNormalizer, metricToScreen, prepareLayer, @@ -28,11 +27,15 @@ import { clearDrafts, clearResults } from "../A00_Common/b_page_state"; import { showToast } from "@ui/ui_template_elements"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan, replanRoute, resetRoutePlan } from "./B05_Profile_Api_Replan"; -import type { RoutePlanCurve } from "./B05_Profile_Api_Replan"; -import { buildEditedPolyline, dragHandleTo as curveDragTo } from "./B05_Profile_UI_RouteEdit_Curve"; +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 { bindRouteEditNavigation, - contourBandRect, handleAtScreen, nodeAtScreen, segmentAtScreen, @@ -58,19 +61,8 @@ import "./B05_Profile_UI_Style_RouteEdit.css"; /** 노드를 잡았다고 볼 거리(px). 손가락·마우스 모두 무리 없는 크기. */ const NODE_HIT_PX = 9; -/** 노드 반지름(px). */ -const NODE_R = 4; /** 선을 두 번 눌러 노드를 끼울 때, 선에서 이만큼(px) 안쪽이면 그 선으로 본다. */ const SEGMENT_HIT_PX = 12; -/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). - * - * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 - * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 - * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ -const CONTOUR_BAND_M = 300; -/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. - * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ -const CURVE_HANDLE_PX = 5; type Vertex = [number, number]; @@ -78,6 +70,8 @@ type Vertex = [number, number]; export async function openRouteEditModal( projectId: string, onApplied: () => void | Promise, + /** 규칙 측점 간격(m) — 좌측 패널이 쥔 값을 그대로 받는다(코드에 굳히지 않는다). */ + stationIntervalM = 20, ): Promise { const overlay = document.createElement("div"); overlay.className = "b05-routeedit"; @@ -123,14 +117,10 @@ export async function openRouteEditModal( /** 사용자가 잡아 옮기는 **노드**(꺾임점). 서버가 이 노드로 폴리라인을 다시 만든다. */ let planned: Vertex[] = []; /** 노드마다의 반지름·내각·법정 위반 — 서버가 함께 내려 준다(표시용). */ - let nodeInfo: Array<{ - radius_m: number | null; - inner_angle_deg: number | null; - violations: string[]; - }> = []; + let nodeInfo: EditedNode[] = []; let minRadiusM = 0; /** 서버가 준 곡선 성분 — 손잡이(곡선 시작·끝점)를 그리는 재료. 편집하면 비운다. */ - let curveInfo: RoutePlanCurve[] = []; + let curveInfo: EditedCurve[] = []; /** 꺾임점마다의 편집값 — 곡선을 둘지, 반지름을 못박을지(2026-09-07 사용자 지시). */ let curveOn: boolean[] = []; let curveRadius: Array = []; @@ -199,111 +189,32 @@ export async function openRouteEditModal( return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; } - function strokePolyline(points: Vertex[], dash: number[], color: string, width: number): void { - if (points.length < 2) return; - context.save(); - context.setLineDash(dash); - context.strokeStyle = color; - context.lineWidth = width; - context.beginPath(); - points.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - if (index === 0) context.moveTo(x, y); - else context.lineTo(x, y); - }); - context.stroke(); - context.restore(); + /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. `metricToScreen` 이 선형이라 + * 100m 떨어진 두 점으로 잰다(1m 로 재면 반올림 오차가 그대로 비율에 실린다). */ + function pxPerMeter(): number { + if (!meta) return 1; + const [x0] = metricToScreen(meta, view, meta.x_min, meta.y_min); + const [x1] = metricToScreen(meta, view, meta.x_min + 100, meta.y_min); + return Math.abs(x1 - x0) / 100; } function draw(): void { if (closed) return; - const style = getComputedStyle(document.documentElement); - context.clearRect(0, 0, view.width, view.height); - context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; - context.fillRect(0, 0, view.width, view.height); - - context.save(); - // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 - // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). - const band = meta - ? contourBandRect(plannedLine.length ? plannedLine : planned, toScreen, CONTOUR_BAND_M) - : null; - if (band) { - context.beginPath(); - context.rect(band.x, band.y, band.width, band.height); - context.clip(); - } - context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; - context.lineWidth = 0.8; - for (const layer of sheets) drawPreparedLayer(context, layer, view, "dot"); - context.restore(); - - strokePolyline( + drawRouteEditScene(context, { + view, + toScreen, + pxPerMeter: pxPerMeter(), + hasMeta: meta !== null, + sheets, expected, - [6, 5], - style.getPropertyValue("--color-text-secondary") || "#9ca3af", - 1.6, - ); - // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. - // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. - strokePolyline( - plannedLine.length ? plannedLine : planned, - [], - style.getPropertyValue("--map-route") || "#f97316", - 2.4, - ); - - context.save(); - context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; - context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; - context.lineWidth = 1; - planned.forEach((vertex, index) => { - const [x, y] = toScreen(vertex); - // 법정 기준을 못 맞춘 자리는 붉게 — 막지는 않고 보이기만 한다(2026-09-06 사용자 확정). - const bad = (nodeInfo[index]?.violations?.length ?? 0) > 0; - context.fillStyle = bad - ? style.getPropertyValue("--color-danger") || "#dc2626" - : style.getPropertyValue("--map-route") || "#f97316"; - context.beginPath(); - context.arc(x, y, index === picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); - context.fill(); - context.stroke(); - // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. - if (curveOn.length && !curveOn[index] && index > 0 && index < planned.length - 1) { - context.save(); - context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; - context.beginPath(); - context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); - context.fill(); - context.restore(); - } + plannedLine, + planned, + nodeInfo, + curveInfo, + curveOn, + picked, + stationIntervalM, }); - - // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). - // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. - context.lineWidth = 2; - curveInfo.forEach((curve) => { - // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 - // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. - // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. - const on = curveOn[curve.node_first] !== false; - if (!on) return; // 곡선을 지운 자리에는 접선점도 없다. - // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. - const isPicked = curve.node_first === picked; - [curve.start, curve.end].forEach((point) => { - const [x, y] = toScreen([point[0], point[1]]); - context.beginPath(); - const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; - context.rect(x - size, y - size, size * 2, size * 2); - context.fillStyle = isPicked - ? style.getPropertyValue("--map-route") || "#f97316" - : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; - context.fill(); - context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; - context.stroke(); - }); - }); - context.restore(); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); } @@ -352,6 +263,12 @@ export async function openRouteEditModal( nodeInfo = built.nodes; } + /** 상태줄 머리 — 지금 그려진 계획노선 길이와 노드 수(계획서 0-9 ①). 원호가 정점으로 + * 펴져 있어 브라우저에서 바로 잴 수 있다 — 서버에 묻지 않는다. */ + const routeHead = (): string => + `길이 ${polylineLengthM(plannedLine.length ? plannedLine : planned).toFixed(1)}m · ` + + `노드 ${planned.length}개`; + /** 상태줄 꼬리 — 셈은 `_Edits` 몫. */ const curveHint = (): string => curveSummary({ @@ -421,8 +338,16 @@ export async function openRouteEditModal( const [screenX, screenY] = toScreen(planned[picked]); curveLabelBox.show({ seat: picked, - // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다 — 모달 밖으로 넘어가도 안 잘린다. + // 패널은 `position: fixed` 라 **화면 좌표**로 넘긴다. 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, + }, centerDirection: pickedCurve ? centerDirectionOf( toScreen([pickedCurve.apex[0], pickedCurve.apex[1]]), @@ -442,7 +367,7 @@ export async function openRouteEditModal( function applyEdit(message: string, record = true): void { markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — ${message} ${curveHint()}`; + status.textContent = `${routeHead()} — ${message} ${curveHint()}`; draw(); if (record) history?.commit(snapshotNow()); historyControls.sync(); @@ -517,7 +442,7 @@ export async function openRouteEditModal( dragMoved = true; markEdited(); syncCurveBar(); - status.textContent = `노드 ${planned.length}개 — 곡선을 잡는 중. ${curveHint()}`; + status.textContent = `${routeHead()} — 곡선을 잡는 중. ${curveHint()}`; draw(); } return; @@ -528,7 +453,7 @@ export async function openRouteEditModal( markEdited(); // 곡선을 그 자리에서 다시 그린다 — 나머지 곡선은 그대로 남는다. // 끄는 동안에도 상태줄이 살아 있어야 한다 — 예전에는 여기서 아무 말이 없어 // 「곡선이 사라졌다」는 인상만 남았다(2026-09-07 사용자 지적 ②). - status.textContent = `노드 ${planned.length}개 — 옮기는 중. ${curveHint()}`; + status.textContent = `${routeHead()} — 옮기는 중. ${curveHint()}`; draw(); return; } @@ -694,8 +619,7 @@ export async function openRouteEditModal( ); view = { ...view, ...fitted }; status.textContent = - `노드 ${planned.length}개 · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + - curveHint(); + `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + curveHint(); draw(); } catch (error) { status.textContent = error instanceof Error ? error.message : "노선을 읽지 못했습니다."; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts index b1a37a79..89f3f290 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Label.ts @@ -11,7 +11,10 @@ * * **자리**(2026-09-07 사용자 지시) * · 몸통은 `document.body` 에 `position: fixed` 로 띄운다 — 모달이 `overflow: hidden` 이라 - * 안에 두면 가장자리에서 **잘린다**. 화면 밖으로도 넘어갈 수 있어야 한다. + * 안에 두면 가장자리에서 **잘린다**. + * · 다만 **지도 칸 밖으로는 안 나간다**(2026-09-12 사용자 지적 ⑨) — 상자 밖이나 하단 + * 정보행 위로 넘어가면 지금 무엇을 고치는지 모달 안에서 안 보인다. 「잘리지 않게」와 + * 「상자 밖으로 나가게」는 다른 문제여서 자리 계산에서만 가둔다. * · 자동 자리는 **곡선 중심의 반대쪽**, **16방위**로 잡는다(4방위는 대각 자리에서 곡선을 물었다). * · 머리를 잡아 **손으로 옮길 수 있다**. 옮긴 자리는 그 꺾임점을 보는 동안 유지되고, * 다른 꺾임점을 고르면 자동 자리로 돌아간다. @@ -60,6 +63,8 @@ export interface CurveLabelState { at: [number, number]; /** **곡선 중심이 있는 쪽**(화면 기준 방향벡터). 패널은 이 반대쪽에 붙는다. */ centerDirection: [number, number] | null; + /** 패널이 넘어가면 안 되는 테두리(화면 좌표) — 보통 모달의 지도 칸. 없으면 안 가둔다. */ + bounds?: { left: number; top: number; right: number; bottom: number }; curveOn: boolean; radiusShown: number | null; /** 곡선 길이(m) = R·Δ. 곡선이 없으면 null. */ @@ -143,6 +148,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { /** 손으로 옮긴 자리 — 꺾임점 기준 어긋남(px). 다른 꺾임점을 고르면 지운다. */ let manual: [number, number] | null = null; let anchor: [number, number] = [0, 0]; + /** 마지막으로 받은 테두리 — 손으로 끌 때도 같은 자리를 지키려고 들고 있는다. */ + let limit: CurveLabelState["bounds"]; const numberOf = (input: HTMLInputElement): number | null => { const value = Number(input.value); @@ -169,8 +176,14 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { }); head.addEventListener("pointermove", (event) => { if (!dragFrom) return; - const left = dragFrom.left + event.clientX - dragFrom.x; - const top = dragFrom.top + event.clientY - dragFrom.y; + // 끄는 동안에도 테두리를 지킨다 — 놓은 뒤에만 가두면 손이 간 자리에서 패널이 튄다. + const [left, top] = clamp( + dragFrom.left + event.clientX - dragFrom.x, + dragFrom.top + event.clientY - dragFrom.y, + root.offsetWidth, + root.offsetHeight, + limit, + ); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; // 꺾임점 기준으로 기억한다 — 지도를 옮기거나 확대해도 같은 자리에 따라온다. @@ -183,7 +196,8 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { head.addEventListener("pointerup", stopDrag); head.addEventListener("pointercancel", stopDrag); - /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. */ + /** 자동 자리 — 곡선 중심의 반대쪽, 16방위. 손으로 옮겼으면 그 어긋남을 얹는다. + * 마지막에 **테두리 안으로 가둔다** — 자동 자리든 손으로 옮긴 자리든 같이 갇힌다. */ function place(state: CurveLabelState): void { const width = root.offsetWidth; const height = root.offsetHeight; @@ -193,12 +207,32 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { : ([1, 0] as [number, number]); const distance = GAP_PX + boxReach(away[0], away[1], width, height); anchor = [nx + away[0] * distance - width / 2, ny + away[1] * distance - height / 2]; - const left = anchor[0] + (manual ? manual[0] : 0); - const top = anchor[1] + (manual ? manual[1] : 0); + const [left, top] = clamp( + anchor[0] + (manual ? manual[0] : 0), + anchor[1] + (manual ? manual[1] : 0), + width, + height, + state.bounds, + ); root.style.left = `${Math.round(left)}px`; root.style.top = `${Math.round(top)}px`; } + /** 테두리 안으로 민다. 패널이 테두리보다 크면 왼쪽·위를 맞춰 **머리가 먼저 보이게** 한다. */ + function clamp( + left: number, + top: number, + width: number, + height: number, + bounds: CurveLabelState["bounds"], + ): [number, number] { + if (!bounds) return [left, top]; + return [ + Math.max(bounds.left, Math.min(left, bounds.right - width)), + Math.max(bounds.top, Math.min(top, bounds.bottom - height)), + ]; + } + return { show(state) { if (state.seat !== seat) { @@ -207,6 +241,7 @@ export function createCurveLabel(handlers: CurveLabelHandlers): CurveLabel { } curveOn = state.curveOn; lock = state.lock; + limit = state.bounds; root.hidden = false; seatText.textContent = `${state.seat + 1}번째 꺾임점`; toggle.textContent = state.curveOn ? "곡선 지우기" : "곡선 넣기"; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts new file mode 100644 index 00000000..d40c27ba --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -0,0 +1,254 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Render.ts + * 계획노선 편집 모달의 **그리기** — 등고선·예상노선·계획노선·노드·곡선 손잡이, + * 그 위에 시점·종점·규칙측점 눈금. + * + * `B05_Profile_UI_RouteEdit.ts` 가 700줄을 넘겨 떼어낸 조각이다(2026-09-12). 본문 로직과 + * 수치는 그대로이고, 모달 클로저가 쥐고 있던 값만 `scene` 으로 받는다. + * + * 측점 눈금은 **B04 지도·배수유역도와 같은 한 곳**(`drawStationTicks`)을 부른다 — 표기가 + * 화면마다 갈리면 같은 자리를 두 이름으로 부르게 된다(계획서 0-9 ②). + * ========================================================================== */ + +import { + drawPreparedLayer, + type PreparedLayer, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays"; +import type { EditedCurve, EditedNode, Vertex } from "./B05_Profile_UI_RouteEdit_Curve"; +import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; +import { formatStation } from "./B05_Profile_Util_Station"; + +/** 노드 반지름(px). */ +const NODE_R = 4; +/** 등고선을 보일 **노선 둘레 띠**(m) — 사용자 지시 ⑥(2026-09-07). + * + * 노선에서 이만큼 밖의 등고선은 안 그린다. **창 크기와 무관한 고정 띠**라 창을 늘리거나 + * 줄여도 띠가 흔들리지 않는다(사용자가 「다이나믹 창이라 조심」이라 한 자리). 화면 밖을 + * 걸러내는 일은 `drawPreparedLayer` 가 이미 하므로 여기서는 띠만 덧씌운다. */ +const CONTOUR_BAND_M = 300; +/** 곡선 시작·끝점 손잡이 크기(px) — 노드 동그라미와 구별되게 **속 빈 네모**로 그린다. + * 처음엔 3.5px 였는데 선과 색이 같아 눈에도 안 띄고 집기도 어려웠다(2026-09-07 실화면). */ +const CURVE_HANDLE_PX = 5; +/** 시점·종점 이름표를 끝점에서 **노선 바깥으로** 밀어내는 거리(px). */ +const OUTWARD_PX = 26; + +/** 노선을 따라간 길이(m) — 원호가 이미 정점으로 펴져 있어 정점 간 거리의 합이 곧 길이다. */ +export function polylineLengthM(points: ReadonlyArray): number { + let total = 0; + for (let index = 1; index < points.length; index += 1) { + total += Math.hypot( + points[index][0] - points[index - 1][0], + points[index][1] - points[index - 1][1], + ); + } + return total; +} + +export interface RouteEditScene { + view: ViewState; + /** 사업지 좌표(m) → 캔버스 px. */ + toScreen: (vertex: Vertex) => [number, number]; + /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 이 값으로 정한다. */ + pxPerMeter: number; + /** 도엽 메타를 읽었나 — 못 읽었으면 등고선 띠를 씌우지 않는다. */ + hasMeta: boolean; + sheets: ReadonlyArray; + expected: ReadonlyArray; + /** 그려 보이는 계획노선(원호 포함). */ + plannedLine: ReadonlyArray; + /** 잡아 옮기는 노드(꺾임점). */ + planned: ReadonlyArray; + nodeInfo: ReadonlyArray; + curveInfo: ReadonlyArray; + curveOn: ReadonlyArray; + /** 지금 고른 꺾임점. 없으면 -1. */ + picked: number; + /** 규칙 측점 간격(m). */ + stationIntervalM: number; +} + +export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: RouteEditScene): void { + const { view, toScreen } = scene; + const style = getComputedStyle(document.documentElement); + const line = scene.plannedLine.length ? scene.plannedLine : scene.planned; + context.clearRect(0, 0, view.width, view.height); + context.fillStyle = style.getPropertyValue("--color-surface") || "#111"; + context.fillRect(0, 0, view.width, view.height); + + context.save(); + // 등고선은 **노선 둘레 300m 안**에서만 그린다 — 노선과 상관없는 산줄기까지 다 그리면 + // 화면이 등고선으로 덮여 노선이 안 보인다(2026-09-07 사용자 지시 ⑥). + const band = scene.hasMeta ? contourBandRect(line as Vertex[], toScreen, CONTOUR_BAND_M) : null; + if (band) { + context.beginPath(); + context.rect(band.x, band.y, band.width, band.height); + context.clip(); + } + context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; + context.lineWidth = 0.8; + for (const layer of scene.sheets) drawPreparedLayer(context, layer, view, "dot"); + context.restore(); + + strokePolyline( + context, + toScreen, + scene.expected, + [6, 5], + style.getPropertyValue("--color-text-secondary") || "#9ca3af", + 1.6, + ); + // 선은 **폴리라인**(원호 포함)을 그리고, 잡는 동그라미는 **노드**에만 찍는다. + // 노드를 옮기는 동안에는 폴리라인이 없으므로 노드를 곧바로 이어 미리 보인다. + strokePolyline( + context, + toScreen, + line, + [], + style.getPropertyValue("--map-route") || "#f97316", + 2.4, + ); + + context.save(); + context.fillStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.strokeStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.lineWidth = 1; + scene.planned.forEach((vertex, index) => { + 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"; + context.beginPath(); + context.arc(x, y, index === scene.picked ? NODE_R + 2 : NODE_R, 0, Math.PI * 2); + context.fill(); + context.stroke(); + // 곡선을 지운 자리는 가운데를 비워 「여기는 곡선이 없다」를 보인다. + if ( + scene.curveOn.length && + !scene.curveOn[index] && + index > 0 && + index < scene.planned.length - 1 + ) { + context.save(); + context.fillStyle = style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.9)"; + context.beginPath(); + context.arc(x, y, NODE_R - 2, 0, Math.PI * 2); + context.fill(); + context.restore(); + } + }); + + // 곡선 시작·끝점 — 잡아서 직선 각도와 R 을 함께 바꾸는 손잡이(2026-09-07 사용자 지시). + // **속을 비우고 테두리를 굵게** 그린다 — 선·노드와 색이 같으면 눈에도 안 띄고 집기도 어렵다. + context.lineWidth = 2; + scene.curveInfo.forEach((curve) => { + // **늘 보인다**(2026-09-07 사용자 지시) — 직선이 곡선에 닿는 자리는 손잡이이기 이전에 + // **읽을 정보**다. 한때 고른 곡선만 내보였더니 「표기가 다 사라졌다」는 지적을 받았다. + // 노드를 못 집던 문제는 집기 우선순위(노드가 먼저)로 따로 풀었으므로 다 내놓아도 된다. + if (scene.curveOn[curve.node_first] === false) return; // 곡선을 지운 자리에는 접선점도 없다. + // 고른 곡선은 속을 채워 도드라지게 — 지금 끌 수 있는 것이 무엇인지 보이게. + const isPicked = curve.node_first === scene.picked; + [curve.start, curve.end].forEach((point) => { + const [x, y] = toScreen([point[0], point[1]]); + context.beginPath(); + const size = isPicked ? CURVE_HANDLE_PX + 1 : CURVE_HANDLE_PX; + context.rect(x - size, y - size, size * 2, size * 2); + context.fillStyle = isPicked + ? style.getPropertyValue("--map-route") || "#f97316" + : style.getPropertyValue("--map-halo") || "rgba(255,255,255,0.95)"; + context.fill(); + context.strokeStyle = style.getPropertyValue("--map-route") || "#f97316"; + context.stroke(); + }); + }); + context.restore(); + + drawStationMarks(context, scene, line); +} + +/** 규칙 측점 눈금·번호와 시점·종점 이름표(계획서 0-9 ②). */ +function drawStationMarks( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + line: ReadonlyArray, +): void { + 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]), + }, + ); + const total = polylineLengthM(line); + const last = line.length - 1; + endLabel(context, scene, line[0], line[1], "시점 0+0.0"); + endLabel( + context, + scene, + line[last], + line[last - 1], + `종점 ${formatStation(total, scene.stationIntervalM)}`, + ); +} + +/** 시점·종점 이름표 — 측점 라벨보다 크고 짙게 찍어 양 끝을 한눈에 알게 한다. + * + * 자리는 **노선 바깥쪽**(끝점에서 노선을 등진 방향)이다. 위로만 띄웠더니 같은 자리의 측점 + * 라벨(0+0.0 · 50+0.0)과 겹쳐 두 글자가 포개졌다 — 측점 라벨은 노선에 **직각**으로 나가므로 + * 노선을 따라 밀면 서로 안 물린다(2026-09-12 실화면). */ +function endLabel( + context: CanvasRenderingContext2D, + scene: RouteEditScene, + at: Vertex, + inward: Vertex, + text: string, +): void { + const [x0, y0] = scene.toScreen(at); + const [x1, y1] = scene.toScreen(inward); + const length = Math.hypot(x0 - x1, y0 - y1) || 1; + const x = x0 + ((x0 - x1) / length) * OUTWARD_PX; + const y = y0 + ((y0 - y1) / length) * OUTWARD_PX; + context.save(); + context.font = "bold 12px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + const width = context.measureText(text).width + 10; + context.fillStyle = "rgba(255, 255, 255, 0.9)"; + context.fillRect(x - width / 2, y - 26, width, 17); + context.strokeStyle = "#f97316"; + context.lineWidth = 1; + context.strokeRect(x - width / 2, y - 26, width, 17); + context.fillStyle = "#111111"; + context.fillText(text, x, y - 17.5); + context.restore(); +} + +function strokePolyline( + context: CanvasRenderingContext2D, + toScreen: (vertex: Vertex) => [number, number], + points: ReadonlyArray, + dash: number[], + color: string, + width: number, +): void { + if (points.length < 2) return; + context.save(); + context.setLineDash(dash); + context.strokeStyle = color; + context.lineWidth = width; + context.beginPath(); + points.forEach((vertex, index) => { + const [x, y] = toScreen(vertex); + if (index === 0) context.moveTo(x, y); + else context.lineTo(x, y); + }); + context.stroke(); + context.restore(); +}