Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-29 20:31:09 +09:00
12 changed files with 849 additions and 369 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 그대로
+38 -6
View File
@@ -3,7 +3,7 @@
- `GET /api/projects/{id}/plan/terrain/extent` — 받기 전 범위(상자 · 도엽 · 칸 수 · 받은 범위)
- `POST …/plan/terrain` — 사업 전체 지형 한 벌 받기(뒤에서 · 누를 때만) · 끝나면 `surface_models` 행
- `GET …/plan/progress` — 일의 진행률
- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리(586-1 GL 바탕 시험)
- `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕)
- `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` ·
`…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` ·
`…/forest-roads`(GET · POST 올리기) · `…/landslide-zones` — 지도 겹
@@ -21,7 +21,7 @@ import json
import logging
from pathlib import Path
from typing import Any
from uuid import UUID
from uuid import UUID, uuid4
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile
from pydantic import BaseModel
@@ -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)
@@ -350,22 +351,53 @@ async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]:
return {"status": "success", **value}
# 위성 조각 기억 — 프로젝트 → 실경로(조각마다 DB 를 안 부름)
_TILE_ROOTS: dict[UUID, Path] = {}
TILE_KIND = "plan_tile"
async def _tile_root(project_id: UUID) -> Path:
root = _TILE_ROOTS.get(project_id)
if root is None:
async with get_db_pool().acquire() as connection:
stored = await get_project_storage_relative_path(connection, project_id)
if not stored:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
root = _TILE_ROOTS[project_id] = Path(resolve_stored_project_path(stored))
return root
@router.get("/{project_id}/plan/tile/{z}/{x}/{y}", response_model=None)
async def plan_satellite_tile(project_id: UUID, z: int, x: int, y: int) -> Response:
"""위성 조각(PLAN 586-1 GL 바탕 시험 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리."""
"""위성 조각(PLAN 586 GL 바탕 · 브이월드 Satellite z6~19) — 키가 화면에 안 새게 대리 ·
받은 조각 = `_derived/plan_tile/Satellite/{z}/{x}_{y}.jpeg`(지문 = 겹 · 조각 번호 ·
지워도 다시 받음)."""
import urllib.request
from B04_PreProcess.B04_PreProcess_Engine_VWorld import VWORLD_API_KEY
from common_util.common_util_derived_store import derived_dir
_ = project_id
if not (6 <= z <= 19 and 0 <= x < 2**z and 0 <= y < 2**z):
raise HTTPException(status_code=400, detail="조각 번호가 범위를 벗어났습니다.")
path = (
derived_dir(await _tile_root(project_id), TILE_KIND)
/ "Satellite"
/ str(z)
/ f"{x}_{y}.jpeg"
)
url = f"http://api.vworld.kr/req/wmts/1.0.0/{VWORLD_API_KEY}/Satellite/{z}/{y}/{x}.jpeg"
def fetch() -> bytes:
if path.is_file():
return path.read_bytes()
request = urllib.request.Request(url, headers={"User-Agent": "Aislo/1.0"})
with urllib.request.urlopen(request, timeout=8) as response:
return response.read()
tile = response.read()
path.parent.mkdir(parents=True, exist_ok=True)
part = path.with_suffix(f".{uuid4().hex}.part")
part.write_bytes(tile)
part.replace(path) # 반쯤 쓴 조각을 남기지 않음
return tile
try:
tile = await asyncio.to_thread(fetch)
@@ -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
+32
View File
@@ -134,3 +134,35 @@ def test_위성_조각_대리는_범위_밖을_막음():
with pytest.raises(HTTPException) as error:
asyncio.run(plan_satellite_tile(uuid.uuid4(), z, x, y))
assert error.value.status_code == 400
def test_위성_조각은_서버에_기억해_다시_안_받음(tmp_path, monkeypatch):
"""586-2 — 받은 조각 = 프로젝트 `_derived/plan_tile` · 두 번째는 브이월드에 안 물음."""
import asyncio
import urllib.request
import uuid
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router
project = uuid.uuid4()
monkeypatch.setitem(router._TILE_ROOTS, project, tmp_path)
calls = []
class Reply:
def __enter__(self):
return self
def __exit__(self, *_):
return False
def read(self):
return b"jpeg"
monkeypatch.setattr(urllib.request, "urlopen", lambda *a, **k: calls.append(1) or Reply())
for _ in range(2):
reply = asyncio.run(router.plan_satellite_tile(project, 14, 13990, 6380))
assert reply.body == b"jpeg"
assert len(calls) == 1
saved = tmp_path / "_derived" / "plan_tile" / "Satellite" / "14" / "13990_6380.jpeg"
assert saved.read_bytes() == b"jpeg"
assert not list(saved.parent.glob("*.part"))