diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 25a991b2e..534b74e5e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -264,8 +264,6 @@ def slope_layer( project_root: Path, models_dir: Path, epsg: int, kind: str = "slope" ) -> dict[str, Any]: """급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본.""" - from common_util.common_util_crs import tm_to_lonlat - npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz" stamp = file_stamp(npz) if stamp is None: @@ -286,14 +284,19 @@ def slope_layer( half = cell / 2 box = (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half) value["box_m"] = [float(v) for v in box] - value["box"] = [ - list(tm_to_lonlat(px, py, epsg)) - for px, py in ((box[0], box[1]), (box[2], box[1]), (box[2], box[3]), (box[0], box[3])) - ] + value["box"] = lonlat_corners(box, epsg) write_derived(project_root, store_kind, key, value) return value +def lonlat_corners(box: Any, epsg: int) -> list[list[float]]: + """TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586).""" + from common_util.common_util_crs import tm_to_lonlat + + x0, y0, x1, y1 = (float(v) for v in box) + return [list(tm_to_lonlat(px, py, epsg)) for px, py in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))] + + # ── 보호 · 규제 구역 ───────────────────────────────────── # (열쇠, 이름, 브이월드 WFS 겹 이름) — `_분석_440-C` 표 1 그대로 diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index c5628f21b..80dfd7972 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -214,7 +214,8 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, raise HTTPException(status_code=503, detail=str(error)) from error except RuntimeError as error: raise HTTPException(status_code=502, detail=str(error)) from error - return {"status": "success", "kind": kind, **value} + box_lonlat = layers.lonlat_corners(value["box_m"], epsg) + return {"status": "success", "kind": kind, **value, "box": box_lonlat} if kind not in layers.RASTER_KINDS: raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}") root, epsg, _ = await _layer_root(project_id) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index b49bdf12a..e3a50f9fb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 · - * 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기. + * D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 · + * 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · + * GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ @@ -11,11 +12,13 @@ import { readByKey } from "../A00_Common/b_page_state"; import { drawPreparedLayer, normalizedToScreen, + type GeoJsonCollection, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { PlanRaster } from "./D03_PlanAnalysis_Api"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; +import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl"; export type PlanLayerKey = | "satellite" @@ -268,3 +271,41 @@ export function readPref(key: string): Record { } return on; } + +/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ +export function glTagged( + collection: GeoJsonCollection, + color: (props: Record) => string, + extra: Record = {}, +): GeoJsonCollection { + return { + ...collection, + features: (collection.features ?? []).map((feature) => ({ + ...feature, + properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) }, + })), + }; +} + +/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */ +export function glImage(raster: PlanRaster, url = raster.image): GlImage | null { + const box = raster.box; + return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null; +} + +/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */ +export function shadeImage(image: HTMLImageElement): string { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const px = pixels.data; + for (let i = 0; i < px.length; i += 4) { + px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255)); + px[i] = px[i + 1] = px[i + 2] = 0; + } + context.putImageData(pixels, 0, 0); + return canvas.toDataURL("image/png"); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts new file mode 100644 index 000000000..23ac319cd --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts @@ -0,0 +1,258 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_LayerPaint.ts + * D03 바탕 겹 옛 캔버스 그리기(PLAN 569-2 · 581 · 584 · 586-2) — 그리기 차례 · 색 · 굵기 · 투명도. + * GL 바탕(586-2)이 같은 꼴을 MapLibre 로 그림(`D03_PlanAnalysis_UI_MapGl`) · 이것은 GL 을 못 쓰거나 끈 때. + * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 켬끔 · GL 이면 쉼). + * ========================================================================== */ + +import { themeColor } from "@ui/ui_template_palette"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { + createNormalizer, + drawPreparedLabels, + lonLatToScreen, + pickLevelStep, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + LINE_KEYS, + LINE_LAYERS, + ZONE_COLORS, + drawRaster, + fillLayer, + stroke, + type PlanLayerKey, +} from "./D03_PlanAnalysis_UI_LayerDefs"; +import type { Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; +import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; + +/** 바탕 겹(아래 → 위) — 켬끔 · 받은 자료 · 위성 틀 · 위성 그림(음영 곱하기)을 그때그때 읽음. */ +export function createCanvasLayers(opts: { + on: Record; + data: Partial; + meta: () => VWorldMeta | null; + satellite: () => HTMLImageElement | null; +}): PlanMapLayer[] { + const { on, data } = opts; + /** 등고 글 폭 기억(글꼴 하나 · 582). */ + const labelWidths = new Map(); + return [ + { + base: true, + draw: (context, map) => { + if (!on.hillshade || !data.hillshade) return; + // 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로 + if (on.satellite && opts.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.landslide || !data.landslide) return; + context.globalAlpha = 0.6; + drawRaster(context, map, data.landslide); + }, + }, + { + 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"); + const step = pickLevelStep(contour, map.view, 5); + context.globalAlpha = 0.7; + stroke(context, map, contour, color, 0.8, step); + 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) => { + const got = on.forestRoadFile && data.forestRoadFile; + if (!got) return; + stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4); + stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4); + }, + }, + { + 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) => { + const got = on.landslideZones && data.landslideZones; + const meta = opts.meta(); + if (!got || !meta) return; + const color = themeColor("--draw-map-landslide-zone", "#e11d48"); + // 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음) + context.setLineDash([6, 3]); + stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2); + stroke(context, map, got.parcels, color, 1.8); + context.setLineDash([]); + const normalizer = createNormalizer(meta); + context.fillStyle = color; + context.strokeStyle = "#ffffff"; + context.lineWidth = 1.5; + for (const point of got.info.points) { + const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat); + if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue; + context.beginPath(); + context.arc(x, y, 5, 0, Math.PI * 2); + context.fill(); + context.stroke(); + } + }, + }, + { + base: true, + draw: (context, map) => { + const meta = opts.meta(); + 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); + }); + } + }, + }, + ]; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index c1b783231..2ae96323b 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -15,8 +15,9 @@ * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). - * GL 바탕 시험(586-1 · 끝 단추 · `aislo:pref:d03.gl` · 처음 = 끔) — 위성 · 급경사 · 지적 · 등고선 · 도로 · 세류를 - * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스는 그 겹을 건너뜀(등고 글은 캔버스). + * GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을 + * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 · + * WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -34,8 +35,6 @@ import { } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, - drawPreparedLabels, - lonLatToScreen, pickLevelStep, type GeoJsonCollection, type PreparedLayer, @@ -61,25 +60,29 @@ import { FOREST_ROAD_KINDS, GROUPS, LAYERS, - LINE_KEYS, LINE_LAYERS, SOIL_COLORS, WATER_KEYS, ZONE_COLORS, - drawRaster, - fillLayer, + glImage, + glTagged, loadImage, readPref, - stroke, + shadeImage, type PlanLayerKey, } from "./D03_PlanAnalysis_UI_LayerDefs"; import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; -import { createGlBase, GL_KEYS } from "./D03_PlanAnalysis_UI_MapGl"; +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"; -const GL_PREF_KEY = "aislo:pref:d03.gl"; +/** 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; @@ -100,6 +103,8 @@ export interface PlanLayers { on: Record; loaded: PlanLayerKey[]; counts: Record; + /** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */ + gl: Record; }; } @@ -116,9 +121,14 @@ export function createPlanLayers(opts: { let uploading = false; let meta: VWorldMeta | null = null; let satellite: HTMLImageElement | null = null; - const gl = createGlBase(projectId); - let glOn = readByKey(GL_PREF_KEY) === "1"; - map.setGl(glOn ? gl : 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) => @@ -150,18 +160,24 @@ export function createPlanLayers(opts: { roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload); const glButton = createIconButton({ icon: "gauge", - title: - "GL 바탕 시험(위성 조각 · 급경사 · 지적 · 등고선 · 도로 · 세류를 그래픽 카드로 · 끔 = 지금 그림)", + title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)", size: 32, pressed: glOn, onClick: () => { - glOn = !glOn; - writeByKey(GL_PREF_KEY, glOn ? "1" : "0"); - glButton.setAttribute("aria-pressed", String(glOn)); - map.setGl(glOn ? gl : null); + 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; @@ -174,8 +190,9 @@ export function createPlanLayers(opts: { legend.className = "d03-plan__legend"; legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이"; + /** 받음 — 위성은 GL 이면 조각(한 장 그림 안 받음). */ const loadedOf = (key: PlanLayerKey): boolean => - key === "satellite" ? !!satellite : !!data[dataKey(key)]; + key === "satellite" ? glOn || !!satellite : !!data[dataKey(key)]; function sync(): void { for (const def of LAYERS) { @@ -187,13 +204,15 @@ export function createPlanLayers(opts: { button.setAttribute("aria-label", title); } upload.disabled = !meta || uploading; - for (const key of GL_KEYS) gl.setVisible(key, on[key]); + 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]; @@ -246,6 +265,7 @@ export function createPlanLayers(opts: { layer: prepareLayer(info.collection, createNormalizer(at)), info, }; + gl.setData("forestRoadFile", info.collection); on.forestRoadFile = true; writeByKey(PREF_KEY, JSON.stringify(on)); showToast( @@ -283,6 +303,7 @@ export function createPlanLayers(opts: { 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}`); @@ -296,12 +317,10 @@ export function createPlanLayers(opts: { 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(","), - ); + 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") { @@ -330,30 +349,52 @@ export function createPlanLayers(opts: { } 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); - data[key] = { raster, image: await loadImage(raster.image) }; - const box = raster.box; // 경위도 귀퉁이(왼쪽 아래부터 돎) - if (key === "slope" && box?.length === 4) - gl.setData("slope", { url: raster.image, corners: [box[3], box[2], box[1], box[0]] }); + 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)), ); - data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) }); + const water = { features: parts.flatMap((part) => part.features ?? []) }; + data.water = prepare(water); + gl.setData("water", water); } else if (key === "building") { - data.building = prepare(await fetchPlanSheetLayer(projectId, "건물")); + 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( @@ -369,6 +410,15 @@ export function createPlanLayers(opts: { })), 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차곡 첫 셈 = 몇 초 — 늦으면 알림 @@ -388,6 +438,7 @@ export function createPlanLayers(opts: { count: got.count, length_m: got.length_m, }; + gl.setData(key, got.collection); } finally { window.clearTimeout(slow); } @@ -398,6 +449,16 @@ export function createPlanLayers(opts: { 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) => { @@ -407,229 +468,35 @@ export function createPlanLayers(opts: { 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); } } - /** 등고 글 폭 기억(글꼴 하나 · 582). */ - const labelWidths = new Map(); + const canvasLayers = createCanvasLayers({ + on, + data, + meta: () => meta, + satellite: () => satellite, + }); + + /** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */ 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 || glOn) return; - context.globalAlpha = 0.55; - drawRaster(context, map, data.slope); - }, - }, - { - base: true, - draw: (context, map) => { - if (!on.landslide || !data.landslide) return; - context.globalAlpha = 0.6; - drawRaster(context, map, data.landslide); - }, - }, - { - 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 || glOn) 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"); - const step = pickLevelStep(contour, map.view, 5); - if (glOn) gl.setContourStep(step); // 선 = GL · 글 = 캔버스 - if (!glOn) { - context.globalAlpha = 0.7; - stroke(context, map, contour, color, 0.8, step); - 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 || glOn) 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) => { - const got = on.forestRoadFile && data.forestRoadFile; - if (!got) return; - stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4); - stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4); - }, - }, - { - base: true, - draw: (context, map) => { - if (!on.stream || !data.stream || glOn) 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([]); - }, + ...canvasLayers.map((layer): PlanMapLayer => ({ + ...layer, + draw: (c, m) => void (glOn || layer.draw(c, m)), })), { base: true, - draw: (context, map) => { - const got = on.landslideZones && data.landslideZones; - if (!got || !meta) return; - const color = themeColor("--draw-map-landslide-zone", "#e11d48"); - // 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음) - context.setLineDash([6, 3]); - stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2); - stroke(context, map, got.parcels, color, 1.8); - context.setLineDash([]); - const normalizer = createNormalizer(meta); - context.fillStyle = color; - context.strokeStyle = "#ffffff"; - context.lineWidth = 1.5; - for (const point of got.info.points) { - const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat); - if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue; - context.beginPath(); - context.arc(x, y, 5, 0, Math.PI * 2); - context.fill(); - context.stroke(); - } - }, - }, - { - 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); - }); - } + draw: (_, m) => { + if (glOn && on.contour && data.contour) + gl.setContourStep(pickLevelStep(data.contour, m.view, 5)); }, }, ]; @@ -643,7 +510,9 @@ export function createPlanLayers(opts: { if (changed) { satellite = null; for (const key of Object.keys(data)) delete data[key as DataKey]; - for (const key of GL_KEYS) if (key !== "satellite") gl.setData(key, null); + hillshade = null; + for (const def of LAYERS) + if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null); } map.update({ meta }); showSatellite(); @@ -660,6 +529,16 @@ export function createPlanLayers(opts: { 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, + ]), + ), }), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index ef846600b..a9d3386a3 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -11,7 +11,9 @@ * 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음. * 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF). * TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴. - * GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림. + * GL 바탕(586 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림. + * 카메라 주인 = 이 화면 `view` · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 단추 · 맞춤 · 앞으로 붙을 팬 단추 · 단축키는 + * 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림. * ========================================================================== */ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; @@ -127,8 +129,8 @@ export interface PlanMap { redraw: () => void; /** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */ redrawBase: () => void; - /** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */ - setGl: (gl: GlBase | null) => void; + /** GL 바탕 켬(MapLibre 를 맨 아래에 · 참 = 섬 · 거짓 = WebGL 못 씀) · null = 끔(옛 캔버스). */ + setGl: (gl: GlBase | null) => boolean; /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ @@ -526,12 +528,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { redraw: drawTop, redrawBase: draw, setGl(next) { - if (next === gl) return; + if (next === gl) return !!gl; gl?.enable(null); - gl = next; - gl?.enable(root); + gl = null; + if (next?.enable(root)) gl = next; gl?.resize(); draw(); + return !!gl; }, fit() { refit(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index 5b024dbaf..10251ba5e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -1,14 +1,20 @@ /* ============================================================================= * D03_PlanAnalysis_UI_MapGl.ts - * D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로: - * 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천. - * 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침). - * 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스). - * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠. + * D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로: + * 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) · + * 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · + * 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) · + * 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — + * 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음). + * 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침). + * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. + * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. + * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. * ========================================================================== */ import { Map as MapLibreMap, + type ExpressionSpecification, type FilterSpecification, type LayerSpecification, type SourceSpecification, @@ -17,138 +23,334 @@ import "maplibre-gl/dist/maplibre-gl.css"; import { API_BASE_URL } from "@config/config_frontend"; import { themeColor } from "@ui/ui_template_palette"; import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs"; -/** GL 로 옮긴 겹. */ -export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream"; -export const GL_KEYS: readonly GlLayerKey[] = [ - "satellite", - "slope", - "cadastral", - "contour", - "road", - "stream", -]; +/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ +export type GlSource = Exclude; -/** 급경사 그림 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ +/** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ export interface GlImage { url: string; corners: [[number, number], [number, number], [number, number], [number, number]]; } +/** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */ +export const GL_FONT = "Noto Sans KR"; +const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT]; + export interface GlBase { /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ - setData: ( - key: Exclude, - data: GeoJsonCollection | GlImage | null, - ) => void; - setVisible: (key: GlLayerKey, on: boolean) => void; + setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void; + setVisible: (key: PlanLayerKey, on: boolean) => void; /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ setContourStep: (step: number) => void; - /** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */ - enable: (parent: HTMLElement | null) => void; + /** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */ + enable: (parent: HTMLElement | null) => boolean; map: () => MapLibreMap | null; /** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */ camera: (center: [number, number], zoom: number, bearingDeg: number) => void; resize: () => void; } -const HALO = "rgba(0, 0, 0, 0.55)"; +type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" }; -const LEVEL = ["to-number", ["get", "등고수치"], 0]; +const HALO = "rgba(0, 0, 0, 0.55)"; +const ROUND = { "line-join": "round", "line-cap": "round" } as const; +const COLOR: ExpressionSpecification = ["get", "_color"]; +const LEVEL: ExpressionSpecification = ["to-number", ["get", "등고수치"], 0]; /** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */ const minorFilter = (step: number): FilterSpecification => ["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification; +const INDEX = ["==", ["%", LEVEL, 25], 0] as unknown as FilterSpecification; +/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */ +const dash = (px: number[], width: number): number[] => px.map((v) => v / width); -/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 그대로. */ -function layerSpecs(step: number): Array { - const contour = themeColor("--draw-map-contour", "#ffd68c"); - const road = themeColor("--draw-map-road", "#f8fafc"); - const stream = themeColor("--draw-map-stream", "#38bdf8"); - const parcel = themeColor("--draw-map-parcel", "#f0abfc"); - const round = { "line-join": "round", "line-cap": "round" } as const; +/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */ +function haloLine( + key: PlanLayerKey, + source: GlSource, + color: string, + width: number, + pattern: number[] = [], + halo = { color: HALO, width: width + 1.6 }, + filter?: FilterSpecification, +): GlSpec[] { + const line = (id: string, paint: Record, w: number): GlSpec => + ({ + key, + id, + type: "line", + source, + ...(filter ? { filter } : {}), + layout: ROUND, + paint: { + ...paint, + "line-width": w, + ...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}), + }, + }) as GlSpec; + return [ + line(`${key}-halo`, { "line-color": halo.color }, halo.width), + line(key, { "line-color": color }, width), + ]; +} + +/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */ +function fillEdge( + key: PlanLayerKey, + source: GlSource, + fill: string | ExpressionSpecification, + opacity: number | ExpressionSpecification, + edge: { color: string | ExpressionSpecification; width: number; dash?: number[] }, +): GlSpec[] { return [ - { key: "satellite", id: "satellite", type: "raster", source: "satellite" }, { - key: "slope", - id: "slope", + key, + id: key, + type: "fill", + source, + paint: { "fill-color": fill, "fill-opacity": opacity }, + } as GlSpec, + { + key, + id: `${key}-edge`, + type: "line", + source, + layout: ROUND, + paint: { + "line-color": edge.color, + "line-width": edge.width, + "line-opacity": 0.9, + ...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}), + }, + } as GlSpec, + ]; +} + +/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */ +function layerSpecs(step: number): GlSpec[] { + const contour = themeColor("--draw-map-contour", "#ffd68c"); + const place = themeColor("--draw-map-place", "#f8fafc"); + const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48"); + const raster = (key: GlSource | "satellite", opacity: number): GlSpec => + ({ + key, + id: key, type: "raster", - source: "slope", - paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 }, - }, + source: key, + paint: { "raster-opacity": opacity, "raster-fade-duration": 0 }, + }) as GlSpec; + const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) => + haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern); + return [ + raster("satellite", 1), + raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈) + raster("slope", 0.55), + raster("landslide", 0.6), + ...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }), + ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), + ...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], { + color: COLOR, + width: 1.2, + dash: [6, 4], + }), { key: "cadastral", id: "cadastral", type: "line", source: "cadastral", - layout: round, - paint: { "line-color": parcel, "line-width": 0.8, "line-opacity": 0.8 }, - }, + layout: ROUND, + paint: { + "line-color": themeColor("--draw-map-parcel", "#f0abfc"), + "line-width": 0.8, + "line-opacity": 0.8, + }, + } as GlSpec, + ...((): GlSpec[] => { + const water = themeColor("--draw-map-water", "#0ea5e9"); + return fillEdge("water", "water", water, 0.45, { color: water, width: 1 }); + })(), + ...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, { + color: HALO, + width: 0.6, + }), { key: "contour", id: "contour-minor", type: "line", source: "contour", filter: minorFilter(step), - layout: round, + layout: ROUND, paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, - }, + } as GlSpec, { key: "contour", id: "contour-index", type: "line", source: "contour", - filter: ["==", ["%", LEVEL, 25], 0], - layout: round, + filter: INDEX, + layout: ROUND, paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, - }, + } as GlSpec, { - key: "road", - id: "road-halo", - type: "line", - source: "road", - layout: round, - paint: { "line-color": HALO, "line-width": 3.2 }, - }, - { - key: "road", - id: "road", - type: "line", - source: "road", - layout: round, - paint: { "line-color": road, "line-width": 1.6 }, - }, + key: "contour", + id: "contour-label", + type: "symbol", + source: "contour", + filter: INDEX, + layout: { + "symbol-placement": "line", + "symbol-spacing": 320, + "text-field": ["to-string", LEVEL], + "text-font": [GL_FONT], + "text-size": 10, + "text-max-angle": 30, + "text-padding": 2, + }, + paint: { + "text-color": contour, + "text-halo-color": "rgba(0, 0, 0, 0.6)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], { + color: HALO, + width: 3.2, + }), + ...haloLine( + "forestRoad", + "road", + themeColor("--draw-map-forest-road", "#f59e0b"), + 2, + [7, 4], + { color: HALO, width: 3.6 }, + [ + "in", + ["get", "도로구분"], + ["literal", ["소로", "미분류"]], + ] as unknown as FilterSpecification, + ), + ...haloLine( + "forestRoadFile", + "forestRoadFile", + themeColor("--draw-map-forest-road-file", "#ea580c"), + 2.4, + [], + { color: HALO, width: 4 }, + ), { key: "stream", id: "stream", type: "line", source: "stream", - layout: round, + layout: ROUND, paint: { - "line-color": stream, + "line-color": themeColor("--draw-map-stream", "#38bdf8"), "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], }, - }, - ] as Array; + } as GlSpec, + ...lineKind("faults", 2.4, []), + ...lineKind("lineaments", 1.8, [8, 4]), + ...lineKind("zeroOrder", 1.8, [4, 3]), + // 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리 + ...haloLine( + "landslideZones", + "landslideZones", + landslideZone, + 1.8, + [6, 3], + { color: "rgba(255, 255, 255, 0.7)", width: 3.2 }, + ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification, + ), + { + key: "landslideZones", + id: "landslideZones-point", + type: "circle", + source: "landslideZones", + filter: ["==", ["geometry-type"], "Point"], + paint: { + "circle-radius": 5, + "circle-color": landslideZone, + "circle-stroke-color": "#ffffff", + "circle-stroke-width": 1.5, + }, + } as GlSpec, + { + key: "place", + id: "place-dot", + type: "circle", + source: "place", + paint: { "circle-radius": 2.5, "circle-color": place }, + } as GlSpec, + { + key: "place", + id: "place", + type: "symbol", + source: "place", + layout: { + "text-field": ["get", "name"], + "text-font": GL_FONT_BOLD, + "text-size": 11, + "text-anchor": "bottom", + "text-offset": [0, -0.36], + "text-padding": 2, + }, + paint: { + "text-color": place, + "text-halo-color": "rgba(0, 0, 0, 0.7)", + "text-halo-width": 1.5, + }, + } as GlSpec, + ]; } +/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */ +const LINE_COLOR = { + faults: "--draw-map-fault", + lineaments: "--draw-map-lineament", + zeroOrder: "--draw-map-zero-order", +} as const; + /** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ const themeKey = (): string => ["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"] .map((name) => themeColor(name, "")) .join(); -export function createGlBase(projectId: string): GlBase { - const data = new Map(); - const visible = new Map(); +const SOURCES: readonly (GlSource | "satellite")[] = [ + "satellite", + "hillshade", + "slope", + "landslide", + "soils", + "zones", + "bands", + "cadastral", + "water", + "building", + "contour", + "road", + "forestRoadFile", + "stream", + "faults", + "lineaments", + "zeroOrder", + "landslideZones", + "place", +]; + +export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase { + const data = new Map(); + const visible = new Map(); let step = 5; let theme = ""; let map: MapLibreMap | null = null; /** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */ let ready = false; - const applied = new Map(); + const applied = new Map(); let container: HTMLDivElement | null = null; - const source = (key: GlLayerKey): SourceSpecification | null => { + const source = (key: GlSource | "satellite"): SourceSpecification | null => { if (key === "satellite") return { type: "raster", @@ -160,23 +362,25 @@ export function createGlBase(projectId: string): GlBase { const got = data.get(key); if (!got) return null; if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners }; - return { type: "geojson", data: got as never, tolerance: 0.375 }; + // 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌 + const collection = { type: "FeatureCollection", features: got.features ?? [] }; + return { type: "geojson", data: collection as never, tolerance: 0.375 }; }; - /** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 겹만 다시 올림. */ + /** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 소스만 다시 올림. */ function apply(): void { if (!map || !ready) return; const now = themeKey(); const specs = layerSpecs(step); - const drop = (key?: GlLayerKey): void => { + const drop = (key?: string): void => { for (const spec of specs) - if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); + if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); }; if (now !== theme) { theme = now; drop(); // 밝음 · 어두움 = 색 다시 } - for (const key of GL_KEYS) { + for (const key of SOURCES) { const got = key === "satellite" ? key : (data.get(key) ?? null); if (applied.get(key) === got) continue; applied.set(key, got); @@ -188,7 +392,7 @@ export function createGlBase(projectId: string): GlBase { if (want) map.addSource(key, want); } specs.forEach(({ key, ...spec }, index) => { - if (!map!.getSource(key)) return; + if (!map!.getSource(spec.source)) return; if (!map!.getLayer(spec.id)) { // 늦게 온 겹도 제 차례(위 겹 밑)에 const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id)); @@ -198,6 +402,15 @@ export function createGlBase(projectId: string): GlBase { }); } + function teardown(): void { + map?.remove(); + container?.remove(); + map = container = null; + ready = false; + theme = ""; + applied.clear(); + } + return { setData(key, next) { if (data.get(key) === next) return; @@ -215,32 +428,36 @@ export function createGlBase(projectId: string): GlBase { if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); }, enable(parent) { - if (!parent === !map) return; if (!parent) { - map?.remove(); - container?.remove(); - map = container = null; - ready = false; - theme = ""; - applied.clear(); - return; + teardown(); + return false; } + if (map) return true; container = document.createElement("div"); container.className = "d03-plan__gl"; parent.prepend(container); - map = new MapLibreMap({ - container, - style: { version: 8, sources: {}, layers: [] }, - interactive: false, - attributionControl: false, - fadeDuration: 0, - maxZoom: 24, - renderWorldCopies: false, - }); + try { + map = new MapLibreMap({ + container, + style: { version: 8, sources: {}, layers: [] }, + interactive: false, + attributionControl: false, + fadeDuration: 0, + maxZoom: 24, + renderWorldCopies: false, + }); + } catch (error) { + teardown(); + console.warn("D03 GL 바탕 못 켬", error); + onFail("브라우저가 WebGL 을 열지 못함"); + return false; + } map.on("load", () => { ready = true; apply(); }); + map.on("webglcontextlost", () => onFail("그래픽 카드 문맥을 잃음")); + return true; }, map: () => map, camera(center, zoom, bearingDeg) { diff --git a/resources/tester/test_581_2_plan_soils.py b/resources/tester/test_581_2_plan_soils.py index 32e03fecf..b8d798427 100644 --- a/resources/tester/test_581_2_plan_soils.py +++ b/resources/tester/test_581_2_plan_soils.py @@ -92,6 +92,8 @@ def test_screen_def_and_colors() -> None: assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M) theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7)) - page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( + "utf-8" + ) # 586-2 assert "if (!on.soils || !data.soils) return;" in page assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582) diff --git a/resources/tester/test_582_contour_lod.py b/resources/tester/test_582_contour_lod.py index 132a3dfca..c4a32af9f 100644 --- a/resources/tester/test_582_contour_lod.py +++ b/resources/tester/test_582_contour_lod.py @@ -75,7 +75,10 @@ def test_D03_만_켬(): layers = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts") assert "indexLayerLod(prepare(collection" in layers # 581-1 합침 — 겹 모두 바탕 캔버스 - assert layers.count("base: true") == layers.count("draw: (context, map)") >= 12 # 겹 모두 바탕(581 · 584 로 늘어남) + paint = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts") # 586-2 캔버스 그리기 따로 + assert ( + paint.count("base: true") == paint.count("draw: (context, map)") >= 12 + ) # 겹 모두 바탕(581 · 584 로 늘어남) plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts") assert "requestAnimationFrame(flush)" in plan_map assert "drawTop();" in plan_map diff --git a/resources/tester/test_584_1_plan_geo_lines.py b/resources/tester/test_584_1_plan_geo_lines.py index 4f855c427..529380914 100644 --- a/resources/tester/test_584_1_plan_geo_lines.py +++ b/resources/tester/test_584_1_plan_geo_lines.py @@ -230,5 +230,7 @@ def test_screen_defs_icons_colors() -> None: assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in defs for kind in layers.LINE_KINDS: assert f'kind: "{kind}"' in defs # 서버 열쇠와 같음 - page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( + "utf-8" + ) # 586-2 assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582) diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 3938dd0b6..87ad0b83e 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -242,7 +242,10 @@ def test_screen_defs() -> None: defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8") icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8") theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8") - page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text( + "utf-8" + ) # 586-2 캔버스 그리기 따로 + page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8") for key, color in ( ("forestRoadFile", "forest-road-file"), @@ -254,8 +257,13 @@ def test_screen_defs() -> None: assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창 assert "") # 겹 모두 바탕 + assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕 assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분 assert "이 시도 자료 없음" in legend - for name in ("D03_PlanAnalysis_UI_Layers.ts", "D03_PlanAnalysis_UI_LayerLegend.ts"): + for name in ( + "D03_PlanAnalysis_UI_Layers.ts", + "D03_PlanAnalysis_UI_LayerLegend.ts", + "D03_PlanAnalysis_UI_LayerPaint.ts", + "D03_PlanAnalysis_UI_MapGl.ts", + ): assert len((folder / name).read_text("utf-8").splitlines()) <= 700