Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts
T
eomsangdonandClaude Opus 5.5 7d4a94a887 feat(D03 겹): 노선분석 지도 단층 · 지질구조선 · 0차곡 겹 — 묶음 셋째 · 처음 끔 (PLAN 584-1 · 요구 443)
- 서버: 선 겹 = 브이월드 WFS lt_l_gimsfault · lt_l_gimslinea 받은 범위로 잘라 가닥 · 길이 · 덮으면 다시 안 받음 · 실패 = 502 · 새 선 겹은 LINE_KINDS 한 줄
- 서버: 0차곡 = 사업 DEM 2 m 솎음 D8 흐름누적(웅덩이 메움 · 평지 넘김 · numpy) · 위 집수 0.1~1 ha 칸 세선화 선 · 10 m 미만 토막 거름 · 파생 저장본(격자 지문 · 임계) · 임계 = 요청 min_m2 · max_m2
- 화면: 단추 셋(bolt · lineDashed · arrowsJoin 부품 상자에 더함) · 색 --draw-map-fault · lineament · zero-order · 범례 = 가닥 · km · 첫 셈 늦으면 알림 · 바탕 캔버스
- 선 긋기 · 그림 겹 놓기 · 그림 읽기를 LayerDefs 로 옮김(700줄 한도)
- test_553_4: 겹 목록에 faults · lineaments · zeroOrder 더함(새 겹 · 기대 목록 갱신)

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

687 lines
25 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_Layers.ts
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
*
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 선 겹(단층 · 지질구조선 · 0차곡) ·
* 계류 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,
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,
fetchPlanLines,
fetchPlanRaster,
fetchPlanSheetLayer,
fetchPlanSoils,
fetchPlanZeroOrder,
fetchPlanZones,
type PlanBand,
type PlanRaster,
type PlanSoils,
type PlanZones,
} from "./D03_PlanAnalysis_Api";
import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
LINE_KEYS,
LINE_LAYERS,
SOIL_COLORS,
WATER_KEYS,
ZONE_COLORS,
drawRaster,
fillLayer,
hectare,
loadImage,
stroke,
type LineLayerKey,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import type { PlanMap, PlanMapLayer } 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;
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
interface PreparedLines {
layer: PreparedLayer;
count: number;
length_m: number;
}
type Prepared = Record<LineLayerKey, PreparedLines> & {
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;
}
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 lines = LINE_KEYS.flatMap((key) => {
const got = on[key] && data[key];
if (!got) return [];
const { label, color, dash } = LINE_LAYERS[key];
return [
{
label: `${label} ${got.count}가닥 ${(got.length_m / 1000).toFixed(2)} km`,
color: `var(${color})`,
kind: dash.length ? ("dash" as const) : ("line" as const),
off: !got.count,
},
];
});
if (lines.length) rows.push(lines);
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 === "faults" || key === "lineaments" || key === "zeroOrder") {
const kind = LINE_LAYERS[key].kind;
// 0차곡 첫 셈 = 몇 초 — 늦으면 알림
const slow = window.setTimeout(
() => showToast(`${LINE_LAYERS[key].label} 셈 중 — 처음 한 번만`, "info"),
600,
);
try {
const got = kind
? await fetchPlanLines(projectId, kind).then((lines) => ({
collection: lines,
...lines.summary,
}))
: await fetchPlanZeroOrder(projectId);
data[key] = {
layer: prepare(got.collection),
count: got.count,
length_m: got.length_m,
};
} finally {
window.clearTimeout(slow);
}
} 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 }];
});
}
}
/** 등고 글 폭 기억(글꼴 하나 · 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);
},
},
...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) => {
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 }),
...Object.fromEntries(
LINE_KEYS.flatMap((key) => {
const got = data[key];
return got
? [
[key, got.count],
[`${key}_m`, got.length_m],
]
: [];
}),
),
});
return {
bar,
legend,
layers,
load,
state: () => ({
on: { ...on },
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
counts: counts(),
}),
};
}