diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts index b7860e90..bc690932 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Panel.ts @@ -1,6 +1,5 @@ import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import { createPanelResizer } from "@ui/ui_template_resizer"; -import { themeColor } from "@ui/ui_template_palette"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { computeDetailBasins, @@ -36,15 +35,18 @@ import { createMapContextMenu } from "@ui/ui_template_context_menu"; import { drawDrainageScene } from "./B05_wf2_Route_UI_Drainage_Render"; import { mountDrainageToggles, - basinColor, DRAINAGE_LAYERS, fetchDrainageLayers, + createProgressReporter, + fitCanvasToViewport, fitViewToRoute, + observeViewportSize, bindPipeContextMenu, COLLAPSED_KEY, MAX_PANEL_WIDTH_RATIO, MIN_PANEL_WIDTH, renderBasinRows, + pipeMarkerColor, pointInRing, reconcilePipes, WIDTH_KEY, @@ -147,11 +149,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const legend = createFlowLegend(); viewport.append(backgroundImage, canvas, status, progress.root, contextMenu.element, legend.root); - /** 진행률(0~1, 모르면 null)과 문구. label이 null이면 서클을 감춘다. */ - function showProgress(ratio: number | null, label: string | null): void { - progress.root.hidden = label === null; - if (label !== null) progress.set(ratio, label); - } + const showProgress = createProgressReporter(progress); // 유역 제원 목록(면적·표고·유하거리·관경). 관경 수식 미확정이라 당분간 "미정"으로 나온다. // 관 개수·세부유역 수·종단 Z 출처 — 세부유역이 갈리는 근거라 목록 위에 한 줄로 남긴다. const summary = document.createElement("div"); @@ -269,19 +267,17 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const rect = viewport.getBoundingClientRect(); const width = Math.max(1, Math.floor(rect.width)); const height = Math.max(1, Math.floor(rect.height)); - const dpr = window.devicePixelRatio || 1; - if (width !== canvasWidth || height !== canvasHeight || dpr !== canvasDpr) { - canvasWidth = width; - canvasHeight = height; - canvasDpr = dpr; - canvas.width = Math.floor(width * dpr); - canvas.height = Math.floor(height * dpr); - canvas.style.width = `${width}px`; - canvas.style.height = `${height}px`; - } + const fitted = fitCanvasToViewport(canvas, width, height, { + width: canvasWidth, + height: canvasHeight, + dpr: canvasDpr, + }); + canvasWidth = fitted.width; + canvasHeight = fitted.height; + canvasDpr = fitted.dpr; const context = canvas.getContext("2d"); if (!context) return; - context.setTransform(dpr, 0, 0, dpr, 0, 0); + context.setTransform(fitted.dpr, 0, 0, fitted.dpr, 0, 0); context.clearRect(0, 0, width, height); const mapRect: MapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; @@ -320,12 +316,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra return { width, height, scale, offsetX, offsetY, mapRect: computeMapRect(meta, width, height) }; } - /** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ - function pipeColor(chainage: number): string { - const basin = basins.find((item) => Math.abs(item.chainage_m - chainage) < 0.51); - if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); - return basinColor(basin.index).replace(/0\.45\)$/, "1)"); - } + const pipeColor = (chainage: number): string => pipeMarkerColor(basins, chainage); /** 마커 선택 ↔ 유역 목록 선택 동기화 + 삭제 버튼 활성화. */ function syncPipeSelection(): void { @@ -606,8 +597,18 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }); viewport.addEventListener("pointercancel", stopDragging); - const resizeObserver = new ResizeObserver(scheduleDraw); - resizeObserver.observe(viewport); + // 패널을 늘리면 지도를 더 보여 줄 뿐, 배율은 그대로 둔다 — 늘릴 때마다 확대되면 + // 방금 보던 자리를 다시 찾아야 한다(2026-08-02 사용자 지시). + const resizeObserver = observeViewportSize(viewport, { + meta: () => meta, + anchor: () => ({ width: 0, height: 0, scale, offsetX, offsetY }), + apply: (next) => { + scale = next.scale; + offsetX = next.offsetX; + offsetY = next.offsetY; + }, + redraw: scheduleDraw, + }); function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts index 16e80ec9..54ee8684 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Drainage_Parts.ts @@ -11,6 +11,7 @@ import { themeColor } from "@ui/ui_template_palette"; import { DRAINAGE_SHEET_LAYERS, fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch"; import { + computeMapRect, computeRouteView, type GeoJsonCollection, type ViewState, @@ -381,3 +382,115 @@ export function fitViewToRoute( ); return { scale: view.scale, offsetX: view.offsetX, offsetY: view.offsetY }; } + +/** 한 프레임의 화면 상태 — 크기가 바뀌었을 때 보던 자리를 지키는 데 쓴다. */ +export interface ViewAnchor { + width: number; + height: number; + scale: number; + offsetX: number; + offsetY: number; +} + +/** + * 패널 크기가 바뀌어도 **배율과 보던 자리를 그대로** 두고 지도를 더 보여 준다. + * + * `computeMapRect()`는 지도를 뷰포트에 맞춰 넣으므로(contain), 패널을 늘리면 지도가 함께 + * 확대돼 방금 보던 지점이 어디로 갔는지 알 수 없게 된다(2026-08-02 사용자 지시). + * 그래서 화면 변환 계수(ax·bx)가 그대로가 되도록 배율·오프셋을 되계산한다. 늘어난 만큼은 + * 확대가 아니라 **더 넓은 범위**로 채워진다. + */ +export function preserveViewOnResize( + meta: VWorldMeta | null, + previous: ViewAnchor, + nextWidth: number, + nextHeight: number, +): { scale: number; offsetX: number; offsetY: number } { + const before = computeMapRect(meta, previous.width, previous.height); + const after = computeMapRect(meta, nextWidth, nextHeight); + if (!(before.width > 0) || !(after.width > 0)) { + return { scale: previous.scale, offsetX: previous.offsetX, offsetY: previous.offsetY }; + } + // 이전 프레임의 변환 계수. + const ax = before.width * previous.scale; + const bx = + before.x * previous.scale + (previous.width / 2) * (1 - previous.scale) + previous.offsetX; + const by = + before.y * previous.scale + (previous.height / 2) * (1 - previous.scale) + previous.offsetY; + // 같은 계수를 유지하는 새 배율·오프셋. mapRect가 지도 비율을 지키므로 x·y가 함께 맞는다. + const scale = ax / after.width; + return { + scale, + offsetX: bx - after.x * scale - (nextWidth / 2) * (1 - scale), + offsetY: by - after.y * scale - (nextHeight / 2) * (1 - scale), + }; +} + +/** 뷰포트 크기 변화를 지켜보며 배율을 지킨다. 반환값은 정리용 `ResizeObserver`. */ +export function observeViewportSize( + viewport: HTMLElement, + ports: { + meta: () => VWorldMeta | null; + anchor: () => ViewAnchor; + apply: (next: { scale: number; offsetX: number; offsetY: number }) => void; + redraw: () => void; + }, +): ResizeObserver { + let last: { width: number; height: number } | null = null; + const observer = new ResizeObserver(() => { + const rect = viewport.getBoundingClientRect(); + const width = Math.max(1, Math.floor(rect.width)); + const height = Math.max(1, Math.floor(rect.height)); + if (last && (last.width !== width || last.height !== height)) { + const previous = ports.anchor(); + ports.apply( + preserveViewOnResize( + ports.meta(), + { ...previous, width: last.width, height: last.height }, + width, + height, + ), + ); + } + last = { width, height }; + ports.redraw(); + }); + observer.observe(viewport); + return observer; +} + +/** 캔버스 버퍼를 뷰포트 크기·DPR에 맞춘다. 실제로 바뀔 때만 재할당한다(재할당 시 내용이 지워진다). */ +export function fitCanvasToViewport( + canvas: HTMLCanvasElement, + width: number, + height: number, + previous: { width: number; height: number; dpr: number }, +): { width: number; height: number; dpr: number } { + const dpr = window.devicePixelRatio || 1; + if (width === previous.width && height === previous.height && dpr === previous.dpr) { + return previous; + } + canvas.width = Math.floor(width * dpr); + canvas.height = Math.floor(height * dpr); + canvas.style.width = `${width}px`; + canvas.style.height = `${height}px`; + return { width, height, dpr }; +} + +/** 진행률(0~1, 모르면 null)과 문구를 로딩 서클에 전달한다. label이 null이면 서클을 감춘다. */ +export function createProgressReporter(progress: { + root: HTMLElement; + set: (ratio: number | null, label: string) => void; +}): (ratio: number | null, label: string | null) => void { + return (ratio, label) => { + progress.root.hidden = label === null; + if (label !== null) progress.set(ratio, label); + }; +} + +/** 배관 마커 색 — 같은 누가거리 유역의 파스텔색(불투명). 유역이 없으면 회색. */ +export function pipeMarkerColor(basins: ReadonlyArray, chainageM: number): string { + const basin = basins.find((item) => Math.abs(item.chainage_m - chainageM) < 0.51); + if (!basin) return themeColor("--map-pipe-orphan", "#e5e7eb"); + return basinColor(basin.index).replace(/0\.45\)$/, "1)"); +}