Files
Aislo/ui_template/ui_template_gl_map.ts
T
eomsangdonandClaude Opus 5.5 21afc379e4 feat(B04·B05): 하단 지도 · 평면 노선 편집 · 배수유역도 바탕 MapLibre 적용 — 공용 GL 부품 · TM 주인 카메라 · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 589-2 · 요구 448)
- 공용 부품 ui_template_gl_map(지도 세우기 · 자료 · 켬끔 · 색 다시 · 카메라) — D03 GL 껍데기를 올림 · D03 은 겹 꼴만(바뀜 없음)
- TM 주인 따라가기(B04_PreProcess_UI_MapGl · logic/_MapGl_Frame) — 화면 가운데 1차 맞춤 닮음꼴 카메라 + 남는 늘림(웹 메르카토르 가로세로 0.43 %)은 지도 칸 CSS 행렬 · 칸 둘레 여유 8 px
- B04 하단 지도 — 위성 조각(줌인 선명) · 백지도 · 혼합 · 도엽 · 지적 · 행정 · 등고선 겹 GL · 등고 글 · 배수유역 · 계획선 · 관 = 위 캔버스
- B05 배수유역도 — 위성 · 세부유역 채움(짝홀 다각형) · 분수령 · 도엽 GL · 번호 · 상류 세류 · 관 · 강도 = 위 캔버스
- B05 평면 노선 편집 — 바탕칠 · LAS · 도엽 등고선(간격 거름 · 1 m 선) · 하천(LAS 상자 자름) GL · 노선 둘레 띠 = 지도 칸 clip-path · 고른 등고선 · 글 · 노선 · 꼭짓점 = 위 캔버스
- 켬끔 칸(GPU · 세 화면 한 취향 aislo:pref:map.canvas) · WebGL 못 씀 · 잃음 = 옛 캔버스 + 알림 · 누름 · 끌기 판정 손 안 댐
- B04 층 단추 글 = MapLayers gisLayerLabel(지도 본체 700 줄)
- 지킴 시험 test_589_2_gl_frame(카메라 대 캔버스 0.5 px · 칸 행렬 늘이기만 · 되돌림 1 mm · 상자 자르기 · 짝홀 다각형)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 21:45:41 +09:00

199 lines
7.9 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,
};
}
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();
map.jumpTo({ center, zoom, bearing: bearingDeg });
if (ready) map.redraw(); // 캔버스와 같은 화면(다음 화면으로 미루지 않음)
},
resize: () => map?.resize(),
};
}