/* ============================================================================= * D03_PlanAnalysis_UI_SheetTiles.ts * D03 수치지형도 선 겹 = 도엽 조각(PLAN 606 · 요구 477) — 위성처럼 지도 보기에 보이는 도엽만. * 보기 멈춘 뒤(`VIEW_IDLE_MS`) 보이는 도엽 목록(서버 · 가운데부터) → 켠 겹의 없는 조각만 하나씩 받음 * (등고 먼저 · 보조 겹 뒤) → 받을 때마다 `onChange`(겹이 합친 자료를 다시 얹음). * 보이는 도엽 12 장 이하 = 다 · 13~30 = 계곡선만(등고선 자리에 · 그 밖 선 겹 빔) · 넘음 = 선 겹 숨김. * 받은 조각은 기억(최근 도엽 `KEEP_SHEETS` 장) — 다시 오면 즉시 · 받는 동안 서클(`track`). * 지형 받기와 따로 — 보기만 옮겨도 조각은 받음 · 지형(받은 범위)은 안 받음(596-4 그대로). * GL 이면 등고선은 안 받음(줌별 벡터 조각 · 615) — 옛 캔버스만 · GL 켬끔이 바뀌면 등고 겹을 다시 얹게 알림. * ========================================================================== */ import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { fetchPlanSheetTile, fetchPlanSheetTiles } from "./D03_PlanAnalysis_Api"; import { WATER_KEYS, type PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; /** 겹 → 도엽 조각 이름(서버 `TILE_NAMES`). */ export const SHEET_TILE_NAMES: Partial> = { contour: ["등고선"], stream: ["하천중심선"], road: ["도로중심선"], forestRoad: ["도로중심선"], water: WATER_KEYS, building: ["건물"], place: ["지명"], }; export const FULL_MAX = 12; export const INDEX_MAX = 30; const VIEW_IDLE_MS = 800; const KEEP_SHEETS = 24; const CHANGE_MS = 150; export type SheetMode = "all" | "index" | "hidden"; export interface SheetTiles { /** 바탕 그릴 때마다 — 보기가 멈추면 보이는 도엽을 다시 셈. */ view: (m: ScreenMap, meta: VWorldMeta) => void; /** 겹 조각 이름의 보이는 도엽 합(모드대로 · 없는 조각은 받기 시작). */ collection: (name: string) => GeoJsonCollection; state: () => { mode: SheetMode; count: number; sheets: string[]; cached: number; loading: boolean; /** 마지막 보기 경위도 상자. */ bbox: string; failed: string[]; /** 조각마다 받은 시간(ms · 마지막 20). */ ms: Array<[string, number]>; }; } export function createSheetTiles(opts: { projectId: string; track: (job: Promise) => Promise; /** 지금 켠 겹의 조각 이름. */ wanted: () => string[]; /** GL 바탕인가(참 = 등고선 조각 안 받음). */ gl: () => boolean; onChange: (names: Set) => void; }): SheetTiles { const { projectId, track } = opts; const cache = new Map(); const failed = new Set(); const ms: Array<[string, number]> = []; let recent: string[] = []; let sheets: string[] = []; let count = 0; let mode: SheetMode = "all"; let lastBox = ""; let timer = 0; let turn = 0; let loading = false; let glWas = opts.gl(); /** 바뀐 조각 이름 — 잠깐 모아 한 번에 알림(겹마다 합친 자료를 다시 얹는 일 줄임). */ const dirty = new Set(); let flush = 0; function changed(names: string[]): void { for (const name of names) dirty.add(name); window.clearTimeout(flush); flush = window.setTimeout(() => { const names = new Set(dirty); dirty.clear(); opts.onChange(names); }, CHANGE_MS); } /** 모드에 따른 실제 조각 — 계곡선만이면 등고선 = 계곡선 · 그 밖 = 없음 · GL 등고 = 없음. */ const source = (name: string): string | null => { if (name === "등고선" && opts.gl()) return null; return mode === "hidden" ? null : mode === "all" ? name : name === "등고선" ? "계곡선" : null; }; function nextJob(): [string, string] | null { const names = [...new Set(opts.wanted())].map(source).filter((n): n is string => !!n); // 등고 먼저 · 보조 겹(zip) 뒤 names.sort( (a, b) => Number(!a.endsWith("고선") && a !== "계곡선") - Number(!b.endsWith("고선") && b !== "계곡선"), ); for (const name of names) for (const sheet of sheets) { const key = `${sheet}/${name}`; if (!cache.has(key) && !failed.has(key)) return [sheet, name]; } return null; } /** 없는 조각을 하나씩 — 목록이 바뀌면(차례) 남은 것은 새 목록으로. */ async function pull(): Promise { if (loading) return; loading = true; try { for (let job = nextJob(); job; job = nextJob()) { const [sheet, name] = job; const key = `${sheet}/${name}`; const started = performance.now(); try { cache.set(key, await fetchPlanSheetTile(projectId, sheet, name)); } catch { failed.add(key); // 이번 화면에선 다시 안 받음(다음 목록 때 다시) } ms.push([key, Math.round(performance.now() - started)]); if (ms.length > 20) ms.shift(); if (sheets.includes(sheet)) changed([name === "계곡선" ? "등고선" : name]); } } finally { loading = false; } } const kick = (): void => void (nextJob() && !loading && track(pull())); async function list(bbox: [number, number, number, number]): Promise { const mine = ++turn; const got = await fetchPlanSheetTiles(projectId, bbox).catch(() => null); if (!got || mine !== turn) return; const before = `${mode}|${sheets.join()}`; count = got.count; sheets = got.sheets; mode = count <= FULL_MAX ? "all" : count <= INDEX_MAX ? "index" : "hidden"; failed.clear(); recent = [...new Set([...sheets, ...recent])].slice(0, Math.max(KEEP_SHEETS, sheets.length)); for (const key of cache.keys()) if (!recent.includes(key.split("/")[0])) cache.delete(key); if (`${mode}|${sheets.join()}` !== before) changed(Object.values(SHEET_TILE_NAMES).flat()); kick(); } return { view: (m, meta) => { if (glWas !== opts.gl()) { glWas = !glWas; changed(["등고선"]); } const { width, height } = m.view; if (!width || !height) return; // 그림 px 네 귀 → 작업 좌표 → 경위도(위성 틀 1차) · 돌린 지도 = 대각선만큼 넓힘 const grow = m.uprightRad ? Math.SQRT2 : 1; const corners = [ [0, 0], [width, 0], [0, height], [width, height], ].map(([px, py]) => m.toMetric(width / 2 + (px - width / 2) * grow, height / 2 + (py - height / 2) * grow), ); const lon = (x: number) => meta.lon_min + ((x - meta.x_min) / (meta.width_meters || 1)) * (meta.lon_max - meta.lon_min); const lat = (y: number) => meta.lat_min + ((y - meta.y_min) / (meta.height_meters || 1)) * (meta.lat_max - meta.lat_min); const lons = corners.map(([x]) => lon(x)); const lats = corners.map(([, y]) => lat(y)); const bbox: [number, number, number, number] = [ Math.min(...lons), Math.min(...lats), Math.max(...lons), Math.max(...lats), ]; const tag = bbox.map((v) => v.toFixed(4)).join(); if (tag === lastBox) return; lastBox = tag; window.clearTimeout(timer); timer = window.setTimeout(() => void list(bbox), VIEW_IDLE_MS); }, collection: (name) => { const src = source(name); if (src) kick(); return { type: "FeatureCollection", features: src ? sheets.flatMap((sheet) => cache.get(`${sheet}/${src}`)?.features ?? []) : [], } as GeoJsonCollection; }, state: () => ({ mode, count, sheets: [...sheets], cached: cache.size, loading, bbox: lastBox, failed: [...failed], ms: [...ms], }), }; }