Files
Aislo/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts
T
eomsangdonandClaude Opus 5.5 57ff92262b feat(D03 지도 겹): 받은 산림 · 환경 자료 겹 다섯 화면 — 국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태 = 서버 줌별 조각 고정 소스 · 범례 · 받는 동안 서클 (PLAN 622-2 · 요구 529)
- 공용 부품 tileSource(벡터 pbf · 그림 png) 덧 · 새 파일 UI_ForestLayers(꼴 · 소스 · 범례 · 토양 무리 색)
- 산림입지도 그림 = forest_soil 조각(z10 이상) · 브이월드 받기 · 요약 그대로 · 범례 · 옛 캔버스 색 = 같은 토양 무리 색
- 산사태위험 GL = 조각 그림(받기 없음) · 옛 캔버스 = 받은 범위 한 장 그대로(끌 때 받음)
- 새 겹 셋(국유림 임도 · 경제림 · 생태자연도) 처음 끔 · 옛 캔버스엔 안 보임 · 색 토큰 --draw-map-* 덧 · 아이콘 trees · plant 덧
- UI_Layers 688 → 680 줄(GL 글꼴 미리 받기 = UI_MapGl 로 · 브이월드 토양 GL 꼬리표 뺌)
- 지킴 시험 test_622_2_forest_layers

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 19:08:01 +09:00

262 lines
9.6 KiB
TypeScript

/* =============================================================================
* D03_PlanAnalysis_UI_LayerLegend.ts
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
* 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
* ========================================================================== */
import type { LegendItem } from "@ui/ui_template_flow_legend";
import type { PreparedLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type {
PlanBand,
PlanForestRoads,
PlanLandslideZones,
PlanRaster,
PlanSoils,
PlanZones,
} from "./D03_PlanAnalysis_Api";
import {
LANDSLIDE_LEGEND,
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
hectare,
type LineLayerKey,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers";
import {
SLOPE_MAX_PCT,
bandRatios,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
export interface PreparedLines {
layer: PreparedLayer;
count: number;
length_m: number;
}
export type Prepared = Record<LineLayerKey, PreparedLines> & {
contour: PreparedLayer;
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
stream: [PreparedLayer, PreparedLayer];
cadastral: PreparedLayer;
/** 전체 · 그중 임도 짐작(소로 · 미분류) — `road` · `forestRoad` 가 함께 씀. */
road: { all: PreparedLayer; small: PreparedLayer };
/** 올린 임도망도(584-3). */
forestRoadFile: { layer: PreparedLayer; info: PlanForestRoads };
/** 급경사(603) — 값 그림 칸 · 고른 범위 · 칠한 그림(blob 주소) · 칠하기 ms. */
slope: {
raster: PlanRaster;
image: HTMLImageElement;
values: ImageData;
bands: SlopeBand[];
url: string;
paintMs: number;
};
hillshade: { raster: PlanRaster; image: HTMLImageElement };
landslide: { raster: PlanRaster; image: HTMLImageElement };
/** 산사태취약지역(584-3) — 점(경위도) · 지번 맞는 필지 테두리. */
landslideZones: { parcels: PreparedLayer; info: PlanLandslideZones };
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 }>;
};
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] {
const ratios = bandRatios(raster.counts ?? [], bands);
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
return bands.map((band, i) => {
const open = band.high >= SLOPE_MAX_PCT;
const range = open
? `${band.low} %↑(${deg(band.low)}°↑)`
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
});
}
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
const color = "var(--draw-map-landslide-zone)";
const items: LegendItem[] = [
{
label: `산사태취약지역 점 ${info.points.length}곳`,
color,
kind: "dot",
off: !info.points.length,
},
{
label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`,
color,
kind: "dash",
off: !info.parcels.features?.length,
},
];
for (const sido of info.sidos)
if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true });
for (const name of info.missing)
items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true });
if (!info.cadastral)
items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true });
return items;
}
export function legendRows(
on: Record<PlanLayerKey, boolean>,
data: Partial<Prepared>,
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
if (on.landslide) rows.push(LANDSLIDE_LEGEND); // GL = 조각(받기 없음 · 622-2)
rows.push(...forestLegendRows(on));
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
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}가닥 ${km(got.length_m)}`,
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 (on.forestRoadFile && data.forestRoadFile)
tail.push({
label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
color: "var(--draw-map-forest-road-file)",
kind: "line",
});
if (tail.length) rows.push(tail);
return rows;
}
/** 검증 창구 — 겹별 수(지물 · 넓이 m² · 길이 m). */
export const layerCounts = (data: Partial<Prepared>): 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.forestRoadFile && {
forestRoadFile: data.forestRoadFile.info.count,
forestRoadFile_m: data.forestRoadFile.info.length_m,
forestRoadFile_total: data.forestRoadFile.info.total,
}),
...(data.landslideZones && {
landslideZone_points: data.landslideZones.info.points.length,
landslideZone_parcels: data.landslideZones.parcels.features.length,
landslideZone_missing: data.landslideZones.info.missing.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 }),
...(data.slope && {
slope_px: data.slope.image.naturalWidth,
slope_cells: (data.slope.raster.counts ?? []).reduce((sum, n) => sum + n, 0),
slope_paint_ms: data.slope.paintMs,
...Object.fromEntries(
bandRatios(data.slope.raster.counts ?? [], data.slope.bands).map((r, i) => [
`slope_band_${i}`,
r,
]),
),
}),
...(data.landslide && { landslide_px: data.landslide.image.naturalWidth }),
...Object.fromEntries(
LINE_KEYS.flatMap((key) => {
const got = data[key];
return got
? [
[key, got.count],
[`${key}_m`, got.length_m],
]
: [];
}),
),
});