diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index ea25466e..4013830b 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -60,6 +60,8 @@ export const STATE_REGISTRY = { /** M02 면적산출 · 재료산출 오른쪽 조합 패널 — 접힘 · 폭 */ "m02-combo-panel-collapsed": { bucket: "pref", scope: "global" }, "m02-combo-panel-width": { bucket: "pref", scope: "global" }, + /** B05 계획노선 편집 창 — 보던 화면(회전 · 가운데 · 배율). 프로젝트마다 따로 */ + "routeedit-view": { bucket: "pref", scope: "project" }, "masshaul-open": { bucket: "pref", scope: "global", diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index ba924f94..3020e7e8 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -15,18 +15,19 @@ import { computeMapRect, - computeRouteView, - createNormalizer, hitPreparedLayer, metricToScreen, pickLevelStep, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; -import { prepareLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender_Prepare"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { showToast } from "@ui/ui_template_elements"; -import { loadRouteEditContours, type RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; +import { + bindMinorContourToggle, + loadRouteEditBackground, + type RouteEditContours, +} from "./B05_Profile_UI_RouteEdit_Contour"; import { fetchDrainageLayers } from "./B05_Profile_UI_Drainage_Parts"; import { fetchRoutePlan } from "./B05_Profile_Api_Replan"; import { bindRouteApply } from "./B05_Profile_UI_RouteEdit_Apply"; @@ -47,7 +48,16 @@ import { import { createCrossPreview } from "./B05_Profile_UI_RouteEdit_Cross"; import { createMapRotation } from "./B05_Profile_UI_RouteEdit_Rotate"; import { createRouteEditChrome } from "./B05_Profile_UI_RouteEdit_Chrome"; -import { bindMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; +import { bindMeasureControls, createMeasureTool } from "./B05_Profile_UI_RouteEdit_Measure"; +import { createSlopeLabels } from "./B05_Profile_UI_RouteEdit_Slope"; +import { + captureView, + createViewMemory, + fitCanvas, + openingView, + screenToMetric, + viewPxPerMeter, +} from "./B05_Profile_UI_RouteEdit_View"; import { createCurveBar } from "./B05_Profile_UI_RouteEdit_CurveBar"; import { applyArcLocks, @@ -57,6 +67,7 @@ import { curveSummary, flattenServerPlan, shortfallCrossed, + spliceNode, type CurveLock, } from "./B05_Profile_UI_RouteEdit_Edits"; import { @@ -100,7 +111,8 @@ export async function openRouteEditModal( ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; const chrome = createRouteEditChrome(); - const { overlay, canvas, status, busy } = chrome; + const { overlay, canvas, status, busy, measureBox, measureText, measureButton } = chrome; + const { minorContourButton } = chrome; const context = canvas.getContext("2d")!; let expected: Vertex[] = []; @@ -153,20 +165,31 @@ export async function openRouteEditModal( }), onShown: () => draw(), // 횡단이 뜨면 그 측점 반폭 선을 그린다. }); - /** 구간 재기 — 계획·예상 노선 위 두 점. 셈·배선은 `_Measure` 몫(계획서 0-9 ⑤). */ - const measure = bindMeasureTool({ - ...chrome, + /** 구간 재기 — 계획·예상 노선 위 두 점. 셈·서버 묻기는 `_Measure` 몫(계획서 0-9 ⑤). */ + const measure = createMeasureTool({ projectId, stationIntervalM, lines: () => ({ planned: plannedLine.length ? plannedLine : planned, expected }), toScreen: (vertex) => toScreen(vertex), isClosed: () => closed, - onShow: () => { + onChange: () => { + measureControls.sync(); + draw(); + }, + }); + /** 재기 단추·작은 창 — 배선은 `_Measure` 몫. */ + const measureControls = bindMeasureControls({ + measure, + box: measureBox, + text: measureText, + button: measureButton, + closeButton: overlay.querySelector(".b05-routeedit__measure-close")!, + onShown: () => { if (picked < 0) return; picked = -1; // 둘이 같이 뜨면 어느 쪽을 만지는지 헷갈린다. syncCurveBar(); }, - draw: () => draw(), + redraw: () => draw(), }); let view: ViewState = { width: 0, @@ -186,8 +209,18 @@ export async function openRouteEditModal( draw(); }, }); + /** 측점 사이 기울기 라벨 — 높이 묻기·그리기는 `_Slope` 몫(계획서 18 ①). */ + const slope = createSlopeLabels({ projectId, isClosed: () => closed, onChange: () => draw() }); + /** 1m 간격 등고선 켬끔 — 기본 꺼짐(계획서 18 ②). */ + const showMinorContours = bindMinorContourToggle(minorContourButton, () => draw()); + /** 보던 화면 기억(계획서 18 ③) — 자료를 읽고 화면을 되살린 **뒤부터** 적는다. */ + let viewReady = false; + const viewMemory = createViewMemory(projectId, () => + meta && viewReady ? captureView(meta, view, rotation.radians()) : null, + ); const close = (): void => { + viewMemory.flush(); closed = true; window.removeEventListener("resize", resize); historyControls.dispose(); // 단축키는 창(window)에 달려 있어 안 떼면 닫힌 뒤에도 산다. @@ -200,15 +233,7 @@ export async function openRouteEditModal( function resize(): void { if (closed) return; - const wrap = canvas.parentElement!; - const ratio = window.devicePixelRatio || 1; - const width = wrap.clientWidth; - const height = wrap.clientHeight; - canvas.width = Math.round(width * ratio); - canvas.height = Math.round(height * ratio); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - context.setTransform(ratio, 0, 0, ratio, 0, 0); + const { width, height } = fitCanvas(canvas, context); view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; draw(); } @@ -217,29 +242,12 @@ export async function openRouteEditModal( const toScreen = (vertex: Vertex): [number, number] => meta ? metricToScreen(meta, view, vertex[0], vertex[1]) : [0, 0]; - /** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */ - function toMetric(px: number, py: number): Vertex { - if (!meta) return [0, 0]; - const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min); - const [x1, y1] = metricToScreen( - meta, - view, - meta.x_min + meta.width_meters, - meta.y_min + meta.height_meters, - ); - const sx = (x1 - x0) / (meta.width_meters || 1); - const sy = (y1 - y0) / (meta.height_meters || 1); - return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; - } + /** 화면 px → 사업지 좌표(m). 셈은 `_View` 몫. */ + const toMetric = (px: number, py: number): Vertex => + meta ? screenToMetric(meta, view, px, py) : [0, 0]; - /** 화면 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; - } + /** 화면 1m 당 픽셀 — 측점 라벨 솎기 단계를 여기서 정한다. */ + const pxPerMeter = (): number => (meta ? viewPxPerMeter(meta, view) : 1); /** 지금 화면에 낼 등고선 간격(m) — 그리기와 집기가 같은 값을 보게 한 자리에서 셈한다. */ const contourStepM = (): number => @@ -258,6 +266,16 @@ export async function openRouteEditModal( contourStepM: contourStepM(), rotationRad: rotation.radians(), uprightRad: rotation.uprightRad(), + minorContours: showMinorContours() ? (contours?.minor?.() ?? null) : null, + overStations: (ctx) => + slope.draw(ctx, { + line: plannedLine.length ? plannedLine : planned, + intervalM: stationIntervalM, + pxPerMeter: pxPerMeter(), + toScreen, + uprightRad: rotation.uprightRad(), + dragging: dragNode >= 0 || dragHandle !== null, + }), measure: measure.marks(), crossWidth: crossPreview.watched(), expected, @@ -271,6 +289,7 @@ export async function openRouteEditModal( }); // 라벨은 **그린 뒤** 자리를 맞춘다 — 확대·이동·창 크기가 바뀌어도 고른 노드에 붙어 있게. syncCurveBar(); + viewMemory.touch(); // 회전·이동·확대 모두 다시 그리기로 끝난다 — 여기 한 곳에서 적는다. } /** 잡기 — 셈은 `_RouteEdit_Input` 몫이고, 여기서는 지금 상태를 건네준다. */ @@ -421,7 +440,7 @@ export async function openRouteEditModal( 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 || measure.mode()) { + if (event.shiftKey || measureControls.mode()) { // 구간 재기가 먼저다 — 노드 위에서도 재려는 뜻으로 본다(계획서 0-9 ⑤). void measure.pick(px, py); return; @@ -560,9 +579,15 @@ export async function openRouteEditModal( // 끄는 동안에는 한 번도 안 부른다(한 장에 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); @@ -572,13 +597,7 @@ export async function openRouteEditModal( const [px, py] = rotation.unrotate(event.clientX - rect.left, event.clientY - rect.top); const segment = segmentAt(px, py); if (segment < 0) return; - planned.splice(segment + 1, 0, toMetric(px, py)); - // 편집값도 같은 자리에 끼워 넣는다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. - curveOn.splice(segment + 1, 0, true); - curveRadius.splice(segment + 1, 0, null); - curveLock.splice(segment + 1, 0, null); - curveArc.splice(segment + 1, 0, null); - apexLock.splice(segment + 1, 0, false); + spliceNode(snapshotNow(), segment + 1, toMetric(px, py)); // 편집값도 같은 자리에 끼운다. picked = segment + 1; applyEdit("새 노드를 넣었습니다(직선 추가)."); }); @@ -592,12 +611,7 @@ export async function openRouteEditModal( showToast("노선은 노드가 2개 이상이어야 합니다.", "error"); return; } - planned.splice(index, 1); - curveOn.splice(index, 1); - curveRadius.splice(index, 1); - curveLock.splice(index, 1); - curveArc.splice(index, 1); - apexLock.splice(index, 1); + spliceNode(snapshotNow(), index); picked = -1; applyEdit("노드를 지웠습니다(직선 삭제)."); }); @@ -656,39 +670,24 @@ export async function openRouteEditModal( syncCurveBar(); if (!planned.length) planned = plannedLine.map((vertex) => [vertex[0], vertex[1]]); meta = drainage.meta; - const normalizer = createNormalizer(drainage.meta); // 등고선은 따로 고른다(LAS 우선). 나머지 도엽 레이어(하천중심선)만 배경으로 깐다. - otherSheets = drainage.layers - .filter(([layer]) => layer !== "도엽_등고선") - .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) - .filter((layer): layer is PreparedLayer => layer !== null); - contours = await loadRouteEditContours( - projectId, - drainage.meta, - normalizer, - drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null, - { - surfaceModelId: options.surfaceModelId ?? null, - intervalM: options.contourIntervalM ?? 1, - smooth: options.smooth ?? false, - }, - ); + const background = await loadRouteEditBackground(projectId, drainage, { + surfaceModelId: options.surfaceModelId ?? null, + intervalM: options.contourIntervalM ?? 1, + smooth: options.smooth ?? false, + }); + otherSheets = background.otherSheets; + contours = background.contours; if (closed) return; + // 1m 선이 있는 자료(LAS)일 때만 단추를 연다 — 도엽에는 1m 선이 없다. + minorContourButton.disabled = !contours?.minor; + if (!contours?.minor) minorContourButton.title = "LAS 등고선이 없어 1m 선이 없습니다"; resize(); - const xs = planned.map((vertex) => vertex[0]); - const ys = planned.map((vertex) => vertex[1]); - const fitted = computeRouteView( - meta, - { - x_min: Math.min(...xs), - x_max: Math.max(...xs), - y_min: Math.min(...ys), - y_max: Math.max(...ys), - }, - view.width, - view.height, - ); - view = { ...view, ...fitted }; + // 보던 화면이 있으면 그대로 연다(계획서 18 ③). 없으면 노선 전체에 맞춘다. + const opening = openingView(projectId, meta, view, planned); + view = opening.view; + rotation.set(opening.rotationRad); + viewReady = true; status.textContent = `${routeHead()} · ${plan.edited ? "고친 계획노선" : "초기 폴리라인"} · ` + `${contours?.source === "las" ? "LAS 등고선" : "도엽 등고선"} · ` + diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 11db2f50..0e1d1d71 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -29,6 +29,7 @@ export interface RouteEditChrome { measureBox: HTMLElement; measureText: HTMLElement; measureButton: HTMLButtonElement; + minorContourButton: HTMLButtonElement; } /** 모달을 만들어 `document.body` 에 붙이고, 자주 쓰는 요소를 집어 돌려준다. */ @@ -40,6 +41,9 @@ export function createRouteEditChrome(): RouteEditChrome {
계획노선 편집 + @@ -93,6 +97,18 @@ export function createRouteEditChrome(): RouteEditChrome { const measureText = overlay.querySelector(".b05-routeedit__measure-text")!; const measureButton = overlay.querySelector('[data-act="measure"]')!; const busy = overlay.querySelector(".b05-routeedit__busy")!; + const minorContourButton = overlay.querySelector( + '[data-act="contour-minor"]', + )!; - return { overlay, canvas, status, busy, measureBox, measureText, measureButton }; + return { + overlay, + canvas, + status, + busy, + measureBox, + measureText, + measureButton, + minorContourButton, + }; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index d60e6372..a1b0f9ce 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -14,6 +14,7 @@ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedJson } from "../A00_Common/b_asset_cache"; import { + createNormalizer, type GeoJsonCollection, type Normalizer, type PreparedLayer, @@ -40,6 +41,9 @@ export interface RouteEditContours { /** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */ intervalM: number; source: "las" | "sheet"; + /** 5m 배수가 아닌 **1m 간격 선**(계획서 18 ②) — 켰을 때 처음 한 번 준비한다(무거워서). + * LAS 자료가 없으면(도엽 갈래) null — 도엽에는 1m 선이 없다. */ + minor: (() => PreparedLayer) | null; } interface ContourResponse { @@ -70,19 +74,26 @@ export async function loadRouteEditContours( `${API_BASE_URL}/projects/${projectId}/surface/models/${options.surfaceModelId}` + `/contour?interval=${interval}&smooth=${options.smooth}`, ); - const lines = (data.contours ?? []) - // 5m 단위만 남긴다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다. - .filter((contour) => Math.abs(contour.level % LAS_CONTOUR_UNIT_M) < 1e-6) - .map((contour) => ({ - points: contour.coordinates.map(([x, y]) => [x, y] as const), - label: contour.level, - })) - .filter((line) => line.points.length >= 2); + const all = data.contours ?? []; + const onUnit = (level: number): boolean => Math.abs(level % LAS_CONTOUR_UNIT_M) < 1e-6; + const toLines = (picked: typeof all) => + picked + .map((contour) => ({ + points: contour.coordinates.map(([x, y]) => [x, y] as const), + label: contour.level, + })) + .filter((line) => line.points.length >= 2); + // 5m 단위만 바로 준비한다 — 1m 자료를 다 들고 있으면 그리기·집기가 다섯 배로 무겁다. + const lines = toLines(all.filter((contour) => onUnit(contour.level))); if (lines.length > 0) { + // 나머지(1m 간격 선)는 켰을 때 처음 한 번만 준비한다 — 안 켜는 사람은 값을 안 치른다. + const rest = all.filter((contour) => !onUnit(contour.level)); + let minor: PreparedLayer | null = null; return { layer: prepareMetricPolylines(lines, meta), intervalM: LAS_CONTOUR_UNIT_M, source: "las", + minor: rest.length ? () => (minor ??= prepareMetricPolylines(toLines(rest), meta)) : null, }; } } catch { @@ -90,7 +101,50 @@ export async function loadRouteEditContours( } } const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS); - return { layer, intervalM: inferIntervalM(layer), source: "sheet" }; + return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null }; +} + +/** + * 편집 창 바탕 한 벌 — 등고선(LAS 우선)과, 등고선 말고 함께 깔 도엽 레이어(하천중심선). + * 도엽 컬렉션은 배수유역도가 받는 것(`fetchDrainageLayers`)을 그대로 받는다. + */ +export async function loadRouteEditBackground( + projectId: string, + drainage: { + meta: VWorldMeta; + layers: ReadonlyArray; + }, + options: { surfaceModelId: number | null; intervalM: number; smooth: boolean }, +): Promise<{ otherSheets: PreparedLayer[]; contours: RouteEditContours }> { + const normalizer = createNormalizer(drainage.meta); + const otherSheets = drainage.layers + .filter(([layer]) => layer !== "도엽_등고선") + .map(([, collection]) => (collection ? prepareLayer(collection, normalizer) : null)) + .filter((layer): layer is PreparedLayer => layer !== null); + const sheet = drainage.layers.find(([layer]) => layer === "도엽_등고선")?.[1] ?? null; + const contours = await loadRouteEditContours( + projectId, + drainage.meta, + normalizer, + sheet, + options, + ); + return { otherSheets, contours }; +} + +/** 1m 등고선 켬끔 단추를 배선한다 — 기본 꺼짐. 지금 켜져 있나를 돌려주는 함수를 낸다. */ +export function bindMinorContourToggle( + button: HTMLButtonElement, + onChange: () => void, +): () => boolean { + let on = false; + button.addEventListener("click", () => { + on = !on; + button.classList.toggle("is-active", on); + button.setAttribute("aria-pressed", String(on)); + onChange(); + }); + return () => on; } /** 도엽 등고선의 간격(m) — 표고 값들의 **가장 좁은 칸**을 간격으로 본다. */ diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts index b8922cfc..6728e4e6 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Edits.ts @@ -270,3 +270,33 @@ export function flattenServerPlan(nodes: ServerNode[], curves: EditedCurve[]): F }); return out; } + +/** 꺾임점마다 한 칸씩 든 편집값 묶음 — 노드를 넣고 뺄 때 **모두 같은 자리**를 움직인다. */ +export interface NodeColumns { + planned: Vertex[]; + curveOn: boolean[]; + curveRadius: Array; + curveLock: CurveLock[]; + curveArc: Array; + apexLock: boolean[]; +} + +/** `vertex` 를 주면 `index` 자리에 새 노드를 끼우고(곡선 켬 · 값 비움), 안 주면 그 노드를 뺀다. + * 편집값도 같은 자리에서 움직인다 — 안 그러면 뒤 노드의 R·켬끔이 한 칸씩 밀린다. */ +export function spliceNode(columns: NodeColumns, index: number, vertex?: Vertex): void { + if (vertex) { + columns.planned.splice(index, 0, vertex); + columns.curveOn.splice(index, 0, true); + columns.curveRadius.splice(index, 0, null); + columns.curveLock.splice(index, 0, null); + columns.curveArc.splice(index, 0, null); + columns.apexLock.splice(index, 0, false); + return; + } + columns.planned.splice(index, 1); + columns.curveOn.splice(index, 1); + columns.curveRadius.splice(index, 1); + columns.curveLock.splice(index, 1); + columns.curveArc.splice(index, 1); + columns.apexLock.splice(index, 1); +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts index 2e0e4f8a..0d05da59 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Measure.ts @@ -64,6 +64,48 @@ export interface MeasureTool { const LINE_NAME: Record = { planned: "", expected: "예상노선 " }; +export interface MeasureControlsParams { + measure: MeasureTool; + /** 잰 값 작은 창·글·[거리 재기] 단추·작은 창 닫기 단추. */ + box: HTMLElement; + text: HTMLElement; + button: HTMLButtonElement; + closeButton: HTMLElement; + /** 잰 창이 떴다 — 곡선 패널과 같이 뜨지 않게 호출부가 고른 꺾임점을 푼다(㉔). */ + onShown: () => void; + redraw: () => void; +} + +/** 재기 단추·작은 창 배선. 창 맞추기(`sync`)와 재기 모드인가(`mode`)를 돌려준다. */ +export function bindMeasureControls(params: MeasureControlsParams): { + sync: () => void; + mode: () => boolean; +} { + const { measure, box, text, button } = params; + /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ + let mode = false; + /** 재고 있으면 작은 창을 띄우고, 아니면 닫는다. **곡선 패널과 같이 뜨지 않는다**(㉔). */ + const sync = (): void => { + const on = measure.active(); + box.hidden = !on; + text.textContent = measure.hint(); + if (on) params.onShown(); + }; + button.addEventListener("click", () => { + mode = !mode; + button.classList.toggle("is-active", mode); + if (!mode) measure.clear(); + }); + params.closeButton.addEventListener("click", () => { + measure.clear(); // 닫으면 잰 것이 지워진다(㉔). + mode = false; + button.classList.remove("is-active"); + sync(); + params.redraw(); + }); + return { sync, mode: () => mode }; +} + export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; @@ -146,48 +188,3 @@ export function createMeasureTool(params: MeasureToolParams): MeasureTool { }, }; } - -export interface MeasureBinding extends MeasureTool { - /** 재기 모드 — 켜면 그냥 눌러도 재진다(Shift 는 지름길로 남긴다, ㉓). */ - mode: () => boolean; -} - -/** 재기 도구와 단추·작은 창 배선. 작은 창이 뜨면 `onShow` — 곡선 패널과 같이 뜨지 않게(㉔). */ -export function bindMeasureTool( - params: Omit & { - overlay: HTMLElement; - measureBox: HTMLElement; - measureText: HTMLElement; - measureButton: HTMLButtonElement; - onShow: () => void; - draw: () => void; - }, -): MeasureBinding { - let measureMode = false; - const sync = (): void => { - const on = tool.active(); - params.measureBox.hidden = !on; - params.measureText.textContent = tool.hint(); - if (on) params.onShow(); - }; - const tool = createMeasureTool({ - ...params, - onChange: () => { - sync(); - params.draw(); - }, - }); - params.measureButton.addEventListener("click", () => { - measureMode = !measureMode; - params.measureButton.classList.toggle("is-active", measureMode); - if (!measureMode) tool.clear(); - }); - params.overlay.querySelector(".b05-routeedit__measure-close")!.addEventListener("click", () => { - tool.clear(); // 닫으면 잰 것이 지워진다(㉔). - measureMode = false; - params.measureButton.classList.remove("is-active"); - sync(); - params.draw(); - }); - return { ...tool, mode: () => measureMode }; -} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index 65d1c231..c4ed86a3 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -94,6 +94,10 @@ export interface RouteEditScene { rotationRad: number; /** 글자만 되돌려 세울 각(라디안) — 0이면 글자도 그림과 함께 돈다(계획서 0-9 ㉚). */ uprightRad: number; + /** 1m 간격 등고선 — 켰을 때만 준다. 끄면 null(계획서 18 ②). */ + minorContours: PreparedLayer | null; + /** 측점 눈금 바로 위에 얹을 것(측점 사이 기울기 라벨, 계획서 18 ①). 회전 안에서 부른다. */ + overStations?: (context: CanvasRenderingContext2D) => void; } /** 글자 자리는 그대로 두고 **글자만** 되돌려 세운 채로 그린다. */ @@ -159,7 +163,16 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou if (scene.contours) { // 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다. const everyM = scene.contourStepM; - context.strokeStyle = style.getPropertyValue("--map-sheet-contour") || "#a5b4fc"; + // 1m 선은 5m 선이 솎이지 않는 배율에서만 — 5m 도 솎이는 데서 1m 를 다 그리면 선으로 + // 덮이고 무겁다. 화면 밖 가닥은 `drawPreparedLayer` 가 건너뛴다(보이는 범위만). + if (scene.minorContours && everyM <= scene.contours.intervalM) { + context.strokeStyle = + style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)"; + context.lineWidth = 0.5; + drawPreparedLayer(context, scene.minorContours, view, "dot"); + } + // 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②). + context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8"; context.lineWidth = 0.8; drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM); // 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다. @@ -249,6 +262,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou drawCrossWidth(context, scene, line); // 측점 라벨이 위에 오게 먼저 그린다. drawStationMarks(context, scene, line); + scene.overStations?.(context); drawMeasureMarks(context, scene, line); // 등고 높이값은 **맨 나중에** 얹는다(2026-09-12 사용자 지시 ⑦) — 노선·측점 눈금보다 먼저 // 그리면 숫자가 그 아래 깔려 안 읽힌다. 띠(300m)는 다시 씌워 그리는 범위는 그대로 둔다. diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts index beca4515..e1235d18 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Rotate.ts @@ -41,6 +41,8 @@ export interface MapRotation { unrotateDelta: (dx: number, dy: number) => [number, number]; /** 글자를 세울 각(라디안) — 그리기가 라벨마다 이만큼 되돌린다. 안 세우면 0. */ uprightRad: () => number; + /** 기억해 둔 각으로 되돌린다(계획서 18 ③). 다시 그리기는 부르는 쪽 몫. */ + set: (radians: number) => void; } export function createMapRotation(params: MapRotationParams): MapRotation { @@ -73,6 +75,9 @@ export function createMapRotation(params: MapRotationParams): MapRotation { unrotate: (px, py) => spin(px, py, -radians), rerotate: (px, py) => spin(px, py, radians), uprightRad: () => (UPRIGHT_LABELS ? -radians : 0), + set: (next) => { + radians = Number.isFinite(next) ? next : 0; + }, unrotateDelta: (dx, dy) => { if (!radians) return [dx, dy]; const cos = Math.cos(-radians); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts new file mode 100644 index 00000000..ec70e16d --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Slope.ts @@ -0,0 +1,224 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Slope.ts + * 계획노선 편집 모달의 **측점 사이 종단기울기 라벨**(계획서 18 ①). + * + * 확대해 측점 라벨이 하나하나 다 보이는 배율부터(`drawStationTicks` 가 솎지 않는 간격), + * 측점과 측점 사이 노선 위에 그 구간의 기울기(%)를 적는다. 멀리 보면 숨긴다. + * + * 기울기는 **지반고** 기준이다 — 구간 재기(`_Measure`)와 같은 통로(`/route/elevations`)로 + * 측점 자리 높이를 한 번에 묻는다. 확정 지표면을 읽기만 하는 통로라 「편집 중에는 계산이 + * 안 나간다」와 부딪히지 않고, 노드를 끄는 동안에는 부르지 않는다(놓은 뒤 `refresh`). + * ========================================================================== */ + +import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; + +type Vertex = [number, number]; + +/** 측점 간격이 화면에서 이만큼(px) 이상이면 라벨을 낸다 — 측점 라벨을 솎지 않는 간격과 같다. */ +const SLOPE_MIN_GAP_PX = 90; +/** 노선에서 라벨을 띄우는 거리(px) — 측점 라벨(반대쪽 16px)과 안 겹치게 반대편에 둔다. */ +const SLOPE_OFFSET_PX = 13; +/** 높이를 묻기 전 잠깐 기다린다 — 연달아 고칠 때 한 번만 묻게. */ +const FETCH_DELAY_MS = 250; +const SLOPE_COLOR = "#0f766e"; +/** 화면 가장자리 여유(px) — 반쯤 걸친 라벨도 그린다. */ +const EDGE_PX = 20; + +export interface SlopeLabelParams { + projectId: string; + /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ + isClosed: () => boolean; + /** 높이를 받아 왔다 — 호출부가 다시 그린다. */ + onChange: () => void; +} + +export interface SlopeDrawArgs { + /** 지금 그려지는 노선(원호 포함). 편집마다 **새 배열**이라 이것으로 낡음을 가린다. */ + line: ReadonlyArray; + intervalM: number; + pxPerMeter: number; + toScreen: (vertex: Vertex) => [number, number]; + /** 글자만 되돌려 세울 각(라디안). */ + uprightRad: number; + /** 노드·손잡이를 끄는 중 — 이때는 묻지 않는다. */ + dragging: boolean; +} + +export interface SlopeLabels { + /** 측점 사이 라벨을 그린다. 그린 수를 돌려준다(검증 수치). */ + draw: (context: CanvasRenderingContext2D, args: SlopeDrawArgs) => number; + /** 끌기를 마쳤다 — 다음 그리기에서 새 노선 높이를 묻게 한다. */ + refresh: () => void; +} + +/** 측점 누가거리 — 0, 간격, 2·간격, … 그리고 종점(끝 자투리 구간도 기울기가 있다). */ +function stationChainages(total: number, intervalM: number): number[] { + const out: number[] = []; + for (let chainage = 0; chainage < total - 1e-6; chainage += intervalM) out.push(chainage); + out.push(total); + return out; +} + +/** 누가거리 목록 자리의 노선 위 점. `drawStationTicks` 와 같은 보간이다. */ +function pointsAt(line: ReadonlyArray, chainages: number[]): Vertex[] { + const cumulative: number[] = [0]; + for (let index = 1; index < line.length; index += 1) { + cumulative.push( + cumulative[index - 1] + + Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]), + ); + } + let cursor = 1; + return chainages.map((chainage) => { + while (cursor < cumulative.length - 1 && cumulative[cursor] < chainage) cursor += 1; + const back = line[cursor - 1]; + const front = line[cursor]; + const segment = cumulative[cursor] - cumulative[cursor - 1] || 1; + const ratio = Math.min(1, Math.max(0, (chainage - cumulative[cursor - 1]) / segment)); + return [back[0] + (front[0] - back[0]) * ratio, back[1] + (front[1] - back[1]) * ratio]; + }); +} + +function lengthOf(line: ReadonlyArray): number { + let total = 0; + for (let index = 1; index < line.length; index += 1) { + total += Math.hypot(line[index][0] - line[index - 1][0], line[index][1] - line[index - 1][1]); + } + return total; +} + +export function createSlopeLabels(params: SlopeLabelParams): SlopeLabels { + /** 높이를 받아 둔 노선·간격과 그 결과. 노선이 바뀌면(새 배열) 버린다. */ + let cachedLine: ReadonlyArray | null = null; + let cachedInterval = 0; + let chainages: number[] = []; + let heights: Array = []; + /** 측점 자리와 구간 가운데 자리 — 받아 올 때 한 번 셈해 둔다(그리기마다 다시 안 셈). */ + let stationPoints: Vertex[] = []; + let middlePoints: Vertex[] = []; + /** 묻는 중인 노선 — 같은 것을 두 번 묻지 않는다. */ + let asking: ReadonlyArray | null = null; + let timer: ReturnType | null = null; + + function schedule(line: ReadonlyArray, intervalM: number): void { + if (asking === line) return; + if (timer) clearTimeout(timer); + asking = line; + timer = setTimeout(() => { + timer = null; + const wanted = stationChainages(lengthOf(line), intervalM); + const points = pointsAt(line, wanted); + void fetchRouteElevations(params.projectId, points) + .then((z) => { + if (params.isClosed() || asking !== line) return; // 그 사이 노선이 또 바뀌었다. + cachedLine = line; + cachedInterval = intervalM; + chainages = wanted; + heights = z; + stationPoints = points; + middlePoints = pointsAt( + line, + wanted.slice(1).map((end, index) => (wanted[index] + end) / 2), + ); + params.onChange(); + }) + .catch(() => { + /* 높이를 못 읽으면 라벨을 안 낸다 — 구간 재기와 같은 처리. */ + }) + .finally(() => { + if (asking === line) asking = null; + }); + }, FETCH_DELAY_MS); + } + + /** 그린 라벨 수와 첫 라벨(글·자리)을 캔버스에 적어 둔다 — 화면 검증이 읽는 수치. */ + function report(context: CanvasRenderingContext2D, count: number, first: string): number { + context.canvas.dataset.slopeLabels = String(count); + context.canvas.dataset.slopeFirst = first; + return count; + } + + return { + refresh() { + asking = null; + }, + draw(context, args) { + let first = ""; + const count = paint(context, args, (text, x, y) => { + if (!first) first = `${text}@${Math.round(x)},${Math.round(y)}`; + }); + return report(context, count, first); + }, + }; + + function paint( + context: CanvasRenderingContext2D, + args: SlopeDrawArgs, + onLabel: (text: string, x: number, y: number) => void, + ): number { + const { line, intervalM } = args; + if (line.length < 2 || !(intervalM > 0)) return 0; + if (intervalM * args.pxPerMeter < SLOPE_MIN_GAP_PX) return 0; // 멀리 보면 숨긴다. + const fresh = cachedLine === line && cachedInterval === intervalM; + if (!fresh) { + if (!args.dragging) schedule(line, intervalM); + return 0; // 낡은 높이로 그리면 엉뚱한 기울기가 적힌다 — 받아 올 때까지 비운다. + } + // 화면 안 판정은 **캔버스 변환 그대로**(회전·고해상도 배율 포함) 옮긴 자리로 한다 — + // 돌린 지도에서도 실제로 보이는 라벨만 그리고 센다. + const toDevice = context.getTransform(); + const ratio = context.canvas.width / (context.canvas.clientWidth || context.canvas.width); + const onScreen = (x: number, y: number): [number, number] | null => { + const point = toDevice.transformPoint(new DOMPoint(x, y)); + const [px, py] = [point.x / ratio, point.y / ratio]; + const inside = + px >= -EDGE_PX && + py >= -EDGE_PX && + px <= context.canvas.clientWidth + EDGE_PX && + py <= context.canvas.clientHeight + EDGE_PX; + return inside ? [px, py] : null; + }; + context.save(); + context.font = "bold 11px system-ui, sans-serif"; + context.textAlign = "center"; + context.textBaseline = "middle"; + let count = 0; + for (let index = 0; index + 1 < chainages.length; index += 1) { + const z0 = heights[index]; + const z1 = heights[index + 1]; + const run = chainages[index + 1] - chainages[index]; + if (z0 === null || z1 === null || z0 === undefined || z1 === undefined) continue; + if (run <= 0.5) continue; // 종점 자투리가 너무 짧으면 기울기가 튄다. + const [sx, sy] = args.toScreen(middlePoints[index]); + // 노선 방향 — 구간 양 끝 측점으로 잡는다. 라벨은 그 직각 반대편(측점 라벨의 맞은편)에. + const [ax, ay] = args.toScreen(stationPoints[index]); + const [bx, by] = args.toScreen(stationPoints[index + 1]); + const length = Math.hypot(bx - ax, by - ay) || 1; + const lx = sx - (-(by - ay) / length) * SLOPE_OFFSET_PX; + const ly = sy - ((bx - ax) / length) * SLOPE_OFFSET_PX; + const seen = onScreen(lx, ly); + if (!seen) continue; + const percent = ((z1 - z0) / run) * 100; + const text = `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`; + context.save(); + if (args.uprightRad) { + context.translate(lx, ly); + context.rotate(args.uprightRad); + context.translate(-lx, -ly); + } + const width = context.measureText(text).width + 8; + context.fillStyle = "rgba(255, 255, 255, 0.88)"; + context.fillRect(lx - width / 2, ly - 8, width, 16); + context.strokeStyle = SLOPE_COLOR; + context.lineWidth = 1; + context.strokeRect(lx - width / 2, ly - 8, width, 16); + context.fillStyle = SLOPE_COLOR; + context.fillText(text, lx, ly); + context.restore(); + onLabel(text, seen[0], seen[1]); // 화면에 보이는 자리(px)로 적는다. + count += 1; + } + context.restore(); + return count; + } +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_View.ts b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts new file mode 100644 index 00000000..606e2286 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_View.ts @@ -0,0 +1,168 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_View.ts + * 계획노선 편집 모달의 **화면 좌표 셈**과 **보던 화면 기억**(계획서 18 ③). + * + * 기억하는 값은 화면 크기와 무관한 꼴로 둔다 — 가운데 자리(사업지 좌표 m)·1m 당 픽셀· + * 회전각. 창 크기가 달라져도 같은 자리를 같은 배율로 다시 연다(px 오프셋을 그대로 두면 + * 창이 바뀔 때 엉뚱한 데가 뜬다). + * + * 자리는 화면 취향 통(`pref`, 프로젝트마다) — 브라우저 저장소에 두고 계정에도 올라간다 + * (`b_page_state`). 설계값이 아니라 [저장] 없이 바로 적는다. + * ========================================================================== */ + +import { readState, writeState } from "../A00_Common/b_page_state"; +import { + computeMapRect, + computeMaxScale, + computeRouteView, + metricToScreen, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; + +type Vertex = [number, number]; + +/** 기억해 두는 화면 — 가운데(m) · 1m 당 픽셀 · 회전각(도). */ +export interface SavedRouteEditView { + cx: number; + cy: number; + pxPerMeter: number; + rotationDeg: number; +} + +/** 적기를 잠깐 모은다 — 휠·팬은 프레임마다 그리므로 매번 적으면 저장소를 두드린다. */ +const SAVE_DELAY_MS = 400; +/** 배율 하한 — 지도 조작(`_Input`)과 같은 값. */ +const MIN_SCALE = 0.5; +const MAX_SCALE_FALLBACK = 16; + +/** 화면 px → 사업지 좌표(m). `metricToScreen`이 선형이므로 두 기준점으로 역산한다. */ +export function screenToMetric(meta: VWorldMeta, view: ViewState, px: number, py: number): Vertex { + const [x0, y0] = metricToScreen(meta, view, meta.x_min, meta.y_min); + const [x1, y1] = metricToScreen( + meta, + view, + meta.x_min + meta.width_meters, + meta.y_min + meta.height_meters, + ); + const sx = (x1 - x0) / (meta.width_meters || 1); + const sy = (y1 - y0) / (meta.height_meters || 1); + return [meta.x_min + (px - x0) / (sx || 1), meta.y_min + (py - y0) / (sy || 1)]; +} + +/** 화면 1m 당 픽셀 — `metricToScreen` 이 선형이라 100m 떨어진 두 점으로 잰다 + * (1m 로 재면 반올림 오차가 그대로 비율에 실린다). */ +export function viewPxPerMeter(meta: VWorldMeta, view: ViewState): number { + 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; +} + +export function readSavedView(projectId: string): SavedRouteEditView | null { + const saved = readState>("routeedit-view", projectId); + if (!saved) return null; + const { cx, cy, pxPerMeter, rotationDeg } = saved; + if (![cx, cy, pxPerMeter, rotationDeg].every((value) => Number.isFinite(value))) return null; + if (!((pxPerMeter as number) > 0)) return null; + return saved as SavedRouteEditView; +} + +/** 기억한 화면을 지금 창 크기에 맞춰 배율·오프셋으로 되돌린다. */ +export function restoreSavedView( + meta: VWorldMeta, + view: ViewState, + saved: SavedRouteEditView, +): Pick { + const mapRect = computeMapRect(meta, view.width, view.height); + const base: ViewState = { ...view, mapRect, scale: 1, offsetX: 0, offsetY: 0 }; + const basePx = viewPxPerMeter(meta, base) || 1; + const maxScale = computeMaxScale(meta, mapRect.width, view.width, MAX_SCALE_FALLBACK); + const scale = Math.min(maxScale, Math.max(MIN_SCALE, saved.pxPerMeter / basePx)); + // screen = 가운데 + (배율1 자리 − 가운데)·배율 + 오프셋 — 가운데 자리가 한가운데 오게 푼다. + const [bx, by] = metricToScreen(meta, base, saved.cx, saved.cy); + return { + scale, + offsetX: -(bx - view.width / 2) * scale, + offsetY: -(by - view.height / 2) * scale, + }; +} + +/** 지금 화면을 기억할 꼴로 — 캔버스 한가운데 자리(회전축이라 돌려도 같다)·배율·회전각. */ +export function captureView( + meta: VWorldMeta, + view: ViewState, + rotationRad: number, +): SavedRouteEditView { + const [cx, cy] = screenToMetric(meta, view, view.width / 2, view.height / 2); + const rotationDeg = (rotationRad * 180) / Math.PI; + return { cx, cy, pxPerMeter: viewPxPerMeter(meta, view), rotationDeg }; +} + +/** 창을 열 때의 화면 — 보던 화면이 있으면 그대로, 없으면 노선 전체에 맞춘다(회전 0). */ +export function openingView( + projectId: string, + meta: VWorldMeta, + view: ViewState, + nodes: ReadonlyArray, +): { view: ViewState; rotationRad: number } { + const saved = readSavedView(projectId); + if (saved) { + const rotationRad = (saved.rotationDeg * Math.PI) / 180; + return { view: { ...view, ...restoreSavedView(meta, view, saved) }, rotationRad }; + } + const xs = nodes.map((vertex) => vertex[0]); + const ys = nodes.map((vertex) => vertex[1]); + const bounds = { + x_min: Math.min(...xs), + x_max: Math.max(...xs), + y_min: Math.min(...ys), + y_max: Math.max(...ys), + }; + return { + view: { ...view, ...computeRouteView(meta, bounds, view.width, view.height) }, + rotationRad: 0, + }; +} + +/** 캔버스 버퍼를 감싼 칸 크기(고해상도 배율 포함)에 맞춘다. 칸의 CSS 크기를 돌려준다. */ +export function fitCanvas( + canvas: HTMLCanvasElement, + context: CanvasRenderingContext2D, +): { width: number; height: number } { + const wrap = canvas.parentElement!; + const ratio = window.devicePixelRatio || 1; + const width = wrap.clientWidth; + const height = wrap.clientHeight; + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + context.setTransform(ratio, 0, 0, ratio, 0, 0); + return { width, height }; +} + +/** 보던 화면을 잠깐 모아 적는 꾼. `touch` 는 그릴 때마다 불러도 된다. */ +export function createViewMemory( + projectId: string, + capture: () => SavedRouteEditView | null, +): { touch: () => void; flush: () => void } { + let timer: ReturnType | null = null; + let last = ""; + const flush = (): void => { + if (timer) clearTimeout(timer); + timer = null; + const now = capture(); + if (!now) return; + const text = JSON.stringify(now); + if (text === last) return; + last = text; + writeState("routeedit-view", now, projectId); + }; + return { + touch() { + if (timer) clearTimeout(timer); + timer = setTimeout(flush, SAVE_DELAY_MS); + }, + flush, + }; +} diff --git a/resources/tester/helper_b05_routeedit_view.cjs b/resources/tester/helper_b05_routeedit_view.cjs new file mode 100644 index 00000000..02dc8ab6 --- /dev/null +++ b/resources/tester/helper_b05_routeedit_view.cjs @@ -0,0 +1,129 @@ +/* 계획노선 편집 창 **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 단위검증 헬퍼 — + * `B05_Profile_UI_RouteEdit_View.ts` · `_Edits.ts` 를 그 자리에서 트랜스파일해 Node 로 돌린다. + * + * ⚠ 확장자가 `.cjs` 인 까닭은 `helper_b05_cut_merged_loop.cjs` 머리에 적힌 것과 같다. + * 브라우저 전용인 저장소·팔레트는 가짜로 채운다 — 셈만 본다. + */ +const fs = require("fs"); +const path = require("path"); +const ts = require(path.join(__dirname, "..", "..", "config", "node_modules", "typescript")); + +const ROOT = path.join(__dirname, "..", ".."); +const cache = new Map(); + +// 브라우저 저장소 흉내 — `b_page_state` 가 읽고 쓰는 자리. +function memoryStorage() { + const map = new Map(); + return { + getItem: (key) => (map.has(key) ? map.get(key) : null), + setItem: (key, value) => map.set(key, String(value)), + removeItem: (key) => map.delete(key), + key: (index) => [...map.keys()][index] ?? null, + get length() { + return map.size; + }, + _map: map, + }; +} +const local = memoryStorage(); +globalThis.window = { localStorage: local, sessionStorage: memoryStorage() }; +globalThis.fetch = () => Promise.resolve({ ok: false }); + +const STUBS = { "@ui/ui_template_palette": { themeColor: (_name, fallback) => fallback } }; + +function loadTs(file) { + const full = path.resolve(file); + if (cache.has(full)) return cache.get(full); + const js = ts.transpileModule(fs.readFileSync(full, "utf8"), { + compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }, + }).outputText; + const box = { exports: {} }; + cache.set(full, box.exports); + const localRequire = (request) => { + if (STUBS[request]) return STUBS[request]; + if (request.startsWith(".")) return loadTs(path.join(path.dirname(full), `${request}.ts`)); + return require(request); + }; + new Function("exports", "module", "require", js)(box.exports, box, localRequire); + cache.set(full, box.exports); + return box.exports; +} + +const viewMod = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_View.ts")); +const render = loadTs(path.join(ROOT, "B04_PreProcess", "B04_PreProcess_UI_MapRender.ts")); +const edits = loadTs(path.join(ROOT, "B05_Profile", "B05_Profile_UI_RouteEdit_Edits.ts")); + +// 도엽 한 장 — 1200m × 900m 사업지 좌표. +const meta = { + x_min: 200000, + y_min: 500000, + width_meters: 1200, + height_meters: 900, + lon_min: 127, + lon_max: 127.01, + lat_min: 37, + lat_max: 37.008, +}; +function viewOf(width, height, scale, offsetX, offsetY) { + return { + width, + height, + scale, + offsetX, + offsetY, + mapRect: render.computeMapRect(meta, width, height), + }; +} + +// ① 한 창 크기에서 잡은 화면을 **다른 창 크기**로 되살린다 — 가운데 자리와 1m 당 픽셀이 같아야 한다. +const before = viewOf(1000, 700, 3.2, -140, 85); +const saved = viewMod.captureView(meta, before, (30 * Math.PI) / 180); +const resized = viewOf(1400, 820, 1, 0, 0); +const restored = { ...resized, ...viewMod.restoreSavedView(meta, resized, saved) }; +const again = viewMod.captureView(meta, restored, (30 * Math.PI) / 180); + +// ② 저장소 왕복 — 적은 값을 같은 프로젝트로 다시 읽고, 다른 프로젝트에는 없다. +const memory = viewMod.createViewMemory("p-1", () => saved); +memory.flush(); +const readBack = viewMod.readSavedView("p-1"); +const otherProject = viewMod.readSavedView("p-2"); +const storedKeys = [...local._map.keys()]; + +// ③ 보던 화면이 없으면 노선 전체에 맞춘다(회전 0). +const fresh = viewMod.openingView("p-none", meta, viewOf(1000, 700, 1, 0, 0), [ + [200300, 500300], + [200600, 500500], +]); +const withSaved = viewMod.openingView("p-1", meta, viewOf(1000, 700, 1, 0, 0), [[200300, 500300]]); + +// ④ 노드 끼우기·빼기 — 편집값 여섯 줄이 같은 자리에서 함께 움직인다. +const columns = { + planned: [ + [0, 0], + [10, 0], + [20, 0], + ], + curveOn: [true, false, true], + curveRadius: [null, 30, null], + curveLock: [null, "radius", null], + curveArc: [null, 12, null], + apexLock: [false, true, false], +}; +edits.spliceNode(columns, 1, [5, 5]); +const afterInsert = JSON.parse(JSON.stringify(columns)); +edits.spliceNode(columns, 1); +const afterRemove = JSON.parse(JSON.stringify(columns)); + +process.stdout.write( + JSON.stringify({ + saved, + again, + readBack, + otherProject, + storedKeys, + fresh: { rotationRad: fresh.rotationRad, scale: fresh.view.scale }, + withSaved: { rotationRad: withSaved.rotationRad }, + afterInsert, + afterRemove, + }), +); diff --git a/resources/tester/test_b05_routeedit_view.py b/resources/tester/test_b05_routeedit_view.py new file mode 100644 index 00000000..4bfdae69 --- /dev/null +++ b/resources/tester/test_b05_routeedit_view.py @@ -0,0 +1,63 @@ +# -*- coding: utf-8 -*- +"""계획노선 편집 창 — **보던 화면 기억**(계획서 18 ③)과 노드 끼우기·빼기 회귀검증. + +화면은 창 크기와 무관한 꼴(가운데 m · 1m 당 픽셀 · 회전각)로 기억한다. 창 크기가 바뀌어도 +같은 자리를 같은 배율로 다시 열어야 한다. 자리는 화면 취향 통(`aislo:pref:`)의 프로젝트 칸. + +Node 헬퍼(`helper_b05_routeedit_view.cjs`)가 TS 를 트랜스파일해 돌리고, 여기서 값을 판정한다. +""" + +import json +import math +import os +import subprocess + +import pytest + +HERE = os.path.dirname(os.path.abspath(__file__)) + + +@pytest.fixture(scope="module") +def results(): + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_b05_routeedit_view.cjs")], + capture_output=True, + text=True, + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return json.loads(proc.stdout) + + +def test_다른_창_크기에서도_같은_가운데와_배율(results): + saved, again = results["saved"], results["again"] + assert again["cx"] == pytest.approx(saved["cx"], abs=1e-6) + assert again["cy"] == pytest.approx(saved["cy"], abs=1e-6) + assert again["pxPerMeter"] == pytest.approx(saved["pxPerMeter"], rel=1e-9) + assert saved["rotationDeg"] == pytest.approx(30.0) + + +def test_저장소_왕복은_프로젝트마다(results): + assert results["readBack"] == pytest.approx(results["saved"]) + assert results["otherProject"] is None + assert results["storedKeys"] == ["aislo:pref:routeedit-view:p-1"] + + +def test_보던_화면이_없으면_노선에_맞추고_회전_0(results): + assert results["fresh"]["rotationRad"] == 0 + assert results["fresh"]["scale"] > 0 + assert results["withSaved"]["rotationRad"] == pytest.approx(math.radians(30)) + + +def test_노드_끼우기_빼기는_편집값이_같은_자리로(results): + inserted = results["afterInsert"] + assert inserted["planned"][1] == [5, 5] + assert inserted["curveOn"] == [True, True, False, True] + assert inserted["curveRadius"] == [None, None, 30, None] + assert inserted["curveLock"] == [None, None, "radius", None] + assert inserted["curveArc"] == [None, None, 12, None] + assert inserted["apexLock"] == [False, False, True, False] + removed = results["afterRemove"] + assert removed["planned"] == [[0, 0], [10, 0], [20, 0]] + assert removed["curveRadius"] == [None, 30, None] + assert removed["apexLock"] == [False, True, False] diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 30fe7c41..225099e3 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -225,6 +225,8 @@ --map-eupmyeondong: #22c55e; --map-contour: #fdba74; --map-sheet-contour: #a5b4fc; + --map-contour-major: #818cf8; /* 계획노선 편집 5m 등고선 — 도엽색보다 한 단 짙게 */ + --map-contour-minor: rgba(165, 180, 252, 0.45); /* 계획노선 편집 1m 등고선 — 얇고 옅게 */ --map-sheet-stream: #2563eb; --map-sheet-elev-point: #f9a8d4; --map-sheet-cutfill: #f43f5e;