fix(B05): 배수유역도 패널 크기를 바꿔도 지도 배율을 그대로 둔다

하단 패널이나 우측 배수유역 패널을 늘리면 computeMapRect()가 지도를 뷰포트에 다시
맞춰(contain) 지도까지 함께 확대됐다. 배율이 바뀌니 방금 보던 자리를 다시 찾아야 했다.

화면 변환 계수(ax·bx)가 그대로가 되도록 배율·오프셋을 되계산해, 늘린 만큼은 확대가
아니라 더 넓은 범위로 채운다.

- preserveViewOnResize(): 이전 프레임의 변환 계수를 새 뷰포트에서 재현
- observeViewportSize(): 크기 변화를 지켜보며 위 보정을 걸고 다시 그린다
- 700줄 유지를 위해 fitCanvasToViewport / createProgressReporter / pipeMarkerColor도
  _Drainage_Parts.ts로 이동

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-02 00:18:55 +09:00
co-authored by Claude Opus 5
parent 32fd199dbf
commit bb0499d6d7
2 changed files with 140 additions and 26 deletions
@@ -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);
@@ -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<DetailBasin>, 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)");
}