feat(D03): 노선분석 바탕 MapLibre 전체 적용 — 겹 스물 · 등고 글 · 지명 GL · 처음 = 켬 · WebGL 못 쓰면 옛 캔버스 (PLAN 586-2 ①②③⑤ · 요구 448)

- 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>
This commit is contained in:
2026-10-29 20:28:13 +09:00
co-authored by Claude Opus 5.5
parent 99db748a7b
commit 8793b4f6b2
11 changed files with 781 additions and 364 deletions
+9 -6
View File
@@ -264,8 +264,6 @@ def slope_layer(
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
) -> dict[str, Any]:
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 단계 · 판) 같으면 저장본."""
from common_util.common_util_crs import tm_to_lonlat
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
stamp = file_stamp(npz)
if stamp is None:
@@ -286,14 +284,19 @@ def slope_layer(
half = cell / 2
box = (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half)
value["box_m"] = [float(v) for v in box]
value["box"] = [
list(tm_to_lonlat(px, py, epsg))
for px, py in ((box[0], box[1]), (box[2], box[1]), (box[2], box[3]), (box[0], box[3]))
]
value["box"] = lonlat_corners(box, epsg)
write_derived(project_root, store_kind, key, value)
return value
def lonlat_corners(box: Any, epsg: int) -> list[list[float]]:
"""TM 상자 → 경위도 네 귀퉁이(왼쪽 아래부터 돎) — GL 바탕 그림 자리(586)."""
from common_util.common_util_crs import tm_to_lonlat
x0, y0, x1, y1 = (float(v) for v in box)
return [list(tm_to_lonlat(px, py, epsg)) for px, py in ((x0, y0), (x1, y0), (x1, y1), (x0, y1))]
# ── 보호 · 규제 구역 ─────────────────────────────────────
# (열쇠, 이름, 브이월드 WFS 겹 이름) — `_분석_440-C` 표 1 그대로
+2 -1
View File
@@ -214,7 +214,8 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str,
raise HTTPException(status_code=503, detail=str(error)) from error
except RuntimeError as error:
raise HTTPException(status_code=502, detail=str(error)) from error
return {"status": "success", "kind": kind, **value}
box_lonlat = layers.lonlat_corners(value["box_m"], epsg)
return {"status": "success", "kind": kind, **value, "box": box_lonlat}
if kind not in layers.RASTER_KINDS:
raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}")
root, epsg, _ = await _layer_root(project_id)
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerDefs.ts
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기.
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 ·
* GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
* ========================================================================== */
@@ -11,11 +12,13 @@ import { readByKey } from "../A00_Common/b_page_state";
import {
drawPreparedLayer,
normalizedToScreen,
type GeoJsonCollection,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanRaster } from "./D03_PlanAnalysis_Api";
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl";
export type PlanLayerKey =
| "satellite"
@@ -268,3 +271,41 @@ export function readPref(key: string): Record<PlanLayerKey, boolean> {
}
return on;
}
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
export function glTagged(
collection: GeoJsonCollection,
color: (props: Record<string, unknown>) => string,
extra: Record<string, unknown> = {},
): GeoJsonCollection {
return {
...collection,
features: (collection.features ?? []).map((feature) => ({
...feature,
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
})),
};
}
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
const box = raster.box;
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
}
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
export function shadeImage(image: HTMLImageElement): string {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const px = pixels.data;
for (let i = 0; i < px.length; i += 4) {
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
px[i] = px[i + 1] = px[i + 2] = 0;
}
context.putImageData(pixels, 0, 0);
return canvas.toDataURL("image/png");
}
@@ -0,0 +1,258 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerPaint.ts
* D03 바탕 겹 옛 캔버스 그리기(PLAN 569-2 · 581 · 584 · 586-2) — 그리기 차례 · 색 · 굵기 · 투명도.
* GL 바탕(586-2)이 같은 꼴을 MapLibre 로 그림(`D03_PlanAnalysis_UI_MapGl`) · 이것은 GL 을 못 쓰거나 끈 때.
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 켬끔 · GL 이면 쉼).
* ========================================================================== */
import { themeColor } from "@ui/ui_template_palette";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
lonLatToScreen,
pickLevelStep,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
drawRaster,
fillLayer,
stroke,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import type { Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import type { PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
/** 바탕 겹(아래 → 위) — 켬끔 · 받은 자료 · 위성 틀 · 위성 그림(음영 곱하기)을 그때그때 읽음. */
export function createCanvasLayers(opts: {
on: Record<PlanLayerKey, boolean>;
data: Partial<Prepared>;
meta: () => VWorldMeta | null;
satellite: () => HTMLImageElement | null;
}): PlanMapLayer[] {
const { on, data } = opts;
/** 등고 글 폭 기억(글꼴 하나 · 582). */
const labelWidths = new Map<string, number>();
return [
{
base: true,
draw: (context, map) => {
if (!on.hillshade || !data.hillshade) return;
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
if (on.satellite && opts.satellite()) {
context.globalCompositeOperation = "multiply";
context.globalAlpha = 0.75;
}
drawRaster(context, map, data.hillshade);
},
},
{
base: true,
draw: (context, map) => {
if (!on.slope || !data.slope) return;
context.globalAlpha = 0.55;
drawRaster(context, map, data.slope);
},
},
{
base: true,
draw: (context, map) => {
if (!on.landslide || !data.landslide) return;
context.globalAlpha = 0.6;
drawRaster(context, map, data.landslide);
},
},
{
base: true,
draw: (context, map) => {
if (!on.soils || !data.soils) return;
for (const { layer, color } of data.soils.kinds) {
const fill = themeColor(color, "#facc15");
fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.zones || !data.zones) return;
for (const [kind, layer] of data.zones.kinds) {
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.bands || !data.bands) return;
const road = themeColor("--draw-map-band-road", "#facc15");
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
fillLayer(context, data.bands.road, map.view, road, 0.16, {
color: road,
width: 1.2,
dash: [6, 4],
});
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
color: stream,
width: 1.2,
dash: [6, 4],
});
},
},
{
base: true,
draw: (context, map) => {
if (!on.cadastral || !data.cadastral) return;
context.globalAlpha = 0.8;
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
},
},
{
base: true,
draw: (context, map) => {
if (!on.water || !data.water) return;
const color = themeColor("--draw-map-water", "#0ea5e9");
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
},
},
{
base: true,
draw: (context, map) => {
if (!on.building || !data.building) return;
const color = themeColor("--draw-map-building", "#fb7185");
fillLayer(context, data.building, map.view, color, 0.7, {
color: "rgba(0, 0, 0, 0.55)",
width: 0.6,
});
},
},
{
base: true,
draw: (context, map) => {
const contour = on.contour && data.contour;
if (!contour) return;
const color = themeColor("--draw-map-contour", "#ffd68c");
const step = pickLevelStep(contour, map.view, 5);
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";
context.textBaseline = "middle";
drawPreparedLabels(
context,
contour,
map.view,
color,
25,
map.uprightRad,
"rgba(0, 0, 0, 0.6)",
labelWidths,
);
},
},
{
base: true,
draw: (context, map) => {
if (!on.road || !data.road) 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);
},
},
{
base: true,
draw: (context, map) => {
if (!on.forestRoad || !data.road) return;
context.setLineDash([7, 4]);
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
context.setLineDash([]);
},
},
{
base: true,
draw: (context, map) => {
const got = on.forestRoadFile && data.forestRoadFile;
if (!got) return;
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4);
stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4);
},
},
{
base: true,
draw: (context, map) => {
if (!on.stream || !data.stream) 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);
},
},
...LINE_KEYS.map((key): PlanMapLayer => ({
base: true,
draw: (context, map) => {
const got = on[key] && data[key];
if (!got) return;
const { color, width, dash } = LINE_LAYERS[key];
context.setLineDash(dash);
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", width + 1.6);
stroke(context, map, got.layer, themeColor(color, "#f43f5e"), width);
context.setLineDash([]);
},
})),
{
base: true,
draw: (context, map) => {
const got = on.landslideZones && data.landslideZones;
const meta = opts.meta();
if (!got || !meta) return;
const color = themeColor("--draw-map-landslide-zone", "#e11d48");
// 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음)
context.setLineDash([6, 3]);
stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2);
stroke(context, map, got.parcels, color, 1.8);
context.setLineDash([]);
const normalizer = createNormalizer(meta);
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 1.5;
for (const point of got.info.points) {
const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat);
if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue;
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.stroke();
}
},
},
{
base: true,
draw: (context, map) => {
const meta = opts.meta();
if (!on.place || !data.place || !meta) return;
const normalizer = createNormalizer(meta);
context.font = "600 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "bottom";
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
for (const place of data.place) {
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
map.upright(context, x, y, () => {
context.beginPath();
context.arc(x, y, 2.5, 0, Math.PI * 2);
context.fill();
context.strokeText(place.name, x, y - 4);
context.fillText(place.name, x, y - 4);
});
}
},
},
];
}
+129 -250
View File
@@ -15,8 +15,9 @@
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
* GL 바탕 시험(586-1 · 끝 단추 · `aislo:pref:d03.gl` · 처음 = 끔) — 위성 · 급경사 · 지적 · 등고선 · 도로 · 세류를
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스는 그 겹을 건너뜀(등고 글은 캔버스).
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
@@ -34,8 +35,6 @@ import {
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
lonLatToScreen,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
@@ -61,25 +60,29 @@ import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
LINE_KEYS,
LINE_LAYERS,
SOIL_COLORS,
WATER_KEYS,
ZONE_COLORS,
drawRaster,
fillLayer,
glImage,
glTagged,
loadImage,
readPref,
stroke,
shadeImage,
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 { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
import { createGlBase, GL_FONT, type GlImage } 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";
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
const glFont = (weight: string, text: string): Promise<unknown> =>
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
/** 옛 캔버스로 그림("1") — 처음 = GL. */
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
type DataKey = keyof Prepared;
@@ -100,6 +103,8 @@ export interface PlanLayers {
on: Record<PlanLayerKey, boolean>;
loaded: PlanLayerKey[];
counts: Record<string, number>;
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
gl: Record<string, number>;
};
}
@@ -116,9 +121,14 @@ 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);
/** 음영 GL 그림 — 그대로 · 위성 위 그늘(「곱하기」 꼴). */
let hillshade: { plain: GlImage; shade: GlImage } | null = null;
const gl = createGlBase(projectId, (why) => {
if (!glOn) return;
showToast(`그래픽 카드 그림을 못 써 옛 그림으로 — ${why}`, "info");
useGl(false);
});
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
const groups = GROUPS.map((group) =>
@@ -150,18 +160,24 @@ export function createPlanLayers(opts: {
roadGroup.splice(roadGroup.indexOf(buttons.get("forestRoadFile")!) + 1, 0, upload);
const glButton = createIconButton({
icon: "gauge",
title:
"GL 바탕 시험(위성 조각 · 급경사 · 지적 · 등고선 · 도로 · 세류를 그래픽 카드로 · 끔 = 지금 그림)",
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
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);
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
useGl(!glOn);
},
});
glButton.dataset.layer = "gl";
/** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */
function useGl(want: boolean): void {
glOn = want && map.setGl(gl);
if (!glOn) map.setGl(null);
glButton.setAttribute("aria-pressed", String(glOn));
void ensure("satellite");
showSatellite();
map.redrawBase();
}
groups.push([glButton]);
const tools = createMapTools({ label: "지도 겹", groups });
const bar = tools.root;
@@ -174,8 +190,9 @@ export function createPlanLayers(opts: {
legend.className = "d03-plan__legend";
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
/** 받음 — 위성은 GL 이면 조각(한 장 그림 안 받음). */
const loadedOf = (key: PlanLayerKey): boolean =>
key === "satellite" ? !!satellite : !!data[dataKey(key)];
key === "satellite" ? glOn || !!satellite : !!data[dataKey(key)];
function sync(): void {
for (const def of LAYERS) {
@@ -187,13 +204,15 @@ export function createPlanLayers(opts: {
button.setAttribute("aria-label", title);
}
upload.disabled = !meta || uploading;
for (const key of GL_KEYS) gl.setVisible(key, on[key]);
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
const rows = meta ? legendRows(on, data) : [];
legend.replaceChildren(...rows.map((row) => createLegend(row)));
legend.hidden = !rows.length;
}
sync();
useGl(glOn);
function toggle(key: PlanLayerKey): void {
on[key] = !on[key];
@@ -246,6 +265,7 @@ export function createPlanLayers(opts: {
layer: prepareLayer(info.collection, createNormalizer(at)),
info,
};
gl.setData("forestRoadFile", info.collection);
on.forestRoadFile = true;
writeByKey(PREF_KEY, JSON.stringify(on));
showToast(
@@ -283,6 +303,7 @@ export function createPlanLayers(opts: {
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
});
if (key === "satellite") {
if (glOn) return; // GL = 조각
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
@@ -296,12 +317,10 @@ export function createPlanLayers(opts: {
Math.max(...xs) + CONTOUR_PAD_M,
Math.max(...ys) + CONTOUR_PAD_M,
].map((v) => v.toFixed(0));
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
"도엽_등고선",
"route",
bbox.join(","),
);
const [collection] = await Promise.all([
fetchCachedSheetLayer<GeoJsonCollection>(projectId, "도엽_등고선", "route", bbox.join(",")),
glFont("400", "0123456789-"),
]);
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
gl.setData("contour", collection);
} else if (key === "stream") {
@@ -330,30 +349,52 @@ export function createPlanLayers(opts: {
} else if (key === "forestRoadFile") {
const info = await fetchPlanForestRoads(projectId);
data.forestRoadFile = { layer: prepare(info.collection), info };
gl.setData("forestRoadFile", info.collection);
} else if (key === "landslideZones") {
const info = await fetchPlanLandslideZones(projectId);
data.landslideZones = { parcels: prepare(info.parcels), info };
gl.setData("landslideZones", {
features: [
...(info.parcels.features ?? []),
...info.points.map((point) => ({
type: "Feature",
properties: {},
geometry: { type: "Point", coordinates: [point.lon, point.lat] },
})),
],
} as GeoJsonCollection);
if (info.missing.length)
showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info");
} 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]] });
const image = await loadImage(raster.image);
data[key] = { raster, image };
const plain = glImage(raster);
if (key !== "hillshade") gl.setData(key, plain);
else if (plain) hillshade = { plain, shade: glImage(raster, shadeImage(image))! };
} else if (key === "water") {
const parts = await Promise.all(
WATER_KEYS.map((name) => fetchPlanSheetLayer(projectId, name)),
);
data.water = prepare({ features: parts.flatMap((part) => part.features ?? []) });
const water = { features: parts.flatMap((part) => part.features ?? []) };
data.water = prepare(water);
gl.setData("water", water);
} else if (key === "building") {
data.building = prepare(await fetchPlanSheetLayer(projectId, "건물"));
const building = await fetchPlanSheetLayer(projectId, "건물");
data.building = prepare(building);
gl.setData("building", building);
} else if (key === "zones") {
const zones = await fetchPlanZones(projectId);
const kinds = new Map<string, PreparedLayer>();
for (const kind of Object.keys(zones.summary))
kinds.set(kind, prepare(pick(zones, (props) => props["kind"] === kind)));
data.zones = { kinds, summary: zones.summary };
gl.setData(
"zones",
glTagged(zones, (props) =>
themeColor(ZONE_COLORS[String(props["kind"])] ?? "--draw-warning", "#eab308"),
),
);
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
if (failed.length)
showToast(
@@ -369,6 +410,15 @@ export function createPlanLayers(opts: {
})),
summary: soils.summary,
};
const colorOf = new Map(
soils.summary.map((soil, i) => [soil.code, data.soils!.kinds[i].color]),
);
gl.setData(
"soils",
glTagged(soils, (props) =>
themeColor(colorOf.get(props["code"] as never) ?? "", "#facc15"),
),
);
} else if (key === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
@@ -388,6 +438,7 @@ export function createPlanLayers(opts: {
count: got.count,
length_m: got.length_m,
};
gl.setData(key, got.collection);
} finally {
window.clearTimeout(slow);
}
@@ -398,6 +449,16 @@ export function createPlanLayers(opts: {
road: prepare(info.road.collection),
info,
};
const band = (name: "stream" | "road", fallback: string): GeoJsonCollection =>
glTagged(info[name].collection, () => themeColor(`--draw-map-band-${name}`, fallback), {
_band: name,
});
gl.setData("bands", {
features: [
...(band("road", "#facc15").features ?? []),
...(band("stream", "#22d3ee").features ?? []),
],
});
} else {
const collection = await fetchPlanSheetLayer(projectId, "지명");
data.place = (collection.features ?? []).flatMap((feature) => {
@@ -407,229 +468,35 @@ export function createPlanLayers(opts: {
const [lon, lat] = geometry.coordinates as number[];
return [{ lon, lat, name }];
});
await glFont("600", data.place.map((place) => place.name).join(""));
gl.setData("place", {
features: data.place.map(({ lon, lat, name }) => ({
type: "Feature",
properties: { name },
geometry: { type: "Point", coordinates: [lon, lat] },
})),
} as GeoJsonCollection);
}
}
/** 등고 글 폭 기억(글꼴 하나 · 582). */
const labelWidths = new Map<string, number>();
const canvasLayers = createCanvasLayers({
on,
data,
meta: () => meta,
satellite: () => satellite,
});
/** GL 이면 캔버스 바탕 겹은 쉼 — 등고 주곡선 간격(화면 안 가닥 수)만 셈. */
const layers: PlanMapLayer[] = [
{
base: true,
draw: (context, map) => {
if (!on.hillshade || !data.hillshade) return;
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
if (on.satellite && satellite) {
context.globalCompositeOperation = "multiply";
context.globalAlpha = 0.75;
}
drawRaster(context, map, data.hillshade);
},
},
{
base: true,
draw: (context, map) => {
if (!on.slope || !data.slope || glOn) return;
context.globalAlpha = 0.55;
drawRaster(context, map, data.slope);
},
},
{
base: true,
draw: (context, map) => {
if (!on.landslide || !data.landslide) return;
context.globalAlpha = 0.6;
drawRaster(context, map, data.landslide);
},
},
{
base: true,
draw: (context, map) => {
if (!on.soils || !data.soils) return;
for (const { layer, color } of data.soils.kinds) {
const fill = themeColor(color, "#facc15");
fillLayer(context, layer, map.view, fill, 0.35, { color: fill, width: 1.4 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.zones || !data.zones) return;
for (const [kind, layer] of data.zones.kinds) {
const color = themeColor(ZONE_COLORS[kind] ?? "--draw-warning", "#eab308");
fillLayer(context, layer, map.view, color, 0.22, { color, width: 1.6 });
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.bands || !data.bands) return;
const road = themeColor("--draw-map-band-road", "#facc15");
const stream = themeColor("--draw-map-band-stream", "#22d3ee");
fillLayer(context, data.bands.road, map.view, road, 0.16, {
color: road,
width: 1.2,
dash: [6, 4],
});
fillLayer(context, data.bands.stream, map.view, stream, 0.18, {
color: stream,
width: 1.2,
dash: [6, 4],
});
},
},
{
base: true,
draw: (context, map) => {
if (!on.cadastral || !data.cadastral || glOn) return;
context.globalAlpha = 0.8;
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
},
},
{
base: true,
draw: (context, map) => {
if (!on.water || !data.water) return;
const color = themeColor("--draw-map-water", "#0ea5e9");
fillLayer(context, data.water, map.view, color, 0.45, { color, width: 1 });
},
},
{
base: true,
draw: (context, map) => {
if (!on.building || !data.building) return;
const color = themeColor("--draw-map-building", "#fb7185");
fillLayer(context, data.building, map.view, color, 0.7, {
color: "rgba(0, 0, 0, 0.55)",
width: 0.6,
});
},
},
{
base: true,
draw: (context, map) => {
const contour = on.contour && data.contour;
if (!contour) return;
const color = themeColor("--draw-map-contour", "#ffd68c");
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";
context.textBaseline = "middle";
drawPreparedLabels(
context,
contour,
map.view,
color,
25,
map.uprightRad,
"rgba(0, 0, 0, 0.6)",
labelWidths,
);
},
},
{
base: true,
draw: (context, map) => {
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);
},
},
{
base: true,
draw: (context, map) => {
if (!on.forestRoad || !data.road) return;
context.setLineDash([7, 4]);
stroke(context, map, data.road.small, "rgba(0, 0, 0, 0.55)", 3.6);
stroke(context, map, data.road.small, themeColor("--draw-map-forest-road", "#f59e0b"), 2);
context.setLineDash([]);
},
},
{
base: true,
draw: (context, map) => {
const got = on.forestRoadFile && data.forestRoadFile;
if (!got) return;
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", 4);
stroke(context, map, got.layer, themeColor("--draw-map-forest-road-file", "#ea580c"), 2.4);
},
},
{
base: true,
draw: (context, map) => {
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);
},
},
...LINE_KEYS.map((key): PlanMapLayer => ({
base: true,
draw: (context, map) => {
const got = on[key] && data[key];
if (!got) return;
const { color, width, dash } = LINE_LAYERS[key];
context.setLineDash(dash);
stroke(context, map, got.layer, "rgba(0, 0, 0, 0.55)", width + 1.6);
stroke(context, map, got.layer, themeColor(color, "#f43f5e"), width);
context.setLineDash([]);
},
...canvasLayers.map((layer): PlanMapLayer => ({
...layer,
draw: (c, m) => void (glOn || layer.draw(c, m)),
})),
{
base: true,
draw: (context, map) => {
const got = on.landslideZones && data.landslideZones;
if (!got || !meta) return;
const color = themeColor("--draw-map-landslide-zone", "#e11d48");
// 필지 = 「이 필지 안 어딘가」 테두리만(지정 넓이 ≪ 필지 · 칠하지 않음)
context.setLineDash([6, 3]);
stroke(context, map, got.parcels, "rgba(255, 255, 255, 0.7)", 3.2);
stroke(context, map, got.parcels, color, 1.8);
context.setLineDash([]);
const normalizer = createNormalizer(meta);
context.fillStyle = color;
context.strokeStyle = "#ffffff";
context.lineWidth = 1.5;
for (const point of got.info.points) {
const [x, y] = lonLatToScreen(normalizer, map.view, point.lon, point.lat);
if (x < -10 || y < -10 || x > map.view.width + 10 || y > map.view.height + 10) continue;
context.beginPath();
context.arc(x, y, 5, 0, Math.PI * 2);
context.fill();
context.stroke();
}
},
},
{
base: true,
draw: (context, map) => {
if (!on.place || !data.place || !meta) return;
const normalizer = createNormalizer(meta);
context.font = "600 11px system-ui, sans-serif";
context.textAlign = "center";
context.textBaseline = "bottom";
context.lineWidth = 3;
context.strokeStyle = "rgba(0, 0, 0, 0.7)";
context.fillStyle = themeColor("--draw-map-place", "#f8fafc");
for (const place of data.place) {
const [x, y] = lonLatToScreen(normalizer, map.view, place.lon, place.lat);
if (x < -60 || y < -20 || x > map.view.width + 60 || y > map.view.height + 20) continue;
map.upright(context, x, y, () => {
context.beginPath();
context.arc(x, y, 2.5, 0, Math.PI * 2);
context.fill();
context.strokeText(place.name, x, y - 4);
context.fillText(place.name, x, y - 4);
});
}
draw: (_, m) => {
if (glOn && on.contour && data.contour)
gl.setContourStep(pickLevelStep(data.contour, m.view, 5));
},
},
];
@@ -643,7 +510,9 @@ 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);
hillshade = null;
for (const def of LAYERS)
if (def.key !== "satellite" && def.key !== "forestRoad") gl.setData(def.key, null);
}
map.update({ meta });
showSatellite();
@@ -660,6 +529,16 @@ export function createPlanLayers(opts: {
on: { ...on },
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
counts: layerCounts(data),
gl: Object.fromEntries(
(gl.map()?.getStyle().layers ?? [])
.filter((layer) => layer.layout?.visibility !== "none")
.map((layer) => [
layer.id,
layer.type === "raster"
? 1
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
}),
};
}
+9 -6
View File
@@ -11,7 +11,9 @@
* 캔버스 두 장(582) — 아래 = 위성 · 바탕 겹(`base`) · 위 = 그은 선 · 재기 · 누름 받음.
* 마우스 움직임 = 위만 다시 그림 · 화면이 바뀌면(옮김 · 확대 · 돌림 · 겹) 둘 다 · 한 화면 한 번(rAF).
* TM 겹(그은 선 · 재기 · 그림 겹)은 경위도로 바꿔 바탕과 같은 자리(586-0 · `localTmFrame`) — 좌표계를 모르면 옛 꼴.
* GL 바탕 시험(586-1 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* GL 바탕(586 · `setGl`) — 맨 아래 MapLibre 가 이 화면을 따라감 · 캔버스 겹은 그 투영으로 그림.
* 카메라 주인 = 이 화면 `view` · 돌림 하나 — 휠 · 가운데 끌기 · 돌림 단추 · 맞춤 · 앞으로 붙을 팬 단추 · 단축키는
* 모두 `view` 를 바꾸고 `draw()` → 한 화면 한 번 `glCamera` 로 MapLibre 를 맞춘 뒤 캔버스를 그림.
* ========================================================================== */
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
@@ -127,8 +129,8 @@ export interface PlanMap {
redraw: () => void;
/** 바탕 겹까지 다시 그림(겹 켬끔 · 받음). */
redrawBase: () => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에) · null = 끔(지금 캔버스). */
setGl: (gl: GlBase | null) => void;
/** GL 바탕 켬(MapLibre 를 맨 아래에 · 참 = 섬 · 거짓 = WebGL 못 씀) · null = 끔(옛 캔버스). */
setGl: (gl: GlBase | null) => boolean;
/** 맞출 점이 들어오게 다시 맞춤. */
fit: () => void;
/** 검증 창구 — 작업 좌표계 점 → 화면에 보이는 px(돌린 뒤 · 그리기 전이면 null). */
@@ -526,12 +528,13 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
redraw: drawTop,
redrawBase: draw,
setGl(next) {
if (next === gl) return;
if (next === gl) return !!gl;
gl?.enable(null);
gl = next;
gl?.enable(root);
gl = null;
if (next?.enable(root)) gl = next;
gl?.resize();
draw();
return !!gl;
},
fit() {
refit();
+310 -93
View File
@@ -1,14 +1,20 @@
/* =============================================================================
* D03_PlanAnalysis_UI_MapGl.ts
* D03 GL 바탕 시험(PLAN 586-1 · 요구 445 B) — 바탕 겹 여섯을 MapLibre(WebGL · BSD-3)로:
* 위성 조각(서버 대리 z6~19) · 급경사 그림 · 지적 · 등고선(주곡선 · 계곡선 25 m) · 도로 · 세류 · 하천.
* 그 밖 겹 · 등고 글 · 그은 선 · 재기 · 누름 = 지금 캔버스 그대로(위에 겹침).
* 카메라 = D03 화면(`ViewState` · 돌림)을 따라감 · 조작은 안 받음(누름은 위 캔버스).
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 지도가 없어도 자료는 담아 둠.
* 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,
@@ -17,138 +23,334 @@ 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";
/** GL 로 옮긴 겹. */
export type GlLayerKey = "satellite" | "slope" | "cadastral" | "contour" | "road" | "stream";
export const GL_KEYS: readonly GlLayerKey[] = [
"satellite",
"slope",
"cadastral",
"contour",
"road",
"stream",
];
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
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: (
key: Exclude<GlLayerKey, "satellite">,
data: GeoJsonCollection | GlImage | null,
) => void;
setVisible: (key: GlLayerKey, on: boolean) => void;
setData: (source: GlSource, data: GeoJsonCollection | GlImage | null) => void;
setVisible: (key: PlanLayerKey, on: boolean) => void;
/** 등고선 주곡선 간격(m) — 캔버스와 같은 「화면 안 가닥 수」 규칙. */
setContourStep: (step: number) => void;
/** 켬 = 지도를 `parent` 맨 앞에 만듦 · 끔 = 버림. */
enable: (parent: HTMLElement | null) => void;
/** 켬 = 지도를 `parent` 맨 앞에 만듦(참 = 섬 · 거짓 = WebGL 못 씀) · null = 버림. */
enable: (parent: HTMLElement | null) => boolean;
map: () => MapLibreMap | null;
/** 화면을 따라감(경위도 가운데 · 줌 · 돌림 도) — 같은 화면에 바로 그림. */
camera: (center: [number, number], zoom: number, bearingDeg: number) => void;
resize: () => void;
}
const HALO = "rgba(0, 0, 0, 0.55)";
type GlSpec = LayerSpecification & { key: PlanLayerKey; source: GlSource | "satellite" };
const LEVEL = ["to-number", ["get", "등고수치"], 0];
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);
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `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;
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 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: "satellite", id: "satellite", type: "raster", source: "satellite" },
{
key: "slope",
id: "slope",
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: "slope",
paint: { "raster-opacity": 0.55, "raster-fade-duration": 0 },
},
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": parcel, "line-width": 0.8, "line-opacity": 0.8 },
},
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,
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: ["==", ["%", LEVEL, 25], 0],
layout: round,
filter: INDEX,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
},
} as GlSpec,
{
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: "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,
layout: ROUND,
paint: {
"line-color": stream,
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
},
},
] as Array<LayerSpecification & { key: GlLayerKey }>;
} 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();
export function createGlBase(projectId: string): GlBase {
const data = new Map<GlLayerKey, GeoJsonCollection | GlImage | null>();
const visible = new Map<GlLayerKey, boolean>();
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<GlLayerKey, unknown>();
const applied = new Map<string, unknown>();
let container: HTMLDivElement | null = null;
const source = (key: GlLayerKey): SourceSpecification | null => {
const source = (key: GlSource | "satellite"): SourceSpecification | null => {
if (key === "satellite")
return {
type: "raster",
@@ -160,23 +362,25 @@ export function createGlBase(projectId: string): GlBase {
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 };
// 묶음 꼴 표시가 없는 자료(여러 겹을 합친 것 등)도 받게 다시 싸 줌
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?: GlLayerKey): void => {
const drop = (key?: string): void => {
for (const spec of specs)
if ((!key || spec.key === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
if ((!key || spec.source === key) && map!.getLayer(spec.id)) map!.removeLayer(spec.id);
};
if (now !== theme) {
theme = now;
drop(); // 밝음 · 어두움 = 색 다시
}
for (const key of GL_KEYS) {
for (const key of SOURCES) {
const got = key === "satellite" ? key : (data.get(key) ?? null);
if (applied.get(key) === got) continue;
applied.set(key, got);
@@ -188,7 +392,7 @@ export function createGlBase(projectId: string): GlBase {
if (want) map.addSource(key, want);
}
specs.forEach(({ key, ...spec }, index) => {
if (!map!.getSource(key)) return;
if (!map!.getSource(spec.source)) return;
if (!map!.getLayer(spec.id)) {
// 늦게 온 겹도 제 차례(위 겹 밑)에
const above = specs.slice(index + 1).find((next) => map!.getLayer(next.id));
@@ -198,6 +402,15 @@ export function createGlBase(projectId: string): GlBase {
});
}
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;
@@ -215,32 +428,36 @@ export function createGlBase(projectId: string): GlBase {
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;
teardown();
return false;
}
if (map) return true;
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,
});
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) {
+3 -1
View File
@@ -92,6 +92,8 @@ def test_screen_def_and_colors() -> None:
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M)
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7))
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2
assert "if (!on.soils || !data.soils) return;" in page
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)
+4 -1
View File
@@ -75,7 +75,10 @@ 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 # 겹 모두 바탕(581 · 584 로 늘어남)
paint = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerPaint.ts") # 586-2 캔버스 그리기 따로
assert (
paint.count("base: true") == paint.count("draw: (context, map)") >= 12
) # 겹 모두 바탕(581 · 584 로 늘어남)
plan_map = _read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
assert "requestAnimationFrame(flush)" in plan_map
assert "drawTop();" in plan_map
@@ -230,5 +230,7 @@ def test_screen_defs_icons_colors() -> None:
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in defs
for kind in layers.LINE_KINDS:
assert f'kind: "{kind}"' in defs # 서버 열쇠와 같음
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
page = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕(582)
@@ -242,7 +242,10 @@ def test_screen_defs() -> None:
defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8")
paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
"utf-8"
) # 586-2 캔버스 그리기 따로
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint
legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8")
for key, color in (
("forestRoadFile", "forest-road-file"),
@@ -254,8 +257,13 @@ def test_screen_defs() -> None:
assert f"--draw-map-{color}:" in theme and f'"--draw-map-{color}"' in page
assert re.search(r"^\s+upload:", icons, re.M) and "pickFiles(" in page # 부품 상자 고르기 창
assert "<input" not in page and 'createElement("input")' not in page
assert page.count("base: true,") == page.count("draw: (context, map) =>") # 겹 모두 바탕
assert paint.count("base: true,") == paint.count("draw: (context, map) =>") # 겹 모두 바탕
assert "임도 짐작" in legend and "올린 임도망도" in legend # 범례 구분
assert "이 시도 자료 없음" in legend
for name in ("D03_PlanAnalysis_UI_Layers.ts", "D03_PlanAnalysis_UI_LayerLegend.ts"):
for name in (
"D03_PlanAnalysis_UI_Layers.ts",
"D03_PlanAnalysis_UI_LayerLegend.ts",
"D03_PlanAnalysis_UI_LayerPaint.ts",
"D03_PlanAnalysis_UI_MapGl.ts",
):
assert len((folder / name).read_text("utf-8").splitlines()) <= 700