/* ============================================================================= * B05_Profile_UI_RouteEdit_Layers.ts * 평면 노선 편집 지도 겹(PLAN 624-3 · 요구 530 · 결정 A② · A③ · A⑦ · A⑨) — 공용 겹 줄 · 범례(`A00_Common/map`)를 B05 평면에. * 겹 목록 · 처음 켬 · 취향 = 공용 표(634-2 · 22 겹 모두 · 처음 = 등고선 · 하천만) · 임도(업로드) = 받은 자료 없으면 막힘 + 까닭. * 등고선 · 하천 = B05 자기 자료(LAS 5 m · 1 m · 도엽 · 집기 · 자물쇠 그대로 · `_Gl` 이 그림 · 노선 띠 300 m 자르기는 이 둘만) · * 켬끔은 GL 에서만(옛 캔버스 = 늘 보임). * 그 밖 겹 = GL 만(A⑦) · 띠로 안 자르는 둘째 GL 칸(맨 밑 · 켠 겹이 있을 때만 섬) · 꼴 = 공용 `mapLayerSpecs` · * 카메라 = `_Gl` 과 같은 화면 따라가기(`glCameraOf`). * 받기 = 켤 때 한 번(서버 `/plan/…` · 624-1 임도 프로젝트 · 지형 = 설계 지형) · 도엽 선 겹 = 프로젝트 범위 도엽 조각 · * 실패 = 그 겹만 끔 + 알림 · 범례 = 켠 겹의 받은 자료 줄(공용 `legendRows`) · 급경사 ⚙ · 위성 밝기. * 부르는 곳 = `_Chrome`(겹 줄 · 범례 자리) · `_Gl`(따라가기 · 켬끔). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; import { createGlMap, GL_FONT, satelliteTileSource, type GlData } from "@ui/ui_template_gl_map"; import { themeColor } from "@ui/ui_template_palette"; import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import type { Normalizer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { glCameraOf } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame"; import { forestSources, soilColor, tileOnly } from "../A00_Common/map/map_forest_layers"; import { fetchPlanBands, fetchPlanCadastral, fetchPlanLandslideZones, fetchPlanLines, fetchPlanRaster, fetchPlanSheetTile, fetchPlanSheetTiles, fetchPlanSoils, fetchPlanZeroOrder, fetchPlanZones, type GeoJsonCollection, } from "../A00_Common/map/map_layer_api"; import { createMapLayerBar } from "../A00_Common/map/map_layer_bar"; import { FOREST_ROAD_KINDS, WATER_KEYS, bandCollection, glImage, glTagged, loadImage, shadeImage, } from "../A00_Common/map/map_layer_data"; import { LAYERS, LINE_LAYERS, ZONE_COLORS, type MapLayerKey, } from "../A00_Common/map/map_layer_list"; import { createMapLegend, legendRows, type LegendData } from "../A00_Common/map/map_legend"; import { MAP_THEME_NAMES, mapLayerSpecs } from "../A00_Common/map/map_layer_specs"; import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light"; import { openSlopeBands, paintSlope, readSlopeBands, slopeGear, slopeValues, writeSlopeBands, } from "../A00_Common/map/map_slope_bands"; /** B05 자기 자료로 그리는 겹(`_Gl` · 띠 자르기). */ const OWN: readonly MapLayerKey[] = ["contour", "stream"]; const isOwn = (key: MapLayerKey): boolean => OWN.includes(key); /** 겹 → 도엽 조각 이름(서버 `TILE_NAMES` · D03 `SHEET_TILE_NAMES` 와 같음). */ const SHEET_NAMES: Partial> = { water: WATER_KEYS, road: ["도로중심선"], forestRoad: ["도로중심선"], building: ["건물"], place: ["지명"], }; /** 겹 → 받는 자료 칸(임도 짐작 = 도로 · 도로 300 m 띠 = 띠 자료 · 634-3). */ const slotOf = (key: MapLayerKey): MapLayerKey => key === "forestRoad" ? "road" : key === "roadBands" ? "bands" : key; /** 도엽이 이보다 많으면 선 겹 숨김(D03 `FULL_MAX` 와 같음). */ const SHEET_MAX = 12; /** 지도 칸 둘레 여유(px · `.ui-gl-map` inset · B04 `createTmGl` 과 같음). */ const GL_MARGIN_PX = 8; export interface RouteEditLayers { /** 겹 줄(도구줄 아래 줄). */ bar: HTMLElement; /** 켠 겹 범례(상태 글판 안). */ legend: HTMLElement; /** B05 자기 겹 켬끔 — 등고선 · 하천을 그리나(옛 캔버스 = 늘 참). */ own: () => { contour: boolean; stream: boolean }; /** GL 켬끔 · 지도 범위가 바뀜 — 단추 · 범례 맞춤. */ sync: (glOn: boolean, meta: VWorldMeta | null) => void; /** 그리기마다(GL 켬일 때만) — 켠 겹이 있으면 둘째 칸을 세우고 화면을 따라감. */ follow: ( meta: VWorldMeta, normalizer: Normalizer, toScreen: (nx: number, ny: number) => [number, number], width: number, height: number, ) => void; /** GL 끔 · 떠남 — 둘째 칸 버림. */ park: () => void; /** 검증 창구 — 켬끔 · 받은 겹 · 둘째 칸 · 겹마다 그린 지물 수. */ state: () => { on: Record; loaded: MapLayerKey[]; map: boolean; gl: Record; sheets: number; }; } export function createRouteEditLayers(opts: { projectId: string; /** 지도 칸(둘째 GL 칸을 맨 앞에 둠). */ wrap: HTMLElement; /** 그래픽 카드 단추 — `_Gl` 켬끔. */ onGl: () => void; /** 장면 다시 그리기. */ redraw: () => void; }): RouteEditLayers { const { projectId, wrap } = opts; let glOn = false; let meta: VWorldMeta | null = null; let light = readSatLight(); const gl = createGlMap({ specs: () => mapLayerSpecs(light, null), fixed: { satellite: satelliteTileSource(projectId), ...forestSources(projectId) }, themeNames: MAP_THEME_NAMES, onFail: (why) => showToast(`지도 겹을 그래픽 카드로 못 그림 — ${why}`, "info"), }); const layerBar = createMapLayerBar({ page: "b05", glOn, onToggle: (key) => { void ensure(key); sync(glOn, meta); opts.redraw(); }, onGl: opts.onGl, }); const { on } = layerBar; layerBar.root.classList.add("b05-routeedit__layers"); const legendBox = createMapLegend("b05-routeedit__maplegend"); const data: Partial = {}; const loaded = new Set(); const pending = new Map>(); let hillshade: { plain: GlData; shade: GlData } | null = null; let size = ""; let enabled = false; /** 급경사 범위(603 · D03 과 같은 취향) — 바꾸면 다시 칠함. */ let slopeBands = readSlopeBands(); let slopeValuesNow: ImageData | null = null; const gear = slopeGear(() => openSlopeBands(slopeBands, (bands) => { slopeBands = bands; writeSlopeBands(bands); void paintSlopeNow(); }), ); async function paintSlopeNow(): Promise { const got = data.slope; if (!got || !slopeValuesNow) return; const bands = slopeBands; const painted = await paintSlope(slopeValuesNow, bands); if (bands !== slopeBands) return URL.revokeObjectURL(painted.url); data.slope = { raster: got.raster, bands }; gl.setData("slope", glImage(got.raster, painted.url)); sync(glOn, meta); opts.redraw(); } const lightRow = createSatLightRow(light, (level) => { light = level; if (gl.map()?.getLayer("satellite")) gl.map()!.setPaintProperty("satellite", "raster-brightness-max", level); opts.redraw(); }); function sync(nextGl: boolean, nextMeta: VWorldMeta | null): void { const first = !meta && nextMeta; glOn = nextGl; meta = nextMeta; // 막힘 까닭(634-3 · 공용 sync 가 단추 · 메뉴 줄에 「제목 — 까닭」) — 자기 겹(등고선 · 하천) = GL 에서만 켬끔 · // 옛 캔버스는 늘 그림 · 그 밖 겹 = GL 만(A⑦) · 임도(업로드) = 받은 자료 없음(546 공유 일감 전) const why: Partial> = { forestRoadFile: "받은 임도 자료 없음" }; for (const def of LAYERS) { if (!isOwn(def.key)) gl.setVisible(def.key, on[def.key]); if (glOn || why[def.key]) continue; if (isOwn(def.key)) why[def.key] = "옛 그림(그래픽 카드 끔)엔 늘 보임"; else if (meta) why[def.key] = "옛 그림(그래픽 카드 끔)엔 안 보임"; } layerBar.sync({ ready: !!meta, glOn, why }); if (hillshade) gl.setData("hillshade", on.satellite ? hillshade.shade : hillshade.plain); const shown = glOn ? on : ({} as Record); legendBox.show(meta ? legendRows(shown, data) : [], glOn && on.satellite ? [lightRow] : []); if (glOn && on.slope && data.slope) legendBox.root.firstElementChild?.append(gear); if (first) void Promise.all(LAYERS.map((def) => ensure(def.key))); } /** 켠 겹 자료 한 번 — 실패 = 그 겹만 끔 + 알림(취향은 안 바꿈). */ function ensure(key: MapLayerKey): Promise { if (!meta || !on[key] || isOwn(key) || tileOnly(key) || key === "landslide") return done(); if (loaded.has(key)) return done(); const slot = slotOf(key); if (loaded.has(slot as MapLayerKey)) return done(); const running = pending.get(slot); if (running) return running; const at = meta; const job = fill(key, at) .then(() => { loaded.add(key); loaded.add(slot as MapLayerKey); sync(glOn, meta); opts.redraw(); }) .catch((error: unknown) => { for (const def of LAYERS) if (def.key === key || slotOf(def.key) === slot) on[def.key] = false; sync(glOn, meta); opts.redraw(); const text = error instanceof Error ? error.message : String(error); showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error"); }) .finally(() => pending.delete(slot)); pending.set(slot, job); return job; } const done = (): Promise => Promise.resolve(); /** 프로젝트 범위 도엽 — 한 번 셈 · 조각은 이름마다 한 번. */ let sheetList: Promise | null = null; const sheetCache = new Map>(); async function sheetCollection(names: string[], at: VWorldMeta): Promise { sheetList ??= fetchPlanSheetTiles(projectId, [ at.lon_min, at.lat_min, at.lon_max, at.lat_max, ]).then((got) => { if (got.count > SHEET_MAX) throw new Error(`도엽 ${got.count}장 — 넓어서 선 겹 숨김`); return got.sheets; }); const sheets = await sheetList.catch((error: unknown) => { sheetList = null; throw error; }); const parts = await Promise.all( sheets.flatMap((sheet) => names.map((name) => { const key = `${sheet}/${name}`; if (!sheetCache.has(key)) sheetCache.set( key, fetchPlanSheetTile(projectId, sheet, name).catch(() => ({ features: [] })), ); return sheetCache.get(key)!; }), ), ); return { features: parts.flatMap((part) => part.features ?? []) }; } async function fill(key: MapLayerKey, at: VWorldMeta): Promise { const names = SHEET_NAMES[key]; if (names) { const collection = await sheetCollection(names, at); if (key === "road" || key === "forestRoad") { const small = (collection.features ?? []).filter((feature) => FOREST_ROAD_KINDS.has(String(feature.properties?.["도로구분"])), ); data.road = { small: { features: small } }; return gl.setData("road", collection); } if (key === "water") data.water = collection; if (key === "building") data.building = collection; if (key !== "place") return gl.setData(key, collection); const places = (collection.features ?? []).flatMap((feature) => { const name = feature.properties?.["명칭"]; return feature.geometry?.type === "Point" && typeof name === "string" && name ? [{ type: "Feature", properties: { name }, geometry: feature.geometry }] : []; }); await document.fonts .load(`600 24px "${GL_FONT}"`, places.map((p) => p.properties.name).join("") || "가") .catch(() => null); return gl.setData("place", { features: places }); } if (key === "bands" || key === "roadBands") { // 계류 100 m · 도로 300 m 띠(634-3 · D03 과 같은 서버 길 · 자료 한 벌 · 겹 둘) const info = await fetchPlanBands(projectId); data.bands = { info }; return gl.setData("bands", bandCollection(info)); } if (key === "hillshade") { const raster = await fetchPlanRaster(projectId, "hillshade"); const image = await loadImage(raster.image); const plain = glImage(raster); if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! }; return; } if (key === "slope") { const raster = await fetchPlanRaster(projectId, "slope"); slopeValuesNow = slopeValues(await loadImage(raster.image)); data.slope = { raster, bands: slopeBands }; return paintSlopeNow(); } if (key === "cadastral") return gl.setData("cadastral", await fetchPlanCadastral(projectId)); if (key === "zones") { const zones = await fetchPlanZones(projectId); data.zones = { summary: zones.summary }; return gl.setData( "zones", glTagged(zones, (props) => themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"), ), ); } if (key === "soils") { // 그림 = 조각(622-2) · 범례 = 받은 범위 요약 const soils = await fetchPlanSoils(projectId); data.soils = { kinds: soils.summary.map((soil) => ({ color: soilColor(soil.code) })), summary: soils.summary, }; return; } if (key === "landslideZones") { const info = await fetchPlanLandslideZones(projectId); data.landslideZones = { info }; return gl.setData("landslideZones", { features: [ ...(info.parcels.features ?? []), ...info.points.map((point) => ({ type: "Feature", properties: {}, geometry: { type: "Point", coordinates: [point.lon, point.lat] }, })), ], }); } if (key === "faults" || key === "lineaments" || key === "zeroOrder") { const kind = LINE_LAYERS[key].kind; const slow = window.setTimeout( () => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"), 600, ); try { const found = kind ? await fetchPlanLines(projectId, kind).then((lines) => ({ collection: lines as GeoJsonCollection, ...lines.summary, })) : await fetchPlanZeroOrder(projectId); data[key] = { count: found.count, length_m: found.length_m }; return gl.setData(key, found.collection); } finally { window.clearTimeout(slow); } } } const wanted = (): boolean => glOn && LAYERS.some((def) => !isOwn(def.key) && on[def.key]); function park(): void { if (!enabled) return; enabled = false; gl.enable(null); } return { bar: layerBar.root, legend: legendBox.root, own: () => ({ contour: !glOn || on.contour, stream: !glOn || on.stream }), sync, follow(at, normalizer, toScreen, width, height) { if (!wanted() || width <= 0 || height <= 0) return park(); if (!enabled) { enabled = gl.enable(wrap); size = ""; if (!enabled) return; } const container = gl.container(); if (!container) return; // 맨 밑(띠로 자른 B05 칸 아래) — 그 칸을 다시 켜면 앞에 붙으므로 그릴 때마다 맞춤 if (wrap.firstElementChild !== container) wrap.prepend(container); const now = `${width}x${height}`; if (now !== size) { size = now; container.style.width = `${width + 2 * GL_MARGIN_PX}px`; container.style.height = `${height + 2 * GL_MARGIN_PX}px`; gl.resize(); } const camera = glCameraOf(at, normalizer, toScreen, width, height); if (!camera) return; gl.camera(camera.center, camera.zoom, camera.bearing); container.style.transform = `matrix(${camera.css.join(", ")}, 0, 0)`; }, park, state: () => ({ on: { ...on }, loaded: [...loaded], map: enabled, 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, ]), ), sheets: sheetCache.size, }), }; }