diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 41b875a89..c3b59becb 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -138,6 +138,8 @@ export async function fetchPlanSurface(projectId: string): Promise; image: string; bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 3a4aa5acd..e37ea5f89 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -3,6 +3,7 @@ - `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위) - `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행 - `GET …/plan/progress` — 일의 진행률 +- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리(586-1 GL 바탕 시험) - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` · `…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` · `…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹 @@ -349,3 +350,29 @@ async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]: cadastral = None value = await asyncio.to_thread(landslide_zone.zone_layer, root, cadastral, box, epsg) return {"status": "success", **value} + + +@router.get("/{project_id}/plan/tile/{z}/{x}/{y}", response_model=None) +async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response: + """위성 조각(PLAN 586-1 GL 바탕 시험 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리.""" + import urllib.request + + from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY + + _ = project_id + if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z): + raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.") + url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg" + + def fetch() -> bytes: + request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"}) + with urllib.request.urlopen(request, timeout=8) as response: + return response.read() + + try: + tile = await asyncio.to_thread(fetch) + except Exception as error: # noqa: BLE001 — 한 조각 빔은 화면을 막을 일이 아님 + raise HTTPException(status_code=502, detail="위성 조각을 받지 못했습니다.") from error + return Response( + content=tile, media_type="image/jpeg", headers={"Cache-Control": "private, max-age=86400"} + ) diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index c5a0a3a7c..c1b783231 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -15,6 +15,8 @@ * 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선. * 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억. * 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`). + * GL 바탕 시험(586-1 · 끝 단추 · `aislo:pref:d03.gl` · 처음 = 끔) — 위성 · 급경사 · 지적 · 등고선 · 도로 · 세류를 + * MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스는 그 겹을 건너뜀(등고 글은 캔버스). * ========================================================================== */ import { showToast } from "@ui/ui_template_elements"; @@ -24,7 +26,7 @@ import { createIconButton } from "@ui/ui_template_button"; import { createMapTools } from "@ui/ui_template_map_tools"; import { themeColor } from "@ui/ui_template_palette"; import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache"; -import { writeByKey } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../A00_Common/b_page_state"; import { fetchVWorldMeta, getVWorldMapUrl, @@ -72,10 +74,12 @@ import { 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 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"; /** 등고선을 받아 올 그은 선 둘레(m). */ const CONTOUR_PAD_M = 600; type DataKey = keyof Prepared; @@ -112,6 +116,9 @@ 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); const buttons = new Map(); const groups = GROUPS.map((group) => @@ -141,6 +148,21 @@ export function createPlanLayers(opts: { upload.dataset.layer = "forestRoadUpload"; const roadGroup = groups.find((group) => group.includes(buttons.get("forestRoadFile")!))!; roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload); + const glButton = createIconButton({ + icon: "gauge", + title: + "GL 바탕 시험(위성 조각 · 급경사 · 지적 · 등고선 · 도로 · 세류를 그래픽 카드로 · 끔 = 지금 그림)", + 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); + }, + }); + glButton.dataset.layer = "gl"; + groups.push([glButton]); const tools = createMapTools({ label: "지도 겹", groups }); const bar = tools.root; bar.classList.add("d03-plan__layers"); @@ -165,6 +187,7 @@ export function createPlanLayers(opts: { button.setAttribute("aria-label", title); } upload.disabled = !meta || uploading; + for (const key of GL_KEYS) gl.setVisible(key, on[key]); const rows = meta ? legendRows(on, data) : []; legend.replaceChildren(...rows.map((row) => createLegend(row))); legend.hidden = !rows.length; @@ -280,6 +303,7 @@ export function createPlanLayers(opts: { bbox.join(","), ); data.contour = indexLayerLod(prepare(collection, ["등고수치"])); + gl.setData("contour", collection); } else if (key === "stream") { const collection = await fetchCachedSheetLayer( projectId, @@ -289,8 +313,11 @@ export function createPlanLayers(opts: { prepare(pick(collection, (props) => props["구분"] === "세류")), prepare(pick(collection, (props) => props["구분"] !== "세류")), ]; + gl.setData("stream", collection); } else if (key === "cadastral") { - data.cadastral = prepare(await fetchPlanCadastral(projectId)); + const collection = await fetchPlanCadastral(projectId); + data.cadastral = prepare(collection); + gl.setData("cadastral", collection); } else if (key === "road" || key === "forestRoad") { const collection = await fetchPlanSheetLayer(projectId, "도로중심선"); data.road = { @@ -299,6 +326,7 @@ export function createPlanLayers(opts: { pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))), ), }; + gl.setData("road", collection); } else if (key === "forestRoadFile") { const info = await fetchPlanForestRoads(projectId); data.forestRoadFile = { layer: prepare(info.collection), info }; @@ -310,6 +338,9 @@ export function createPlanLayers(opts: { } 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]] }); } else if (key === "water") { const parts = await Promise.all( WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)), @@ -397,7 +428,7 @@ export function createPlanLayers(opts: { { base: true, draw: (context, map) => { - if (!on.slope || !data.slope) return; + if (!on.slope || !data.slope || glOn) return; context.globalAlpha = 0.55; drawRaster(context, map, data.slope); }, @@ -451,7 +482,7 @@ export function createPlanLayers(opts: { { base: true, draw: (context, map) => { - if (!on.cadastral || !data.cadastral) return; + if (!on.cadastral || !data.cadastral || glOn) return; context.globalAlpha = 0.8; stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8); }, @@ -481,9 +512,13 @@ export function createPlanLayers(opts: { const contour = on.contour && data.contour; if (!contour) return; const color = themeColor("--draw-map-contour", "#ffd68c"); - context.globalAlpha = 0.7; - stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5)); - stroke(context, map, contour, color, 1.6, 25); // 계곡선 + 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"; @@ -503,7 +538,7 @@ export function createPlanLayers(opts: { { base: true, draw: (context, map) => { - if (!on.road || !data.road) return; + 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); }, @@ -530,7 +565,7 @@ export function createPlanLayers(opts: { { base: true, draw: (context, map) => { - if (!on.stream || !data.stream) return; + 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); @@ -608,6 +643,7 @@ 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); } map.update({ meta }); showSatellite(); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index f461304d4..a5b27cf7a 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -11,6 +11,7 @@ * 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음. * 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF). * TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴. + * GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림. * ========================================================================== */ import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools"; @@ -29,6 +30,7 @@ import { import { bindRouteEditNavigation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Input"; import { createMapRotation } from "../B05_Profile/B05_Profile_UI_RouteEdit_Rotate"; import { fitCanvas, screenToMetric } from "../B05_Profile/B05_Profile_UI_RouteEdit_View"; +import type { GlBase } from "./D03_PlanAnalysis_UI_MapGl"; import type { Vertex } from "./logic/D03_PlanAnalysis_Plan"; import { localTmFrame, tmParams } from "./logic/D03_PlanAnalysis_Tm"; @@ -83,6 +85,12 @@ export interface PlanMapLayer { leave?: () => void; } +/** 경위도 ↔ 그림 px(돌리기 전). */ +interface LonLatFrame { + project: (lon: number, lat: number) => [number, number]; + unproject: (px: number, py: number) => [number, number]; +} + export interface PlanMapEvents { onUndo: () => void; onRedo: () => void; @@ -107,6 +115,8 @@ export interface PlanMap { redraw: () => void; /** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */ redrawBase: () => void; + /** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */ + setGl: (gl: GlBase | null) => void; /** 맞출 점이 들어오게 다시 맞춤. */ fit: () => void; /** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */ @@ -198,6 +208,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { let layers: PlanMapLayer[] = []; let meta: VWorldMeta | null = null; let fitted = false; + let gl: GlBase | null = null; let view: ViewState = { width: 0, height: 0, @@ -212,6 +223,71 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { onChange: () => draw(), }); + /** 경위도 ↔ 그림 px(돌리기 전) — 캔버스 = 위성 직사각형에 폄. */ + const canvasLonLat = (meta: VWorldMeta): LonLatFrame => { + const normalizer = createNormalizer(meta); + const [x0, y0] = normalizedToScreen(view, 0, 0); + const [x1, y1] = normalizedToScreen(view, 1, 1); + return { + project: (lon, lat) => lonLatToScreen(normalizer, view, lon, lat), + unproject: (px, py) => [ + normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange, + normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange, + ], + }; + }; + /** 지금 바탕의 경위도 ↔ 그림 px — GL 이면 MapLibre 투영(돌림 되돌림). */ + const lonLat = (meta: VWorldMeta): LonLatFrame => { + const glMap = gl?.map(); + if (!glMap) return canvasLonLat(meta); + return { + project: (lon, lat) => { + const point = glMap.project([lon, lat]); + return rotation.unrotate(point.x, point.y); + }, + unproject: (px, py) => { + const at = glMap.unproject(rotation.rerotate(px, py)); + return [at.lng, at.lat]; + }, + }; + }; + /** GL 바탕일 때 캔버스 경위도 겹이 쓸 틀 — MapLibre 투영을 화면 가운데에서 1차로 맞춤. */ + const glView = (meta: VWorldMeta): ViewState => { + const frame = lonLat(meta); + const normalizer = createNormalizer(meta); + const [lon, lat] = frame.unproject(view.width / 2, view.height / 2); + const step = 1e-4; + const [cx, cy] = frame.project(lon, lat); + const [ex] = frame.project(lon + step, lat); + const [wx] = frame.project(lon - step, lat); + const [, ny] = frame.project(lon, lat + step); + const [, sy] = frame.project(lon, lat - step); + const ax = ((ex - wx) / (2 * step)) * normalizer.lonRange; + const ay = -((ny - sy) / (2 * step)) * normalizer.latRange; + const u = (lon - normalizer.lonMin) / normalizer.lonRange; + const v = 1 - (lat - normalizer.latMin) / normalizer.latRange; + return { + ...view, + scale: 1, + offsetX: cx - ax * u, + offsetY: cy - ay * v, + mapRect: { x: 0, y: 0, width: ax, height: ay }, + }; + }; + /** GL 카메라를 지금 화면(가운데 · 확대 · 돌림)에 맞춤. */ + function glCamera(): void { + if (!gl?.map() || !scene.meta || !view.width) return; + const frame = canvasLonLat(scene.meta); + const [x0] = normalizedToScreen(view, 0, 0); + const [x1] = normalizedToScreen(view, 1, 1); + const pxPerDegree = (x1 - x0) / (scene.meta.lon_max - scene.meta.lon_min || 1); + gl.camera( + frame.unproject(view.width / 2, view.height / 2), + Math.log2((pxPerDegree * 360) / 512), + -(rotation.radians() * 180) / Math.PI, + ); + } + /** TM ↔ 그림 px — 경위도 바탕에 맞춤(586-0) · 가짜 도엽 · 모르는 좌표계 = 옛 「같은 직사각형」 꼴. */ const metricFrame = (): Pick => { const params = tmParams(scene.epsg); @@ -223,19 +299,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { pxPerMeter: projector.pxPerMeter, }; } - const normalizer = createNormalizer(scene.meta); - const [x0, y0] = normalizedToScreen(view, 0, 0); - const [x1, y1] = normalizedToScreen(view, 1, 1); - const frame = localTmFrame( - params, - (lon, lat) => lonLatToScreen(normalizer, view, lon, lat), - (px, py) => [ - normalizer.lonMin + ((px - x0) / (x1 - x0)) * normalizer.lonRange, - normalizer.latMin + (1 - (py - y0) / (y1 - y0)) * normalizer.latRange, - ], - view.width / 2, - view.height / 2, - ); + const { project, unproject } = lonLat(scene.meta); + const frame = localTmFrame(params, project, unproject, view.width / 2, view.height / 2); return { toScreen: (point) => frame.toScreen(point[0], point[1]), toMetric: (px, py) => frame.toMetric(px, py), @@ -246,7 +311,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const screenMap = (): ScreenMap => { return { ...metricFrame(), - view, + view: gl?.map() && scene.meta ? glView(scene.meta) : view, ...marks, uprightRad: rotation.uprightRad(), upright: (ctx, x, y, paint) => { @@ -289,7 +354,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { ctx.translate(view.width / 2, view.height / 2); ctx.rotate(rotation.radians()); ctx.translate(-view.width / 2, -view.height / 2); - if (base && scene.image?.complete && scene.image.naturalWidth && scene.meta) { + if (base && !gl && scene.image?.complete && scene.image.naturalWidth && scene.meta) { const [x0, y0] = normalizedToScreen(view, 0, 0); const [x1, y1] = normalizedToScreen(view, 1, 1); ctx.drawImage(scene.image, x0, y0, x1 - x0, y1 - y0); @@ -311,6 +376,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { function flush(): void { cancelAnimationFrame(frame); frame = 0; + if (baseDirty) glCamera(); if (baseDirty) paint(baseContext, true); if (topDirty) paint(context, false); baseDirty = topDirty = false; @@ -330,6 +396,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { if (!width || !height) return; view = { ...view, width, height, mapRect: computeMapRect(meta, width, height) }; if (!fitted) refit(); + gl?.resize(); // 크기를 바꾸면 캔버스가 비므로 같은 화면에 바로 그림(빈 화면 한 번 없음) baseDirty = topDirty = true; flush(); @@ -407,6 +474,14 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { }, redraw: drawTop, redrawBase: draw, + setGl(next) { + if (next === gl) return; + gl?.enable(null); + gl = next; + gl?.enable(root); + gl?.resize(); + draw(); + }, fit() { refit(); draw(); @@ -416,12 +491,11 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { const [x, y] = screenMap().toScreen(point); return rotation.rerotate(x, y); }, - screenOfLonLat: (lon, lat) => { - if (!scene.meta) return null; - return rotation.rerotate(...lonLatToScreen(createNormalizer(scene.meta), view, lon, lat)); - }, + screenOfLonLat: (lon, lat) => + scene.meta ? rotation.rerotate(...lonLat(scene.meta).project(lon, lat)) : null, rotationDeg: () => (rotation.radians() * 180) / Math.PI, dispose: () => { + gl?.enable(null); observer.disconnect(); cancelAnimationFrame(frame); }, diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts new file mode 100644 index 000000000..5b024dbaf --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -0,0 +1,254 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_MapGl.ts + * D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로: + * 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천. + * 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침). + * 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스). + * 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠. + * ========================================================================== */ + +import { + Map as MapLibreMap, + type FilterSpecification, + type LayerSpecification, + type SourceSpecification, +} from "maplibre-gl"; +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"; + +/** GL 로 옮긴 겹. */ +export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream"; +export const GL_KEYS: readonly GlLayerKey[] = [ + "satellite", + "slope", + "cadastral", + "contour", + "road", + "stream", +]; + +/** 급경사 그림 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */ +export interface GlImage { + url: string; + corners: [[number, number], [number, number], [number, number], [number, number]]; +} + +export interface GlBase { + /** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */ + setData: ( + key: Exclude, + data: GeoJsonCollection | GlImage | null, + ) => void; + setVisible: (key: GlLayerKey, on: boolean) => void; + /** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */ + setContourStep: (step: number) => void; + /** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */ + enable: (parent: HTMLElement | null) => void; + map: () => MapLibreMap | null; + /** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */ + camera: (center: [number, number], zoom: number, bearingDeg: number) => void; + resize: () => void; +} + +const HALO = "rgba(0, 0, 0, 0.55)"; + +const LEVEL = ["to-number", ["get", "등고수치"], 0]; +/** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */ +const minorFilter = (step: number): FilterSpecification => + ["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification; + +/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `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; + return [ + { key: "satellite", id: "satellite", type: "raster", source: "satellite" }, + { + key: "slope", + id: "slope", + type: "raster", + source: "slope", + paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 }, + }, + { + key: "cadastral", + id: "cadastral", + type: "line", + source: "cadastral", + layout: round, + paint: { "line-color": parcel, "line-width": 0.8, "line-opacity": 0.8 }, + }, + { + key: "contour", + id: "contour-minor", + type: "line", + source: "contour", + filter: minorFilter(step), + layout: round, + paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 }, + }, + { + key: "contour", + id: "contour-index", + type: "line", + source: "contour", + filter: ["==", ["%", LEVEL, 25], 0], + layout: round, + paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 }, + }, + { + 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: "stream", + id: "stream", + type: "line", + source: "stream", + layout: round, + paint: { + "line-color": stream, + "line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4], + }, + }, + ] as Array; +} + +/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */ +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(); + 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: GlLayerKey): 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 }; + return { type: "geojson", data: got as never, tolerance: 0.375 }; + }; + + /** 자료 · 켬끔 · 색을 지도에 맞춤(지도가 다 서면) — 자료는 바뀐 겹만 다시 올림. */ + function apply(): void { + if (!map || !ready) return; + const now = themeKey(); + const specs = layerSpecs(step); + const drop = (key?: GlLayerKey): void => { + for (const spec of specs) + if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id); + }; + if (now !== theme) { + theme = now; + drop(); // 밝음 · 어두움 = 색 다시 + } + for (const key of GL_KEYS) { + 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(key)) 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"); + }); + } + + 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(); + }, + setContourStep(next) { + if (next === step) return; + step = next; + 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; + } + 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, + }); + map.on("load", () => { + ready = true; + apply(); + }); + }, + 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/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 84d4434f9..33ce7ca35 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -32,6 +32,13 @@ pointer-events: none; } +/* 586-1 GL 바탕 시험 — 바탕 캔버스 밑 MapLibre · 누름은 위 캔버스가 받음 */ +.d03-plan__map .d03-plan__gl { + position: absolute; + inset: 0; + pointer-events: none; +} + .d03-plan__map canvas:not(.d03-plan__base) { position: relative; } diff --git a/resources/tester/test_582_contour_lod.py b/resources/tester/test_582_contour_lod.py index a6f76ac10..311617eb5 100644 --- a/resources/tester/test_582_contour_lod.py +++ b/resources/tester/test_582_contour_lod.py @@ -75,7 +75,7 @@ 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 + assert layers.count("base: true") == layers.count("draw: (context, map)") >= 12 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_586_tm_frame.py b/resources/tester/test_586_tm_frame.py index 6f9f14d88..65ea8ed8f 100644 --- a/resources/tester/test_586_tm_frame.py +++ b/resources/tester/test_586_tm_frame.py @@ -5,6 +5,7 @@ - 모르는 좌표계(5174 Bessel) = null(지금 꼴로 그림) - 화면 TM 틀(가운데 1차 맞춤) = 정식 변환 자리와 화면 안 0.5 px 안 · 되돌림 = 1 mm 안 - 옛 「같은 직사각형」 꼴은 D 프로젝트 귀퉁이에서 수십 m 어긋남(고친 까닭) +- 586-1 위성 조각 대리 = 범위 밖 400 Node 헬퍼(`helper_586_tm_frame.cjs`)가 진짜 TS 를 돌린다. """ @@ -118,3 +119,18 @@ def test_옛_같은_직사각형_꼴은_귀퉁이에서_어긋남(): dx = ((lon - META["lon_min"]) / (META["lon_max"] - META["lon_min"]) - 1) * w dy = ((META["lat_max"] - lat) / (META["lat_max"] - META["lat_min"])) * h assert math.hypot(dx, dy) > 50 + + +def test_위성_조각_대리는_범위_밖을_막음(): + """586-1 GL 바탕 위성 조각 — z6~19 · 칸 번호 범위 밖 = 400(브이월드에 안 물음).""" + import asyncio + import uuid + + from fastapi import HTTPException + + from D03_PlanAnalysis.D03_PlanAnalysis_Router import plan_satellite_tile + + for z, x, y in ((5, 0, 0), (20, 0, 0), (14, 2**14, 0), (14, 0, -1)): + with pytest.raises(HTTPException) as error: + asyncio.run(plan_satellite_tile(uuid.uuid4(), z, x, y)) + assert error.value.status_code == 400