/* ============================================================================= * 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, metricToScreen, ROUTE_VIEW_MARGIN_M, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { coveredPx, watchCover } from "./B05_Profile_UI_RouteEdit_Cover"; import { fitRouteToVisible } from "./logic/B05_Profile_RouteEdit_Visible"; 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; const restored: ViewState = { ...view, ...restoreSavedView(meta, view, saved) }; // 노선이 한 점도 안 보이는 기억(밀려 저장된 화면)은 버린다 — 빈 화면으로 열리지 않게. // 가운데에서 짧은 변 절반 안 — 회전축이 가운데라 돌려도 같은 판정이다. const reach = Math.min(view.width, view.height) / 2; const seen = nodes.some(([x, y]) => { const [px, py] = metricToScreen(meta, restored, x, y); // 뜬 종단 판 밑은 안 보임 — 판 위 영역 안만(345-5 덧). return ( py < view.height - coveredPx() && Math.hypot(px - view.width / 2, py - view.height / 2) < reach ); }); if (seen) return { view: restored, rotationRad }; } const xs = nodes.map((vertex) => vertex[0]); const ys = nodes.map((vertex) => vertex[1]); const [xMin, xMax, yMin, yMax] = [ Math.min(...xs), Math.max(...xs), Math.min(...ys), Math.max(...ys), ]; // 노선 맞춤 = 뜬 종단 판 위 보이는 영역 가운데(345-5 덧 · 덮인 높이 0 이면 computeRouteView 와 같음). const fitted = fitRouteToVisible({ mapWidthM: meta.width_meters, mapHeightM: meta.height_meters, mapRect: computeMapRect(meta, view.width, view.height), width: view.width, height: view.height, coveredPx: coveredPx(), centerXM: (xMin + xMax) / 2 - meta.x_min, centerYM: (yMin + yMax) / 2 - meta.y_min, wantWidthM: Math.max(xMax - xMin, 1) + ROUTE_VIEW_MARGIN_M * 2, wantHeightM: Math.max(yMax - yMin, 1) + ROUTE_VIEW_MARGIN_M * 2, }); return { view: { ...view, ...fitted }, 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); // 편집 평면만(추천 지도도 이 셈을 씀) — 뜬 종단 판이 덮은 높이 · 맞춤 · 글판 자리(345-5 덧). if (wrap.classList.contains("b05-routeedit__canvas-wrap")) watchCover(wrap); 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, }; }