diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 4cdd47984..db579a352 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -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 => request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); -export const fetchPlanRaster = (projectId: string, kind: "slope"): Promise => - request(`${base(projectId)}/layers/raster?kind=${kind}`); +export const fetchPlanRaster = ( + projectId: string, + kind: "slope" | "hillshade", +): Promise => request(`${base(projectId)}/layers/raster?kind=${kind}`); + +/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */ +export interface PlanZones extends GeoJsonCollection { + summary: Record; +} + +export const fetchPlanZones = (projectId: string): Promise => + 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`); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py index 0aee294eb..55de83314 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Layers.py @@ -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"] diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts new file mode 100644 index 000000000..43b19d6d7 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -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 = { + 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([]); +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 250eb3551..e52430dff 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -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; 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): DataKey => + key === "forestRoad" ? "road" : key; export interface PlanLayers { bar: HTMLElement; @@ -77,8 +89,12 @@ export interface PlanLayers { layers: PlanMapLayer[]; /** 지형(위성 틀)을 다시 읽음 — 처음 · 지형 받은 뒤. */ load: () => Promise; - /** 검증 창구 — 켬끔 · 받은 겹. */ - state: () => { on: Record; loaded: PlanLayerKey[] }; + /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ + state: () => { + on: Record; + loaded: PlanLayerKey[]; + counts: Record; + }; } function readPref(): Record { @@ -113,31 +129,94 @@ export function createPlanLayers(opts: { const { projectId, map } = opts; const on = readPref(); const data: Partial = {}; - const pending = new Map>(); + const pending = new Map>(); let meta: VWorldMeta | null = null; let satellite: HTMLImageElement | null = null; const buttons = new Map(); - 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 { - 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) => 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(); + 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 { @@ -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 => ({ + ...(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(), }), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 77484c73f..ba4fa0b2e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -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 { diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index 667385454..3be6245d2 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -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 diff --git a/resources/tester/test_581_1_plan_gis_layers.py b/resources/tester/test_581_1_plan_gis_layers.py index 3bd2f37f8..6ac60270a 100644 --- a/resources/tester/test_581_1_plan_gis_layers.py +++ b/resources/tester/test_581_1_plan_gis_layers.py @@ -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() diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 7ac90773f..169b30b56 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -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; diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 7384c6da8..da5c48b76 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -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"] {