/* ============================================================================= * 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, }; }