Files
Aislo/ui_template/ui_template_gl_map.ts
T
eomsangdonandClaude Opus 5.5 57ff92262b feat(D03 지도 겹): 받은 산림 · 환경 자료 겹 다섯 화면 — 국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태 = 서버 줌별 조각 고정 소스 · 범례 · 받는 동안 서클 (PLAN 622-2 · 요구 529)
- 공용 부품 tileSource(벡터 pbf · 그림 png) 덧 · 새 파일 UI_ForestLayers(꼴 · 소스 · 범례 · 토양 무리 색)
- 산림입지도 그림 = forest_soil 조각(z10 이상) · 브이월드 받기 · 요약 그대로 · 범례 · 옛 캔버스 색 = 같은 토양 무리 색
- 산사태위험 GL = 조각 그림(받기 없음) · 옛 캔버스 = 받은 범위 한 장 그대로(끌 때 받음)
- 새 겹 셋(국유림 임도 · 경제림 · 생태자연도) 처음 끔 · 옛 캔버스엔 안 보임 · 색 토큰 --draw-map-* 덧 · 아이콘 trees · plant 덧
- UI_Layers 688 → 680 줄(GL 글꼴 미리 받기 = UI_MapGl 로 · 브이월드 토양 GL 꼬리표 뺌)
- 지킴 시험 test_622_2_forest_layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:08:01 +09:00

238 lines
9.5 KiB
TypeScript

/* =============================================================================
* 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<K extends string = string> = LayerSpecification & { key: K; source: string };
export interface GlMap<K extends string = string> {
/** 자료 담기(지도 없어도 · 켤 때 올림) — 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,
};
}
/** 등고선 줌별 벡터 조각(PLAN 615 · 서버 공용 창고 z8~15 · 16 이상 = z15 늘려 씀) — 겹 `contour` · `h` 높이 · `k` 굵은 선. */
export function contourTileSource(projectId: string): SourceSpecification {
return {
type: "vector",
tiles: [`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/contour/{z}/{x}/{y}.pbf`],
minzoom: 8,
maxzoom: 15,
bounds: [124, 33, 132, 39],
};
}
/** 서버 줌별 조각(PLAN 622 · `…/plan/<path>/{z}/{x}/{y}.<ext>`) — pbf = 벡터(겹 이름 = 자료 이름) · png = 그림 256 px. */
export function tileSource(
projectId: string,
path: string,
ext: "pbf" | "png",
minzoom: number,
maxzoom: number,
): SourceSpecification {
const tiles = [
`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/${path}/{z}/{x}/{y}.${ext}`,
];
const bounds: [number, number, number, number] = [124, 33, 132, 39];
return ext === "pbf"
? { type: "vector", tiles, minzoom, maxzoom, bounds }
: { type: "raster", tiles, tileSize: 256, minzoom, maxzoom, bounds };
}
/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */
export function perfTime<T>(name: string, run: () => T): T {
if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run();
const start = performance.now();
try {
return run();
} finally {
performance.measure(name, { start, end: performance.now() });
}
}
export function createGlMap<K extends string>(opts: {
/** 겹 차례(아래 → 위)와 꼴 — 밝음 · 어두움이 바뀌면 다시 부름. */
specs: () => GlSpec<K>[];
/** 늘 올리는 자료(위성 조각 등). */
fixed?: Record<string, SourceSpecification>;
/** 색 지문 — 이 색 값이 바뀌면 겹을 다시 만듦. */
themeNames: readonly string[];
/** 지도 칸 클래스(없으면 `ui-gl-map` · 칸 가득 · 누름 안 받음). */
className?: string;
onFail: (why: string) => void;
}): GlMap<K> {
const data = new Map<string, GlData | null>();
const visible = new Map<K, boolean>();
let theme = "";
let map: MapLibreMap | null = null;
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
let ready = false;
const applied = new Map<string, unknown>();
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();
perfTime("gl:jumpTo", () => map!.jumpTo({ center, zoom, bearing: bearingDeg }));
if (ready) perfTime("gl:redraw", () => map!.redraw()); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
},
resize: () => map?.resize(),
};
}