/* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로). * * 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 · * 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음. * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선. * ========================================================================== */ import { createIconButton } from "@ui/ui_template_button"; import { showToast } from "@ui/ui_template_elements"; import { createLegend } from "@ui/ui_template_flow_legend"; import type { IconName } from "@ui/ui_template_icons"; import { themeColor } from "@ui/ui_template_palette"; import { createToolbar } from "@ui/ui_template_toolbar"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { fetchVWorldMeta, getVWorldMapUrl, type VWorldMeta, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, drawPreparedLabels, drawPreparedLayer, pickLevelStep, type GeoJsonCollection, type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { fetchPlanCadastral, fetchPlanRaster, fetchPlanSheetLayer, type PlanRaster, } from "./D03_PlanAnalysis_Api"; import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope"; interface LayerDef { key: PlanLayerKey; icon: IconName; title: string; on: boolean; } const LAYERS: readonly LayerDef[] = [ { key: "satellite", icon: "satellite", title: "위성사진", on: true }, { key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true }, { key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true }, { key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false }, { key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false }, { key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false }, ]; const PREF_KEY = "aislo:pref:d03.layers"; /** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; interface Prepared { contour: PreparedLayer; /** 세류 · 하천(소하천 · 지방하천 · 연결선). */ stream: [PreparedLayer, PreparedLayer]; cadastral: PreparedLayer; road: PreparedLayer; slope: { raster: PlanRaster; image: HTMLImageElement }; } export interface PlanLayers { bar: HTMLElement; legend: HTMLElement; /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ layers: PlanMapLayer[]; /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */ load: () => Promise; /** 검증 창구 — 켬끔 · 받은 겹. */ state: () => { on: Record; loaded: PlanLayerKey[] }; } function readPref(): Record { const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record< PlanLayerKey, boolean >; try { const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial>; for (const def of LAYERS) if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean; } catch { // 깨진 값 = 처음 값 } return on; } const loadImage = (src: string): Promise => new Promise((done, fail) => { const image = new Image(); image.onload = () => done(image); image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); image.src = src; }); export function createPlanLayers(opts: { projectId: string; map: PlanMap; /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */ points: () => Vertex[]; }): PlanLayers { const { projectId, map } = opts; const on = readPref(); const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; let satellite: HTMLImageElement | null = null; const buttons = new Map(); for (const def of LAYERS) { const button = createIconButton({ icon: def.icon, title: def.title, pressed: on[def.key], onClick: () => toggle(def.key), }); button.dataset.layer = def.key; buttons.set(def.key, button); } const bar = createToolbar({ glass: true, size: 32, label: "지도 겹", items: [...buttons.values()], }); bar.classList.add("d03-plan__layers"); const legend = document.createElement("div"); legend.className = "d03-plan__legend"; legend.title = "급경사 색 — 단계 · 받은 지형 안 넓이 비율"; function sync(): void { for (const def of LAYERS) { const button = buttons.get(def.key)!; button.setAttribute("aria-pressed", String(on[def.key])); button.disabled = !meta; const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`; button.title = title; button.setAttribute("aria-label", title); } legend.hidden = !meta || !on.slope || !data.slope; } sync(); function toggle(key: PlanLayerKey): void { on[key] = !on[key]; writeByKey(PREF_KEY, JSON.stringify(on)); void ensure(key); showSatellite(); sync(); map.redraw(); } function showSatellite(): void { map.update({ image: on.satellite ? satellite : null }); } /** 켜진 겹의 자료를 받음(한 번). */ function ensure(key: PlanLayerKey): Promise { if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared])) return Promise.resolve(); const running = pending.get(key); if (running) return running; const at = meta; const job = fetchLayer(key, at) .then(() => { if (meta !== at) return; showSatellite(); sync(); map.redraw(); }) .catch((error: unknown) => { const text = error instanceof Error ? error.message : String(error); showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); }) .finally(() => pending.delete(key)); pending.set(key, job); return job; } async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise { const normalizer = createNormalizer(at); const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => prepareLayer(collection, normalizer, labels); if (key === "satellite") { // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`; satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`); } else if (key === "contour") { const points = opts.points(); const xs = points.map(([x]) => x); const ys = points.map(([, y]) => y); const bbox = [ Math.min(...xs) - CONTOUR_PAD_M, Math.min(...ys) - CONTOUR_PAD_M, Math.max(...xs) + CONTOUR_PAD_M, Math.max(...ys) + CONTOUR_PAD_M, ].map((v) => v.toFixed(0)); const collection = await fetchCachedSheetLayer( projectId, "도엽_등고선", "route", bbox.join(","), ); data.contour = prepare(collection, ["등고수치"]); } else if (key === "stream") { const collection = await fetchCachedSheetLayer( projectId, "도엽_하천중심선", ); const pick = (small: boolean): GeoJsonCollection => ({ ...collection, features: (collection.features ?? []).filter( (feature) => (feature.properties?.["구분"] === "세류") === small, ), }); data.stream = [prepare(pick(true)), prepare(pick(false))]; } else if (key === "cadastral") { data.cadastral = prepare(await fetchPlanCadastral(projectId)); } else if (key === "road") { data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선")); } else { const raster = await fetchPlanRaster(projectId, "slope"); data.slope = { raster, image: await loadImage(raster.image) }; // 범례 = 서버 단계 색 그대로(색 한 곳) legend.replaceChildren( createLegend( raster.bands.map((band) => ({ label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`, color: band.color, })), ), ); } } const stroke = ( context: CanvasRenderingContext2D, map: ScreenMap, layer: PreparedLayer, color: string, width: number, everyM?: number, ): void => { context.strokeStyle = color; context.lineWidth = width; drawPreparedLayer(context, layer, map.view, "dot", everyM); }; const layers: PlanMapLayer[] = [ { draw: (context, map) => { const slope = on.slope && data.slope; if (!slope) return; const [x0, y0, x1, y1] = slope.raster.box_m; const [left, top] = map.toScreen([x0, y1]); const [right, bottom] = map.toScreen([x1, y0]); context.globalAlpha = 0.55; context.drawImage(slope.image, left, top, right - left, bottom - top); }, }, { draw: (context, map) => { if (!on.cadastral || !data.cadastral) return; context.globalAlpha = 0.8; stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8); }, }, { draw: (context, map) => { const contour = on.contour && data.contour; if (!contour) return; const color = themeColor("--draw-map-contour", "#ffd68c"); context.globalAlpha = 0.7; stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5)); stroke(context, map, contour, color, 1.6, 25); // 계곡선 context.globalAlpha = 1; context.font = "10px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "middle"; drawPreparedLabels( context, contour, map.view, color, 25, map.uprightRad, "rgba(0, 0, 0, 0.6)", ); }, }, { draw: (context, map) => { if (!on.road || !data.road) return; stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2); stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6); }, }, { draw: (context, map) => { if (!on.stream || !data.stream) return; const color = themeColor("--draw-map-stream", "#38bdf8"); stroke(context, map, data.stream[0], color, 1); stroke(context, map, data.stream[1], color, 2.4); }, }, ]; async function load(): Promise { const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); const changed = JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !== JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]); meta = next; if (changed) { satellite = null; for (const key of Object.keys(data)) delete data[key as keyof Prepared]; } map.update({ meta }); showSatellite(); sync(); await Promise.all(LAYERS.map((def) => ensure(def.key))); } return { bar, legend, layers, load, state: () => ({ on: { ...on }, loaded: [ ...(satellite ? (["satellite"] as PlanLayerKey[]) : []), ...(Object.keys(data) as PlanLayerKey[]), ], }), }; }