Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts
T
eomsangdonandClaude Opus 5.5 d2412297c7 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>
2026-10-29 19:10:37 +09:00

255 lines
8.7 KiB
TypeScript

/* =============================================================================
* 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(),
};
}