feat(D03): 노선분석 GL 바탕 시험 — MapLibre 바탕 겹 여섯 · 위 캔버스 그대로 · 처음 = 끔 (PLAN 586-1 · 요구 445 B)

- 겹 단추줄 끝 [GL 바탕 시험] 켬끔(aislo:pref:d03.gl · 처음 = 지금 캔버스)
- 켬 = MapLibre(설치본 5.24 · BSD-3)가 위성 조각 · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선) · 도로 · 세류를 그림
  · 받는 GeoJSON 그대로 · 등고 글 · 그 밖 겹 · 그은 선 · 재기 · 누름 = 캔버스 그대로
- 카메라 = D03 화면(가운데 · 확대 · 돌림)을 따라감 · 같은 화면에 바로 그림 · 캔버스 겹은 MapLibre 투영으로
- 위성 조각 대리 GET …/plan/tile/{z}/{x}/{y}(브이월드 Satellite z6~19 · 키 안 샘)
- 잼(headless 실 GPU · 검증_sub2_D · 겹 여섯): 줌아웃 화면 간격 중앙 20.9 → 4.2 ms · p90 29.2 → 12.5 · 끊김 5 → 0/30
  · 끌기 20.8 → 4.2 ms · 끊김 2 → 0/40 · 그은 선 대 GL 바탕 0.02 px(돌림 30° 같음) · 재기 차 0 · 긋기 점 두 판 차 0.00001 m
- 줌인 = 위성 z19 조각으로 선명 · 급경사 그림 매끈 · 선 자리 두 판 같음
- 시험: test_586_tm_frame 조각 대리 범위 밖 400 · test_582 겹 수 낡은 셈(12 → 584 뒤 17) 고침

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 19:10:37 +09:00
co-authored by Claude Opus 5.5
parent 08864a1108
commit d2412297c7
8 changed files with 445 additions and 29 deletions
+2
View File
@@ -138,6 +138,8 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북) · 단계 색 · 넓이 비율. */
export interface PlanRaster {
box_m: [number, number, number, number];
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
box?: Array<[number, number]>;
image: string;
bands: Array<{ min_deg: number; max_deg: number; color: string; ratio: number }>;
}
@@ -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"}
)
+45 -9
View File
@@ -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<PlanLayerKey, HTMLButtonElement>();
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<GeoJsonCollection>(
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();
+93 -19
View File
@@ -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<ScreenMap, "toScreen" | "toMetric" | "pxPerMeter"> => {
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);
},
@@ -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<GlLayerKey, "satellite">,
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<LayerSpecification & { key: GlLayerKey }> {
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<LayerSpecification & { key: GlLayerKey }>;
}
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
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<GlLayerKey, GeoJsonCollection | GlImage | null>();
const visible = new Map<GlLayerKey, boolean>();
let step = 5;
let theme = "";
let map: MapLibreMap | null = null;
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
let ready = false;
const applied = new Map<GlLayerKey, unknown>();
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(),
};
}
@@ -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;
}
+1 -1
View File
@@ -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
+16
View File
@@ -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