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:
@@ -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 그대로
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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,
|
||||
]),
|
||||
),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user