- GL 겹 스물: 위성 조각 · 음영 · 급경사 · 산사태위험(image) · 산림입지 · 보호구역 · 띠 · 물 면 · 건물(fill + 테두리) · 지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지(line · 점선 px 그대로) · 산사태취약 점 · 지명 점(circle) — 색 · 굵기 · 투명도 · 차례 = 옛 캔버스 그대로 - 음영 = 위성 위 「곱하기 0.75」와 같은 셈의 검은 반투명 그림(밝기 g → 투명도 0.75·(1−g)) · 위성 끔 = 그대로 - 등고 수치 · 지명 = symbol(겹치면 뺌 · 등고 글은 선 따라) · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 (꼴에 글꼴 주소 없음 = 글꼴 조각 파일 없음 · 받기 전에 그 글자 글꼴 미리 받음) - 카메라 주인 = D03 화면 view · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 · 맞춤 · 단축키 모두 draw() → glCamera - 처음 = GL(aislo:pref:d03.canvas = "1" 이면 옛 캔버스) · 겹 단추줄 끝 단추로 바꿈 · WebGL 못 열거나 잃음 = 저절로 옛 캔버스 + 알림 - GL 이면 캔버스 바탕 겹은 쉼(등고 주곡선 간격 셈만) · GL 이면 위성 한 장 그림 안 받음 - 산사태위험 그림도 경위도 귀퉁이(lonlat_corners 한 곳) · 묶음 꼴 표시 없는 합친 자료도 GL 이 받게 다시 쌈 - 캔버스 그리기 목록 = 새 D03_PlanAnalysis_UI_LayerPaint.ts(700줄 규칙) · 검증 창구 layers().gl = GL 겹별 그린 지물 수 - 잼(headless Edge 실 GPU · 검증_sub2_D · 겹 18 켬 = 자료 있는 겹 전부): 줌아웃 화면 간격 중앙 58.4 → 4.2 ms · 끊김 27 → 0/30 · 끌기 62.5 → 4.2 ms · 끊김 37 → 0/40 · 마우스 4.2 ms 끊김 0 그대로 · 그은 선 대 GL 0.019 px(돌림 30° 0.019 · 확대 0.077) - 시험: 캔버스 그리기를 옮긴 자리로 지킴 시험 넷 고침(581-2 · 582 · 584-1 · 584-3 — 그리기 차례 · 색 값이 LayerPaint 로) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
16 KiB
TypeScript
472 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* D03_PlanAnalysis_UI_MapGl.ts
|
|
* D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로:
|
|
* 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) ·
|
|
* 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 ·
|
|
* 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) ·
|
|
* 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 —
|
|
* 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음).
|
|
* 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침).
|
|
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
|
|
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
|
|
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
|
|
* ========================================================================== */
|
|
|
|
import {
|
|
Map as MapLibreMap,
|
|
type ExpressionSpecification,
|
|
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";
|
|
import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
|
|
|
|
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
|
|
export type GlSource = Exclude<PlanLayerKey, "satellite" | "forestRoad">;
|
|
|
|
/** 그림 겹 — 주소 + 네 귀퉁이 경위도(왼쪽 위 · 오른쪽 위 · 오른쪽 아래 · 왼쪽 아래). */
|
|
export interface GlImage {
|
|
url: string;
|
|
corners: [[number, number], [number, number], [number, number], [number, number]];
|
|
}
|
|
|
|
/** 글꼴 — 화면 `@font-face`(ui_template/fonts · OFL) 이름. 미리 받아 둬야 첫 글자가 대체 글꼴로 안 굳음. */
|
|
export const GL_FONT = "Noto Sans KR";
|
|
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
|
|
|
|
export interface GlBase {
|
|
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
|
|
setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void;
|
|
setVisible: (key: PlanLayerKey, on: boolean) => void;
|
|
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
|
|
setContourStep: (step: number) => void;
|
|
/** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */
|
|
enable: (parent: HTMLElement | null) => boolean;
|
|
map: () => MapLibreMap | null;
|
|
/** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */
|
|
camera: (center: [number, number], zoom: number, bearingDeg: number) => void;
|
|
resize: () => void;
|
|
}
|
|
|
|
type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" };
|
|
|
|
const HALO = "rgba(0, 0, 0, 0.55)";
|
|
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
|
|
const COLOR: ExpressionSpecification = ["get", "_color"];
|
|
const LEVEL: ExpressionSpecification = ["to-number", ["get", "등고수치"], 0];
|
|
/** 주곡선 = 간격 배수(계곡선도 — 캔버스처럼 계곡선은 두 번 겹쳐 짙음). */
|
|
const minorFilter = (step: number): FilterSpecification =>
|
|
["==", ["%", LEVEL, step], 0] as unknown as FilterSpecification;
|
|
const INDEX = ["==", ["%", LEVEL, 25], 0] as unknown as FilterSpecification;
|
|
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
|
|
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
|
|
|
|
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */
|
|
function haloLine(
|
|
key: PlanLayerKey,
|
|
source: GlSource,
|
|
color: string,
|
|
width: number,
|
|
pattern: number[] = [],
|
|
halo = { color: HALO, width: width + 1.6 },
|
|
filter?: FilterSpecification,
|
|
): GlSpec[] {
|
|
const line = (id: string, paint: Record<string, unknown>, w: number): GlSpec =>
|
|
({
|
|
key,
|
|
id,
|
|
type: "line",
|
|
source,
|
|
...(filter ? { filter } : {}),
|
|
layout: ROUND,
|
|
paint: {
|
|
...paint,
|
|
"line-width": w,
|
|
...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}),
|
|
},
|
|
}) as GlSpec;
|
|
return [
|
|
line(`${key}-halo`, { "line-color": halo.color }, halo.width),
|
|
line(key, { "line-color": color }, width),
|
|
];
|
|
}
|
|
|
|
/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */
|
|
function fillEdge(
|
|
key: PlanLayerKey,
|
|
source: GlSource,
|
|
fill: string | ExpressionSpecification,
|
|
opacity: number | ExpressionSpecification,
|
|
edge: { color: string | ExpressionSpecification; width: number; dash?: number[] },
|
|
): GlSpec[] {
|
|
return [
|
|
{
|
|
key,
|
|
id: key,
|
|
type: "fill",
|
|
source,
|
|
paint: { "fill-color": fill, "fill-opacity": opacity },
|
|
} as GlSpec,
|
|
{
|
|
key,
|
|
id: `${key}-edge`,
|
|
type: "line",
|
|
source,
|
|
layout: ROUND,
|
|
paint: {
|
|
"line-color": edge.color,
|
|
"line-width": edge.width,
|
|
"line-opacity": 0.9,
|
|
...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}),
|
|
},
|
|
} as GlSpec,
|
|
];
|
|
}
|
|
|
|
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */
|
|
function layerSpecs(step: number): GlSpec[] {
|
|
const contour = themeColor("--draw-map-contour", "#ffd68c");
|
|
const place = themeColor("--draw-map-place", "#f8fafc");
|
|
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
|
|
const raster = (key: GlSource | "satellite", opacity: number): GlSpec =>
|
|
({
|
|
key,
|
|
id: key,
|
|
type: "raster",
|
|
source: key,
|
|
paint: { "raster-opacity": opacity, "raster-fade-duration": 0 },
|
|
}) as GlSpec;
|
|
const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) =>
|
|
haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern);
|
|
return [
|
|
raster("satellite", 1),
|
|
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
|
|
raster("slope", 0.55),
|
|
raster("landslide", 0.6),
|
|
...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }),
|
|
...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }),
|
|
...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], {
|
|
color: COLOR,
|
|
width: 1.2,
|
|
dash: [6, 4],
|
|
}),
|
|
{
|
|
key: "cadastral",
|
|
id: "cadastral",
|
|
type: "line",
|
|
source: "cadastral",
|
|
layout: ROUND,
|
|
paint: {
|
|
"line-color": themeColor("--draw-map-parcel", "#f0abfc"),
|
|
"line-width": 0.8,
|
|
"line-opacity": 0.8,
|
|
},
|
|
} as GlSpec,
|
|
...((): GlSpec[] => {
|
|
const water = themeColor("--draw-map-water", "#0ea5e9");
|
|
return fillEdge("water", "water", water, 0.45, { color: water, width: 1 });
|
|
})(),
|
|
...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, {
|
|
color: HALO,
|
|
width: 0.6,
|
|
}),
|
|
{
|
|
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 },
|
|
} as GlSpec,
|
|
{
|
|
key: "contour",
|
|
id: "contour-index",
|
|
type: "line",
|
|
source: "contour",
|
|
filter: INDEX,
|
|
layout: ROUND,
|
|
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
|
|
} as GlSpec,
|
|
{
|
|
key: "contour",
|
|
id: "contour-label",
|
|
type: "symbol",
|
|
source: "contour",
|
|
filter: INDEX,
|
|
layout: {
|
|
"symbol-placement": "line",
|
|
"symbol-spacing": 320,
|
|
"text-field": ["to-string", LEVEL],
|
|
"text-font": [GL_FONT],
|
|
"text-size": 10,
|
|
"text-max-angle": 30,
|
|
"text-padding": 2,
|
|
},
|
|
paint: {
|
|
"text-color": contour,
|
|
"text-halo-color": "rgba(0, 0, 0, 0.6)",
|
|
"text-halo-width": 1.5,
|
|
},
|
|
} as GlSpec,
|
|
...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], {
|
|
color: HALO,
|
|
width: 3.2,
|
|
}),
|
|
...haloLine(
|
|
"forestRoad",
|
|
"road",
|
|
themeColor("--draw-map-forest-road", "#f59e0b"),
|
|
2,
|
|
[7, 4],
|
|
{ color: HALO, width: 3.6 },
|
|
[
|
|
"in",
|
|
["get", "도로구분"],
|
|
["literal", ["소로", "미분류"]],
|
|
] as unknown as FilterSpecification,
|
|
),
|
|
...haloLine(
|
|
"forestRoadFile",
|
|
"forestRoadFile",
|
|
themeColor("--draw-map-forest-road-file", "#ea580c"),
|
|
2.4,
|
|
[],
|
|
{ color: HALO, width: 4 },
|
|
),
|
|
{
|
|
key: "stream",
|
|
id: "stream",
|
|
type: "line",
|
|
source: "stream",
|
|
layout: ROUND,
|
|
paint: {
|
|
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
|
|
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
|
|
},
|
|
} as GlSpec,
|
|
...lineKind("faults", 2.4, []),
|
|
...lineKind("lineaments", 1.8, [8, 4]),
|
|
...lineKind("zeroOrder", 1.8, [4, 3]),
|
|
// 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리
|
|
...haloLine(
|
|
"landslideZones",
|
|
"landslideZones",
|
|
landslideZone,
|
|
1.8,
|
|
[6, 3],
|
|
{ color: "rgba(255, 255, 255, 0.7)", width: 3.2 },
|
|
["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification,
|
|
),
|
|
{
|
|
key: "landslideZones",
|
|
id: "landslideZones-point",
|
|
type: "circle",
|
|
source: "landslideZones",
|
|
filter: ["==", ["geometry-type"], "Point"],
|
|
paint: {
|
|
"circle-radius": 5,
|
|
"circle-color": landslideZone,
|
|
"circle-stroke-color": "#ffffff",
|
|
"circle-stroke-width": 1.5,
|
|
},
|
|
} as GlSpec,
|
|
{
|
|
key: "place",
|
|
id: "place-dot",
|
|
type: "circle",
|
|
source: "place",
|
|
paint: { "circle-radius": 2.5, "circle-color": place },
|
|
} as GlSpec,
|
|
{
|
|
key: "place",
|
|
id: "place",
|
|
type: "symbol",
|
|
source: "place",
|
|
layout: {
|
|
"text-field": ["get", "name"],
|
|
"text-font": GL_FONT_BOLD,
|
|
"text-size": 11,
|
|
"text-anchor": "bottom",
|
|
"text-offset": [0, -0.36],
|
|
"text-padding": 2,
|
|
},
|
|
paint: {
|
|
"text-color": place,
|
|
"text-halo-color": "rgba(0, 0, 0, 0.7)",
|
|
"text-halo-width": 1.5,
|
|
},
|
|
} as GlSpec,
|
|
];
|
|
}
|
|
|
|
/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */
|
|
const LINE_COLOR = {
|
|
faults: "--draw-map-fault",
|
|
lineaments: "--draw-map-lineament",
|
|
zeroOrder: "--draw-map-zero-order",
|
|
} as const;
|
|
|
|
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
|
|
const themeKey = (): string =>
|
|
["--draw-map-contour", "--draw-map-road", "--draw-map-stream", "--draw-map-parcel"]
|
|
.map((name) => themeColor(name, ""))
|
|
.join();
|
|
|
|
const SOURCES: readonly (GlSource | "satellite")[] = [
|
|
"satellite",
|
|
"hillshade",
|
|
"slope",
|
|
"landslide",
|
|
"soils",
|
|
"zones",
|
|
"bands",
|
|
"cadastral",
|
|
"water",
|
|
"building",
|
|
"contour",
|
|
"road",
|
|
"forestRoadFile",
|
|
"stream",
|
|
"faults",
|
|
"lineaments",
|
|
"zeroOrder",
|
|
"landslideZones",
|
|
"place",
|
|
];
|
|
|
|
export function createGlBase(projectId: string, onFail: (why: string) => void): GlBase {
|
|
const data = new Map<GlSource, GeoJsonCollection | GlImage | null>();
|
|
const visible = new Map<PlanLayerKey, boolean>();
|
|
let step = 5;
|
|
let theme = "";
|
|
let map: MapLibreMap | null = null;
|
|
/** 첫 꼴이 섬(`isStyleLoaded` 는 조각 받는 동안 거짓이라 안 씀). */
|
|
let ready = false;
|
|
const applied = new Map<string, unknown>();
|
|
let container: HTMLDivElement | null = null;
|
|
|
|
const source = (key: GlSource | "satellite"): 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 };
|
|
// 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌
|
|
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 = layerSpecs(step);
|
|
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 SOURCES) {
|
|
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(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();
|
|
},
|
|
setContourStep(next) {
|
|
if (next === step) return;
|
|
step = next;
|
|
if (map?.getLayer("contour-minor")) map.setFilter("contour-minor", minorFilter(step));
|
|
},
|
|
enable(parent) {
|
|
if (!parent) {
|
|
teardown();
|
|
return false;
|
|
}
|
|
if (map) return true;
|
|
container = document.createElement("div");
|
|
container.className = "d03-plan__gl";
|
|
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("D03 GL 바탕 못 켬", error);
|
|
onFail("브라우저가 WebGL 을 열지 못함");
|
|
return false;
|
|
}
|
|
map.on("load", () => {
|
|
ready = true;
|
|
apply();
|
|
});
|
|
map.on("webglcontextlost", () => onFail("그래픽 카드 문맥을 잃음"));
|
|
return true;
|
|
},
|
|
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(),
|
|
};
|
|
}
|