/* ============================================================================= * 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 · 올리기 = 왼쪽 「위치」 칸 `uploadRoads`)) · * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) · * 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 · * 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 · * 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`). * 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). * 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`). * 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 { createIconButton } from "@ui/ui_template_button"; import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, 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, fetchPlanSoils, fetchPlanZeroOrder, fetchPlanZones, deletePlanForestRoad, uploadPlanForestRoads, type PlanForestRoads, } 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, type RoadPick, } 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 { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; import { createSheetTiles, SHEET_TILE_NAMES, type SheetTiles, } from "./D03_PlanAnalysis_UI_SheetTiles"; import { openSlopeBands, paintSlope, readSlopeBands, slopeGear, slopeValues, writeSlopeBands, } from "./D03_PlanAnalysis_UI_SlopeBands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; 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"; /** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */ const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림"; type DataKey = keyof Prepared; /** 겹 → 받는 자료(임도 짐작 = 도로 자료). */ const dataKey = (key: Exclude): DataKey => key === "forestRoad" ? "road" : key; export interface PlanLayers { bar: HTMLElement; legend: HTMLElement; /** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */ layers: PlanMapLayer[]; /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤 · 받은 범위(`have` · 지형면을 다 읽은 뒤 값)가 바뀌면 켠 겹 모두 새로. */ load: (have: [number, number, number, number] | null) => Promise; /** 임도망도 올리기(왼쪽 「위치」 칸) — 받으면 그 겹을 켬 · 못 받으면 알림 + null. */ uploadRoads: (files: File[]) => Promise; /** 올린 묶음 하나 지움(605-1) — 지도에서 바로 빠짐 · 못 지우면 알림 + 거짓. */ deleteRoad: (bundle: string) => Promise; /** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */ pickRoad: (pick: RoadPick | null) => void; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ state: () => { on: Record; loaded: PlanLayerKey[]; counts: Record; /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ gl: Record; /** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */ area: [number, number, number, number] | null; turn: number; tiles: ReturnType; }; } export function createPlanLayers(opts: { projectId: string; map: PlanMap; /** 받는 일을 진행 서클에 셈(끝나면 뺌). */ track: (job: Promise) => Promise; }): PlanLayers { const { projectId, map, track } = opts; const on = readPref(PREF_KEY); const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; let area: [number, number, number, number] | null = null; /** 차례 — 받은 범위 · 위성 틀이 바뀔 때 +1 · 옛 차례 답은 자료 · GL 에 안 씀. */ let areaTurn = 0; /** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */ let hillshade: { plain: GlImage; shade: GlImage } | null = null; const gl = createGlBase(projectId, track, (why) => { if (!glOn) return; showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why} · ${NO_SATELLITE_NOTE}`, "info"); useGl(false); }); let glOn = readByKey(CANVAS_PREF_KEY) !== "1"; /** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */ const tiles = createSheetTiles({ projectId, track, wanted: () => meta ? LAYERS.flatMap((def) => (on[def.key] && SHEET_TILE_NAMES[def.key]) || []) : [], gl: () => glOn, onChange: (names) => { for (const def of LAYERS) if (meta && on[def.key] && SHEET_TILE_NAMES[def.key]?.some((name) => names.has(name))) void fetchLayer(def.key, meta, areaTurn).then(() => (sync(), map.redrawBase())); }, }); 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 glButton = createIconButton({ icon: "gauge", title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", size: 32, pressed: glOn, onClick: () => { writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0"); useGl(!glOn); if (!glOn) showToast(NO_SATELLITE_NOTE, "info"); }, }); 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)); sync(); 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" || !!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.key === "satellite" && !glOn ? `${def.title} — ${NO_SATELLITE_NOTE}` : def.title; button.title = title; button.setAttribute("aria-label", title); } 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) : []; const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음 legend.replaceChildren(...rows.map((row) => createLegend(row)), ...(light ? [lightRow] : [])); if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); legend.hidden = !rows.length && !light; } /** 위성 밝기(요구 475) — 위성 켠 동안 범례 맨 아래 한 줄 · 바로 반영. */ gl.setSatelliteLight(readSatLight()); const lightRow = createSatLightRow(readSatLight(), (level) => { gl.setSatelliteLight(level); map.redrawBase(); }); /** 급경사 범위(603) — 바꾸면 범례 바로 · 그림은 다시 칠함(늦은 칠하기는 버림). */ let slopeBands = readSlopeBands(); let slopeTurn = 0; const gear = slopeGear(() => openSlopeBands(slopeBands, (bands) => { slopeBands = bands; writeSlopeBands(bands); if (data.slope) data.slope.bands = bands; sync(); void repaintSlope(); }), ); async function repaintSlope(): Promise { const got = data.slope; if (!got) return; const turn = ++slopeTurn; got.paintMs = -1; // 칠하는 중(검증 창구) const started = performance.now(); const painted = await paintSlope(got.values, got.bands); if (turn !== slopeTurn || data.slope !== got) return URL.revokeObjectURL(painted.url); const old = got.url; Object.assign(got, { image: painted.image, url: painted.url }); got.paintMs = Math.round(performance.now() - started); gl.setData("slope", glImage(got.raster, painted.url)); if (old) URL.revokeObjectURL(old); sync(); map.redrawBase(); } sync(); useGl(glOn); function toggle(key: PlanLayerKey): void { on[key] = !on[key]; writeByKey(PREF_KEY, JSON.stringify(on)); void ensure(key); sync(); map.redrawBase(); } /** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */ 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 mine = areaTurn; const job: Promise = track(fetchLayer(key, meta, mine)) .then(() => { if (mine !== areaTurn) return; sync(); map.redrawBase(); }) .catch((error: unknown) => { if (mine !== areaTurn) return; 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.get(slot) === job && pending.delete(slot)); pending.set(slot, job); return job; } /** 올리기 — 받으면 그 겹을 켜고 바로 그림(지형 받기 전이면 받은 뒤 그림) · 못 받으면 옛 것 그대로 + 서버 까닭 알림. */ async function uploadRoads(files: File[]): Promise { const at = meta; const job = track(uploadPlanForestRoads(projectId, files)).then((info) => { if (meta !== at) return info; applyRoads(info); showToast( `임도망도 받음 — 범위 안 ${info.count}가닥 ${(info.length_m / 1000).toFixed(2)} km (파일 ${info.total}가닥)`, "success", ); return info; }); // 거절은 아래 catch 가 알림 — 기다림 몫은 삼킴(처리 안 된 거절 없음) pending.set( "forestRoadFile", job.then( () => undefined, () => undefined, ), ); try { return await job; } catch (error) { showToast( `임도망도 올리지 못함 — ${error instanceof Error ? error.message : String(error)}`, "error", ); return null; } finally { pending.delete("forestRoadFile"); sync(); map.redrawBase(); } } /** 올린 임도망 자료를 바꿔 그림(올림 · 지움) — 켜 둠 · null = 하나도 안 남음(끔). */ function applyRoads(info: PlanForestRoads | null): void { if (info) { if (meta) data.forestRoadFile = { layer: prepareLayer(info.collection, createNormalizer(meta)), info, }; gl.setData("forestRoadFile", info.collection); } else { delete data.forestRoadFile; gl.setData("forestRoadFile", null); roadPick = null; gl.setRoadPick(null); } on.forestRoadFile = !!info; writeByKey(PREF_KEY, JSON.stringify(on)); } async function deleteRoad(bundle: string): Promise { try { const info = await deletePlanForestRoad(projectId, bundle); if (roadPick?.bundle === bundle) pickRoad(null); applyRoads(info); showToast( info ? `임도망도 묶음 지움 — 남은 ${info.bundles.length}묶음` : "임도망도 묶음 지움 — 남은 것 없음", "success", ); return true; } catch (error) { showToast( `임도망도 묶음을 지우지 못함 — ${error instanceof Error ? error.message : String(error)}`, "error", ); return false; } finally { sync(); map.redrawBase(); } } /** 고른 묶음 · 선만 담은 캔버스 자료(옛 그림용) — 자료 · 고름이 같으면 그대로. */ let roadPick: RoadPick | null = null; let pickMemo: { from: unknown; pick: RoadPick; layer: PreparedLayer } | null = null; function pickLayer(): PreparedLayer | null { const got = data.forestRoadFile; if (!roadPick || !got || !meta) return null; if (pickMemo?.from !== got || pickMemo.pick !== roadPick) { const mine = roadPick; const features = (got.info.collection.features ?? []).filter((f) => { const props = f.properties ?? {}; return ( props["__bundle"] === mine.bundle && (mine.line === undefined || props["__line"] === mine.line) ); }); pickMemo = { from: got, pick: mine, layer: prepareLayer({ ...got.info.collection, features }, createNormalizer(meta)), }; } return pickMemo.layer; } function pickRoad(pick: RoadPick | null): void { roadPick = pick; gl.setRoadPick(pick); if (pick && !on.forestRoadFile) toggle("forestRoadFile"); else map.redrawBase(); } /** 겹 하나 받기 — 자료 · GL 은 모아 두었다가 지금 차례일 때만 씀(옛 받은 범위 답 버림 · 475). */ async function fetchLayer(key: PlanLayerKey, at: VWorldMeta, mine: number): Promise { const got: Partial = {}; const sets: Array> = []; const put = (...args: Parameters): void => void sets.push(args); let shade = null as typeof hillshade; await fill(); if (mine !== areaTurn) { if (got.slope?.url) URL.revokeObjectURL(got.slope.url); return; } if (got.slope && data.slope?.url) URL.revokeObjectURL(data.slope.url); Object.assign(data, got); if (shade) hillshade = shade; for (const args of sets) gl.setData(...args); // 칠하는 동안 범위가 바뀜 = 새 범위로 다시 if (got.slope && got.slope.bands !== slopeBands) { got.slope.bands = slopeBands; void repaintSlope(); } async function fill(): 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 === "contour") { await glFont("400", "0123456789-"); const collection = tiles.collection("등고선"); got.contour = indexLayerLod(prepare(collection, ["등고수치"])); put("contour", collection); } else if (key === "stream") { const collection = tiles.collection("하천중심선"); got.stream = [ prepare(pick(collection, (props) => props["구분"] === "세류")), prepare(pick(collection, (props) => props["구분"] !== "세류")), ]; put("stream", collection); } else if (key === "cadastral") { const collection = await fetchPlanCadastral(projectId); got.cadastral = prepare(collection); put("cadastral", collection); } else if (key === "road" || key === "forestRoad") { const collection = tiles.collection("도로중심선"); got.road = { all: prepare(collection), small: prepare( pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))), ), }; put("road", collection); } else if (key === "forestRoadFile") { const info = await fetchPlanForestRoads(projectId); got.forestRoadFile = { layer: prepare(info.collection), info }; put("forestRoadFile", info.collection); } else if (key === "landslideZones") { const info = await fetchPlanLandslideZones(projectId); got.landslideZones = { parcels: prepare(info.parcels), info }; put("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") { const raster = await fetchPlanRaster(projectId, key); const values = slopeValues(await loadImage(raster.image)); const bands = slopeBands; const started = performance.now(); const painted = await paintSlope(values, bands); const paintMs = Math.round(performance.now() - started); got.slope = { raster, values, bands, ...painted, paintMs }; put("slope", glImage(raster, painted.url)); } else if (key === "hillshade" || key === "landslide") { const raster = await fetchPlanRaster(projectId, key); const image = await loadImage(raster.image); got[key] = { raster, image }; const plain = glImage(raster); if (key !== "hillshade") put(key, plain); else if (plain) shade = { plain, shade: glImage(raster, shadeImage(image))! }; } else if (key === "water") { const parts = WATER_KEYS.map((name) => tiles.collection(name)); const water = { features: parts.flatMap((part) => part.features ?? []) }; got.water = prepare(water); put("water", water); } else if (key === "building") { const building = tiles.collection("건물"); got.building = prepare(building); put("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))); got.zones = { kinds, summary: zones.summary }; put( "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); got.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, got.soils!.kinds[i].color]), ); put( "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 found = kind ? await fetchPlanLines(projectId, kind).then((lines) => ({ collection: lines, ...lines.summary, })) : await fetchPlanZeroOrder(projectId); got[key] = { layer: prepare(found.collection), count: found.count, length_m: found.length_m, }; put(key, found.collection); } finally { window.clearTimeout(slow); } } else if (key === "bands") { const info = await fetchPlanBands(projectId); got.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, }); put("bands", { features: [ ...(band("road", "#facc15").features ?? []), ...(band("stream", "#22d3ee").features ?? []), ], }); } else { const collection = tiles.collection("지명"); got.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", got.place.map((place) => place.name).join("")); put("place", { features: got.place.map(({ lon, lat, name }) => ({ type: "Feature", properties: { name }, geometry: { type: "Point", coordinates: [lon, lat] }, })), } as GeoJsonCollection); } } } const canvasLayers = createCanvasLayers({ on, data, meta: () => meta, roadPick: pickLayer, }); /** GL 이면 캔버스 바탕 겹은 쉼 — 보이는 도엽 조각만 셈. */ const layers: PlanMapLayer[] = [ ...canvasLayers.map((layer): PlanMapLayer => ({ ...layer, draw: (c, m) => void (glOn || layer.draw(c, m)), })), { base: true, draw: (_, m) => void (meta && tiles.view(m, meta)), }, ]; async function load(have: [number, number, number, number] | null): Promise { const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null); // 바뀜 = 받은 범위 지문 또는 위성 틀 → 차례 +1 · 자료 · 받는 중 비움 · 켠 겹 모두 다시(475 · 596-4) const frame = (m: VWorldMeta | null) => JSON.stringify(m && [m.x_min, m.x_max, m.y_min, m.y_max]); const changed = JSON.stringify(have) !== JSON.stringify(area) || frame(next) !== frame(meta); [meta, area] = [next, have]; if (changed) { areaTurn += 1; pending.clear(); 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 }); sync(); await Promise.all(LAYERS.map((def) => ensure(def.key))); } return { bar, legend, layers, load, uploadRoads, deleteRoad, pickRoad, 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, ]), ), area, turn: areaTurn, tiles: tiles.state(), }), }; }