Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts
T
eomsangdonandClaude Opus 5.5 f29fb01dd5 feat(D03 겹): 노선분석 지도 산림입지도 겹 — 브이월드 WFS 토양형 · 묶음 셋째 · 처음 끔 (PLAN 581-2 · 요구 440-8)
- 서버: lt_c_fsdifrsts 받은 범위로 잘라 토양형 코드 · 이름 · 넓이 · 토양형별 합 · 덮으면 다시 안 받음 · 실패 = 502
- 화면: 단추 하나(잎 아이콘 · 부품 상자에 더함) · 토양형 색 --draw-map-soil-1~6 · 범례 = 토양형 이름 · ha · 바탕 캔버스
- 산사태위험지도(생활안전지도 = 공공누리 4유형 · 데이터 준비 중) · 단층 · 지질구조선(이용 조건 문서 없음)은 넣지 않음(브레인 결정 A)
- test_553_4: 겹 목록에 soils 더함(새 겹 · 기대 목록 갱신)

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

646 lines
23 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Layers.ts
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
*
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
import { createIconButton } from "@ui/ui_template_button";
import { createMapTools } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import {
fetchVWorldMeta,
getVWorldMapUrl,
type VWorldMeta,
} from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
lonLatToScreen,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import {
indexLayerLod,
prepareLayer,
} from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import {
fetchPlanBands,
fetchPlanCadastral,
fetchPlanRaster,
fetchPlanSheetLayer,
fetchPlanSoils,
fetchPlanZones,
type PlanBand,
type PlanRaster,
type PlanSoils,
type PlanZones,
} from "./D03_PlanAnalysis_Api";
import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
SOIL_COLORS,
WATER_KEYS,
ZONE_COLORS,
fillLayer,
hectare,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import type { PlanMap, PlanMapLayer, ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { Vertex } from "./logic/D03_PlanAnalysis_Plan";
const PREF_KEY = "aislo:pref:d03.layers";
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
interface Prepared {
contour: PreparedLayer;
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
stream: [PreparedLayer, PreparedLayer];
cadastral: PreparedLayer;
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
road: { all: PreparedLayer; small: PreparedLayer };
slope: { raster: PlanRaster; image: HTMLImageElement };
hillshade: { raster: PlanRaster; image: HTMLImageElement };
water: PreparedLayer;
zones: { kinds: Map<string, PreparedLayer>; summary: PlanZones["summary"] };
/** 토양형(코드 차례) → 지물 · 색 값. */
soils: { kinds: Array<{ layer: PreparedLayer; color: string }>; summary: PlanSoils["summary"] };
bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } };
building: PreparedLayer;
place: Array<{ lon: number; lat: number; name: string }>;
}
type DataKey = keyof Prepared;
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
key === "forestRoad" ? "road" : key;
export interface PlanLayers {
bar: HTMLElement;
legend: HTMLElement;
/** 바탕 겹(아래 → 위 · 위성 뺌) — 그은 선 겹 아래에 둠. */
layers: PlanMapLayer[];
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
load: () => Promise<void>;
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
state: () => {
on: Record<PlanLayerKey, boolean>;
loaded: PlanLayerKey[];
counts: Record<string, number>;
};
}
function readPref(): Record<PlanLayerKey, boolean> {
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
PlanLayerKey,
boolean
>;
try {
const saved = JSON.parse(readByKey(PREF_KEY) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
for (const def of LAYERS)
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
} catch {
// 깨진 값 = 처음 값
}
return on;
}
const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((done, fail) => {
const image = new Image();
image.onload = () => done(image);
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
image.src = src;
});
export function createPlanLayers(opts: {
projectId: string;
map: PlanMap;
/** 등고선 받을 범위를 정할 점(시점 · 종점 · 그은 선). */
points: () => Vertex[];
}): PlanLayers {
const { projectId, map } = opts;
const on = readPref();
const data: Partial<Prepared> = {};
const pending = new Map<DataKey | "satellite", Promise<void>>();
let meta: VWorldMeta | null = null;
let satellite: HTMLImageElement | null = null;
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
const groups = GROUPS.map((group) =>
group.map((def) => {
const button = createIconButton({
icon: def.icon,
title: def.title,
size: 32,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
buttons.set(def.key, button);
return button;
}),
);
const tools = createMapTools({ label: "지도 겹", groups });
const bar = tools.root;
bar.classList.add("d03-plan__layers");
// 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`)
new ResizeObserver(() =>
map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`),
).observe(tools.bar);
const legend = document.createElement("div");
legend.className = "d03-plan__legend";
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
const loadedOf = (key: PlanLayerKey): boolean =>
key === "satellite" ? !!satellite : !!data[dataKey(key)];
function legendRows(): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope)
rows.push(
data.slope.raster.bands.map((band) => ({
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
color: band.color,
})),
);
if (on.zones && data.zones) {
const items = Object.entries(data.zones.summary)
.filter(([, kind]) => kind.count || kind.failed)
.map(([key, kind]) => ({
label: kind.failed
? `${kind.label} 받지 못함`
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
color: `var(${ZONE_COLORS[key]})`,
off: !!kind.failed,
}));
rows.push(
items.length
? items
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.soils && data.soils) {
const { kinds, summary } = data.soils;
rows.push(
summary.length
? summary.map((soil, i) => ({
label: `${soil.name} ${hectare(soil.area_m2)}`,
color: `var(${kinds[i].color})`,
}))
: [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.bands && data.bands) {
const { stream, road } = data.bands.info;
rows.push([
{
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-stream)",
},
{
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-road)",
},
]);
}
const tail: LegendItem[] = [];
if (on.water && data.water)
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
if (on.building && data.building)
tail.push({
label: `건물 ${data.building.features.length.toLocaleString()}동`,
color: "var(--draw-map-building)",
});
if (on.forestRoad && data.road)
tail.push({
label: `임도 짐작 ${data.road.small.features.length}가닥`,
color: "var(--draw-map-forest-road)",
kind: "dash",
});
if (tail.length) rows.push(tail);
return rows;
}
function sync(): void {
for (const def of LAYERS) {
const button = buttons.get(def.key)!;
button.setAttribute("aria-pressed", String(on[def.key]));
button.disabled = !meta;
const title = meta ? def.title : `${def.title} — 지형 받기 뒤 보임`;
button.title = title;
button.setAttribute("aria-label", title);
}
const rows = meta ? legendRows() : [];
legend.replaceChildren(...rows.map((row) => createLegend(row)));
legend.hidden = !rows.length;
}
sync();
function toggle(key: PlanLayerKey): void {
on[key] = !on[key];
writeByKey(PREF_KEY, JSON.stringify(on));
void ensure(key);
showSatellite();
sync();
map.redrawBase();
}
function showSatellite(): void {
map.update({ image: on.satellite ? satellite : null });
}
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
function ensure(key: PlanLayerKey): Promise<void> {
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
const slot = key === "satellite" ? key : dataKey(key);
const running = pending.get(slot);
if (running) return running;
const at = meta;
const job = fetchLayer(key, at)
.then(() => {
if (meta !== at) return;
showSatellite();
sync();
map.redrawBase();
})
.catch((error: unknown) => {
const text = error instanceof Error ? error.message : String(error);
for (const def of LAYERS)
if (def.key === key || (def.key !== "satellite" && dataKey(def.key) === slot))
on[def.key] = false;
sync();
map.redraw();
showToast(`${LAYERS.find((def) => def.key === key)!.title} 받지 못함 — ${text}`, "error");
})
.finally(() => pending.delete(slot));
pending.set(slot, job);
return job;
}
async function fetchLayer(key: PlanLayerKey, at: VWorldMeta): Promise<void> {
const normalizer = createNormalizer(at);
const prepare = (collection: GeoJsonCollection, labels?: string[]): PreparedLayer =>
prepareLayer(collection, normalizer, labels);
const pick = (
collection: GeoJsonCollection,
test: (props: Record<string, unknown>) => boolean,
): GeoJsonCollection => ({
...collection,
features: (collection.features ?? []).filter((feature) => test(feature.properties ?? {})),
});
if (key === "satellite") {
// 범위가 넓어지면 주소가 바뀌어 옛 그림을 안 씀
const v = `${at.x_min}_${at.x_max}_${at.y_min}_${at.y_max}`;
satellite = await loadImage(`${getVWorldMapUrl(projectId, "satellite")}&v=${v}`);
} else if (key === "contour") {
const points = opts.points();
const xs = points.map(([x]) => x);
const ys = points.map(([, y]) => y);
const bbox = [
Math.min(...xs) - CONTOUR_PAD_M,
Math.min(...ys) - CONTOUR_PAD_M,
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(","),
);
data.contour = indexLayerLod(prepare(collection, ["등고수치"]));
} else if (key === "stream") {
const collection = await fetchCachedSheetLayer<GeoJsonCollection>(
projectId,
"도엽_하천중심선",
);
data.stream = [
prepare(pick(collection, (props) => props["구분"] === "세류")),
prepare(pick(collection, (props) => props["구분"] !== "세류")),
];
} else if (key === "cadastral") {
data.cadastral = prepare(await fetchPlanCadastral(projectId));
} else if (key === "road" || key === "forestRoad") {
const collection = await fetchPlanSheetLayer(projectId, "도로중심선");
data.road = {
all: prepare(collection),
small: prepare(
pick(collection, (props) => FOREST_ROAD_KINDS.has(String(props["도로구분"]))),
),
};
} else if (key === "slope" || key === "hillshade") {
const raster = await fetchPlanRaster(projectId, key);
data[key] = { raster, image: await loadImage(raster.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 ?? []) });
} else if (key === "building") {
data.building = prepare(await fetchPlanSheetLayer(projectId, "건물"));
} 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 };
const failed = Object.values(zones.summary).filter((kind) => kind.failed);
if (failed.length)
showToast(
`보호 · 규제 구역 일부 받지 못함 — ${failed.map((kind) => kind.label).join(" · ")}`,
"error",
);
} else if (key === "soils") {
const soils = await fetchPlanSoils(projectId);
data.soils = {
kinds: soils.summary.map((soil, i) => ({
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
color: SOIL_COLORS[i % SOIL_COLORS.length],
})),
summary: soils.summary,
};
} else if (key === "bands") {
const info = await fetchPlanBands(projectId);
data.bands = {
stream: prepare(info.stream.collection),
road: prepare(info.road.collection),
info,
};
} else {
const collection = await fetchPlanSheetLayer(projectId, "지명");
data.place = (collection.features ?? []).flatMap((feature) => {
const geometry = feature.geometry;
const name = feature.properties?.["명칭"];
if (geometry?.type !== "Point" || typeof name !== "string" || !name) return [];
const [lon, lat] = geometry.coordinates as number[];
return [{ lon, lat, name }];
});
}
}
const stroke = (
context: CanvasRenderingContext2D,
map: ScreenMap,
layer: PreparedLayer,
color: string,
width: number,
everyM?: number,
): void => {
context.strokeStyle = color;
context.lineWidth = width;
drawPreparedLayer(context, layer, map.view, "dot", everyM);
};
const drawRaster = (
context: CanvasRenderingContext2D,
map: ScreenMap,
raster: { raster: PlanRaster; image: HTMLImageElement },
): void => {
const [x0, y0, x1, y1] = raster.raster.box_m;
const [left, top] = map.toScreen([x0, y1]);
const [right, bottom] = map.toScreen([x1, y0]);
context.drawImage(raster.image, left, top, right - left, bottom - top);
};
/** 등고 글 폭 기억(글꼴 하나 · 582). */
const labelWidths = new Map<string, number>();
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) return;
context.globalAlpha = 0.55;
drawRaster(context, map, data.slope);
},
},
{
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");
context.globalAlpha = 0.7;
stroke(context, map, contour, color, 0.8, pickLevelStep(contour, map.view, 5));
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) => {
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);
},
},
{
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);
});
}
},
},
];
async function load(): Promise<void> {
const next = await fetchVWorldMeta(projectId, "satellite").catch(() => null);
const changed =
JSON.stringify(next && [next.x_min, next.x_max, next.y_min, next.y_max]) !==
JSON.stringify(meta && [meta.x_min, meta.x_max, meta.y_min, meta.y_max]);
meta = next;
if (changed) {
satellite = null;
for (const key of Object.keys(data)) delete data[key as DataKey];
}
map.update({ meta });
showSatellite();
sync();
await Promise.all(LAYERS.map((def) => ensure(def.key)));
}
const counts = (): Record<string, number> => ({
...(data.building && { building: data.building.features.length }),
...(data.water && { water: data.water.features.length }),
...(data.road && {
road: data.road.all.features.length,
forestRoad: data.road.small.features.length,
}),
...(data.place && { place: data.place.length }),
...(data.zones &&
Object.fromEntries(
Object.entries(data.zones.summary).flatMap(([kind, value]) => [
[`zone_${kind}`, value.count],
[`zone_${kind}_m2`, value.area_m2],
]),
)),
...(data.soils &&
Object.fromEntries(
data.soils.summary.flatMap((soil) => [
[`soil_${soil.code}`, soil.count],
[`soil_${soil.code}_m2`, soil.area_m2],
]),
)),
...(data.bands && {
band_stream_m2: data.bands.info.stream.area_m2,
band_road_m2: data.bands.info.road.area_m2,
}),
...(data.hillshade && { hillshade_px: data.hillshade.image.naturalWidth }),
});
return {
bar,
legend,
layers,
load,
state: () => ({
on: { ...on },
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
counts: counts(),
}),
};
}