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>
This commit is contained in:
2026-10-31 19:08:01 +09:00
co-authored by Claude Opus 5.5
parent 2648c92b70
commit 57ff92262b
9 changed files with 348 additions and 37 deletions
@@ -0,0 +1,190 @@
/* =============================================================================
* D03_PlanAnalysis_UI_ForestLayers.ts
* D03 받은 산림 · 환경 자료 겹(PLAN 622-2 · 요구 529) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴).
* 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 ·
* 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) /
* 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로).
* 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임.
* 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것.
* 부르는 곳 = `D03_PlanAnalysis_UI_MapGl`(꼴 · 소스) · `UI_Layers`(받기 거름) · `UI_LayerLegend`(범례).
* ========================================================================== */
import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { tileSource, type GlSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
/** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */
const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const;
export type TileOnlyKey = (typeof TILE_ONLY)[number];
export const tileOnly = (key: PlanLayerKey): key is TileOnlyKey =>
(TILE_ONLY as readonly string[]).includes(key);
/** 받는 동안 서클에 세는 조각 소스(등고선 포함). */
export const TRACKED_SOURCES = [
"contour",
"forest_road",
"forest_soil",
"eco_forest",
"ecology",
"landslide",
];
/** 고정 소스 — 벡터 z8~15(서버 줌별 단순화) · 그림 z6~19. */
export function forestSources(projectId: string): Record<string, SourceSpecification> {
const vt = (name: string) => tileSource(projectId, `vt/${name}`, "pbf", 8, 15);
const wmt = (name: string) => tileSource(projectId, `wmt/${name}`, "png", 6, 19);
return {
forest_road: vt("forest_road"),
forest_soil: vt("forest_soil"),
eco_forest: vt("eco_forest"),
ecology: wmt("ecology"),
landslide: wmt("landslide"),
};
}
/** 토양형 코드(산림입지토양도 SLTP_CD) → 기호. 무리 = 갈색 · 적황색 · 암적색 · 회갈색 · 화산회 · 침식 · 미숙 · 그 밖(비산림). */
const SOIL_SYMBOLS: Record<string, string> = {
"01": "B1", "02": "B2", "03": "B3", "04": "B4",
"05": "rB1", "06": "rB2", "07": "R1", "08": "R2", "09": "Y",
"10": "DR1", "11": "DR2", "12": "DR3", "13": "DRb1", "14": "DRb2",
"15": "GrB1", "16": "GrB2", "17": "Va1", "18": "Va2", "19": "Va3", "20": "Va4",
"21": "Va-R1", "22": "Va-R2", "23": "Va-gr",
"24": "Er1", "25": "Er2", "26": "Er-c", "27": "Im", "28": "Li", "29": "Bs",
}; // prettier-ignore
/** 무리 첫 코드(차례 = `--draw-map-soil-1..6`). */
const SOIL_GROUP_START = [1, 5, 10, 15, 24, 30];
const soilToken = (group: number): string => `--draw-map-soil-${group + 1}`;
const SUB = /[₀-₉]/g;
/** 토양형(조각 두 자리 코드 · 브이월드 기호 「B₁」) → 무리 색 값 · 모름 = 「그 밖」. */
export function soilColor(code: string): string {
const plain = code.trim().replace(SUB, (c) => String(c.charCodeAt(0) - 0x2080));
const found = /^\d+$/.test(plain)
? Number(plain)
: Number(Object.keys(SOIL_SYMBOLS).find((key) => SOIL_SYMBOLS[key] === plain) ?? 99);
return soilToken(Math.max(SOIL_GROUP_START.filter((start) => found >= start).length - 1, 0));
}
/** 임도 사업 구분(`kind` 1 간선 · 2 작업 · 3 산불) 색 값. */
const ROAD_KINDS = [
{ kind: 1, label: "간선", color: "--draw-map-national-road-main" },
{ kind: 2, label: "작업", color: "--draw-map-national-road-work" },
{ kind: 3, label: "산불", color: "--draw-map-national-road-fire" },
];
const ECO_KINDS = [
{ own: "국유", color: "--draw-map-eco-national" },
{ own: "사유", color: "--draw-map-eco-private" },
];
/** 생태자연도 범례 = 환경부 원도 색(조각 그림 그대로). */
const ECOLOGY_LEGEND = [
{ label: "1등급", color: "--draw-map-ecology-1" },
{ label: "2등급", color: "--draw-map-ecology-2" },
{ label: "3등급", color: "--draw-map-ecology-3" },
{ label: "별도관리", color: "--draw-map-ecology-x" },
];
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification =>
["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification;
type Spec = GlSpec<PlanLayerKey>;
const raster = (key: "landslide" | "ecology", opacity: number): Spec =>
({
key,
id: key,
type: "raster",
source: key,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0 },
}) as Spec;
/** 그림 겹(산사태위험 · 생태자연도) — 아래 차례(급경사 위). */
export const forestRasterSpecs = (): Spec[] => [raster("landslide", 0.6), raster("ecology", 0.55)];
/** 면 겹(산림입지도 · 경제림) — 보호구역 아래. */
export function forestAreaSpecs(): Spec[] {
const soilAt = (group: number): string => themeColor(soilToken(group), "#e879f9");
const soil = [
"step",
["to-number", ["get", "code"], 99],
soilAt(0),
...SOIL_GROUP_START.slice(1).flatMap((start, i) => [start, soilAt(i + 1)]),
] as ExpressionSpecification;
const eco = [
"match",
["get", "own"],
...ECO_KINDS.flatMap(({ own, color }) => [own, themeColor(color, "#34d399")]),
themeColor(ECO_KINDS[1].color, "#a78bfa"),
] as unknown as ExpressionSpecification;
const vector = (source: string) => ({ source, "source-layer": source });
return [
{
key: "soils",
id: "soils",
type: "fill",
...vector("forest_soil"),
minzoom: 10,
paint: { "fill-color": soil, "fill-opacity": 0.35 },
} as Spec,
{
key: "soils",
id: "soils-edge",
type: "line",
...vector("forest_soil"),
minzoom: 10,
layout: ROUND,
paint: { "line-color": soil, "line-width": 0.8, "line-opacity": 0.9 },
} as Spec,
{
key: "ecoForest",
id: "ecoForest",
type: "line",
...vector("eco_forest"),
layout: ROUND,
paint: { "line-color": eco, "line-width": zoomWidth(1, 1.8, 2.4) },
} as Spec,
];
}
/** 국유림 임도 선 — 검은 테 + 사업 구분 색(올린 임도망도 위). */
export function forestRoadSpecs(): Spec[] {
const color = [
"match",
["get", "kind"],
...ROAD_KINDS.flatMap(({ kind, color }) => [kind, themeColor(color, "#ff6b00")]),
themeColor(ROAD_KINDS[0].color, "#ff6b00"),
] as unknown as ExpressionSpecification;
const line = (id: string, paint: Record<string, unknown>): Spec =>
({
key: "nationalRoad",
id,
type: "line",
source: "forest_road",
"source-layer": "forest_road",
layout: ROUND,
paint,
}) as Spec;
return [
line("nationalRoad-halo", { "line-color": HALO, "line-width": zoomWidth(2.2, 3.6, 5.6) }),
line("nationalRoad", { "line-color": color, "line-width": zoomWidth(1, 2, 3.6) }),
];
}
/** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */
export function forestLegendRows(on: Record<PlanLayerKey, boolean>): LegendItem[][] {
const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({
label,
color: `var(${color})`,
...(kind ? { kind } : {}),
});
const rows: LegendItem[][] = [];
if (on.nationalRoad)
rows.push(ROAD_KINDS.map(({ label, color }) => item(`국유림 임도 ${label}`, color, "line")));
if (on.ecoForest)
rows.push(ECO_KINDS.map(({ own, color }) => item(`경제림 ${own}`, color, "line")));
if (on.ecology)
rows.push(ECOLOGY_LEGEND.map(({ label, color }) => item(`생태자연도 ${label}`, color)));
return rows;
}
@@ -30,6 +30,9 @@ export type PlanLayerKey =
| "road"
| "forestRoad"
| "forestRoadFile"
| "nationalRoad"
| "ecoForest"
| "ecology"
| "cadastral"
| "zones"
| "soils"
@@ -80,6 +83,13 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
"올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
on: false,
},
{
key: "nationalRoad",
icon: "flag",
title:
"국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)",
on: false,
},
],
[
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
@@ -92,7 +102,19 @@ export const GROUPS: readonly (readonly LayerDef[])[] = [
{
key: "soils",
icon: "leaf",
title: "산림입지도(토양형 · 산림청 산림입지토양도 · 참고용)",
title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)",
on: false,
},
{
key: "ecoForest",
icon: "trees",
title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)",
on: false,
},
{
key: "ecology",
icon: "plant",
title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)",
on: false,
},
{
@@ -164,8 +186,6 @@ export const LINE_LAYERS: Record<
zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] },
};
export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[];
/** 토양형 색 — 받은 범위 안 코드 차례로 돌려 씀. */
export const SOIL_COLORS = [1, 2, 3, 4, 5, 6].map((n) => `--draw-map-soil-${n}`);
/** 도로 중 기존 임도로 짐작하는 구분. */
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
/** 물 면 = 도엽 수계(E) 면 셋. */
@@ -24,6 +24,7 @@ import {
type LineLayerKey,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers";
import {
SLOPE_MAX_PCT,
bandRatios,
@@ -115,7 +116,8 @@ export function legendRows(
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
if (on.landslide && data.landslide) rows.push(LANDSLIDE_LEGEND);
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)
+16 -24
View File
@@ -21,6 +21,8 @@
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
* 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 ·
* 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = `D03_PlanAnalysis_UI_ForestLayers`.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
@@ -58,7 +60,6 @@ import {
GROUPS,
LAYERS,
LINE_LAYERS,
SOIL_COLORS,
WATER_KEYS,
ZONE_COLORS,
glImage,
@@ -71,7 +72,8 @@ import {
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
import { createGlBase, GL_FONT, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
import { soilColor, tileOnly, type TileOnlyKey } from "./D03_PlanAnalysis_UI_ForestLayers";
import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight";
import {
createSheetTiles,
@@ -89,17 +91,14 @@ import {
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
const PREF_KEY = "aislo:pref:d03.layers";
/** 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";
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
const NO_SATELLITE_NOTE = "옛 그림은 위성 바탕 없이 그림";
type DataKey = keyof Prepared;
/** 겹 → 받는 자료(임도 짐작 = 도로 자료). */
const dataKey = (key: Exclude<PlanLayerKey, "satellite">): DataKey =>
/** 겹 → 받는 자료(임도 짐작 = 도로 자료 · 조각만 쓰는 겹 = 없음). */
const dataKey = (key: Exclude<PlanLayerKey, TileOnlyKey>): DataKey =>
key === "forestRoad" ? "road" : key;
export interface PlanLayers {
@@ -197,6 +196,7 @@ export function createPlanLayers(opts: {
glOn = want && map.setGl(gl);
if (!glOn) map.setGl(null);
glButton.setAttribute("aria-pressed", String(glOn));
if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2)
sync();
map.redrawBase();
}
@@ -212,8 +212,9 @@ export function createPlanLayers(opts: {
legend.className = "d03-plan__legend";
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
/** 받음 — 위성 = GL 조각(받을 것 없음 · 옛 캔버스 = 위성 없음). */
const loadedOf = (key: PlanLayerKey): boolean => key === "satellite" || !!data[dataKey(key)];
/** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */
const loadedOf = (key: PlanLayerKey): boolean =>
tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)];
function sync(): void {
for (const def of LAYERS) {
@@ -222,8 +223,8 @@ export function createPlanLayers(opts: {
button.disabled = !meta;
const title = !meta
? `${def.title} — 지형 받기 뒤 보임`
: def.key === "satellite" && !glOn
? `${def.title} — ${NO_SATELLITE_NOTE}`
: tileOnly(def.key) && !glOn
? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`
: def.title;
button.title = title;
button.setAttribute("aria-label", title);
@@ -287,7 +288,7 @@ export function createPlanLayers(opts: {
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
function ensure(key: PlanLayerKey): Promise<void> {
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
const slot = key === "satellite" ? key : dataKey(key);
const slot = tileOnly(key) ? "satellite" : dataKey(key);
const running = pending.get(slot);
if (running) return running;
const mine = areaTurn;
@@ -301,7 +302,7 @@ export function createPlanLayers(opts: {
if (mine !== areaTurn) return;
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))
if (def.key === key || (!tileOnly(def.key) && dataKey(def.key) === slot))
on[def.key] = false;
sync();
map.redraw();
@@ -543,21 +544,12 @@ export function createPlanLayers(opts: {
} else if (key === "soils") {
const soils = await fetchPlanSoils(projectId);
got.soils = {
kinds: soils.summary.map((soil, i) => ({
kinds: soils.summary.map((soil) => ({
layer: prepare(pick(soils, (props) => props["code"] === soil.code)),
color: SOIL_COLORS[i % SOIL_COLORS.length],
color: soilColor(soil.code), // GL 조각과 같은 무리 색(그림 = 조각 · 622-2)
})),
summary: soils.summary,
};
const colorOf = new Map(
soils.summary.map((soil, i) => [soil.code, got.soils!.kinds[i].color]),
);
put(
"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차곡 첫 셈 = 몇 초 — 늦으면 알림
+25 -8
View File
@@ -10,6 +10,7 @@
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
* 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클.
* 받은 산림 · 환경 자료(622-2) = 같은 꼴 고정 소스 다섯(`D03_PlanAnalysis_UI_ForestLayers`) · 받는 동안 서클.
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
* 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만.
* ========================================================================== */
@@ -26,6 +27,13 @@ import {
import { themeColor } from "@ui/ui_template_palette";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs";
import {
forestAreaSpecs,
forestRasterSpecs,
forestRoadSpecs,
forestSources,
TRACKED_SOURCES,
} from "./D03_PlanAnalysis_UI_ForestLayers";
import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight";
export { GL_FONT, type GlImage };
@@ -34,6 +42,9 @@ export { GL_FONT, type GlImage };
export type GlSource = Exclude<PlanLayerKey, "satellite" | "forestRoad">;
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
export const glFont = (weight: string, text: string): Promise<unknown> =>
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
export interface GlBase {
/** 자료 담기(지도 없어도 · 켤 때 올림) — null = 비움. */
@@ -129,7 +140,7 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
const raster = (
key: GlSource | "satellite",
key: "satellite" | "hillshade" | "slope",
opacity: number,
extra: Record<string, number> = {},
): GlSpec =>
@@ -147,8 +158,8 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }),
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
raster("slope", 0.55),
raster("landslide", 0.6),
...fillEdge("soils", "soils", COLOR, 0.35, { color: COLOR, width: 1.4 }),
...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2)
...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각
...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,
@@ -241,6 +252,7 @@ function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
[],
{ color: HALO, width: 4 },
),
...forestRoadSpecs(), // 국유림 임도 = 벡터 조각
// 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게
...["-pick-halo", "-pick"].map(
(tail, i) =>
@@ -348,7 +360,7 @@ const THEME_NAMES = [
/** 위성 채도(−1~1) — 조금 연하게. */
const SAT_SATURATION = -0.3;
/** `track` = 받는 일을 진행 서클에 셈(등고 조각 받는 동안). */
/** `track` = 받는 일을 진행 서클에 셈(등고 · 산림 조각 받는 동안). */
export function createGlBase(
projectId: string,
track: <T>(job: Promise<T>) => Promise<T>,
@@ -358,12 +370,16 @@ export function createGlBase(
let pick: RoadPick | null = null;
const base = createGlMap<PlanLayerKey>({
specs: () => layerSpecs(light, pick),
fixed: { satellite: satelliteTileSource(projectId), contour: contourTileSource(projectId) },
fixed: {
satellite: satelliteTileSource(projectId),
contour: contourTileSource(projectId),
...forestSources(projectId),
},
themeNames: THEME_NAMES,
className: "d03-plan__gl",
onFail,
});
/** 등고 조각 받는 동안 서클 하나 — 다 받거나 지도가 쉬면(`idle`) · 지도를 버리면 끝. */
/** 등고 · 산림 조각 받는 동안 서클 하나 — 다 받거나 지도가 쉬면(`idle`) · 지도를 버리면 끝. */
let settle: (() => void) | null = null;
const done = (): void => {
settle?.();
@@ -373,11 +389,12 @@ export function createGlBase(
function watch(map: MapLibreMap): void {
watched = map;
map.on("sourcedataloading", (event) => {
if (event.sourceId !== "contour" || settle) return;
if (!TRACKED_SOURCES.includes(event.sourceId) || settle) return;
void track(new Promise<void>((resolve) => (settle = resolve)));
});
map.on("sourcedata", (event) => {
if (event.sourceId === "contour" && map.isSourceLoaded("contour")) done();
const loaded = (id: string) => !map.getSource(id) || map.isSourceLoaded(id);
if (TRACKED_SOURCES.includes(event.sourceId) && TRACKED_SOURCES.every(loaded)) done();
});
map.on("idle", done);
}
@@ -0,0 +1,59 @@
"""PLAN 622-2 — 예정노선 받은 산림 · 환경 자료 겹 다섯(국유림 임도 · 산림입지도 · 경제림 · 생태자연도 · 산사태) 화면 지킴.
./venv/Scripts/python.exe -m pytest resources/tester/test_622_2_forest_layers.py -q
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
D03 = ROOT / "D03_PlanAnalysis"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def test_fixed_sources_follow_plan_622_routes() -> None:
gl = _read(ROOT / "ui_template" / "ui_template_gl_map.ts")
assert "export function tileSource" in gl
assert "/plan/${path}/{z}/{x}/{y}.${ext}" in gl
forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts")
for name in ("forest_road", "forest_soil", "eco_forest"):
assert f'vt("{name}")' in forest
assert f'...vector("{name}")' in forest or f'"source-layer": "{name}"' in forest
for name in ("ecology", "landslide"):
assert f'wmt("{name}")' in forest
assert '`vt/${name}`, "pbf", 8, 15' in forest and '`wmt/${name}`, "png", 6, 19' in forest
assert "minzoom: 10" in forest # 산림입지도 z10 아래 숨김
base = _read(D03 / "D03_PlanAnalysis_UI_MapGl.ts")
assert "...forestSources(projectId)" in base
assert 'raster("landslide"' not in base # 산사태 = 조각(한 장 GL 그림 없음)
assert "TRACKED_SOURCES.includes(event.sourceId)" in base # 받는 동안 서클
def test_layer_list_five_off_by_default() -> None:
defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts")
icons = _read(ROOT / "ui_template" / "ui_template_icons.ts")
for key in ("nationalRoad", "soils", "ecoForest", "ecology", "landslide"):
entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs)
assert entry and entry.group(2) == "false", key
assert re.search(rf"^\s+{entry.group(1)}:", icons, re.M), entry.group(1)
def test_colors_are_draw_map_tokens() -> None:
theme = _read(ROOT / "ui_template" / "ui_template_theme.css")
forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts")
for token in set(re.findall(r'"(--draw-map-[\w-]+)"', forest)):
assert f"{token}:" in theme, token
assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7))
assert not re.search(r"#[0-9a-fA-F]{6}\b(?!\")", forest) # 색 = 토큰(뒷값만 글)
def test_canvas_keeps_old_landslide_and_layers_file_not_grown() -> None:
layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts")
assert len(layers.splitlines()) <= 688
assert '(glOn && key === "landslide")' in layers # GL = 조각 · 받기 없음
assert "if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key)))" in layers
paint = _read(D03 / "D03_PlanAnalysis_UI_LayerPaint.ts")
assert "drawRaster(context, map, data.landslide);" in paint # 옛 캔버스 = 한 장 그대로
+17
View File
@@ -76,6 +76,23 @@ export function contourTileSource(projectId: string): SourceSpecification {
};
}
/** 서버 줌별 조각(PLAN 622 · `…/plan/<path>/{z}/{x}/{y}.<ext>`) — pbf = 벡터(겹 이름 = 자료 이름) · png = 그림 256 px. */
export function tileSource(
projectId: string,
path: string,
ext: "pbf" | "png",
minzoom: number,
maxzoom: number,
): SourceSpecification {
const tiles = [
`${location.origin}${API_BASE_URL}/projects/${projectId}/plan/${path}/{z}/{x}/{y}.${ext}`,
];
const bounds: [number, number, number, number] = [124, 33, 132, 39];
return ext === "pbf"
? { type: "vector", tiles, minzoom, maxzoom, bounds }
: { type: "raster", tiles, tileSize: 256, minzoom, maxzoom, bounds };
}
/** 느린 조작 잼(600-4) — `globalThis.__aisloPerf = true` 일 때만 `performance.measure(이름)` 으로 걸린 ms 를 남김(꺼져 있으면 그냥 실행). */
export function perfTime<T>(name: string, run: () => T): T {
if (!(globalThis as { __aisloPerf?: boolean }).__aisloPerf) return run();
+5
View File
@@ -153,6 +153,11 @@ const PATHS = {
"M12.802 2.165l5.575 2.389c.48 .206 .863 .589 1.07 1.07l2.388 5.574c.22 .512 .22 1.092 0 1.604l-2.389 5.575c-.206 .48 -.589 .863 -1.07 1.07l-5.574 2.388c-.512 .22 -1.092 .22 -1.604 0l-5.575 -2.389a2.036 2.036 0 0 1 -1.07 -1.07l-2.388 -5.574a2.036 2.036 0 0 1 0 -1.604l2.389 -5.575c.206 -.48 .589 -.863 1.07 -1.07l5.574 -2.388a2.036 2.036 0 0 1 1.604 0z|M12 8v4|M12 16h.01",
// 585-3 — 연도 구역 마커(D03 도구줄 끌어 놓기)
flag: "M5 5a5 5 0 0 1 7 0a5 5 0 0 0 7 0v9a5 5 0 0 1 -7 0a5 5 0 0 0 -7 0v-9z|M5 21v-7",
// 622-2 — 지도 겹(경제림 · 생태자연도)
trees:
"M16 5l3 3l-2 1l4 4l-3 1l4 4h-9|M15 21l0 -3|M8 13l-2 -2|M8 12l2 -2|M8 21v-13|M5.824 16a3 3 0 0 1 -2.743 -3.69a3 3 0 0 1 .304 -4.833a3 3 0 0 1 4.615 -3.707a3 3 0 0 1 4.614 3.707a3 3 0 0 1 .305 4.833a3 3 0 0 1 -2.919 3.695h-4z",
plant:
"M7 15h10v4a2 2 0 0 1 -2 2h-6a2 2 0 0 1 -2 -2z|M12 9a6 6 0 0 0 -6 -6h-3v2a6 6 0 0 0 6 6h3|M12 11a6 6 0 0 1 6 -6h3v1a6 6 0 0 1 -6 6h-3|M12 15l0 -6",
} as const;
export type IconName = keyof typeof PATHS;
+10 -1
View File
@@ -569,7 +569,7 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-zone-wetland: #2dd4bf; /* 습지보호지역 */
--draw-map-zone-wildlife: #eab308; /* 야생생물보호구역 */
--draw-map-zone-greenbelt: #f97316; /* 개발제한구역 */
--draw-map-soil-1: #fef3c7; /* 산림입지도 토양형 — 받은 범위 안 코드 차례(흙빛 · 급경사 노랑과 다름) */
--draw-map-soil-1: #fef3c7; /* 산림입지도 토양 무리 — 갈색 · 적황색 · 암적색 · 회갈색 화산회 · 침식 미숙 · 그 밖(흙빛 · 급경사 노랑과 다름) */
--draw-map-soil-2: #f5c08a;
--draw-map-soil-3: #d9895a;
--draw-map-soil-4: #bef264;
@@ -585,6 +585,15 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-zero-order: #7dd3fc; /* 0차곡(지형 추정 골짜기 머리 · 점선) */
--draw-map-forest-road-file: #ff2fd0; /* 올린 임도망도(실선 · 검은 테 · 위성 위 밝은 분홍 · 짐작 = 주황 점선) */
--draw-map-landslide-zone: #e11d48; /* 산사태취약지역 점 · 필지 테두리 */
--draw-map-national-road-main: #ff6b00; /* 국유림 임도 간선(실선 · 검은 테 · 짐작 = 주황 점선과 가름) */
--draw-map-national-road-work: #ffd23f; /* 국유림 임도 작업 */
--draw-map-national-road-fire: #ff3b3b; /* 국유림 임도 산불 */
--draw-map-eco-national: #34d399; /* 경제림육성단지 국유(테두리) */
--draw-map-eco-private: #a78bfa; /* 경제림육성단지 사유(테두리) */
--draw-map-ecology-1: #2e9e4f; /* 생태자연도 1등급(환경부 원도 색 — 범례만) */
--draw-map-ecology-2: #b5d96b; /* 2등급 */
--draw-map-ecology-3: #f2f2c2; /* 3등급 */
--draw-map-ecology-x: #9b7bd4; /* 별도관리지역 */
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
--draw-map-measure: #ffffff; /* 거리재기 */
--draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */