/* ============================================================================= * D03_PlanAnalysis_UI_Layers.ts * D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄 * (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로). * * 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) · * 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) · * 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 선 겹(단층 · 지질구조선 · 0차곡) · * 계류 100 m · 도로 300 m 띠 · 건물 · 지명). * 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억. * 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림. * 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 → * 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 선 겹 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { createLegend, type LegendItem } 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 { 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, lonLatToScreen, 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, fetchPlanLines, fetchPlanRaster, fetchPlanSheetLayer, fetchPlanSoils, fetchPlanZeroOrder, fetchPlanZones, type PlanBand, type PlanRaster, type PlanSoils, type PlanZones, } from "./D03_PlanAnalysis_Api"; import { FOREST_ROAD_KINDS, GROUPS, LAYERS, LINE_KEYS, LINE_LAYERS, SOIL_COLORS, WATER_KEYS, ZONE_COLORS, drawRaster, fillLayer, hectare, loadImage, stroke, type LineLayerKey, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; const PREF_KEY = "aislo:pref:d03.layers"; /** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ interface PreparedLines { layer: PreparedLayer; count: number; length_m: number; } type Prepared = Record & { contour: PreparedLayer; /** 세류 · 하천(소하천 · 지방하천 · 연결선). */ stream: [PreparedLayer, PreparedLayer]; cadastral: PreparedLayer; /** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */ road: { all: PreparedLayer; small: PreparedLayer }; slope: { raster: PlanRaster; image: HTMLImageElement }; hillshade: { raster: PlanRaster; image: HTMLImageElement }; water: PreparedLayer; zones: { kinds: Map; summary: PlanZones["summary"] }; /** 토양형(코드 차례) → 지물 · 색 값. */ soils: { kinds: Array<{ layer: PreparedLayer; color: string }>; summary: PlanSoils["summary"] }; bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } }; building: PreparedLayer; place: Array<{ lon: number; lat: number; name: string }>; }; 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; }; } 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; } 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(); 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 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 = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" ? !!satellite : !!data[dataKey(key)]; function legendRows(): LegendItem[][] { const rows: LegendItem[][] = []; if (on.slope && data.slope) rows.push( data.slope.raster.bands.map((band) => ({ label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`, color: band.color, })), ); if (on.zones && data.zones) { const items = Object.entries(data.zones.summary) .filter(([, kind]) => kind.count || kind.failed) .map(([key, kind]) => ({ label: kind.failed ? `${kind.label} 받지 못함` : `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`, color: `var(${ZONE_COLORS[key]})`, off: !!kind.failed, })); rows.push( items.length ? items : [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }], ); } if (on.soils && data.soils) { const { kinds, summary } = data.soils; rows.push( summary.length ? summary.map((soil, i) => ({ label: `${soil.name} ${hectare(soil.area_m2)}`, color: `var(${kinds[i].color})`, })) : [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }], ); } if (on.bands && data.bands) { const { stream, road } = data.bands.info; rows.push([ { label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`, color: "var(--draw-map-band-stream)", }, { label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`, color: "var(--draw-map-band-road)", }, ]); } const lines = LINE_KEYS.flatMap((key) => { const got = on[key] && data[key]; if (!got) return []; const { label, color, dash } = LINE_LAYERS[key]; return [ { label: `${label} ${got.count}가닥 ${(got.length_m / 1000).toFixed(2)} km`, color: `var(${color})`, kind: dash.length ? ("dash" as const) : ("line" as const), off: !got.count, }, ]; }); if (lines.length) rows.push(lines); const tail: LegendItem[] = []; if (on.water && data.water) tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" }); if (on.building && data.building) tail.push({ label: `건물 ${data.building.features.length.toLocaleString()}동`, color: "var(--draw-map-building)", }); if (on.forestRoad && data.road) tail.push({ label: `임도 짐작 ${data.road.small.features.length}가닥`, color: "var(--draw-map-forest-road)", kind: "dash", }); if (tail.length) rows.push(tail); return rows; } 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); } const rows = meta ? legendRows() : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); legend.hidden = !rows.length; } sync(); 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 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") { // 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀 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 = indexLayerLod(prepare(collection, ["등고수치"])); } else if (key === "stream") { const collection = await fetchCachedSheetLayer( projectId, "도엽_하천중심선", ); data.stream = [ prepare(pick(collection, (props) => props["구분"] === "세류")), prepare(pick(collection, (props) => props["구분"] !== "세류")), ]; } else if (key === "cadastral") { data.cadastral = prepare(await fetchPlanCadastral(projectId)); } 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["도로구분"]))), ), }; } else if (key === "slope" || key === "hillshade") { const raster = await fetchPlanRaster(projectId, key); data[key] = { raster, image: await loadImage(raster.image) }; } else if (key === "water") { const parts = await Promise.all( WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)), ); data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) }); } else if (key === "building") { data.building = prepare(await fetchPlanSheetLayer(projectId, "건물")); } 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 }; 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, }; } 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, }; } 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, }; } 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 }]; }); } } /** 등고 글 폭 기억(글꼴 하나 · 582). */ const labelWidths = new Map(); const layers: PlanMapLayer[] = [ { base: true, draw: (context, map) => { if (!on.hillshade || !data.hillshade) return; // 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로 if (on.satellite && satellite) { context.globalCompositeOperation = "multiply"; context.globalAlpha = 0.75; } drawRaster(context, map, data.hillshade); }, }, { base: true, draw: (context, map) => { if (!on.slope || !data.slope) return; context.globalAlpha = 0.55; drawRaster(context, map, data.slope); }, }, { base: true, draw: (context, map) => { if (!on.soils || !data.soils) return; for (const { layer, color } of data.soils.kinds) { const fill = themeColor(color, "#facc15"); fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 }); } }, }, { base: true, draw: (context, map) => { if (!on.zones || !data.zones) return; for (const [kind, layer] of data.zones.kinds) { const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308"); fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 }); } }, }, { base: true, draw: (context, map) => { if (!on.bands || !data.bands) return; const road = themeColor("--draw-map-band-road", "#facc15"); const stream = themeColor("--draw-map-band-stream", "#22d3ee"); fillLayer(context, data.bands.road, map.view, road, 0.16, { color: road, width: 1.2, dash: [6, 4], }); fillLayer(context, data.bands.stream, map.view, stream, 0.18, { color: stream, width: 1.2, dash: [6, 4], }); }, }, { base: true, 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); }, }, { base: true, draw: (context, map) => { if (!on.water || !data.water) return; const color = themeColor("--draw-map-water", "#0ea5e9"); fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 }); }, }, { base: true, draw: (context, map) => { if (!on.building || !data.building) return; const color = themeColor("--draw-map-building", "#fb7185"); fillLayer(context, data.building, map.view, color, 0.7, { color: "rgba(0, 0, 0, 0.55)", width: 0.6, }); }, }, { base: true, 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)", labelWidths, ); }, }, { base: true, draw: (context, map) => { if (!on.road || !data.road) return; stroke(context, map, data.road.all, "rgba(0, 0, 0, 0.55)", 3.2); stroke(context, map, data.road.all, themeColor("--draw-map-road", "#f8fafc"), 1.6); }, }, { base: true, draw: (context, map) => { if (!on.forestRoad || !data.road) return; context.setLineDash([7, 4]); stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6); stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2); context.setLineDash([]); }, }, { base: true, 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); }, }, ...LINE_KEYS.map((key): PlanMapLayer => ({ base: true, draw: (context, map) => { const got = on[key] && data[key]; if (!got) return; const { color, width, dash } = LINE_LAYERS[key]; context.setLineDash(dash); stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", width + 1.6); stroke(context, map, got.layer, themeColor(color, "#f43f5e"), width); context.setLineDash([]); }, })), { base: true, draw: (context, map) => { if (!on.place || !data.place || !meta) return; const normalizer = createNormalizer(meta); context.font = "600 11px system-ui, sans-serif"; context.textAlign = "center"; context.textBaseline = "bottom"; context.lineWidth = 3; context.strokeStyle = "rgba(0, 0, 0, 0.7)"; context.fillStyle = themeColor("--draw-map-place", "#f8fafc"); for (const place of data.place) { const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat); if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue; map.upright(context, x, y, () => { context.beginPath(); context.arc(x, y, 2.5, 0, Math.PI * 2); context.fill(); context.strokeText(place.name, x, y - 4); context.fillText(place.name, x, y - 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 DataKey]; } map.update({ meta }); showSatellite(); sync(); await Promise.all(LAYERS.map((def) => ensure(def.key))); } const counts = (): Record => ({ ...(data.building && { building: data.building.features.length }), ...(data.water && { water: data.water.features.length }), ...(data.road && { road: data.road.all.features.length, forestRoad: data.road.small.features.length, }), ...(data.place && { place: data.place.length }), ...(data.zones && Object.fromEntries( Object.entries(data.zones.summary).flatMap(([kind, value]) => [ [`zone_${kind}`, value.count], [`zone_${kind}_m2`, value.area_m2], ]), )), ...(data.soils && Object.fromEntries( data.soils.summary.flatMap((soil) => [ [`soil_${soil.code}`, soil.count], [`soil_${soil.code}_m2`, soil.area_m2], ]), )), ...(data.bands && { band_stream_m2: data.bands.info.stream.area_m2, band_road_m2: data.bands.info.road.area_m2, }), ...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }), ...Object.fromEntries( LINE_KEYS.flatMap((key) => { const got = data[key]; return got ? [ [key, got.count], [`${key}_m`, got.length_m], ] : []; }), ), }); return { bar, legend, layers, load, state: () => ({ on: { ...on }, loaded: LAYERS.map((def) => def.key).filter(loadedOf), counts: counts(), }), }; }