feat(D03 겹): 노선분석 지도 새 겹 일곱 — 음영기복 · 물 면 · 기존 임도 짐작 · 보호 · 규제 구역 · 계류 · 도로 띠 · 건물 · 지명 (PLAN 581-1 · 요구 440-8 · 442)

- 겹 단추 = 공용 지도 도구줄(createMapTools) 한 줄 · 묶음 셋(바탕 · 물 · 길 · 구역) · 넘치면 가로 밀기 · 오른쪽 도구줄 자리 = 겹 줄 폭(--d03-layers-w)
- 새 겹 처음 값 = 끔 · 켬끔 기억(aislo:pref:d03.layers) · 받기 실패 = 그 겹만 꺼짐 + 알림
- 범례 = 켠 겹마다 한 줄(급경사 비율 · 보호구역 종류별 곳 · ha · 띠 ha · % · 물 면 · 건물 · 임도 짐작 수)
- 면 겹 채움 · 겹 정의 = D03_PlanAnalysis_UI_LayerDefs.ts(700줄 한도로 나눔)
- 부품 상자: 아이콘 shield · borderOuter · 그림 색 --draw-map-* 열넷
- 음영기복 빈 칸 투명(HILLSHADE_VERSION 2)
- 시험 test_553_4_de_parts 의 D03 겹 단추 대조 고침 — 겹 단추가 createToolbar 에서 공용 지도 도구줄로 · 겹 열셋으로 바뀌어 옛 대조가 낡음

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-29 14:47:51 +09:00
co-authored by Claude Opus 5.5
parent 9c8710a934
commit 86507df52f
9 changed files with 556 additions and 106 deletions
+25 -3
View File
@@ -2,7 +2,7 @@
* D03_PlanAnalysis_Api.ts
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 응답에 D02 시점 · 종점) ·
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림).
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
@@ -149,5 +149,27 @@ export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise<PlanRaster> =>
request(`${base(projectId)}/layers/raster?kind=${kind}`);
export const fetchPlanRaster = (
projectId: string,
kind: "slope" | "hillshade",
): Promise<PlanRaster> => request(`${base(projectId)}/layers/raster?kind=${kind}`);
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
export interface PlanZones extends GeoJsonCollection {
summary: Record<string, { label: string; count: number; area_m2: number; failed?: boolean }>;
}
export const fetchPlanZones = (projectId: string): Promise<PlanZones> =>
request(`${base(projectId)}/layers/zones`);
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
export interface PlanBand {
dist_m: number;
lines: number;
area_m2: number;
ratio: number;
collection: GeoJsonCollection;
}
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
+15 -7
View File
@@ -56,6 +56,7 @@ WFS_WORKERS = 4
SLOPE_KIND = "plan_slope"
HILLSHADE_KIND = "plan_hillshade"
HILLSHADE_VERSION = 2 # 빈 칸 투명
RASTER_KINDS = ("slope", "hillshade")
SLOPE_VERSION = 1
# (아래 °, 위 °, 색 RGBA) — 임도망_편성 완 · 중 · 급경사지 · 타당성평가 35° · 노선_배치 40° 연속
@@ -239,11 +240,18 @@ def slope_bands_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str
def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict[str, Any]:
"""음영기복 PNG(B07 그대로 · 빈 칸 흰색 = 화면이 곱하기로 겹쳐 투명)."""
"""음영기복 PNG(B07 그대로 · 빈 칸 흰색 255 → 투명 · 값 칸은 90~250)."""
from PIL import Image
from B07_DesignDetail.B07_DesignDetail_Engine_Cad_Lidar import hillshade_png
image, width, height = hillshade_png(z, valid, cell_m)
return {"width": width, "height": height, "image": image, "bands": []}
gray = Image.open(io.BytesIO(base64.b64decode(image.split(",", 1)[1]))).convert("L")
gray.putalpha(gray.point(lambda v: 0 if v == 255 else 255))
buffer = io.BytesIO()
gray.save(buffer, format="PNG", optimize=True)
data = "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
return {"width": width, "height": height, "image": data, "bands": []}
def slope_layer(
@@ -256,12 +264,12 @@ def slope_layer(
stamp = file_stamp(npz)
if stamp is None:
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
store_kind, build = (
(SLOPE_KIND, slope_bands_png) if kind == "slope" else (HILLSHADE_KIND, hillshade_layer_png)
)
key = derived_key(
store_kind, SLOPE_VERSION, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX
store_kind, version, build = (
(SLOPE_KIND, SLOPE_VERSION, slope_bands_png)
if kind == "slope"
else (HILLSHADE_KIND, HILLSHADE_VERSION, hillshade_layer_png)
)
key = derived_key(store_kind, version, npz.name, stamp, int(epsg), SLOPE_BANDS, MAX_SIDE_PX)
saved = read_derived(project_root, store_kind, key)
if saved is not None:
return saved["value"]
@@ -0,0 +1,135 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerDefs.ts
* D03 바탕 겹 정의(PLAN 569-2 · 581-1) — 겹 단추 묶음 · 처음 값 · 보호구역 색 · 면 겹 채우기.
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import {
normalizedToScreen,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
export type PlanLayerKey =
| "satellite"
| "hillshade"
| "contour"
| "slope"
| "stream"
| "water"
| "road"
| "forestRoad"
| "cadastral"
| "zones"
| "bands"
| "building"
| "place";
export interface LayerDef {
key: PlanLayerKey;
icon: IconName;
title: string;
on: boolean;
}
/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */
export const GROUPS: readonly (readonly LayerDef[])[] = [
[
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
],
[
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
{ key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false },
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
{
key: "forestRoad",
icon: "route",
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
on: false,
},
],
[
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
{
key: "zones",
icon: "shield",
title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)",
on: false,
},
{
key: "bands",
icon: "borderOuter",
title: "계류 100 m · 도로 300 m 띠(타당성평가)",
on: false,
},
{ key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false },
{ key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false },
],
];
export const LAYERS: readonly LayerDef[] = GROUPS.flat();
/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */
export const ZONE_COLORS: Record<string, string> = {
forest: "--draw-map-zone-forest",
baekdu: "--draw-map-zone-baekdu",
water: "--draw-map-zone-water",
park: "--draw-map-zone-park",
heritage: "--draw-map-zone-heritage",
wetland: "--draw-map-zone-wetland",
wildlife: "--draw-map-zone-wildlife",
greenbelt: "--draw-map-zone-greenbelt",
};
/** 도로 중 기존 임도로 짐작하는 구분. */
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
/** 물 면 = 도엽 수계(E) 면 셋. */
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`;
/** 면 겹 채움 — 지물마다 한 길(evenodd = 구멍) · 화면 밖 지물 거름(돌린 지도 귀퉁이까지 여유). */
export function fillLayer(
context: CanvasRenderingContext2D,
layer: PreparedLayer,
view: ViewState,
fill: string,
alpha: number,
edge?: { color: string; width: number; dash?: number[] },
): void {
const [ox, oy] = normalizedToScreen(view, 0, 0);
const [ex, ey] = normalizedToScreen(view, 1, 1);
const ax = ex - ox;
const ay = ey - oy;
const pad = Math.max(view.width, view.height) / 2;
context.fillStyle = fill;
if (edge) {
context.strokeStyle = edge.color;
context.lineWidth = edge.width;
context.setLineDash(edge.dash ?? []);
}
for (const feature of layer.features) {
if (
feature.maxX * ax + ox < -pad ||
feature.minX * ax + ox > view.width + pad ||
feature.maxY * ay + oy < -pad ||
feature.minY * ay + oy > view.height + pad
)
continue;
context.beginPath();
for (const { coords } of feature.parts) {
context.moveTo(coords[0] * ax + ox, coords[1] * ay + oy);
for (let i = 2; i < coords.length; i += 2)
context.lineTo(coords[i] * ax + ox, coords[i + 1] * ay + oy);
context.closePath();
}
context.globalAlpha = alpha;
context.fill("evenodd");
if (edge) {
context.globalAlpha = 0.9;
context.stroke();
}
}
context.setLineDash([]);
}
+322 -87
View File
@@ -1,20 +1,23 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Layers.ts
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 요구 395-2) — 지도 왼쪽 위 아이콘 한 줄(도구줄과 따로).
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 요구 395-2 · 440-8 · 442) — 지도 왼쪽 위 아이콘 한 줄
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
*
* 겹 = 위성 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 세류선(가는 파랑) · 하천(굵은 파랑) · 지적도 ·
* 기존 도로(도엽 도로중심선) · 급경사 색(15° · 35° · 40° · 범례 작은 칸).
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류)) ·
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음.
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 급경사 → 지적 → 등고선 → 도로 → 세류 → 그은 선.
* 받기 = 켤 때만(한 번 받으면 다시 안 받음) · 지형을 안 받았으면 단추를 막음 · 받기 실패 = 그 겹만 꺼짐 + 알림.
* 그리기 차례(아래 → 위) = 위성(`scene.image`) → 음영기복 → 급경사 → 보호구역 → 띠 → 지적 → 물 면 →
* 건물 → 등고선 → 도로 → 임도 짐작 → 세류 → 지명 → 그은 선(모두 `layers` = 바탕 겹 · 582 두 장이면 아래 장).
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래).
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { showToast } from "@ui/ui_template_elements";
import { createLegend } from "@ui/ui_template_flow_legend";
import type { IconName } from "@ui/ui_template_icons";
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 { createToolbar } from "@ui/ui_template_toolbar";
import { fetchCachedSheetLayer } from "../A00_Common/b_asset_cache";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import {
@@ -26,49 +29,58 @@ import {
createNormalizer,
drawPreparedLabels,
drawPreparedLayer,
lonLatToScreen,
pickLevelStep,
type GeoJsonCollection,
type PreparedLayer,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { prepareLayer } from "../B04_PreProcess/logic/B04_PreProcess_UI_MapRender_Prepare";
import {
fetchPlanBands,
fetchPlanCadastral,
fetchPlanRaster,
fetchPlanSheetLayer,
fetchPlanZones,
type PlanBand,
type PlanRaster,
type PlanZones,
} from "./D03_PlanAnalysis_Api";
import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
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";
export type PlanLayerKey = "satellite" | "contour" | "stream" | "cadastral" | "road" | "slope";
interface LayerDef {
key: PlanLayerKey;
icon: IconName;
title: string;
on: boolean;
}
const LAYERS: readonly LayerDef[] = [
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
{ key: "slope", icon: "angle", title: "급경사 색(15° · 35° · 40° 이상)", on: false },
];
const PREF_KEY = "aislo:pref:d03.layers";
/** 등고선을 받아 올 그은 선 둘레(m). */
const CONTOUR_PAD_M = 600;
interface Prepared {
contour: PreparedLayer;
/** 세류 · 하천(소하천 · 지방하천 · 연결선). */
stream: [PreparedLayer, PreparedLayer];
cadastral: PreparedLayer;
road: 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"] };
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;
@@ -77,8 +89,12 @@ export interface PlanLayers {
layers: PlanMapLayer[];
/** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */
load: () => Promise<void>;
/** 검증 창구 — 켬끔 · 받은 겹. */
state: () => { on: Record<PlanLayerKey, boolean>; loaded: PlanLayerKey[] };
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
state: () => {
on: Record<PlanLayerKey, boolean>;
loaded: PlanLayerKey[];
counts: Record<string, number>;
};
}
function readPref(): Record<PlanLayerKey, boolean> {
@@ -113,31 +129,94 @@ export function createPlanLayers(opts: {
const { projectId, map } = opts;
const on = readPref();
const data: Partial<Prepared> = {};
const pending = new Map<PlanLayerKey, Promise<void>>();
const pending = new Map<DataKey | "satellite", Promise<void>>();
let meta: VWorldMeta | null = null;
let satellite: HTMLImageElement | null = null;
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
for (const def of LAYERS) {
const button = createIconButton({
icon: def.icon,
title: def.title,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
buttons.set(def.key, button);
}
const bar = createToolbar({
glass: true,
size: 32,
label: "지도 겹",
items: [...buttons.values()],
});
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 = "급경사 색 — 단계 · 받은 지형 안 넓이 비율";
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.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) {
@@ -148,7 +227,9 @@ export function createPlanLayers(opts: {
button.title = title;
button.setAttribute("aria-label", title);
}
legend.hidden = !meta || !on.slope || !data.slope;
const rows = meta ? legendRows() : [];
legend.replaceChildren(...rows.map((row) => createLegend(row)));
legend.hidden = !rows.length;
}
sync();
@@ -166,11 +247,11 @@ export function createPlanLayers(opts: {
map.update({ image: on.satellite ? satellite : null });
}
/** 켜진 겹의 자료를 받음(한 번). */
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
function ensure(key: PlanLayerKey): Promise<void> {
if (!meta || !on[key] || (key === "satellite" ? satellite : data[key as keyof Prepared]))
return Promise.resolve();
const running = pending.get(key);
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)
@@ -182,10 +263,15 @@ export function createPlanLayers(opts: {
})
.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(key));
pending.set(key, job);
.finally(() => pending.delete(slot));
pending.set(slot, job);
return job;
}
@@ -193,6 +279,13 @@ export function createPlanLayers(opts: {
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}`;
@@ -219,29 +312,58 @@ export function createPlanLayers(opts: {
projectId,
"도엽_하천중심선",
);
const pick = (small: boolean): GeoJsonCollection => ({
...collection,
features: (collection.features ?? []).filter(
(feature) => (feature.properties?.["구분"] === "세류") === small,
),
});
data.stream = [prepare(pick(true)), prepare(pick(false))];
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") {
data.road = prepare(await fetchPlanSheetLayer(projectId, "도로중심선"));
} else {
const raster = await fetchPlanRaster(projectId, "slope");
data.slope = { raster, image: await loadImage(raster.image) };
// 범례 = 서버 단계 색 그대로(색 한 곳)
legend.replaceChildren(
createLegend(
raster.bands.map((band) => ({
label: `${band.min_deg}${band.max_deg < 90 ? `~${band.max_deg}°` : "°↑"} ${(band.ratio * 100).toFixed(1)} %`,
color: band.color,
})),
} 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 === "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 }];
});
}
}
@@ -258,16 +380,60 @@ export function createPlanLayers(opts: {
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);
};
const layers: PlanMapLayer[] = [
{
draw: (context, map) => {
const slope = on.slope && data.slope;
if (!slope) return;
const [x0, y0, x1, y1] = slope.raster.box_m;
const [left, top] = map.toScreen([x0, y1]);
const [right, bottom] = map.toScreen([x1, y0]);
if (!on.hillshade || !data.hillshade) return;
// 위성 위 = 곱하기(밝은 곳은 그대로 · 그늘만 짙게) · 위성 없으면 그대로
if (on.satellite && satellite) {
context.globalCompositeOperation = "multiply";
context.globalAlpha = 0.75;
}
drawRaster(context, map, data.hillshade);
},
},
{
draw: (context, map) => {
if (!on.slope || !data.slope) return;
context.globalAlpha = 0.55;
context.drawImage(slope.image, left, top, right - left, bottom - top);
drawRaster(context, map, data.slope);
},
},
{
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 });
}
},
},
{
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],
});
},
},
{
@@ -277,6 +443,23 @@ export function createPlanLayers(opts: {
stroke(context, map, data.cadastral, themeColor("--draw-map-parcel", "#f0abfc"), 0.8);
},
},
{
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 });
},
},
{
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,
});
},
},
{
draw: (context, map) => {
const contour = on.contour && data.contour;
@@ -303,8 +486,17 @@ export function createPlanLayers(opts: {
{
draw: (context, map) => {
if (!on.road || !data.road) return;
stroke(context, map, data.road, "rgba(0, 0, 0, 0.55)", 3.2);
stroke(context, map, data.road, themeColor("--draw-map-road", "#f8fafc"), 1.6);
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);
},
},
{
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([]);
},
},
{
@@ -315,6 +507,29 @@ export function createPlanLayers(opts: {
stroke(context, map, data.stream[1], color, 2.4);
},
},
{
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> {
@@ -325,7 +540,7 @@ export function createPlanLayers(opts: {
meta = next;
if (changed) {
satellite = null;
for (const key of Object.keys(data)) delete data[key as keyof Prepared];
for (const key of Object.keys(data)) delete data[key as DataKey];
}
map.update({ meta });
showSatellite();
@@ -333,6 +548,28 @@ export function createPlanLayers(opts: {
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.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,
@@ -340,10 +577,8 @@ export function createPlanLayers(opts: {
load,
state: () => ({
on: { ...on },
loaded: [
...(satellite ? (["satellite"] as PlanLayerKey[]) : []),
...(Object.keys(data) as PlanLayerKey[]),
],
loaded: LAYERS.map((def) => def.key).filter(loadedOf),
counts: counts(),
}),
};
}
+12 -3
View File
@@ -25,20 +25,21 @@
touch-action: none;
}
/* 571-1 — 왼쪽은 겹 단추줄(222px) 오른쪽까지 · 그 사이가 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
/* 571-1 — 왼쪽은 겹 단추줄(폭 = JS `--d03-layers-w`) 오른쪽까지 · 모자라면 도구줄이 가로 밀림 · 오른쪽 붙임 그대로 */
.d03-plan__tools {
position: absolute;
top: var(--spacing-8);
right: var(--spacing-8);
left: calc(2 * var(--spacing-8) + 222px);
left: calc(2 * var(--spacing-8) + var(--d03-layers-w, 222px));
align-items: flex-end;
}
/* 569-2 바탕 겹 단추 — 왼쪽 위 · 급경사 범례 — 왼쪽 아래 */
/* 569-2 · 581-1 바탕 겹 단추 — 왼쪽 위 · 지도 반 넘으면 가로 밀림 · 범례 — 왼쪽 아래(켠 겹마다 한 줄) */
.d03-plan__layers {
position: absolute;
top: var(--spacing-8);
left: var(--spacing-8);
max-width: calc(55% - var(--spacing-8));
}
.d03-plan__legend {
@@ -48,6 +49,14 @@
padding: var(--spacing-4) var(--spacing-8);
border-radius: var(--radius-lg);
background: var(--color-surface);
display: flex;
flex-direction: column;
gap: var(--spacing-4);
max-width: calc(100% - 2 * var(--spacing-8));
}
.d03-plan__legend[hidden] {
display: none;
}
.d03-plan__hint {
+15 -6
View File
@@ -48,17 +48,26 @@ def test_d03_parts():
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
assert "map.stations" in draw and "map.slopes" in draw
# 569-2 — 바탕 겹 단추(왼쪽 위 아이콘 줄) · 처음 켬 셋 · 취향 기억
assert "createToolbar({" in layers and "createIconButton({" in layers
# 569-2 · 581-1 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts")
assert "createMapTools({" in layers and "createIconButton({" in layers
assert "createToolbar(" not in layers
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers
keys = re.findall(r'\{ key: "(\w+)", icon: "\w+", title: "[^"]+", on: (true|false) \}', layers)
keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs)
assert keys == [
("satellite", "true"),
("hillshade", "false"),
("contour", "true"),
("stream", "true"),
("cadastral", "false"),
("road", "false"),
("slope", "false"),
("stream", "true"),
("water", "false"),
("road", "false"),
("forestRoad", "false"),
("cadastral", "false"),
("zones", "false"),
("bands", "false"),
("building", "false"),
("place", "false"),
], keys
assert ".d03-plan__layers" in css and ".d03-plan__legend" in css
assert "createSegment({" in draw and draw.count("createInputField(") == 2
@@ -5,12 +5,15 @@
./venv/Scripts/python.exe -m pytest resources/tester/test_581_1_plan_gis_layers.py -q
"""
import base64
import io
import json
import math
from pathlib import Path
import numpy as np
import pytest
from PIL import Image
from shapely.geometry import LineString
from B04_PreProcess.B04_PreProcess_Engine_SheetParser import SHEET_LAYERS
@@ -199,3 +202,14 @@ def test_hillshade_saved_apart_from_slope(tmp_path) -> None:
assert shade["image"].startswith("data:image/png;base64,") and shade["bands"] == []
assert slope["bands"] and shade["box_m"] == slope["box_m"]
assert layers.slope_layer(tmp_path, models, EPSG, "hillshade") == shade
def test_hillshade_hole_transparent() -> None:
z = np.tile(np.arange(20) * 1.0, (20, 1))
valid = np.ones(z.shape, bool)
valid[:5, :5] = False # 남서 귀퉁이 = 그림 왼쪽 아래
value = layers.hillshade_layer_png(z, valid, 2.0)
alpha = np.asarray(Image.open(io.BytesIO(base64.b64decode(value["image"].split(",", 1)[1]))))[
..., 1
]
assert (alpha[-5:, :5] == 0).all() and (alpha[:10, 10:] == 255).all()
+4
View File
@@ -138,6 +138,10 @@ const PATHS = {
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M4 12l16 0|M12 4l0 16",
angle:
"M21 19h-18l9 -15|M20.615 15.171h.015|M19.515 11.771h.015|M17.715 8.671h.015|M15.415 5.971h.015",
// 581-1 — 지도 겹(보호 · 규제 구역 · 계류 · 도로 띠)
shield: "M12 3a12 12 0 0 0 8.5 3a12 12 0 0 1 -8.5 15a12 12 0 0 1 -8.5 -15a12 12 0 0 0 8.5 -3",
borderOuter:
"M4 6a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z|M12 8l0 .01|M8 12l0 .01|M12 12l0 .01|M16 12l0 .01|M12 16l0 .01",
} as const;
export type IconName = keyof typeof PATHS;
+14
View File
@@ -555,6 +555,20 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-stream: #38bdf8; /* 세류 · 하천 */
--draw-map-road: #f8fafc; /* 기존 도로 */
--draw-map-parcel: #f0abfc; /* 지적 경계 */
--draw-map-building: #fb7185; /* 도엽 건물 */
--draw-map-water: #0ea5e9; /* 실폭하천 · 저수지 등 물 면 */
--draw-map-forest-road: #f59e0b; /* 기존 임도 짐작(소로 · 미분류) */
--draw-map-band-stream: #22d3ee; /* 계류 100 m 띠 */
--draw-map-band-road: #facc15; /* 도로 300 m 띠 */
--draw-map-place: #f8fafc; /* 지명 */
--draw-map-zone-forest: #22c55e; /* 보호 · 규제 구역 — 산림보호구역 */
--draw-map-zone-baekdu: #84cc16; /* 백두대간보호지역 */
--draw-map-zone-water: #3b82f6; /* 상수원보호구역 */
--draw-map-zone-park: #10b981; /* 자연공원 */
--draw-map-zone-heritage: #c084fc; /* 국가유산 지정 · 보호구역 */
--draw-map-zone-wetland: #2dd4bf; /* 습지보호지역 */
--draw-map-zone-wildlife: #eab308; /* 야생생물보호구역 */
--draw-map-zone-greenbelt: #f97316; /* 개발제한구역 */
}
:root[data-theme="dark"] {