diff --git a/B04_PreProcess/B04_PreProcess_UI_MapGl.ts b/B04_PreProcess/B04_PreProcess_UI_MapGl.ts new file mode 100644 index 000000000..a63f3f420 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_MapGl.ts @@ -0,0 +1,215 @@ +/* ============================================================================= + * B04_PreProcess_UI_MapGl.ts + * TM 주인 화면의 GL 바탕(PLAN 589-2 · 요구 448) — B04 하단 지도 · B05 평면 노선 편집 · 배수유역도. + * 공용 부품 `ui_template_gl_map`(MapLibre) 을 지도 칸 맨 밑에 깔고, 화면(`ViewState` · 돌림)을 + * 따라감 — 카메라 = 화면 가운데 1차 맞춤(`logic/…_MapGl_Frame` · 닮음꼴) + 남는 늘림 = 지도 칸 CSS 행렬 · 위 그림(노선 · 관 · 측점 · 고르기)은 + * 위 캔버스 그대로 · 누름 · 끌기 판정 그대로. + * 처음 = 켬 · 끔 = 옛 캔버스(취향 `aislo:pref:map.canvas` = "1" · 세 화면 하나) · WebGL 못 씀 · 잃음 = + * 저절로 옛 캔버스 + 알림(취향은 안 바꿈). + * ========================================================================== */ + +import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl"; +import { showToast } from "@ui/ui_template_elements"; +import { + createGlMap, + GL_FONT, + satelliteTileSource, + type GlMap, + type GlRaster, + type GlSpec, +} from "@ui/ui_template_gl_map"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import type { VWorldMeta } from "./B04_PreProcess_Api_Fetch"; +import type { Normalizer } from "./B04_PreProcess_UI_MapRender"; +import { glCameraOf } from "./logic/B04_PreProcess_UI_MapGl_Frame"; + +/** 옛 캔버스로 그림("1") — 처음 = GL. */ +const CANVAS_PREF_KEY = "aislo:pref:map.canvas"; +export const GL_TOGGLE_TITLE = "그래픽 카드 그림(바탕 겹을 GPU 로 · 끔 = 옛 캔버스 그림)"; + +export interface TmGl { + gl: GlMap; + /** 지금 GL 로 그리나. */ + on: () => boolean; + /** 켬끔 단추 — 취향을 적고 바꿈. */ + toggle: () => void; + /** 화면을 따라감 — `toScreen` = 정규화 → 화면 px(돌림 포함 · 캔버스와 같은 식). */ + follow: ( + meta: VWorldMeta | null, + normalizer: Normalizer | null, + toScreen: (nx: number, ny: number) => [number, number], + width: number, + height: number, + ) => void; + /** 화면 px → 지도 칸 안 px(칸 행렬 · 둘레 여유를 되돌림) — 칸 자르기(clip-path)용. */ + local: (x: number, y: number) => [number, number]; + dispose: () => void; +} + +/** 지도 칸 둘레 여유(px · CSS `.ui-gl-map` inset) — 칸 행렬(늘림 0.4 %)이 화면 귀퉁이를 비우지 않게. */ +const GL_MARGIN_PX = 8; + +/** 그림 · 선 겹을 GL 로(위성 조각 = `satelliteData`) — `parent` 맨 앞에 지도 칸 · `onChange` = 켬끔이 바뀜(다시 그림 · 처음 켬은 안 부름). */ +export function createTmGl(opts: { + parent: HTMLElement; + specs: () => GlSpec[]; + themeNames: readonly string[]; + onChange: (on: boolean) => void; +}): TmGl { + let active = false; + /** 켜려는 중 · 켜 둠 — 못 켬 알림은 이때만. */ + let wanted = false; + let size = ""; + let css: [number, number, number, number] = [1, 0, 0, 1]; + /** 처음 켬이 끝남 — 그 전(만드는 중)에는 `onChange` 를 안 부름(부르는 쪽 `on()` 으로 읽음). */ + let started = false; + const gl = createGlMap({ + specs: opts.specs, + themeNames: opts.themeNames, + onFail: (why) => { + if (!wanted) return; + showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info"); + use(false); + }, + }); + function use(want: boolean): void { + wanted = want; + active = want && gl.enable(opts.parent); + if (!active) gl.enable(null); + size = ""; + if (started) opts.onChange(active); + } + use(readByKey(CANVAS_PREF_KEY) !== "1"); + started = true; + return { + gl, + on: () => active, + toggle() { + writeByKey(CANVAS_PREF_KEY, active ? "1" : "0"); + use(!active); + }, + follow(meta, normalizer, toScreen, width, height) { + if (!active || !meta || !normalizer || width <= 0 || height <= 0) return; + const container = gl.container(); + const now = `${width}x${height}`; + if (container && now !== size) { + // 칸 = 화면(캔버스 정수 px) + 둘레 여유 — 가운데가 캔버스 가운데와 같게 + 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(meta, normalizer, toScreen, width, height); + if (!camera) return; + gl.camera(camera.center, camera.zoom, camera.bearing); + css = camera.css; + if (!container) return; + container.style.transform = `matrix(${css.join(", ")}, 0, 0)`; + // 검증 창구 — 캔버스 자리(정규화 → 화면) 대 MapLibre 자리를 화면에서 잼 + Object.assign(container, { + glCheck: { map: gl.map(), normalizer, toScreen, css, width, height }, + }); + }, + local(x, y) { + const [a, b, c, d] = css; + const det = a * d - b * c || 1; + const [w, h] = size.split("x").map(Number); + const dx = x - w / 2; + const dy = y - h / 2; + return [ + (d * dx - c * dy) / det + w / 2 + GL_MARGIN_PX, + (a * dy - b * dx) / det + h / 2 + GL_MARGIN_PX, + ]; + }, + dispose: () => gl.enable(null), + }; +} + +const NOT_POINT = ["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification; +const IS_POINT = ["==", ["geometry-type"], "Point"] as unknown as FilterSpecification; + +/** 위성 조각 자료(`setData("satellite", …)` · 프로젝트마다). */ +export const satelliteData = (projectId: string): GlRaster => ({ + raster: satelliteTileSource(projectId), +}); + +/** 위성 · 그림 겹(조각 · 한 장). */ +export const rasterSpec = (key: K, source: string = key): GlSpec => + ({ + key, + id: `${key}`, + type: "raster", + source, + paint: { "raster-opacity": 1, "raster-fade-duration": 0 }, + }) as GlSpec; + +/** 선 겹(캔버스 `drawPreparedLayer` 꼴) — 선 + 점(점 = 지름 4 동그라미 · x = 표고점 X 글자). */ +export function lineSpecs( + key: K, + color: string | ExpressionSpecification, + width: number, + opts: { + source?: string; + marker?: "dot" | "x"; + filter?: unknown; + opacity?: number; + dash?: number[]; + } = {}, +): GlSpec[] { + const source = opts.source ?? key; + const lineFilter = ( + opts.filter ? ["all", NOT_POINT, opts.filter] : NOT_POINT + ) as FilterSpecification; + const specs: GlSpec[] = [ + { + key, + id: `${key}-line`, + type: "line", + source, + filter: lineFilter, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": color, + "line-width": width, + ...(opts.opacity !== undefined ? { "line-opacity": opts.opacity } : {}), + ...(opts.dash ? { "line-dasharray": opts.dash.map((px) => px / width) } : {}), + }, + } as GlSpec, + ]; + if (opts.marker === "x") + specs.push({ + key, + id: `${key}-point`, + type: "symbol", + source, + filter: IS_POINT, + layout: { + "text-field": "×", + "text-font": [GL_FONT], + "text-size": 16, + "text-allow-overlap": true, + "text-ignore-placement": true, + }, + paint: { "text-color": color }, + } as GlSpec); + else if (opts.marker === "dot") + specs.push({ + key, + id: `${key}-point`, + type: "circle", + source, + filter: IS_POINT, + paint: { "circle-radius": 2, "circle-color": color }, + } as GlSpec); + return specs; +} + +/** 지도 칸이 켰을 때 쓰는 바탕 그림(위성 한 장 · 백지도 · 혼합)의 네 귀퉁이 — 메타 상자 = 메르카토르 그림 네모. */ +export const metaCorners = ( + meta: VWorldMeta, +): [[number, number], [number, number], [number, number], [number, number]] => [ + [meta.lon_min, meta.lat_max], + [meta.lon_max, meta.lat_max], + [meta.lon_max, meta.lat_min], + [meta.lon_min, meta.lat_min], +]; diff --git a/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts b/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts index 53d4bd636..9b8234f64 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapLayers.ts @@ -7,6 +7,7 @@ * ========================================================================== */ import { themeColor } from "@ui/ui_template_palette"; +import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; import { fetchGisGeoJson } from "./B04_PreProcess_Api_Fetch"; import type { GeoJsonCollection } from "./B04_PreProcess_UI_MapRender"; @@ -99,6 +100,22 @@ const GIS_LAYER_COLOR_TOKENS: Record 도엽_옹벽석축: ["--map-sheet-wall", "#0f766e"], }; +const GIS_LABEL_KEYS: Record = { + 지적도: "B04_Surface_Map_Cadastral", + 행정구역_시군구: "B04_Surface_Map_Sigungu", + 행정구역_읍면동: "B04_Surface_Map_Eupmyeondong", + 등고선: "B04_Surface_Map_Contour", + 도엽_등고선: "B04_Surface_Map_SheetContour", + 도엽_하천중심선: "B04_Surface_Map_SheetStream", + 도엽_표고점: "B04_Surface_Map_SheetElevPoint", + 도엽_성절토: "B04_Surface_Map_SheetCutFill", + 도엽_옹벽석축: "B04_Surface_Map_SheetWall", +}; + +/** 층 단추 글(지금 말). */ +export const gisLayerLabel = (layer: GisLayer): string => + ui_locales[GIS_LABEL_KEYS[layer]][currentLanguageIndex]; + export const gisLayerColor = (layer: GisLayer): string => themeColor(...GIS_LAYER_COLOR_TOKENS[layer]); diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts index d13e5296a..cc3a75ddf 100644 --- a/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer.ts @@ -22,9 +22,11 @@ import { GIS_LAYERS, fetchGisLayer, gisLayerColor, + gisLayerLabel, type BackgroundLayer, type GisLayer, } from "./B04_PreProcess_UI_MapLayers"; +import { createSurfaceMapGl } from "./B04_PreProcess_UI_MapViewer_Gl"; import { createFlowStrengthOverlay } from "./B04_PreProcess_UI_FlowStrength"; import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle"; import { createWatershedOverlay } from "./B04_PreProcess_UI_Watershed"; @@ -220,17 +222,6 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { backgroundButtons.append(makeLayerButton(backgroundLabels[layer], activeBackgrounds, layer)); }); - const gisLabels: Record = { - 지적도: L("B04_Surface_Map_Cadastral"), - 행정구역_시군구: L("B04_Surface_Map_Sigungu"), - 행정구역_읍면동: L("B04_Surface_Map_Eupmyeondong"), - 등고선: L("B04_Surface_Map_Contour"), - 도엽_등고선: L("B04_Surface_Map_SheetContour"), - 도엽_하천중심선: L("B04_Surface_Map_SheetStream"), - 도엽_표고점: L("B04_Surface_Map_SheetElevPoint"), - 도엽_성절토: L("B04_Surface_Map_SheetCutFill"), - 도엽_옹벽석축: L("B04_Surface_Map_SheetWall"), - }; // 계획선 토글 — 도엽 레이어와 같은 양식으로 레이어 줄 맨 앞에 둔다. 색은 B05 배수유역도와 // 같은 주황(정의처: MapRender). 계획노선을 아직 올리지 않았으면 버튼이 눌리지 않는다. const routeToggle = createLayerToggle({ @@ -246,7 +237,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { GIS_LAYERS.forEach((layer) => { gisButtons.append( - makeLayerButton(gisLabels[layer], activeGisLayers, layer, gisLayerColor(layer)), + makeLayerButton(gisLayerLabel(layer), activeGisLayers, layer, gisLayerColor(layer)), ); }); @@ -260,6 +251,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { }, }); gisButtons.append(contourLabelToggle.root); + const surfaceGl = createSurfaceMapGl(viewport, backgroundButtons, () => syncLayerVisibility()); // 배수유역 분석 오버레이 — 계산은 백엔드가 하고 여기서는 겹쳐 그리기만 한다. // 상태 문구는 오버레이 전용 줄에 쓴다 — 지도 자체 상태(레이어 로딩)와 같은 칸을 쓰면 @@ -317,8 +309,9 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { function syncLayerVisibility(): void { backgroundImages.forEach((image, layer) => { - image.hidden = !activeBackgrounds.has(layer); + image.hidden = !activeBackgrounds.has(layer) || surfaceGl.on(); }); + surfaceGl.sync(activeBackgrounds, activeGisLayers); activeGisLayers.forEach((layer) => void loadLayerOnDemand(layer)); empty.hidden = activeBackgrounds.size > 0 || activeGisLayers.size > 0; scheduleDraw(); @@ -389,7 +382,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const mapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; DRAW_ORDER.forEach((layer) => { - if (!activeGisLayers.has(layer)) return; + if (!activeGisLayers.has(layer) || surfaceGl.on()) return; const prepared = preparedLayers.get(layer); if (!prepared) return; context.lineWidth = isContourLayer(layer) ? 0.7 : 1.5; @@ -430,6 +423,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { // 유입 집중점(집중유역)은 맨 마지막 — 켰을 때 무엇에도 가리지 않아야 고를 수 있다. flowStrength.drawTop(context, normalizer, view); updateImageTransform(view); + surfaceGl.follow(meta, normalizer, view); drawScaleBar(mapRect); } @@ -449,6 +443,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const data = await fetchGisLayer(projectId, layer); if (!data || projectId !== currentProjectId || !normalizer) return; preparedLayers.set(layer, prepareLayer(data, normalizer, CONTOUR_LABEL_KEYS[layer])); + surfaceGl.setLayer(layer, data); scheduleDraw(); } @@ -458,6 +453,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { const sequence = ++loadSequence; backgroundImages.forEach((image) => image.removeAttribute("src")); meta = null; + surfaceGl.setMeta(null, null); preparedLayers.clear(); routeLayer = null; routePoints = []; @@ -487,6 +483,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { meta = nextMeta; // 좌표 변환은 여기서 1회만 수행하고, 이후 프레임은 사전 투영 결과만 사용한다. normalizer = createTmNormalizer(nextMeta); + surfaceGl.setMeta(projectId, nextMeta); routeLayer = planned.points.length > 1 ? prepareMetricPolyline(planned.points, nextMeta) : null; routePoints = planned.points; @@ -502,6 +499,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { if (!data) return; featureCount += data.features?.length ?? 0; preparedLayers.set(layer, prepareLayer(data, normalizer!, CONTOUR_LABEL_KEYS[layer])); + surfaceGl.setLayer(layer, data); }); BACKGROUND_LAYERS.forEach((layer) => { // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. @@ -688,6 +686,7 @@ export function createSurfaceMapViewer(): SurfaceMapViewer { frameHandle = 0; } resizeObserver.disconnect(); + surfaceGl.dispose(); flowStrength.dispose(); detailBasins.dispose(); }, diff --git a/B04_PreProcess/B04_PreProcess_UI_MapViewer_Gl.ts b/B04_PreProcess/B04_PreProcess_UI_MapViewer_Gl.ts new file mode 100644 index 000000000..aad6b51f2 --- /dev/null +++ b/B04_PreProcess/B04_PreProcess_UI_MapViewer_Gl.ts @@ -0,0 +1,111 @@ +/* ============================================================================= + * B04_PreProcess_UI_MapViewer_Gl.ts + * B04 하단 지도 GL 바탕(PLAN 589-2) — 백지도 · 혼합(한 장 그림) · 위성(조각 · 줌인 선명) · 도엽 · 지적 · 행정 · + * 등고선 겹을 MapLibre 로. 등고 글 · 배수유역 · 계획선 · 측점 · 흐름 강도 · 세부유역 · 관 = 위 캔버스 그대로. + * 지도 본체(`_MapViewer`)는 켬끔 · 자료 · 따라가기만 부름(700 줄). + * ========================================================================== */ + +import { getVWorldMapUrl, type VWorldMeta } from "./B04_PreProcess_Api_Fetch"; +import { + BACKGROUND_LAYERS, + GIS_LAYERS, + gisLayerColor, + type BackgroundLayer, + type GisLayer, +} from "./B04_PreProcess_UI_MapLayers"; +import { createLayerToggle } from "./B04_PreProcess_UI_LayerToggle"; +import { + createTmGl, + GL_TOGGLE_TITLE, + lineSpecs, + metaCorners, + rasterSpec, + satelliteData, +} from "./B04_PreProcess_UI_MapGl"; +import { + normalizedToScreen, + type GeoJsonCollection, + type Normalizer, + type ViewState, +} from "./B04_PreProcess_UI_MapRender"; + +type Key = BackgroundLayer | GisLayer; +const isContour = (layer: GisLayer): boolean => layer === "등고선" || layer === "도엽_등고선"; +/** 캔버스와 같은 차례 — 등고선이 맨 밑. */ +const ORDER = [...GIS_LAYERS].sort((a, b) => (isContour(a) ? -1 : isContour(b) ? 1 : 0)); + +export interface SurfaceMapGl { + /** 지금 GL 로 그리나 — 참이면 캔버스는 바탕 겹을 쉼 · 그림(img)은 숨김. */ + on: () => boolean; + /** 켠 겹. */ + sync: (backgrounds: ReadonlySet, layers: ReadonlySet) => void; + /** 받은 겹(원본 경위도 GeoJSON) — null = 비움. */ + setLayer: (layer: GisLayer, data: GeoJsonCollection | null) => void; + /** 프로젝트 · 메타 — 그림 · 조각 주소(null = 다 비움). */ + setMeta: (projectId: string | null, meta: VWorldMeta | null) => void; + follow: (meta: VWorldMeta | null, normalizer: Normalizer | null, view: ViewState) => void; + dispose: () => void; +} + +/** `buttons` = 켬끔 칩 자리 · `onChange` = 켬끔이 바뀜(그림 숨김 · 다시 그림). */ +export function createSurfaceMapGl( + viewport: HTMLElement, + buttons: HTMLElement, + onChange: () => void, +): SurfaceMapGl { + const toggle = createLayerToggle({ + label: "GPU", + title: GL_TOGGLE_TITLE, + onChange: () => tm.toggle(), + }); + toggle.root.dataset.layer = "gl"; + buttons.append(toggle.root); + const tm = createTmGl({ + parent: viewport, + specs: () => [ + ...BACKGROUND_LAYERS.map((layer) => rasterSpec(layer)), + ...ORDER.flatMap((layer) => + lineSpecs(layer, gisLayerColor(layer), isContour(layer) ? 0.7 : 1.5, { + marker: layer === "도엽_표고점" ? "x" : "dot", + }), + ), + ], + themeNames: ["--color-surface", "--map-cadastral", "--map-sheet-contour", "--map-sheet-stream"], + onChange: (on) => { + toggle.set(on); + onChange(); + }, + }); + toggle.set(tm.on()); + return { + on: tm.on, + sync(backgrounds, layers) { + for (const layer of BACKGROUND_LAYERS) tm.gl.setVisible(layer, backgrounds.has(layer)); + for (const layer of GIS_LAYERS) tm.gl.setVisible(layer, layers.has(layer)); + }, + setLayer: (layer, data) => tm.gl.setData(layer, data), + setMeta(projectId, meta) { + tm.gl.setData("satellite", projectId ? satelliteData(projectId) : null); + for (const layer of ["white", "hybrid"] as const) + tm.gl.setData( + layer, + projectId && meta + ? { + url: location.origin + getVWorldMapUrl(projectId, layer), + corners: metaCorners(meta), + } + : null, + ); + if (!meta) for (const layer of GIS_LAYERS) tm.gl.setData(layer, null); + }, + follow: (meta, normalizer, view) => + tm.follow( + meta, + normalizer, + (nx, ny) => normalizedToScreen(view, nx, ny), + view.width, + view.height, + ), + dispose: tm.dispose, + }; +} diff --git a/B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame.ts b/B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame.ts new file mode 100644 index 000000000..150532845 --- /dev/null +++ b/B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame.ts @@ -0,0 +1,266 @@ +/* ============================================================================= + * B04_PreProcess_UI_MapGl_Frame.ts + * GL 바탕이 TM 주인 화면을 따라가는 셈(PLAN 589-2) — B04 하단 지도 · B05 평면 노선 편집 · 배수유역도. + * 카메라 = 위성 그림 행렬(589-1 `imageScreenMatrix`)과 같은 길을 화면 가운데에서 1차로 맞춘 웹 메르카토르 + * 가운데 · 줌 · 돌림(MapLibre 세계 512 px · 돌림 = 동쪽이 화면에서 도는 각의 반대). + * TM 겹 → 경위도 = 고를 칸 `toNorm` 되돌림(고정 기울기 되풀이 · 격자가 거의 1차라 몇 번에 1 mm 안). + * 순수 셈 — 화면 · fetch 없음. + * ========================================================================== */ + +import type { VWorldMeta } from "../B04_PreProcess_Api_Fetch"; +import type { Normalizer, PreparedLayer } from "../B04_PreProcess_UI_MapRender"; +import { imageScreenMatrix, normOfLonLat } from "./B04_PreProcess_UI_MapRender_Prepare"; + +export interface GlCamera { + center: [number, number]; + zoom: number; + bearing: number; + /** + * 지도 칸 CSS 행렬 `[a, b, c, d]`(칸 가운데 축) — MapLibre 는 닮음꼴(확대 · 돌림)만 그리므로 남는 늘림 + * (웹 메르카토르 px 가로세로가 땅과 0.4 % 다름 · 1 − M/N)을 칸에 줌. 늘이기만(고윳값 ≥ 1) — 칸 둘레 여유로 빈틈 없음. + */ + css: [number, number, number, number]; +} + +const IMAGE = 2 ** 20; +const latOfMercator = (m: number): number => (Math.atan(Math.exp(m)) * 360) / Math.PI - 90; +const mercatorOf = (lat: number): number => Math.log(Math.tan(Math.PI / 4 + (lat * Math.PI) / 360)); + +/** 지금 화면(정규화 → 화면 px · 돌림 포함)에 맞는 GL 카메라 — 못 셈이면 null. */ +export function glCameraOf( + meta: VWorldMeta, + normalizer: Normalizer, + toScreen: (nx: number, ny: number) => [number, number], + width: number, + height: number, +): GlCamera | null { + // 가상 그림(한 변 IMAGE px) — 행렬 셈의 미분 간격(0.5 px)이 상자 백만 분의 일이 되게 + const [a, b, c, d, e, f] = imageScreenMatrix( + meta, + normalizer, + IMAGE, + IMAGE, + toScreen, + width / 2, + height / 2, + ); + const det = a * d - b * c; + const lonRange = meta.lon_max - meta.lon_min; + const mTop = mercatorOf(meta.lat_max); + const mSpan = mTop - mercatorOf(meta.lat_min); + if (!Number.isFinite(det) || Math.abs(det) < 1e-18 || !lonRange || !mSpan) return null; + const ex = width / 2 - e; + const ey = height / 2 - f; + const u = (d * ex - c * ey) / det / IMAGE; + const v = (a * ey - b * ex) / det / IMAGE; + // 화면 px / 세계 px(줌 0 = 512) — 열 = 세계 동 · 남 + const ku = (lonRange / IMAGE / 360) * 512; + const kv = (mSpan / IMAGE / (2 * Math.PI)) * 512; + const m = [a / ku, b / ku, c / kv, d / kv]; + // 극 분해 — 돌림 R · 대칭 늘림 P(작은 늘림 = 1 이 되게 줌을 고름) + const turn = Math.atan2(m[1] - m[2], m[0] + m[3]); + const cos = Math.cos(turn); + const sin = Math.sin(turn); + // P = Rᵀ M + const p = [ + cos * m[0] + sin * m[1], + -sin * m[0] + cos * m[1], + cos * m[2] + sin * m[3], + -sin * m[2] + cos * m[3], + ]; + const trace = (p[0] + p[3]) / 2; + const least = trace - Math.hypot((p[0] - p[3]) / 2, (p[1] + p[2]) / 2); + if (!(least > 0)) return null; + // 칸 행렬 T = M Rᵀ / 작은 늘림 = R P Rᵀ / 작은 늘림 + const css: [number, number, number, number] = [ + (m[0] * cos - m[2] * sin) / least, + (m[1] * cos - m[3] * sin) / least, + (m[0] * sin + m[2] * cos) / least, + (m[1] * sin + m[3] * cos) / least, + ]; + return { + center: [meta.lon_min + u * lonRange, latOfMercator(mTop - v * mSpan)], + zoom: Math.log2(least), + bearing: -(turn * 180) / Math.PI, + css, + }; +} + +/** 정규화(TM 자리) → 경위도 — `normOfLonLat` 되돌림. 상자 기울기로 되풀이(1차에 가까워 다섯 번이면 넉넉). */ +export function lonLatOfNormFn( + normalizer: Normalizer, +): (nx: number, ny: number) => [number, number] { + const { lonMin, latMin, lonRange, latRange } = normalizer; + const latMax = latMin + latRange; + // 상자 기울기(정규화 1 당 경위도) — 같은 네모 식이면 곧바로 맞음 + const [x00, y00] = normOfLonLat(normalizer, lonMin, latMax); + const [x10, y10] = normOfLonLat(normalizer, lonMin + lonRange, latMax); + const [x01, y01] = normOfLonLat(normalizer, lonMin, latMin); + const ja = (x10 - x00) / lonRange; + const jb = (y10 - y00) / lonRange; + const jc = (x01 - x00) / -latRange; + const jd = (y01 - y00) / -latRange; + const det = ja * jd - jb * jc || 1; + return (nx, ny) => { + let lon = lonMin + nx * lonRange; + let lat = latMax - ny * latRange; + for (let step = 0; step < 5; step += 1) { + const [x, y] = normOfLonLat(normalizer, lon, lat); + const dx = nx - x; + const dy = ny - y; + lon += (jd * dx - jc * dy) / det; + lat += (ja * dy - jb * dx) / det; + if (Math.abs(dx) + Math.abs(dy) < 1e-12) break; + } + return [lon, lat]; + }; +} + +type LonLat = [number, number]; +type Box = readonly [minX: number, minY: number, maxX: number, maxY: number]; + +/** 선분 [p, q] 를 상자에 자름(리앙-바스키) — 밖이면 null. */ +function clipSegment( + px: number, + py: number, + qx: number, + qy: number, + box: Box, +): [number, number, number, number] | null { + let t0 = 0; + let t1 = 1; + const dx = qx - px; + const dy = qy - py; + const edges: [number, number][] = [ + [-dx, px - box[0]], + [dx, box[2] - px], + [-dy, py - box[1]], + [dy, box[3] - py], + ]; + for (const [p, q] of edges) { + if (p === 0) { + if (q < 0) return null; + continue; + } + const r = q / p; + if (p < 0) t0 = Math.max(t0, r); + else t1 = Math.min(t1, r); + if (t0 > t1) return null; + } + return [px + t0 * dx, py + t0 * dy, px + t1 * dx, py + t1 * dy]; +} + +/** 정규화 점렬 → 상자 안 조각들(상자 없으면 통째). */ +function runsOf(coords: ArrayLike, closed: boolean, box?: Box): number[][] { + const n = coords.length / 2; + const points: number[] = Array.from(coords); + if (closed && n > 2) points.push(coords[0], coords[1]); + if (!box) return [points]; + const runs: number[][] = []; + let run: number[] = []; + for (let i = 0; i + 3 < points.length; i += 2) { + const cut = clipSegment(points[i], points[i + 1], points[i + 2], points[i + 3], box); + if (!cut) { + if (run.length >= 4) runs.push(run); + run = []; + continue; + } + const [ax, ay, bx, by] = cut; + const last = run.length; + if (!last || run[last - 2] !== ax || run[last - 1] !== ay) { + if (last >= 4) runs.push(run); + run = [ax, ay]; + } + run.push(bx, by); + } + if (run.length >= 4) runs.push(run); + return runs; +} + +/** + * 준비한 겹(정규화 · TM 자리) → GL 경위도 GeoJSON. 선 = LineString(닫힌 것은 첫 점 다시) · 점 = MultiPoint · + * 성질 `level` = 표고(없으면 뺌). `box`(정규화) = 그 안만(B05 LAS 등고선이 있는 데까지 · 0-9 ⑮). + */ +export function preparedToGeoJson( + layer: PreparedLayer, + toLonLat: (nx: number, ny: number) => LonLat, + box?: Box, +): { type: "FeatureCollection"; features: unknown[] } { + const features: unknown[] = []; + for (const feature of layer.features) { + const properties = feature.labelValue === null ? {} : { level: feature.labelValue }; + for (const part of feature.parts) { + if (feature.kind === "point") { + const coordinates: LonLat[] = []; + for (let i = 0; i + 1 < part.coords.length; i += 2) { + const x = part.coords[i]; + const y = part.coords[i + 1]; + if (box && (x < box[0] || x > box[2] || y < box[1] || y > box[3])) continue; + coordinates.push(toLonLat(x, y)); + } + if (coordinates.length) + features.push({ + type: "Feature", + properties, + geometry: { type: "MultiPoint", coordinates }, + }); + continue; + } + for (const run of runsOf(part.coords, part.closed, box)) { + if (run.length < 4) continue; + const coordinates: LonLat[] = []; + for (let i = 0; i < run.length; i += 2) coordinates.push(toLonLat(run[i], run[i + 1])); + features.push({ + type: "Feature", + properties, + geometry: { type: "LineString", coordinates }, + }); + } + } + } + return { type: "FeatureCollection", features }; +} + +/** 점이 고리 안인가(짝홀). */ +function inside(x: number, y: number, ring: ReadonlyArray): boolean { + let hit = false; + for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) { + const [xi, yi] = ring[i]; + const [xj, yj] = ring[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit; + } + return hit; +} + +/** + * 고리 목록(조각 · 구멍 섞임 · 캔버스 `evenodd`) → GeoJSON 다각형들 — 몇 겹 안에 드나(짝 = 바깥 · 홀 = 구멍)로 + * 구멍을 제 바깥에 붙임. 같은 칠 = 캔버스 짝홀 채움. + */ +export function ringsToPolygons( + rings: ReadonlyArray>, +): LonLat[][][] { + const usable = rings.filter((ring) => ring.length >= 3); + const depth = usable.map((ring, i) => + usable.reduce( + (count, other, j) => count + (i !== j && inside(ring[0][0], ring[0][1], other) ? 1 : 0), + 0, + ), + ); + const polygons: LonLat[][][] = []; + const owner = new Map(); + usable.forEach((ring, i) => { + if (depth[i] % 2) return; + owner.set(i, polygons.length); + polygons.push([ring.map(([x, y]) => [x, y] as LonLat)]); + }); + usable.forEach((ring, i) => { + if (!(depth[i] % 2)) return; + // 제 바깥 = 저를 품은 고리 중 한 겹 얕은 것 + const parent = usable.findIndex( + (other, j) => depth[j] === depth[i] - 1 && inside(ring[0][0], ring[0][1], other), + ); + const at = owner.get(parent); + if (at !== undefined) polygons[at].push(ring.map(([x, y]) => [x, y] as LonLat)); + }); + return polygons; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Gl.ts b/B05_Profile/B05_Profile_UI_Drainage_Gl.ts new file mode 100644 index 000000000..0e5547ce6 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_Drainage_Gl.ts @@ -0,0 +1,165 @@ +/* ============================================================================= + * B05_Profile_UI_Drainage_Gl.ts + * 배수유역도 GL 바탕(PLAN 589-2) — 위성(조각 · 줌인 선명) · 세부유역 채움 · 분수령 · 도엽(등고선 · 하천)을 + * MapLibre 로(캔버스와 같은 차례 · 색). 유역 번호 · 상류 세류 · 노선 · 관 · 강도 · 화살표 · 띠 · 측점 = + * 위 캔버스 그대로 · 고르기 판정 그대로. 판(`_Drainage_Panel`)은 만들기 · 자료 · 그리기 때 부름만. + * ========================================================================== */ + +import { createToggle } from "@ui/ui_template_toggle"; +import { themeColor } from "@ui/ui_template_palette"; +import { + createTmGl, + GL_TOGGLE_TITLE, + lineSpecs, + rasterSpec, + satelliteData, +} from "../B04_PreProcess/B04_PreProcess_UI_MapGl"; +import { + normalizedToScreen, + type GeoJsonCollection, + type ViewState, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { ringsToPolygons } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame"; +import { + basinColor, + DRAINAGE_LAYERS, + layerColor, + type DrainageLayer, +} from "./B05_Profile_UI_Drainage_Parts"; +import type { GlSpec } from "@ui/ui_template_gl_map"; +import type { DrainageScene } from "./B05_Profile_UI_Drainage_Render"; + +type Key = "satellite" | "basins" | "ridge" | DrainageLayer; + +export interface DrainageGl { + /** 그리기마다 — 켜져 있으면 바탕 겹을 맞추고 참(캔버스는 그 겹을 쉼). */ + draw: ( + view: ViewState, + scene: Pick< + DrainageScene, + "meta" | "normalizer" | "basins" | "selectedBasin" | "mainBoundary" | "activeLayers" + >, + satellite: boolean, + ) => boolean; + setLayer: (layer: DrainageLayer, data: GeoJsonCollection | null) => void; + setProject: (projectId: string | null) => void; + dispose: () => void; +} + +export function createDrainageGl( + viewport: HTMLElement, + buttons: HTMLElement, + backgroundImage: HTMLImageElement, + redraw: () => void, +): DrainageGl { + const toggle = createToggle({ + kind: "chip", + label: "GPU", + size: 20, + onChange: () => tm.toggle(), + }); + toggle.root.title = GL_TOGGLE_TITLE; + toggle.root.dataset.layer = "gl"; + toggle.root.classList.add("b05-drainage__layer-button"); + buttons.append(toggle.root); + const mark = (on: boolean): void => { + toggle.set(on); + toggle.root.classList.toggle("is-active", on); + }; + const tm = createTmGl({ + parent: viewport, + specs: () => [ + rasterSpec("satellite"), + { + key: "basins", + id: "basins", + type: "fill", + source: "basins", + paint: { "fill-color": ["get", "_color"] }, + } as GlSpec, + ...lineSpecs("basins", ["get", "_color"], 1.6), + ...lineSpecs("ridge", themeColor("--map-basin-outline", "#92400e"), 1.8, { + dash: [7, 4], + }), + ...DRAINAGE_LAYERS.flatMap((layer) => + lineSpecs(layer, layerColor(layer), layer === "도엽_등고선" ? 0.7 : 1.5, { + marker: "dot", + }), + ), + ], + themeNames: ["--color-surface", "--map-basin-outline", "--map-sheet-contour", "--map-basin-1"], + onChange: (on) => { + mark(on); + redraw(); + }, + }); + mark(tm.on()); + let shown: unknown[] = []; + + return { + draw(view, frame, satellite) { + backgroundImage.hidden = !satellite || tm.on(); + if (!tm.on()) return false; + const { gl } = tm; + gl.setVisible("satellite", satellite); + gl.setVisible("basins", true); + gl.setVisible("ridge", true); + for (const layer of DRAINAGE_LAYERS) gl.setVisible(layer, frame.activeLayers.has(layer)); + const key = [ + frame.basins, + frame.selectedBasin, + frame.mainBoundary, + themeColor("--map-basin-1", ""), + ]; + if (key.some((value, index) => value !== shown[index])) { + shown = key; + gl.setData("basins", { + features: frame.basins.map((basin) => { + const color = basinColor(basin.index); + const rings = basin.polygon_rings_lonlat?.length + ? basin.polygon_rings_lonlat + : [basin.polygon_lonlat]; + return { + type: "Feature", + properties: { + // 하나를 고르면 나머지는 옅게(캔버스와 같은 셈) + _color: + frame.selectedBasin === null || frame.selectedBasin === basin.index + ? color + : color.replace(/0\.45\)$/, "0.18)"), + }, + geometry: { type: "MultiPolygon", coordinates: ringsToPolygons(rings) }, + }; + }), + }); + const ring = frame.mainBoundary; + gl.setData( + "ridge", + ring.length > 2 + ? { + features: [ + { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [...ring, ring[0]] }, + }, + ], + } + : null, + ); + } + tm.follow( + frame.meta, + frame.normalizer, + (nx, ny) => normalizedToScreen(view, nx, ny), + view.width, + view.height, + ); + return true; + }, + setLayer: (layer, data) => tm.gl.setData(layer, data), + setProject: (projectId) => + tm.gl.setData("satellite", projectId ? satelliteData(projectId) : null), + dispose: tm.dispose, + }; +} diff --git a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts index 1b1ac56ac..fe43a6271 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Panel.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Panel.ts @@ -42,7 +42,8 @@ import { createDrainageLive } from "./B05_Profile_UI_Drainage_Live"; import { createDrainagePropose } from "./B05_Profile_UI_Drainage_Propose"; import { bindDrainageInteractions } from "./B05_Profile_UI_Drainage_Interact"; import type { RouteSpanBand } from "./B05_Profile_UI_Drainage_Spans"; -import { drawDrainageScene } from "./B05_Profile_UI_Drainage_Render"; +import { drawDrainageScene, type DrainageScene } from "./B05_Profile_UI_Drainage_Render"; +import { createDrainageGl } from "./B05_Profile_UI_Drainage_Gl"; import { computeZoomOutLimit } from "./B05_Profile_UI_Drainage_ZoomRange"; import { mountDrainageShow } from "./B05_Profile_UI_Drainage_Show"; import { @@ -188,6 +189,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra }); backgroundImage.addEventListener("load", () => scheduleDraw()); + const drainGl = createDrainageGl(viewport, layerButtons, backgroundImage, () => scheduleDraw()); function draw(): void { const rect = viewport.getBoundingClientRect(); @@ -207,7 +209,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra context.clearRect(0, 0, width, height); const mapRect: MapRect = computeMapRect(meta, width, height); const view: ViewState = { width, height, scale, offsetX, offsetY, mapRect }; - drawDrainageScene(context, view, { + const scene: DrainageScene = { meta, normalizer, basins, @@ -233,7 +235,9 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra markedChainage, stationIntervalM: MAP_STATION_INTERVAL_M, intervalSpans, - }); + }; + scene.glBase = drainGl.draw(view, scene, show.satellite); + drawDrainageScene(context, view, scene); placeMapImage(backgroundImage, meta, normalizer, view); } @@ -460,6 +464,8 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra const sequence = ++loadSequence; meta = null; preparedLayers.clear(); + drainGl.setProject(activeProjectId); + DRAINAGE_LAYERS.forEach((layer) => drainGl.setLayer(layer, null)); backgroundImage.removeAttribute("src"); status.hidden = false; status.textContent = L("B05_Drainage_Status_LoadingBase"); @@ -476,6 +482,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra if (!data) return; featureCount += data.features?.length ?? 0; preparedLayers.set(layer, prepareLayer(data, normalizer!)); + drainGl.setLayer(layer, data); }); // 주소에 시각을 붙이면 브라우저가 매번 다시 받는다. 서버가 ETag를 주므로 그대로 쓴다. backgroundImage.src = getVWorldMapUrl(activeProjectId, "satellite"); @@ -676,6 +683,7 @@ export function createDrainagePanel(callbacks: DrainagePanelCallbacks = {}): Dra frameHandle = 0; } resizeObserver.disconnect(); + drainGl.dispose(); widthResizer.dispose(); }, rehome: () => resizeObserver.rehome(), diff --git a/B05_Profile/B05_Profile_UI_Drainage_Render.ts b/B05_Profile/B05_Profile_UI_Drainage_Render.ts index 54395e6cb..73f5b2b67 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Render.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Render.ts @@ -70,6 +70,8 @@ export interface DrainageScene { stationIntervalM: number; /** 구간형 구조물이 놓인 자리 — 계획선 위에 띠로 얹는다(계획서 3-6). 없으면 빈 배열. */ intervalSpans: ReadonlyArray; + /** GL 바탕이 유역 채움 · 분수령 · 도엽을 그림(589-2) — 캔버스는 그 겹을 쉼(번호는 그대로). */ + glBase?: boolean; } export function drawDrainageScene( @@ -92,25 +94,26 @@ export function drawDrainageScene( label: String(basin.index), }); } - drawFilledRing( - context, - { ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat }, - normalizer, - view, - // 하나를 고르면 나머지는 옅게 물러난다. - scene.selectedBasin === null || scene.selectedBasin === basin.index - ? color - : color.replace(/0\.45\)$/, "0.18)"), - ); + if (!scene.glBase) + drawFilledRing( + context, + { ring: basin.polygon_lonlat, rings: basin.polygon_rings_lonlat }, + normalizer, + view, + // 하나를 고르면 나머지는 옅게 물러난다. + scene.selectedBasin === null || scene.selectedBasin === basin.index + ? color + : color.replace(/0\.45\)$/, "0.18)"), + ); }); // 전체 유역 외곽선 = 분수령(능선). 해석 결과를 그대로 그린다 — 손으로 고치지 않는다. - if (scene.mainBoundary.length > 2) { + if (!scene.glBase && scene.mainBoundary.length > 2) { drawRidgeRing(context, scene.mainBoundary as Array<[number, number]>, normalizer, view); } } // 등고선을 얇게 깔고 세류를 그 위에, 노선을 맨 위에 둔다. DRAINAGE_LAYERS.forEach((layer) => { - if (!scene.activeLayers.has(layer)) return; + if (scene.glBase || !scene.activeLayers.has(layer)) return; const prepared = scene.preparedLayers.get(layer); if (!prepared) return; context.lineWidth = layer === "도엽_등고선" ? 0.7 : 1.5; diff --git a/B05_Profile/B05_Profile_UI_Drainage_Show.ts b/B05_Profile/B05_Profile_UI_Drainage_Show.ts index 68666ccaf..43782af6a 100644 --- a/B05_Profile/B05_Profile_UI_Drainage_Show.ts +++ b/B05_Profile/B05_Profile_UI_Drainage_Show.ts @@ -8,6 +8,8 @@ import { mountDrainageToggles, type DrainageLayer } from "./B05_Profile_UI_Drain /** 지금 켜진 표시 — 그리기가 읽음(토글이 고침). */ export interface DrainageShow { + /** 위성 — 기본 꺼짐(그림 숨김은 그리기 때 · GL 이면 GL 위성). */ + satellite: boolean; arrows: boolean; /** 유입 강도 색칠 — B04 지도와 같은 색띠·같은 값(2026-08-01 사용자 지시). */ strength: boolean; @@ -27,11 +29,18 @@ export function mountDrainageShow( redraw: () => void; }, ): DrainageShow { - const show: DrainageShow = { arrows: true, strength: true, hotspots: false, upstream: true }; + const show: DrainageShow = { + satellite: false, + arrows: true, + strength: true, + hotspots: false, + upstream: true, + }; deps.backgroundImage.hidden = true; mountDrainageToggles(layerButtons, { - initial: { satellite: false, ...show }, + initial: show, onSatellite: (next) => { + show.satellite = next; deps.backgroundImage.hidden = !next; deps.redraw(); }, diff --git a/B05_Profile/B05_Profile_UI_RouteEdit.ts b/B05_Profile/B05_Profile_UI_RouteEdit.ts index 7c9449072..4299518eb 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit.ts @@ -115,7 +115,7 @@ export async function mountRouteEditor( options: RouteEditOptions, ): Promise { const stationIntervalM = options.stationIntervalM ?? 20; - const chrome = createRouteEditChrome(parent, options.tools); + const chrome = createRouteEditChrome(parent, options.tools, host); const { overlay, canvas, status, measureBox, measureText, measureButton } = chrome; const { minorContourButton, terrainName, stationSnapButton } = chrome; const context = canvas.getContext("2d")!; diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts index 83ec6d31e..776c2754a 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Chrome.ts @@ -3,6 +3,7 @@ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { bindFootprintToggle } from "./B05_Profile_UI_RouteEdit_Footprint"; import { bindMarkToggle } from "./B05_Profile_UI_RouteEdit_Marks"; +import { createRouteEditGl } from "./B05_Profile_UI_RouteEdit_Gl"; import { bottomRowNarrow, infoLiftPx } from "./logic/B05_Profile_RouteEdit_InfoLift"; /* ============================================================================= @@ -85,6 +86,7 @@ let infoCollapsed = false; export function createRouteEditChrome( parent: HTMLElement, tools: HTMLElement[] = [], + host?: { onDispose: (fn: () => void) => void }, ): RouteEditChrome { const overlay = document.createElement("div"); overlay.className = "b05-routeedit"; @@ -247,6 +249,7 @@ export function createRouteEditChrome( const measureButton = mapTools.button("measure")!; const busy = overlay.querySelector(".b05-routeedit__busy")!; const minorContourButton = mapTools.button("contour-minor")!; + createRouteEditGl(canvas, minorContourButton, host); // GL 바탕(589-2 · 그리기가 `routeGlOf` 로 찾음) const stationSnapButton = mapTools.button("station-snap")!; bindFootprintToggle(mapTools.button("footprint")!); bindMarkToggle(mapTools.button("stations")!, "stations"); diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts index 5c233b6cf..cef066d36 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Contour.ts @@ -43,6 +43,8 @@ export interface RouteEditContours { /** 등고선 간격(m) — 라벨을 몇 줄마다 낼지 정하는 기준. */ intervalM: number; source: "las" | "sheet"; + /** 준비할 때 쓴 메타(정규화 자) — GL 바탕이 경위도로 되돌릴 때(589-2). */ + meta?: VWorldMeta; /** 5m 배수가 아닌 **1m 간격 선**(계획서 18 ②) — 켰을 때 처음 한 번 준비한다(무거워서). * LAS 자료가 없으면(도엽 갈래) null — 도엽에는 1m 선이 없다. */ minor: (() => PreparedLayer) | null; @@ -101,6 +103,7 @@ export async function loadRouteEditContours( layer: prepareMetricPolylines(lines, meta), intervalM: LAS_CONTOUR_UNIT_M, source: "las", + meta, minor: rest.length ? () => (minor ??= prepareMetricPolylines(toLines(rest), meta)) : null, }; } @@ -109,7 +112,7 @@ export async function loadRouteEditContours( } } const layer = prepareLayer(sheet ?? undefined, normalizer, SHEET_ELEVATION_KEYS); - return { layer, intervalM: inferIntervalM(layer), source: "sheet", minor: null }; + return { layer, intervalM: inferIntervalM(layer), source: "sheet", meta, minor: null }; } /** 잘라 받은 도엽(`clip_lonlat`)이 노선 띠(bbox + `bandM`)를 덮나 — 안 잘렸으면 덮음(509-1). diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts new file mode 100644 index 000000000..ebd419bd2 --- /dev/null +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Gl.ts @@ -0,0 +1,205 @@ +/* ============================================================================= + * B05_Profile_UI_RouteEdit_Gl.ts + * 평면 노선 편집 GL 바탕(PLAN 589-2) — 바탕칠 · 등고선(LAS 5 m · 1 m 선 · 도엽) · 도엽 하천을 MapLibre 로. + * TM 겹(LAS) · 도엽 모두 준비한 겹(정규화 · TM 자리)을 경위도로 되돌려 올림(`preparedToGeoJson`) — + * 캔버스와 같은 자리 · 같은 솎기(간격 배수 · 1 m 선 켬 조건) · 노선 둘레 띠 자르기(지도 칸 clip-path) · + * 하천 = LAS 등고선이 있는 데까지(자료를 상자로 자름 · 0-9 ⑮). + * 고른 등고선 · 등고 글 · 노선 · 꼭짓점 · 곡선 · 측점 · 재기 · 끌기 = 위 캔버스 그대로 · 집기 판정 그대로. + * ========================================================================== */ + +import type { FilterSpecification } from "maplibre-gl"; +import { createIconButton } from "@ui/ui_template_elements"; +import { themeColor } from "@ui/ui_template_palette"; +import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; +import { createTmGl, GL_TOGGLE_TITLE, lineSpecs } from "../B04_PreProcess/B04_PreProcess_UI_MapGl"; +import { + createTmNormalizer, + normalizedToScreen, + type Normalizer, + type PreparedLayer, +} from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; +import { + lonLatOfNormFn, + preparedToGeoJson, +} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapGl_Frame"; +import type { RouteEditContours } from "./B05_Profile_UI_RouteEdit_Contour"; +import { contourBandRect } from "./B05_Profile_UI_RouteEdit_Input"; +import type { RouteEditScene } from "./B05_Profile_UI_RouteEdit_Render"; +import type { Vertex } from "./logic/B05_Profile_UI_RouteEdit_Curve"; + +type Key = "sheets" | "minor" | "contour"; + +/** 간격 배수만(높이 모르는 줄은 늘) — 캔버스 `drawPreparedLayer(everyM)` 와 같은 거름. */ +const stepFilter = (step: number): FilterSpecification => + (step > 0 + ? ["any", ["!", ["has", "level"]], ["==", ["%", ["get", "level"], step], 0]] + : ["literal", true]) as unknown as FilterSpecification; + +export interface RouteEditGl { + /** 그리기마다 — 켜져 있으면 화면을 따라가고 참(캔버스는 바탕을 쉼) · `again` = 장면 다시 그리기(켬끔 뒤). */ + follow: (scene: RouteEditScene, bandM: number, again: () => void) => boolean; + dispose: () => void; +} + +const BY_CANVAS = new WeakMap(); +/** 그 캔버스의 GL 바탕(편집 뼈대가 만듦) — 없으면 옛 캔버스 그대로. */ +export const routeGlOf = (canvas: HTMLCanvasElement): RouteEditGl | undefined => + BY_CANVAS.get(canvas); + +/** `canvas` = 편집 캔버스(칸 = 그 부모) · `after` = 켬끔 단추를 그 뒤에(도구줄) · `host` = 떠날 때 버림. */ +export function createRouteEditGl( + canvas: HTMLCanvasElement, + after: HTMLElement, + host?: { onDispose: (fn: () => void) => void }, +): RouteEditGl { + const wrap = canvas.parentElement!; + let redraw: (() => void) | null = null; + let shown: [RouteEditContours | null, ReadonlyArray | null] = [null, null]; + let step = 0; + let meta: VWorldMeta | null = null; + let normalizer: Normalizer | null = null; + let toLonLat: ((nx: number, ny: number) => [number, number]) | null = null; + let minorShown: PreparedLayer | null = null; + const button = createIconButton({ + icon: "gauge", + title: GL_TOGGLE_TITLE, + size: 32, + onClick: () => tm.toggle(), + }); + button.dataset.act = "gl"; + after.after(button); + const tm = createTmGl({ + parent: wrap, + specs: () => [ + ...lineSpecs("sheets", themeColor("--map-sheet-stream", "#2563eb"), 1.2, { + marker: "dot", + }), + ...lineSpecs( + "minor", + themeColor("--map-contour-minor", "rgba(165, 180, 252, 0.45)"), + 0.5, + ), + ...lineSpecs("contour", themeColor("--map-contour-major", "#818cf8"), 0.8, { + filter: stepFilter(step), + }), + ], + themeNames: ["--draw-surface", "--map-contour-major", "--map-sheet-stream"], + onChange: (on) => { + mark(on); + redraw?.(); + }, + }); + const mark = (on: boolean): void => { + button.setAttribute("aria-pressed", String(on)); + // 바탕칠 = 칸(지도 칸은 띠로 잘림 · 캔버스는 투명) + wrap.style.background = on ? "var(--draw-surface)" : ""; + }; + mark(tm.on()); + + /** 바탕 한 벌을 바꿈(받은 뒤 · 띠 따라 다시 받은 뒤). */ + function setBackground( + nextMeta: VWorldMeta, + contours: RouteEditContours | null, + otherSheets: ReadonlyArray, + ): void { + meta = nextMeta; + normalizer = createTmNormalizer(nextMeta); + toLonLat = lonLatOfNormFn(normalizer); + minorShown = null; + tm.gl.setData("minor", null); + tm.gl.setData("contour", contours ? preparedToGeoJson(contours.layer, toLonLat) : null); + // 하천 = LAS 등고선이 있는 데까지(정규화 상자) + const features = contours?.source === "las" ? contours.layer.features : []; + const box = features.length + ? features.reduce( + (got, feature) => + [ + Math.min(got[0], feature.minX), + Math.min(got[1], feature.minY), + Math.max(got[2], feature.maxX), + Math.max(got[3], feature.maxY), + ] as const, + [Infinity, Infinity, -Infinity, -Infinity] as const, + ) + : undefined; + tm.gl.setData("sheets", { + features: otherSheets.flatMap((layer) => preparedToGeoJson(layer, toLonLat!, box).features), + }); + } + + const api: RouteEditGl = { + follow(scene, bandM, again) { + redraw = again; + const container = tm.gl.container(); + const at = scene.contours?.meta; + if (!tm.on() || !at || !container) return false; + if (shown[0] !== scene.contours || shown[1] !== scene.otherSheets) { + shown = [scene.contours, scene.otherSheets]; + setBackground(at, scene.contours, scene.otherSheets); + } + if (!meta || !normalizer) return false; + const { view } = scene; + const { gl } = tm; + const minor = + !!scene.minorContours && !!scene.contours && scene.contourStepM <= scene.contours.intervalM; + if (minor && scene.minorContours !== minorShown && toLonLat) { + minorShown = scene.minorContours; + gl.setData("minor", preparedToGeoJson(scene.minorContours!, toLonLat)); + } + gl.setVisible("sheets", true); + gl.setVisible("contour", true); + gl.setVisible("minor", minor); + if (scene.contourStepM !== step) { + step = scene.contourStepM; + if (gl.map()?.getLayer("contour-line")) + gl.map()!.setFilter("contour-line", [ + "all", + ["!=", ["geometry-type"], "Point"], + stepFilter(step), + ] as unknown as FilterSpecification); + } + // 캔버스 돌림(한가운데 축) — 같은 식으로 카메라 · 띠 귀퉁이 + const cos = Math.cos(scene.rotationRad); + const sin = Math.sin(scene.rotationRad); + const cx = view.width / 2; + const cy = view.height / 2; + const turn = (x: number, y: number): [number, number] => [ + cx + (x - cx) * cos - (y - cy) * sin, + cy + (x - cx) * sin + (y - cy) * cos, + ]; + tm.follow( + meta, + normalizer, + (nx, ny) => turn(...normalizedToScreen(view, nx, ny)), + view.width, + view.height, + ); + // 노선 둘레 띠만(캔버스 자르기와 같은 네모 · 돌림 · 칸 안 px) + const line = (scene.plannedLine.length ? scene.plannedLine : scene.planned) as Vertex[]; + const band = scene.hasMeta ? contourBandRect(line, scene.toScreen, bandM) : null; + const corners: [number, number][] = band + ? [ + [band.x, band.y], + [band.x + band.width, band.y], + [band.x + band.width, band.y + band.height], + [band.x, band.y + band.height], + ] + : []; + container.style.clipPath = band + ? `polygon(${corners + .map(([x, y]) => + tm + .local(...turn(x, y)) + .map((v) => `${v}px`) + .join(" "), + ) + .join(", ")})` + : ""; + return true; + }, + dispose: tm.dispose, + }; + BY_CANVAS.set(canvas, api); + host?.onDispose(api.dispose); + return api; +} diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts index eb38a92cf..4e0d5a6b0 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Render.ts @@ -35,6 +35,7 @@ import { arcLocked, straightLocked } from "./B05_Profile_UI_RouteEdit_Lock"; import { drawFootprint } from "./B05_Profile_UI_RouteEdit_Footprint"; import { markShown } from "./B05_Profile_UI_RouteEdit_Marks"; import { drawStructureMarks } from "./B05_Profile_UI_RouteEdit_StructureMarks"; +import { routeGlOf } from "./B05_Profile_UI_RouteEdit_Gl"; /** 노드 반지름(px) — 요구 241-14 로 4 → 6(고르면 +2). */ const NODE_R = 6; @@ -169,8 +170,13 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou const style = getComputedStyle(document.documentElement); const line = scene.plannedLine.length ? scene.plannedLine : scene.planned; context.clearRect(0, 0, view.width, view.height); + // GL 바탕(589-2) — 켜져 있으면 바탕칠 · 등고선 · 하천을 그쪽이 그림(고른 등고선 · 글은 여기) + const glBase = + routeGlOf(context.canvas)?.follow(scene, CONTOUR_BAND_M, () => + drawRouteEditScene(context, scene), + ) ?? false; context.fillStyle = style.getPropertyValue("--draw-surface") || "#111"; - context.fillRect(0, 0, view.width, view.height); + if (!glBase) context.fillRect(0, 0, view.width, view.height); // 여기서부터 **그림 전체**가 돈다 — 글자도 함께 돈다(CAD 도면과 같은 방식, 사용자 지시 ⑯). // 바탕칠은 돌리기 **전에** 해 두었다 — 돌린 뒤에 칠하면 모서리에 빈 곳이 생긴다. @@ -204,15 +210,16 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou context.rect(lasBox.x, lasBox.y, lasBox.width, lasBox.height); context.clip(); } - for (const layer of scene.otherSheets) - drawPreparedLayer(context, layer, view, "dot", undefined, pad); + if (!glBase) + for (const layer of scene.otherSheets) + drawPreparedLayer(context, layer, view, "dot", undefined, pad); context.restore(); if (scene.contours) { // 그리는 줄과 라벨을 **같은 눈금**으로 솎는다 — 그린 줄에만 숫자가 붙어야 짝이 맞는다. const everyM = scene.contourStepM; // 1m 선은 5m 선이 솎이지 않는 배율에서만 — 5m 도 솎이는 데서 1m 를 다 그리면 선으로 // 덮이고 무겁다. 화면 밖 가닥은 `drawPreparedLayer` 가 건너뛴다(보이는 범위만). - if (scene.minorContours && everyM <= scene.contours.intervalM) { + if (!glBase && scene.minorContours && everyM <= scene.contours.intervalM) { context.strokeStyle = style.getPropertyValue("--map-contour-minor") || "rgba(165, 180, 252, 0.45)"; context.lineWidth = 0.5; @@ -221,7 +228,7 @@ export function drawRouteEditScene(context: CanvasRenderingContext2D, scene: Rou // 5m 선은 도엽색보다 한 단 짙게(계획서 18 ②). context.strokeStyle = style.getPropertyValue("--map-contour-major") || "#818cf8"; context.lineWidth = 0.8; - drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad); + if (!glBase) drawPreparedLayer(context, scene.contours.layer, view, "dot", everyM, pad); // 고른 가닥은 굵고 다른 색으로 덧그린다 — 지우고 다시 그리지 않고 위에 얹는다. // 끊어져 여러 가닥으로 나뉜 **같은 높이 줄은 함께** 덧그린다(높이를 모르면 고른 가닥만). if (scene.pickedContour >= 0) { diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 7c35b874b..b7887a6b9 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -38,6 +38,7 @@ html.is-bm-route .b05-routeedit__tools { } .b05-routeedit__canvas { + position: relative; /* GL 바탕(589-2 · 칸 맨 밑) 위 */ display: block; width: 100%; height: 100%; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index 10251ba5e..ec78b180c 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -10,32 +10,26 @@ * 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음. * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`. * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. + * 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만. * ========================================================================== */ +import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl"; import { - Map as MapLibreMap, - type ExpressionSpecification, - type FilterSpecification, - type LayerSpecification, - type SourceSpecification, -} from "maplibre-gl"; -import "maplibre-gl/dist/maplibre-gl.css"; -import { API_BASE_URL } from "@config/config_frontend"; + createGlMap, + GL_FONT, + satelliteTileSource, + type GlImage, + type GlSpec as GlMapSpec, +} from "@ui/ui_template_gl_map"; 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"; +export { GL_FONT, type GlImage }; + /** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */ 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 { @@ -52,7 +46,7 @@ export interface GlBase { resize: () => void; } -type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" }; +type GlSpec = GlMapSpec; const HALO = "rgba(0, 0, 0, 0.55)"; const ROUND = { "line-join": "round", "line-cap": "round" } as const; @@ -312,160 +306,29 @@ const LINE_COLOR = { } as const; /** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ -const themeKey = (): string => - ["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"] - .map((name) => themeColor(name, "")) - .join(); - -const SOURCES: readonly (GlSource | "satellite")[] = [ - "satellite", - "hillshade", - "slope", - "landslide", - "soils", - "zones", - "bands", - "cadastral", - "water", - "building", - "contour", - "road", - "forestRoadFile", - "stream", - "faults", - "lineaments", - "zeroOrder", - "landslideZones", - "place", -]; +const THEME_NAMES = [ + "--draw-map-contour", + "--draw-map-road", + "--draw-map-stream", + "--draw-map-parcel", +] as const; 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(); - let container: HTMLDivElement | null = null; - - const source = (key: GlSource | "satellite"): SourceSpecification | null => { - if (key === "satellite") - return { - type: "raster", - tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/tile/{z}/{x}/{y}`], - tileSize: 256, - minzoom: 6, - maxzoom: 19, - }; - const got = data.get(key); - if (!got) return null; - if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners }; - // 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌 - 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?: string): void => { - for (const spec of specs) - if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); - }; - if (now !== theme) { - theme = now; - drop(); // 밝음 · 어두움 = 색 다시 - } - for (const key of SOURCES) { - const got = key === "satellite" ? key : (data.get(key) ?? null); - if (applied.get(key) === got) continue; - applied.set(key, got); - if (map.getSource(key)) { - drop(key); - map.removeSource(key); - } - const want = source(key); - if (want) map.addSource(key, want); - } - specs.forEach(({ key, ...spec }, index) => { - if (!map!.getSource(spec.source)) return; - if (!map!.getLayer(spec.id)) { - // 늦게 온 겹도 제 차례(위 겹 밑)에 - const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id)); - map!.addLayer(spec as LayerSpecification, above?.id); - } - map!.setLayoutProperty(spec.id, "visibility", visible.get(key) ? "visible" : "none"); - }); - } - - function teardown(): void { - map?.remove(); - container?.remove(); - map = container = null; - ready = false; - theme = ""; - applied.clear(); - } - + const base = createGlMap({ + specs: () => layerSpecs(step), + fixed: { satellite: satelliteTileSource(projectId) }, + themeNames: THEME_NAMES, + className: "d03-plan__gl", + onFail, + }); return { - setData(key, next) { - if (data.get(key) === next) return; - data.set(key, next); - apply(); - }, - setVisible(key, on) { - if (visible.get(key) === on) return; - visible.set(key, on); - apply(); - }, + ...base, setContourStep(next) { if (next === step) return; step = next; + const map = base.map(); if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step)); }, - enable(parent) { - if (!parent) { - teardown(); - return false; - } - if (map) return true; - container = document.createElement("div"); - container.className = "d03-plan__gl"; - parent.prepend(container); - 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) { - if (!map) return; - if (ready && themeKey() !== theme) apply(); - map.jumpTo({ center, zoom, bearing: bearingDeg }); - if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) - }, - resize: () => map?.resize(), }; } diff --git a/resources/tester/helper_589_2_gl_frame.cjs b/resources/tester/helper_589_2_gl_frame.cjs new file mode 100644 index 000000000..f4625f8f2 --- /dev/null +++ b/resources/tester/helper_589_2_gl_frame.cjs @@ -0,0 +1,84 @@ +/* 589-2 GL 바탕 따라가기 헬퍼 — `logic/B04_PreProcess_UI_MapGl_Frame.ts` 의 `glCameraOf` · + * `lonLatOfNormFn` · `preparedToGeoJson` · `ringsToPolygons` 를 stdin 입력으로 돌려 JSON 으로 냄. + * 입력 { meta, lonlat: [[lon, lat]], views: [[w, h, scale, ox, oy, rotDeg]], norm: [[nx, ny]], + * layer, box, rings } + * views 마다 = 카메라 + 점마다 [캔버스 화면 px, MapLibre 식(세계 512 px · 돌림) 화면 px] + */ +const path = require("path"); +const { makeLoader } = require("./helper_ts_stub_loader.cjs"); + +const ROOT = path.join(__dirname, "..", ".."); +const { load } = makeLoader({ root: ROOT, ui: () => ({ themeColor: () => "" }) }); +const render = load("B04_PreProcess", "B04_PreProcess_UI_MapRender.ts"); +const prep = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapRender_Prepare.ts"); +const frame = load("B04_PreProcess", "logic", "B04_PreProcess_UI_MapGl_Frame.ts"); + +const input = JSON.parse(require("fs").readFileSync(0, "utf-8")); +const { meta } = input; +const norm = render.createTmNormalizer(meta); + +/** MapLibre 투영(웹 메르카토르 · 세계 512·2^z px · 돌림 = 화면 = R(−도) · 세계). */ +function glScreen(camera, w, h, lon, lat) { + const world = 512 * 2 ** camera.zoom; + const X = (l) => ((l + 180) / 360) * world; + const Y = (p) => + (0.5 - Math.log(Math.tan(Math.PI / 4 + (p * Math.PI) / 360)) / (2 * Math.PI)) * world; + const dx = X(lon) - X(camera.center[0]); + const dy = Y(lat) - Y(camera.center[1]); + const a = (-camera.bearing * Math.PI) / 180; + const gx = dx * Math.cos(a) - dy * Math.sin(a); + const gy = dx * Math.sin(a) + dy * Math.cos(a); + // 지도 칸 CSS 행렬(칸 가운데 축) + const [ca, cb, cc, cd] = camera.css; + return [w / 2 + ca * gx + cc * gy, h / 2 + cb * gx + cd * gy]; +} + +const out = { + views: input.views.map(([w, h, scale, ox, oy, rotDeg]) => { + const view = { width: w, height: h, scale, offsetX: ox, offsetY: oy }; + view.mapRect = render.computeMapRect(meta, w, h); + const r = (rotDeg * Math.PI) / 180; + const turn = ([x, y]) => [ + w / 2 + (x - w / 2) * Math.cos(r) - (y - h / 2) * Math.sin(r), + h / 2 + (x - w / 2) * Math.sin(r) + (y - h / 2) * Math.cos(r), + ]; + const toScreen = (nx, ny) => turn(render.normalizedToScreen(view, nx, ny)); + const camera = frame.glCameraOf(meta, norm, toScreen, w, h); + const points = input.lonlat.map(([lon, lat]) => { + const [nx, ny] = prep.normOfLonLat(norm, lon, lat); + return [toScreen(nx, ny), glScreen(camera, w, h, lon, lat)]; + }); + // 화면 격자 → (되돌림) 정규화 → 경위도 → GL 화면 = 제자리여야 + const [ox0, oy0] = toScreen(0, 0); + const [ox1, oy1] = toScreen(1, 0); + const [ox2, oy2] = toScreen(0, 1); + const ex = [ox1 - ox0, oy1 - oy0]; + const ey = [ox2 - ox0, oy2 - oy0]; + const det = ex[0] * ey[1] - ex[1] * ey[0]; + const toLonLat = frame.lonLatOfNormFn(norm); + const screen = []; + for (let i = 0; i <= 4; i += 1) + for (let j = 0; j <= 4; j += 1) { + const sx = (w * i) / 4; + const sy = (h * j) / 4; + const dx = sx - ox0; + const dy = sy - oy0; + const nx = (dx * ey[1] - dy * ey[0]) / det; + const ny = (ex[0] * dy - ex[1] * dx) / det; + const [lon, lat] = toLonLat(nx, ny); + screen.push([[sx, sy], glScreen(camera, w, h, lon, lat)]); + } + return { camera, points, screen }; + }), + back: (() => { + const toLonLat = frame.lonLatOfNormFn(norm); + return input.norm.map(([nx, ny]) => { + const [lon, lat] = toLonLat(nx, ny); + return [lon, lat, ...prep.normOfLonLat(norm, lon, lat)]; + }); + })(), + geo: frame.preparedToGeoJson(input.layer, (nx, ny) => [nx, ny], input.box), + geoAll: frame.preparedToGeoJson(input.layer, (nx, ny) => [nx, ny]), + polygons: frame.ringsToPolygons(input.rings), +}; +process.stdout.write(JSON.stringify(out)); diff --git a/resources/tester/test_589_2_gl_frame.py b/resources/tester/test_589_2_gl_frame.py new file mode 100644 index 000000000..95eeac2cf --- /dev/null +++ b/resources/tester/test_589_2_gl_frame.py @@ -0,0 +1,170 @@ +# -*- coding: utf-8 -*- +"""589-2 B04 · B05 GL 바탕 — MapLibre 카메라가 TM 주인 화면(589-1 자리)을 따라감. + +- 카메라(화면 가운데 1차 맞춤 · 닮음꼴) + 칸 CSS 행렬(남는 늘림) → MapLibre 식 화면 px = 캔버스 자리 + (격자 쌍선형 · 화면 식) 0.5 px 안(처음 화면 · 노선 자리 · 크게 당김 · 돌림 30° · 화면 밖 반 폭까지) +- 칸 행렬 = 늘이기만(고윳값 ≥ 1 · 웹 메르카토르 가로세로 차 0.4 %) +- TM 겹 되돌림(정규화 → 경위도) — 다시 정규화하면 1 mm 안 · pyproj 와 2 cm 안 +- 준비한 겹 → GeoJSON — 상자 자르기(하천 = LAS 등고선 있는 데까지) · 닫힌 고리 · 점 +- 유역 고리(조각 · 구멍) → 다각형 — 캔버스 짝홀 채움과 같은 묶음 + +Node 헬퍼(`helper_589_2_gl_frame.cjs`)가 진짜 TS 를 돌린다. +""" + +import json +import math +import os +import subprocess + +import pytest +from pyproj import Transformer + +from B04_PreProcess.B04_PreProcess_Engine_VWorld import tm_grid_for_meta +from resources.tester.test_589_tm_grid import SITES, _meta + +HERE = os.path.dirname(os.path.abspath(__file__)) +# (w, h, 배율, 옮김 x, y, 돌림 도) — 처음 화면 · 노선 자리 · 크게 당김 · 돌림 +VIEWS = [ + (1400, 560, 1, 0, 0, 0), + (1200, 700, 2.33, -120, 80, 0), + (1400, 560, 300, -60000, 40000, 0), + (1200, 700, 4, 200, -150, 30), +] +LAYER = { + "features": [ + { + "kind": "line", + "labelValue": 125, + "parts": [{"coords": [0.0, 0.5, 0.5, 0.5, 1.0, 0.5], "closed": False}], + }, + { + "kind": "line", + "labelValue": None, + "parts": [{"coords": [0.2, 0.2, 0.4, 0.2, 0.4, 0.4], "closed": True}], + }, + {"kind": "point", "labelValue": None, "parts": [{"coords": [0.3, 0.3, 0.9, 0.9]}]}, + ] +} +BOX = [0.25, 0.0, 0.75, 1.0] +RINGS = [ + [[0, 0], [10, 0], [10, 10], [0, 10]], # 바깥 + [[2, 2], [8, 2], [8, 8], [2, 8]], # 구멍 + [[4, 4], [6, 4], [6, 6], [4, 6]], # 구멍 속 섬 + [[20, 0], [25, 0], [25, 5]], # 떨어진 조각 +] + + +def _run(site): + crs, box, _ = SITES[site] + meta = _meta(crs, box) + meta["tm_grid"] = tm_grid_for_meta(meta, crs) + lon_min, lon_max, lat_min, lat_max = box + # 상자 안 · 둘레 반 폭(화면 밖까지) + lonlat = [ + (lon_min + a / 8 * (lon_max - lon_min), lat_min + b / 8 * (lat_max - lat_min)) + for a in range(-4, 13) + for b in range(-4, 13) + ] + norm = [(a / 6, b / 6) for a in range(-1, 8) for b in range(-1, 8)] + payload = { + "meta": meta, + "lonlat": lonlat, + "views": VIEWS, + "norm": norm, + "layer": LAYER, + "box": BOX, + "rings": RINGS, + } + proc = subprocess.run( + ["node", os.path.join(HERE, "helper_589_2_gl_frame.cjs")], + input=json.dumps(payload), + capture_output=True, + text=True, + encoding="utf-8", + timeout=60, + ) + assert proc.returncode == 0, proc.stderr + return meta, norm, json.loads(proc.stdout) + + +@pytest.fixture(scope="module", params=["B", "D"]) +def site(request): + return request.param, *_run(request.param) + + +def test_GL_카메라는_캔버스_자리를_1px_안에(site): + name, _, _, out = site + for (w, h, scale, *_rest), view in zip(VIEWS, out["views"]): + worst = 0.0 + for canvas, gl in view["points"]: + # 화면 안 · 둘레 반 폭까지만 잰다(그 밖은 안 보임) + if not (-w / 2 <= canvas[0] <= w * 1.5 and -h / 2 <= canvas[1] <= h * 1.5): + continue + worst = max(worst, math.hypot(canvas[0] - gl[0], canvas[1] - gl[1])) + assert worst < 0.5, (name, scale, worst) + # 화면 격자(귀퉁이 · 가장자리 · 가운데) — 되돌림 거쳐 제자리 + corner = max(math.hypot(s[0] - g[0], s[1] - g[1]) for s, g in view["screen"]) + assert corner < 0.5, (name, scale, corner) + + +def test_남는_늘림은_칸_행렬로_늘이기만(site): + _, _, _, out = site + for view in out["views"]: + a, b, c, d = view["camera"]["css"] + # 대칭 · 고윳값 ≥ 1 · 웹 메르카토르 가로세로 차(0.4 %) 안팎 + assert abs(b - c) < 1e-9 + trace, det = a + d, a * d - b * c + least = trace / 2 - math.sqrt(max(trace * trace / 4 - det, 0)) + most = trace / 2 + math.sqrt(max(trace * trace / 4 - det, 0)) + assert least == pytest.approx(1, abs=1e-9) and 1.003 < most < 1.006 + + +def test_돌림은_카메라_도에_그대로(): + _, _, out = _run("D") + plain = out["views"][1]["camera"]["bearing"] + turned = out["views"][3]["camera"]["bearing"] + # 화면 30° 돌림 = 카메라 −30° (+ 수렴각 몫은 같음) + assert abs((turned - plain) + 30) < 0.01 + + +def test_TM_되돌림은_다시_정규화하면_제자리(site): + name, meta, norm, out = site + inv = Transformer.from_crs(SITES[name][0], "EPSG:4326", always_xy=True) + for (nx, ny), (lon, lat, bx, by) in zip(norm, out["back"]): + meters = math.hypot((bx - nx) * meta["width_meters"], (by - ny) * meta["height_meters"]) + assert meters < 1e-3, (nx, ny, meters) + x = meta["x_min"] + nx * meta["width_meters"] + y = meta["y_min"] + (1 - ny) * meta["height_meters"] + want = inv.transform(x, y) + fwd = Transformer.from_crs("EPSG:4326", SITES[name][0], always_xy=True) + gx, gy = fwd.transform(lon, lat) + assert math.hypot(gx - x, gy - y) < 0.02, (nx, ny, want) + + +def test_준비한_겹_GeoJSON_자르기(): + _, _, out = _run("B") + lines = [f for f in out["geo"]["features"] if f["geometry"]["type"] == "LineString"] + # 가로줄 → 상자 0.25~0.75 만 · 높이 그대로 + first = lines[0] + assert first["properties"] == {"level": 125} + xs = [p[0] for p in first["geometry"]["coordinates"]] + assert min(xs) == pytest.approx(0.25) and max(xs) == pytest.approx(0.75) + # 닫힌 고리 → 상자 안 조각만(0.25 왼쪽 몫 잘림) + ring = lines[1]["geometry"]["coordinates"] + assert all(p[0] >= 0.25 - 1e-12 for p in ring) + # 점 — 상자 밖(0.9) 뺌 + points = [f for f in out["geo"]["features"] if f["geometry"]["type"] == "MultiPoint"] + assert points[0]["geometry"]["coordinates"] == [[0.3, 0.3]] + # 상자 없으면 통째 · 닫힌 고리는 첫 점으로 닫음 + closed = out["geoAll"]["features"][1]["geometry"]["coordinates"] + assert closed[0] == closed[-1] and len(closed) == 4 + + +def test_유역_고리는_짝홀_다각형(): + _, _, out = _run("B") + polygons = out["polygons"] + # 바깥(+ 구멍) · 구멍 속 섬 · 떨어진 조각 = 셋 + assert len(polygons) == 3 + assert len(polygons[0]) == 2 and polygons[0][1][0] == [2, 2] + assert len(polygons[1]) == 1 and polygons[1][0][0] == [4, 4] + assert len(polygons[2]) == 1 and polygons[2][0][0] == [20, 0] diff --git a/ui_template/ui_template_gl_map.css b/ui_template/ui_template_gl_map.css new file mode 100644 index 000000000..cbacc686b --- /dev/null +++ b/ui_template/ui_template_gl_map.css @@ -0,0 +1,6 @@ +/* 지도 GL 바탕 부품 — 위 캔버스 밑 MapLibre 한 장 · 누름은 위 캔버스가 받음 */ +.ui-gl-map { + position: absolute; + inset: -8px; /* 둘레 여유 — 부르는 쪽 칸 행렬(늘림)이 귀퉁이를 비우지 않게(가운데는 그대로) */ + pointer-events: none; +} diff --git a/ui_template/ui_template_gl_map.ts b/ui_template/ui_template_gl_map.ts new file mode 100644 index 000000000..e61fe4e95 --- /dev/null +++ b/ui_template/ui_template_gl_map.ts @@ -0,0 +1,198 @@ +/* ============================================================================= + * ui_template_gl_map.ts + * 지도 GL 바탕 부품(PLAN 586 · 589-2 · 요구 448) — 바탕 겹을 MapLibre(WebGL · BSD-3) 한 장으로 그림. + * 쓰는 곳 = D03 노선분석(`D03_PlanAnalysis_UI_MapGl`) · B04 하단 지도 · B05 평면 노선 편집 · 배수유역도 + * (`B04_PreProcess_UI_MapGl`). 겹 꼴(`specs`) · 자료는 부르는 쪽 몫 — 여기는 지도 세우기 · 자료 올리기 · + * 켬끔 · 밝음/어두움 색 다시 · 카메라 따라가기만. + * 카메라 주인 = 부르는 화면 하나 — 여기는 따라가기만 · 조작은 안 받음(누름 · 끌기는 위 캔버스). + * 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관) — 꼴에 글꼴 주소가 없으면 MapLibre 가 화면 글꼴로 그림. + * 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로. + * 쓰는 법 = createGlMap({ specs, fixed, themeNames, onFail }) → enable(parent) · setData · setVisible · camera + * ========================================================================== */ + +import { Map as MapLibreMap, type LayerSpecification, type SourceSpecification } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import "./ui_template_gl_map.css"; +import { API_BASE_URL } from "@config/config_frontend"; +import { themeColor } from "./ui_template_palette"; + +/** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ +export interface GlImage { + url: string; + corners: [[number, number], [number, number], [number, number], [number, number]]; +} + +/** 조각 겹(위성 조각 등 · 프로젝트마다 주소가 다른 것). */ +export interface GlRaster { + raster: SourceSpecification; +} + +/** 경위도 GeoJSON 묶음(꼴 표시가 없어도 받음). */ +export interface GlCollection { + features?: unknown[]; +} + +/** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */ +export const GL_FONT = "Noto Sans KR"; + +export type GlData = GlCollection | GlImage | GlRaster; + +/** 겹 하나 — `key` = 켬끔 이름 · `source` = 자료 이름. */ +export type GlSpec = LayerSpecification & { key: K; source: string }; + +export interface GlMap { + /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ + setData: (source: string, data: GlData | null) => void; + setVisible: (key: K, on: boolean) => void; + /** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */ + enable: (parent: HTMLElement | null) => boolean; + map: () => MapLibreMap | null; + /** 지도 칸(켰을 때) — 자르기 · 바탕색은 부르는 쪽. */ + container: () => HTMLDivElement | null; + /** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */ + camera: (center: [number, number], zoom: number, bearingDeg: number) => void; + resize: () => void; +} + +/** 브이월드 위성 조각(서버 대리 · 서버 기억 z6~19 · `D03_PlanAnalysis_Router`). */ +export function satelliteTileSource(projectId: string): SourceSpecification { + return { + type: "raster", + tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/tile/{z}/{x}/{y}`], + tileSize: 256, + minzoom: 6, + maxzoom: 19, + }; +} + +export function createGlMap(opts: { + /** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */ + specs: () => GlSpec[]; + /** 늘 올리는 자료(위성 조각 등). */ + fixed?: Record; + /** 색 지문 — 이 색 값이 바뀌면 겹을 다시 만듦. */ + themeNames: readonly string[]; + /** 지도 칸 클래스(없으면 `ui-gl-map` · 칸 가득 · 누름 안 받음). */ + className?: string; + onFail: (why: string) => void; +}): GlMap { + const data = new Map(); + const visible = new Map(); + let theme = ""; + let map: MapLibreMap | null = null; + /** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */ + let ready = false; + const applied = new Map(); + let container: HTMLDivElement | null = null; + const themeKey = (): string => opts.themeNames.map((name) => themeColor(name, "")).join(); + + const source = (key: string): SourceSpecification | null => { + const fixed = opts.fixed?.[key]; + if (fixed) return fixed; + const got = data.get(key); + if (!got) return null; + if ("raster" in got) return got.raster; + if ("url" in got) return { type: "image", url: got.url, coordinates: got.corners }; + // 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌 + 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 = opts.specs(); + const drop = (key?: string): void => { + for (const spec of specs) + if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); + }; + if (now !== theme) { + theme = now; + drop(); // 밝음 · 어두움 = 색 다시 + } + for (const key of new Set(specs.map((spec) => spec.source))) { + const got = opts.fixed?.[key] ?? data.get(key) ?? null; + if (applied.get(key) === got) continue; + applied.set(key, got); + if (map.getSource(key)) { + drop(key); + map.removeSource(key); + } + const want = source(key); + if (want) map.addSource(key, want); + } + specs.forEach(({ key, ...spec }, index) => { + if (!map!.getSource(spec.source)) return; + if (!map!.getLayer(spec.id)) { + // 늦게 온 겹도 제 차례(위 겹 밑)에 + const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id)); + map!.addLayer(spec as LayerSpecification, above?.id); + } + map!.setLayoutProperty(spec.id, "visibility", visible.get(key) ? "visible" : "none"); + }); + } + + 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; + data.set(key, next); + apply(); + }, + setVisible(key, on) { + if (visible.get(key) === on) return; + visible.set(key, on); + apply(); + }, + enable(parent) { + if (!parent) { + teardown(); + return false; + } + if (map) return true; + container = document.createElement("div"); + container.className = opts.className ?? "ui-gl-map"; + parent.prepend(container); + 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("GL 바탕 못 켬", error); + opts.onFail("브라우저가 WebGL 을 열지 못함"); + return false; + } + map.on("load", () => { + ready = true; + apply(); + }); + map.on("webglcontextlost", () => opts.onFail("그래픽 카드 문맥을 잃음")); + return true; + }, + map: () => map, + container: () => container, + camera(center, zoom, bearingDeg) { + if (!map) return; + if (ready && themeKey() !== theme) apply(); + map.jumpTo({ center, zoom, bearing: bearingDeg }); + if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음) + }, + resize: () => map?.resize(), + }; +}