/* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 * (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) · * 올린 임도망도(시군구 SHP · 옆 올리기 단추)) · * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) · * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림. * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { createLegend } from "@ui/ui_template_flow_legend"; import { pickFiles } from "@ui/ui_template_drop"; import { createIconButton } from "@ui/ui_template_button"; import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; 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, pickLevelStep, type GeoJsonCollection, type PreparedLayer, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { indexLayerLod, prepareLayer, } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare"; import { fetchPlanBands, fetchPlanCadastral, fetchPlanForestRoads, fetchPlanLandslideZones, fetchPlanLines, fetchPlanRaster, fetchPlanSheetLayer, fetchPlanSoils, fetchPlanZeroOrder, fetchPlanZones, uploadPlanForestRoads, } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, GROUPS, LAYERS, LINE_LAYERS, SOIL_COLORS, WATER_KEYS, ZONE_COLORS, glImage, glTagged, loadImage, readPref, shadeImage, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; const PREF_KEY = "aislo:pref:d03.layers"; /** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */ const glFont = (weight: string, text: string): Promise => document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null); /** 옛 캔버스로 그림("1") — 처음 = GL. */ const CANVAS_PREF_KEY = "aislo:pref:d03.canvas"; /** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; type DataKey = keyof Prepared; /** 겹 → 받는 자료(임도 짐작 = 도로 자료). */ const dataKey = (key: Exclude): DataKey => key === "forestRoad" ? "road" : key; export interface PlanLayers { bar: HTMLElement; legend: HTMLElement; /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ layers: PlanMapLayer[]; /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */ load: () => Promise; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ state: () => { on: Record; loaded: PlanLayerKey[]; counts: Record; /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ gl: Record; }; } export function createPlanLayers(opts: { projectId: string; map: PlanMap; /** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */ points: () => Vertex[]; }): PlanLayers { const { projectId, map } = opts; const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); let uploading = false; let meta: VWorldMeta | null = null; let satellite: HTMLImageElement | null = null; /** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */ let hillshade: { plain: GlImage; shade: GlImage } | null = null; const gl = createGlBase(projectId, (why) => { if (!glOn) return; showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info"); useGl(false); }); let glOn = readByKey(CANVAS_PREF_KEY) !== "1"; const buttons = new Map(); const groups = GROUPS.map((group) => group.map((def) => { const button = createIconButton({ icon: def.icon, title: def.title, size: 32, pressed: on[def.key], onClick: () => toggle(def.key), }); button.dataset.layer = def.key; buttons.set(def.key, button); return button; }), ); const upload = createIconButton({ icon: "upload", title: "임도망도 SHP 올리기(시군구 zip 하나 · 또는 shp · shx · dbf · prj 함께 · 100 MB 까지)", size: 32, onClick: () => pickFiles( { accept: ".zip,.shp,.shx,.dbf,.prj,.cpg", multiple: true }, (files) => void uploadRoads(files), ), }); upload.dataset.layer = "forestRoadUpload"; const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!; roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload); const glButton = createIconButton({ icon: "gauge", title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", size: 32, pressed: glOn, onClick: () => { writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); useGl(!glOn); }, }); glButton.dataset.layer = "gl"; /** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */ function useGl(want: boolean): void { glOn = want && map.setGl(gl); if (!glOn) map.setGl(null); glButton.setAttribute("aria-pressed", String(glOn)); void ensure("satellite"); showSatellite(); map.redrawBase(); } groups.push([glButton]); const tools = createMapTools({ label: "지도 겹", groups }); const bar = tools.root; bar.classList.add("d03-plan__layers"); // 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`) new ResizeObserver(() => map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`), ).observe(tools.bar); const legend = document.createElement("div"); legend.className = "d03-plan__legend"; legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; /** 받음 — 위성은 GL 이면 조각(한 장 그림 안 받음). */ const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" ? glOn || !!satellite : !!data[dataKey(key)]; 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); } upload.disabled = !meta || uploading; for (const def of LAYERS) gl.setVisible(def.key, on[def.key]); gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const rows = meta ? legendRows(on, data) : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); legend.hidden = !rows.length; } sync(); useGl(glOn); function toggle(key: PlanLayerKey): void { on[key] = !on[key]; writeByKey(PREF_KEY, JSON.stringify(on)); void ensure(key); showSatellite(); sync(); map.redrawBase(); } function showSatellite(): void { map.update({ image: on.satellite ? satellite : null }); } /** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */ function ensure(key: PlanLayerKey): Promise { if (!meta || !on[key] || loadedOf(key)) return Promise.resolve(); const slot = key === "satellite" ? key : dataKey(key); const running = pending.get(slot); if (running) return running; const at = meta; const job = fetchLayer(key, at) .then(() => { if (meta !== at) return; showSatellite(); sync(); map.redrawBase(); }) .catch((error: unknown) => { const text = error instanceof Error ? error.message : String(error); for (const def of LAYERS) if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot)) on[def.key] = false; sync(); map.redraw(); showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); }) .finally(() => pending.delete(slot)); pending.set(slot, job); return job; } /** 올리기 — 받으면 그 겹을 켜고 바로 그림 · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ async function uploadRoads(files: File[]): Promise { if (!meta) return; const at = meta; const job = uploadPlanForestRoads(projectId, files).then((info) => { if (meta !== at) return; data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(at)), info, }; gl.setData("forestRoadFile", info.collection); on.forestRoadFile = true; writeByKey(PREF_KEY, JSON.stringify(on)); showToast( `임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`, "success", ); }); uploading = true; pending.set("forestRoadFile", job); sync(); try { await job; } catch (error) { showToast( `임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`, "error", ); } finally { uploading = false; pending.delete("forestRoadFile"); sync(); map.redrawBase(); } } async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise { const normalizer = createNormalizer(at); const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer => prepareLayer(collection, normalizer, labels); const pick = ( collection: GeoJsonCollection, test: (props: Record) => boolean, ): GeoJsonCollection => ({ ...collection, features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})), }); if (key === "satellite") { if (glOn) return; // GL = 조각 // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 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 Promise.all([ fetchCachedSheetLayer(projectId, "도엽_등고선", "route", bbox.join(",")), glFont("400", "0123456789-"), ]); data.contour = indexLayerLod(prepare(collection, ["등고수치"])); gl.setData("contour", collection); } else if (key === "stream") { const collection = await fetchCachedSheetLayer( projectId, "도엽_하천중심선", ); data.stream = [ prepare(pick(collection, (props) => props["구분"] === "세류")), prepare(pick(collection, (props) => props["구분"] !== "세류")), ]; gl.setData("stream", collection); } else if (key === "cadastral") { const collection = await fetchPlanCadastral(projectId); data.cadastral = prepare(collection); gl.setData("cadastral", collection); } else if (key === "road" || key === "forestRoad") { const collection = await fetchPlanSheetLayer(projectId, "도로중심선"); data.road = { all: prepare(collection), small: prepare( pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))), ), }; gl.setData("road", collection); } else if (key === "forestRoadFile") { const info = await fetchPlanForestRoads(projectId); data.forestRoadFile = { layer: prepare(info.collection), info }; gl.setData("forestRoadFile", info.collection); } else if (key === "landslideZones") { const info = await fetchPlanLandslideZones(projectId); data.landslideZones = { parcels: prepare(info.parcels), info }; gl.setData("landslideZones", { features: [ ...(info.parcels.features ?? []), ...info.points.map((point) => ({ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [point.lon, point.lat] }, })), ], } as GeoJsonCollection); if (info.missing.length) showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); } else if (key === "slope" || key === "hillshade" || key === "landslide") { const raster = await fetchPlanRaster(projectId, key); const image = await loadImage(raster.image); data[key] = { raster, image }; const plain = glImage(raster); if (key !== "hillshade") gl.setData(key, plain); else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! }; } else if (key === "water") { const parts = await Promise.all( WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)), ); const water = { features: parts.flatMap((part) => part.features ?? []) }; data.water = prepare(water); gl.setData("water", water); } else if (key === "building") { const building = await fetchPlanSheetLayer(projectId, "건물"); data.building = prepare(building); gl.setData("building", building); } else if (key === "zones") { const zones = await fetchPlanZones(projectId); const kinds = new Map(); for (const kind of Object.keys(zones.summary)) kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind))); data.zones = { kinds, summary: zones.summary }; gl.setData( "zones", glTagged(zones, (props) => themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"), ), ); const failed = Object.values(zones.summary).filter((kind) => kind.failed); if (failed.length) showToast( `보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`, "error", ); } else if (key === "soils") { const soils = await fetchPlanSoils(projectId); data.soils = { kinds: soils.summary.map((soil, i) => ({ layer: prepare(pick(soils, (props) => props["code"] === soil.code)), color: SOIL_COLORS[i % SOIL_COLORS.length], })), summary: soils.summary, }; const colorOf = new Map( soils.summary.map((soil, i) => [soil.code, data.soils!.kinds[i].color]), ); gl.setData( "soils", glTagged(soils, (props) => themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"), ), ); } else if (key === "faults" || key === "lineaments" || key === "zeroOrder") { const kind = LINE_LAYERS[key].kind; // 0차곡 첫 셈 = 몇 초 — 늦으면 알림 const slow = window.setTimeout( () => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"), 600, ); try { const got = kind ? await fetchPlanLines(projectId, kind).then((lines) => ({ collection: lines, ...lines.summary, })) : await fetchPlanZeroOrder(projectId); data[key] = { layer: prepare(got.collection), count: got.count, length_m: got.length_m, }; gl.setData(key, got.collection); } finally { window.clearTimeout(slow); } } else if (key === "bands") { const info = await fetchPlanBands(projectId); data.bands = { stream: prepare(info.stream.collection), road: prepare(info.road.collection), info, }; const band = (name: "stream" | "road", fallback: string): GeoJsonCollection => glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), { _band: name, }); gl.setData("bands", { features: [ ...(band("road", "#facc15").features ?? []), ...(band("stream", "#22d3ee").features ?? []), ], }); } else { const collection = await fetchPlanSheetLayer(projectId, "지명"); data.place = (collection.features ?? []).flatMap((feature) => { const geometry = feature.geometry; const name = feature.properties?.["명칭"]; if (geometry?.type !== "Point" || typeof name !== "string" || !name) return []; const [lon, lat] = geometry.coordinates as number[]; return [{ lon, lat, name }]; }); await glFont("600", data.place.map((place) => place.name).join("")); gl.setData("place", { features: data.place.map(({ lon, lat, name }) => ({ type: "Feature", properties: { name }, geometry: { type: "Point", coordinates: [lon, lat] }, })), } as GeoJsonCollection); } } const canvasLayers = createCanvasLayers({ on, data, meta: () => meta, satellite: () => satellite, }); /** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */ const layers: PlanMapLayer[] = [ ...canvasLayers.map((layer): PlanMapLayer => ({ ...layer, draw: (c, m) => void (glOn || layer.draw(c, m)), })), { base: true, draw: (_, m) => { if (glOn && on.contour && data.contour) gl.setContourStep(pickLevelStep(data.contour, m.view, 5)); }, }, ]; 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 DataKey]; hillshade = null; for (const def of LAYERS) if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null); } map.update({ meta }); showSatellite(); sync(); await Promise.all(LAYERS.map((def) => ensure(def.key))); } return { bar, legend, layers, load, state: () => ({ on: { ...on }, loaded: LAYERS.map((def) => def.key).filter(loadedOf), counts: layerCounts(data), gl: Object.fromEntries( (gl.map()?.getStyle().layers ?? []) .filter((layer) => layer.layout?.visibility !== "none") .map((layer) => [ layer.id, layer.type === "raster" ? 1 : gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length, ]), ), }), }; }