From c6a1efad0c4fe5d777f6ea3cae7f4854d9f66635 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 20:47:50 +0900 Subject: [PATCH] =?UTF-8?q?refactor(=EA=B3=B5=EC=9A=A9=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84):=20=EA=B2=B9=20=EB=B0=9B=EA=B8=B0=20=C2=B7=20?= =?UTF-8?q?=EB=B2=94=EB=A1=80=20=EC=A4=84=20=C2=B7=20=EA=B8=89=EA=B2=BD?= =?UTF-8?q?=EC=82=AC=20=EB=B2=94=EC=9C=84=20=C2=B7=20=EC=9C=84=EC=84=B1=20?= =?UTF-8?q?=EB=B0=9D=EA=B8=B0=20=C2=B7=20=EA=B2=B9=20=EC=9E=90=EB=A3=8C=20?= =?UTF-8?q?=EB=8F=84=EC=9B=80=EC=9D=84=20A00=5FCommon/map=20=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=E2=80=94=20D03=20=EC=9D=80=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EB=82=B4=EB=86=93=EA=B8=B0=20=C2=B7=20=EB=8F=99=EC=9E=91=20?= =?UTF-8?q?=EA=B7=B8=EB=8C=80=EB=A1=9C=20(PLAN=20624-3=20=C2=B7=20?= =?UTF-8?q?=EC=9A=94=EA=B5=AC=20530)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - D03_Api 겹 받기 → map_layer_api.ts(D03_Api 가 다시 내놓음) - UI_SlopeBands · logic SlopeBands · UI_SatLight → map_slope_bands · logic/map_slope_bands · map_sat_light - 범례 줄 legendRows → map_legend.ts(받은 자료 꼴 LegendData · D03 LayerLegend 가 다시 내놓음) - glImage · glTagged · shadeImage · loadImage · WATER_KEYS · FOREST_ROAD_KINDS · hectare → map_layer_data.ts - 시험 경로 고침 — test_603(TS 자리) · test_606_1(밝기 파일) · test_584_3(범례 글 = 공용 파일도 읽음) Co-Authored-By: Claude Opus 5.5 --- .../map/logic/map_slope_bands.ts | 6 +- A00_Common/map/map_layer_api.ts | 221 ++++++++++++++++++ A00_Common/map/map_layer_data.ts | 61 +++++ A00_Common/map/map_legend.ts | 174 +++++++++++++- .../map/map_sat_light.ts | 8 +- .../map/map_slope_bands.ts | 12 +- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 192 +-------------- .../D03_PlanAnalysis_UI_LayerDefs.ts | 68 +----- .../D03_PlanAnalysis_UI_LayerLegend.ts | 152 +----------- .../D03_PlanAnalysis_UI_Layers.ts | 4 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts | 2 +- .../test_584_3_plan_forest_road_zones.py | 1 + resources/tester/test_603_plan_slope_bands.py | 6 +- resources/tester/test_606_1_sat_light.py | 2 +- 14 files changed, 496 insertions(+), 413 deletions(-) rename D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts => A00_Common/map/logic/map_slope_bands.ts (93%) create mode 100644 A00_Common/map/map_layer_api.ts create mode 100644 A00_Common/map/map_layer_data.ts rename D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts => A00_Common/map/map_sat_light.ts (78%) rename D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts => A00_Common/map/map_slope_bands.ts (94%) diff --git a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts b/A00_Common/map/logic/map_slope_bands.ts similarity index 93% rename from D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts rename to A00_Common/map/logic/map_slope_bands.ts index 2fa0ab653..5f9b27a0d 100644 --- a/D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.ts +++ b/A00_Common/map/logic/map_slope_bands.ts @@ -1,10 +1,10 @@ /* ============================================================================= - * D03_PlanAnalysis_SlopeBands.ts - * D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). + * map_slope_bands.ts(logic) + * 공용 지도 급경사 범위(624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이). * 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) · * 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저. * 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`. - * 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`. + * 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`. * ========================================================================== */ export interface SlopeBand { diff --git a/A00_Common/map/map_layer_api.ts b/A00_Common/map/map_layer_api.ts new file mode 100644 index 000000000..94ab56ac8 --- /dev/null +++ b/A00_Common/map/map_layer_api.ts @@ -0,0 +1,221 @@ +/* ============================================================================= + * map_layer_api.ts + * 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김). + * 서버 길 = `/api/projects/{id}/plan/…`(624-1 · 임도 프로젝트도 · 띠 · 올린 임도망도 = 예정노선만). + * 지적도 · 도엽 겹 GeoJSON 경위도 · 도엽 조각 · 그림 겹(급경사 · 음영기복 · 산사태위험) · 보호구역 · 산림입지도 · + * 선 겹(단층 · 지질구조선) · 0차곡 · 띠 · 올린 임도망도(올리기 · 지움) · 산사태취약지역. + * D03 = `D03_PlanAnalysis_Api` 가 다시 내놓음(부르는 곳 그대로). + * ========================================================================== */ + +import { API_BASE_URL } from "@config/config_frontend"; + +/** 경위도 GeoJSON 묶음 — B04 `GeoJsonCollection` 과 같은 꼴(공용 폴더는 페이지 파일을 안 부름). */ +export type GeoJsonCollection = { + features?: Array<{ + geometry?: { type: string; coordinates: unknown } | null; + properties?: Record | null; + }>; +}; + +async function request(path: string): Promise { + const response = await fetch(`${API_BASE_URL}${path}`, { + credentials: "include", + headers: { "Content-Type": "application/json" }, + }); + const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown }; + if (!response.ok) { + const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`; + throw new Error(detail); + } + return body; +} + +const base = (projectId: string): string => `/projects/${projectId}/plan`; + +/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */ +export interface PlanRaster { + box_m: [number, number, number, number]; + /** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */ + box?: Array<[number, number]>; + image: string; + /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ + counts?: number[]; +} + +export const fetchPlanCadastral = (projectId: string): Promise => + request(`${base(projectId)}/layers/cadastral`); + +/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */ +export const fetchPlanSheetLayer = (projectId: string, key: string): Promise => + request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); + +/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */ +export const fetchPlanSheetTiles = ( + projectId: string, + bbox: [number, number, number, number], +): Promise<{ count: number; sheets: string[] }> => + request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`); + +/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */ +export const fetchPlanSheetTile = ( + projectId: string, + sheet: string, + name: string, +): Promise => + request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); + +/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 + * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ +export const fetchPlanRaster = async ( + projectId: string, + kind: "slope" | "hillshade" | "landslide", +): Promise => { + const path = `${base(projectId)}/layers/raster?kind=${kind}`; + if (kind !== "slope") return request(path); + const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); + if (!response.ok) { + const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + } + const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; + return { ...meta, image: URL.createObjectURL(await response.blob()) }; +}; + +/** 보호 · 규제 구역(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`); + +/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */ +export interface PlanSoils extends GeoJsonCollection { + summary: Array<{ code: string; name: string; count: number; area_m2: number }>; +} + +export const fetchPlanSoils = (projectId: string): Promise => + request(`${base(projectId)}/layers/soils`); + +/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */ +export interface PlanBand { + dist_m: number; + lines: number; + area_m2: number; + ratio: number; + collection: GeoJsonCollection; +} + +/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */ +export interface PlanLines extends GeoJsonCollection { + summary: { count: number; length_m: number }; +} + +/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */ +export const fetchPlanLines = (projectId: string, kind: string): Promise => + request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`); + +/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */ +export interface PlanZeroOrder { + min_m2: number; + max_m2: number; + cell_m: number; + count: number; + length_m: number; + collection: GeoJsonCollection; +} + +export const fetchPlanZeroOrder = (projectId: string): Promise => + request(`${base(projectId)}/layers/zero-order`); + +export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => + request(`${base(projectId)}/layers/bands`); + +/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */ +export interface PlanRoadBundle { + id: string; + name: string; + files: string[]; + bytes: number | null; + uploaded_at: string | null; + count: number; + length_m: number; + total: number; + bounds: [number, number, number, number] | null; + distance_m: number | null; +} + +/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선 + * (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 · + * 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */ +export interface PlanForestRoads { + bundles: PlanRoadBundle[]; + count: number; + length_m: number; + total: number; + crs: string[]; + bounds: [number, number, number, number] | null; + distance_m: number | null; + source: { files: string[]; bytes: number; uploaded_at: string } | null; + collection: GeoJsonCollection; +} + +/** 올린 적 없으면 오류(404 글 그대로). */ +export const fetchPlanForestRoads = (projectId: string): Promise => + request(`${base(projectId)}/layers/forest-roads`); + +/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */ +export async function deletePlanForestRoad( + projectId: string, + bundle: string, +): Promise { + const response = await fetch( + `${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`, + { method: "DELETE", credentials: "include" }, + ); + const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { + detail?: unknown; + }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body.bundles.length ? body : null; +} + +/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */ +export async function uploadPlanForestRoads( + projectId: string, + files: File[], +): Promise { + const form = new FormData(); + for (const file of files) form.append("files", file); + const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, { + method: "POST", + credentials: "include", + body: form, + }); + const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { + detail?: unknown; + }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} + +/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */ +export interface PlanLandslideZones { + points: Array<{ + lon: number; + lat: number; + kind: string | null; + area_m2: number | null; + place: string; + }>; + parcels: GeoJsonCollection; + sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>; + missing: string[]; + /** 지적도를 받았나(못 받으면 점만). */ + cadastral: boolean; +} + +export const fetchPlanLandslideZones = (projectId: string): Promise => + request(`${base(projectId)}/layers/landslide-zones`); diff --git a/A00_Common/map/map_layer_data.ts b/A00_Common/map/map_layer_data.ts new file mode 100644 index 000000000..5d82fb0c7 --- /dev/null +++ b/A00_Common/map/map_layer_data.ts @@ -0,0 +1,61 @@ +/* ============================================================================= + * map_layer_data.ts + * 공용 지도 겹 자료 도움(PLAN 569-2 · 581 · 586-2 · 624-3) — D03 예정노선 · B05 평면이 같이 씀(D03 `UI_LayerDefs` 에서 옮김). + * 임도 짐작 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * ========================================================================== */ + +import type { GlImage } from "@ui/ui_template_gl_map"; +import type { GeoJsonCollection, PlanRaster } from "./map_layer_api"; + +/** 도로 중 기존 임도로 짐작하는 구분. */ +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`; + +export const loadImage = (src: string): Promise => + new Promise((done, fail) => { + const image = new Image(); + image.onload = () => done(image); + image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); + image.src = src; + }); + +/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ +export function glTagged( + collection: GeoJsonCollection, + color: (props: Record) => string, + extra: Record = {}, +): GeoJsonCollection { + return { + ...collection, + features: (collection.features ?? []).map((feature) => ({ + ...feature, + properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) }, + })), + }; +} + +/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */ +export function glImage(raster: PlanRaster, url = raster.image): GlImage | null { + const box = raster.box; + return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null; +} + +/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */ +export function shadeImage(image: HTMLImageElement): string { + const canvas = document.createElement("canvas"); + canvas.width = image.naturalWidth; + canvas.height = image.naturalHeight; + const context = canvas.getContext("2d")!; + context.drawImage(image, 0, 0); + const pixels = context.getImageData(0, 0, canvas.width, canvas.height); + const px = pixels.data; + for (let i = 0; i < px.length; i += 4) { + px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255)); + px[i] = px[i + 1] = px[i + 2] = 0; + } + context.putImageData(pixels, 0, 0); + return canvas.toDataURL("image/png"); +} diff --git a/A00_Common/map/map_legend.ts b/A00_Common/map/map_legend.ts index e2dd3cc67..96a3aafb7 100644 --- a/A00_Common/map/map_legend.ts +++ b/A00_Common/map/map_legend.ts @@ -2,13 +2,52 @@ * map_legend.ts * 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄. * 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`). - * 받은 자료 범례(수 · 넓이 · 급경사 범위)는 받는 페이지 몫 — 줄을 만들어 `show` 에 넘김. + * 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData` + * (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫). * 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김. * ========================================================================== */ import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend"; import { forestLegendRows } from "./map_forest_layers"; -import { LANDSLIDE_LEGEND, type MapLayerKey } from "./map_layer_list"; +import type { + PlanBand, + PlanForestRoads, + PlanLandslideZones, + PlanRaster, + PlanSoils, + PlanZones, +} from "./map_layer_api"; +import { hectare } from "./map_layer_data"; +import { + LANDSLIDE_LEGEND, + LINE_KEYS, + LINE_LAYERS, + ZONE_COLORS, + type MapLayerKey, +} from "./map_layer_list"; +import { SLOPE_MAX_PCT, bandRatios, pctToDeg, type SlopeBand } from "./logic/map_slope_bands"; + +/** 지물 수만 보는 겹(옛 캔버스 자료 · GeoJSON 둘 다). */ +interface Counted { + features?: ArrayLike; +} + +/** 범례가 읽는 받은 자료 — 받은 겹만. */ +export interface LegendData { + slope: { raster: PlanRaster; bands: SlopeBand[] }; + landslideZones: { info: PlanLandslideZones }; + zones: { summary: PlanZones["summary"] }; + /** 토양형(코드 차례) 색 값 · 요약. */ + soils: { kinds: Array<{ color: string }>; summary: PlanSoils["summary"] }; + bands: { info: { stream: PlanBand; road: PlanBand } }; + water: Counted; + building: Counted; + road: { small: Counted }; + forestRoadFile: { info: PlanForestRoads }; + faults: { count: number; length_m: number }; + lineaments: { count: number; length_m: number }; + zeroOrder: { count: number; length_m: number }; +} export interface MapLegend { root: HTMLElement; @@ -34,3 +73,134 @@ export function createMapLegend(className: string): MapLegend { }, }; } + +const km = (m: number): string => `${(m / 1000).toFixed(2)} km`; + +/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */ +function slopeRow({ raster, bands }: LegendData["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 }: LegendData["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, + data: Partial, +): LegendItem[][] { + const rows: LegendItem[][] = []; + if (on.slope && data.slope) rows.push(slopeRow(data.slope)); + rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) + 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 ?? 0}곳`, + color: "var(--draw-map-water)", + }); + if (on.building && data.building) + tail.push({ + label: `건물 ${(data.building.features?.length ?? 0).toLocaleString()}동`, + color: "var(--draw-map-building)", + }); + if (on.forestRoad && data.road) + tail.push({ + label: `임도 짐작 ${data.road.small.features?.length ?? 0}가닥`, + 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; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts b/A00_Common/map/map_sat_light.ts similarity index 78% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts rename to A00_Common/map/map_sat_light.ts index c5dab6be4..85956f8bf 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts +++ b/A00_Common/map/map_sat_light.ts @@ -1,11 +1,11 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_SatLight.ts - * D03 위성 바탕 밝기(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절. - * 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = `D03_PlanAnalysis_UI_MapGl` `setSatelliteLight`. + * map_sat_light.ts + * 공용 지도 위성 바탕 밝기(D03 · B05 · 624-3 공용으로 옮김)(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절. + * 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = D03 `UI_MapGl` `setSatelliteLight` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createSlider } from "@ui/ui_template_input"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; +import { readByKey, writeByKey } from "../b_page_state"; const PREF_KEY = "aislo:pref:d03.satellite-light"; export const SAT_LIGHT_MIN = 0.4; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts b/A00_Common/map/map_slope_bands.ts similarity index 94% rename from D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts rename to A00_Common/map/map_slope_bands.ts index 9c7b0314c..5ab73e403 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_SlopeBands.ts +++ b/A00_Common/map/map_slope_bands.ts @@ -1,10 +1,10 @@ /* ============================================================================= - * D03_PlanAnalysis_UI_SlopeBands.ts - * D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 + * map_slope_bands.ts + * 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달 * (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음). * 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관). * 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장). - * 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`. + * 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`. * ========================================================================== */ import { createButton, createIconButton } from "@ui/ui_template_button"; @@ -13,8 +13,8 @@ import { createLegend } from "@ui/ui_template_flow_legend"; import { openModal } from "@ui/ui_template_modal"; import { themeColor } from "@ui/ui_template_palette"; import { createText } from "@ui/ui_template_text"; -import { readByKey, writeByKey } from "../A00_Common/b_page_state"; -import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs"; +import { readByKey, writeByKey } from "../b_page_state"; +import { loadImage } from "./map_layer_data"; import { DEFAULT_SLOPE_BANDS, SLOPE_COLORS, @@ -25,7 +25,7 @@ import { parseBands, pctToDeg, type SlopeBand, -} from "./logic/D03_PlanAnalysis_SlopeBands"; +} from "./logic/map_slope_bands"; const PREF_KEY = "aislo:pref:d03.slope-bands"; /** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 0026c5d0c..83978361e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -3,13 +3,12 @@ * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 · * 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) · - * 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도). + * 지도 겹 받기 = 공용 `A00_Common/map/map_layer_api`(624-3 · 다시 내놓음). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { fetchCachedBytes } from "../A00_Common/b_asset_cache"; import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; -import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler"; import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan"; @@ -180,190 +179,5 @@ export async function fetchPlanSurface(projectId: string): Promise; - image: string; - /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ - counts?: number[]; -} - -export const fetchPlanCadastral = (projectId: string): Promise => - request(`${base(projectId)}/layers/cadastral`); - -/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */ -export const fetchPlanSheetLayer = (projectId: string, key: string): Promise => - request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`); - -/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */ -export const fetchPlanSheetTiles = ( - projectId: string, - bbox: [number, number, number, number], -): Promise<{ count: number; sheets: string[] }> => - request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`); - -/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */ -export const fetchPlanSheetTile = ( - projectId: string, - sheet: string, - name: string, -): Promise => - request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`); - -/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면 - * `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */ -export const fetchPlanRaster = async ( - projectId: string, - kind: "slope" | "hillshade" | "landslide", -): Promise => { - const path = `${base(projectId)}/layers/raster?kind=${kind}`; - if (kind !== "slope") return request(path); - const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); - if (!response.ok) { - const body = (await response.json().catch(() => ({}))) as { detail?: unknown }; - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - } - const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster; - return { ...meta, image: URL.createObjectURL(await response.blob()) }; -}; - -/** 보호 · 규제 구역(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`); - -/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */ -export interface PlanSoils extends GeoJsonCollection { - summary: Array<{ code: string; name: string; count: number; area_m2: number }>; -} - -export const fetchPlanSoils = (projectId: string): Promise => - request(`${base(projectId)}/layers/soils`); - -/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */ -export interface PlanBand { - dist_m: number; - lines: number; - area_m2: number; - ratio: number; - collection: GeoJsonCollection; -} - -/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */ -export interface PlanLines extends GeoJsonCollection { - summary: { count: number; length_m: number }; -} - -/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */ -export const fetchPlanLines = (projectId: string, kind: string): Promise => - request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`); - -/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */ -export interface PlanZeroOrder { - min_m2: number; - max_m2: number; - cell_m: number; - count: number; - length_m: number; - collection: GeoJsonCollection; -} - -export const fetchPlanZeroOrder = (projectId: string): Promise => - request(`${base(projectId)}/layers/zero-order`); - -export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => - request(`${base(projectId)}/layers/bands`); - -/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */ -export interface PlanRoadBundle { - id: string; - name: string; - files: string[]; - bytes: number | null; - uploaded_at: string | null; - count: number; - length_m: number; - total: number; - bounds: [number, number, number, number] | null; - distance_m: number | null; -} - -/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선 - * (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 · - * 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */ -export interface PlanForestRoads { - bundles: PlanRoadBundle[]; - count: number; - length_m: number; - total: number; - crs: string[]; - bounds: [number, number, number, number] | null; - distance_m: number | null; - source: { files: string[]; bytes: number; uploaded_at: string } | null; - collection: GeoJsonCollection; -} - -/** 올린 적 없으면 오류(404 글 그대로). */ -export const fetchPlanForestRoads = (projectId: string): Promise => - request(`${base(projectId)}/layers/forest-roads`); - -/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */ -export async function deletePlanForestRoad( - projectId: string, - bundle: string, -): Promise { - const response = await fetch( - `${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`, - { method: "DELETE", credentials: "include" }, - ); - const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { - detail?: unknown; - }; - if (!response.ok) - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - return body.bundles.length ? body : null; -} - -/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */ -export async function uploadPlanForestRoads( - projectId: string, - files: File[], -): Promise { - const form = new FormData(); - for (const file of files) form.append("files", file); - const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, { - method: "POST", - credentials: "include", - body: form, - }); - const body = (await response.json().catch(() => ({}))) as PlanForestRoads & { - detail?: unknown; - }; - if (!response.ok) - throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); - return body; -} - -/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */ -export interface PlanLandslideZones { - points: Array<{ - lon: number; - lat: number; - kind: string | null; - area_m2: number | null; - place: string; - }>; - parcels: GeoJsonCollection; - sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>; - missing: string[]; - /** 지적도를 받았나(못 받으면 점만). */ - cadastral: boolean; -} - -export const fetchPlanLandslideZones = (projectId: string): Promise => - request(`${base(projectId)}/layers/landslide-zones`); +/** 지도 겹 받기 = 공용(624-3 · B05 평면도 씀). */ +export * from "../A00_Common/map/map_layer_api"; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts index c8bc37042..dfdfe2b9e 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts @@ -1,7 +1,7 @@ /* ============================================================================= * D03_PlanAnalysis_UI_LayerDefs.ts - * D03 바탕 겹 받기 · 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2) — 임도 짐작 구분 · 물 면 · - * 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 · GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림). + * D03 바탕 겹 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2 · 624-3) — 면 겹 채우기 · 선 긋기 · 그림 겹 놓기. + * 임도 짐작 구분 · 물 면 · 그림 읽기 · GL 자료 = 공용 `A00_Common/map/map_layer_data`(624-3 · 다시 내놓음). * 겹 목록 · 처음 값 · 켬끔 취향 · 선 겹 꼴 · 보호구역 색 = 공용 `A00_Common/map/map_layer_list`(624-2). * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례). * ========================================================================== */ @@ -10,16 +10,23 @@ import type { MapLayerKey } from "../A00_Common/map/map_layer_list"; import { drawPreparedLayer, normalizedToScreen, - type GeoJsonCollection, type PreparedLayer, type ViewState, } from "../B04_PreProcess/B04_PreProcess_UI_MapRender"; import type { PlanRaster } from "./D03_PlanAnalysis_Api"; import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map"; -import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl"; /** 겹 목록 · 페이지 표 = 공용 `map_layer_list`(624-2) — 옛 이름으로 다시 내놓음(캔버스 · 조각 · 임도망 목록이 읽음). */ export type PlanLayerKey = MapLayerKey; +export { + FOREST_ROAD_KINDS, + WATER_KEYS, + glImage, + glTagged, + hectare, + loadImage, + shadeImage, +} from "../A00_Common/map/map_layer_data"; export { LINE_KEYS, LINE_LAYERS, @@ -27,13 +34,6 @@ export { type RoadPick, } from "../A00_Common/map/map_layer_list"; -/** 도로 중 기존 임도로 짐작하는 구분. */ -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, @@ -79,14 +79,6 @@ export function fillLayer( context.setLineDash([]); } -export const loadImage = (src: string): Promise => - new Promise((done, fail) => { - const image = new Image(); - image.onload = () => done(image); - image.onerror = () => fail(new Error("그림을 읽지 못했습니다")); - image.src = src; - }); - /** 선 겹 긋기(점 솎음 `dot` · `everyM` = 등고 간격 거름). */ export function stroke( context: CanvasRenderingContext2D, @@ -123,41 +115,3 @@ export function drawRaster( ); context.drawImage(raster.image, 0, 0); } - -/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */ -export function glTagged( - collection: GeoJsonCollection, - color: (props: Record) => string, - extra: Record = {}, -): GeoJsonCollection { - return { - ...collection, - features: (collection.features ?? []).map((feature) => ({ - ...feature, - properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) }, - })), - }; -} - -/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */ -export function glImage(raster: PlanRaster, url = raster.image): GlImage | null { - const box = raster.box; - return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null; -} - -/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */ -export function shadeImage(image: HTMLImageElement): string { - const canvas = document.createElement("canvas"); - canvas.width = image.naturalWidth; - canvas.height = image.naturalHeight; - const context = canvas.getContext("2d")!; - context.drawImage(image, 0, 0); - const pixels = context.getImageData(0, 0, canvas.width, canvas.height); - const px = pixels.data; - for (let i = 0; i < px.length; i += 4) { - px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255)); - px[i] = px[i + 1] = px[i + 2] = 0; - } - context.putImageData(pixels, 0, 0); - return canvas.toDataURL("image/png"); -} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 27658bfda..ca9057ee1 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -3,34 +3,23 @@ * D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이. * 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈). * 범례 칸 · 조각 겹 범례 = 공용 `A00_Common/map/map_legend`(624-2) · 여기는 받은 자료 줄만. - * 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기). + * 범례 줄 셈 = 공용 `map_legend` `legendRows`(624-3) · 여기는 자료 꼴 · 겹별 수 · 부르는 곳 = `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, + PlanBand, } from "./D03_PlanAnalysis_Api"; -import { - LINE_KEYS, - LINE_LAYERS, - ZONE_COLORS, - type LineLayerKey, - type MapLayerKey, -} from "../A00_Common/map/map_layer_list"; -import { tileLegendRows } from "../A00_Common/map/map_legend"; -import { hectare } from "./D03_PlanAnalysis_UI_LayerDefs"; -import { - SLOPE_MAX_PCT, - bandRatios, - pctToDeg, - type SlopeBand, -} from "./logic/D03_PlanAnalysis_SlopeBands"; +import { LINE_KEYS, type LineLayerKey } from "../A00_Common/map/map_layer_list"; +import { bandRatios, type SlopeBand } from "../A00_Common/map/logic/map_slope_bands"; + +/** 범례 줄 = 공용(624-3 · B05 평면도 씀) — 옛 이름으로 다시 내놓음. */ +export { legendRows } from "../A00_Common/map/map_legend"; /** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */ export interface PreparedLines { @@ -69,133 +58,6 @@ export type Prepared = Record & { 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, - data: Partial, -): LegendItem[][] { - const rows: LegendItem[][] = []; - if (on.slope && data.slope) rows.push(slopeRow(data.slope)); - rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례) - 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): Record => ({ ...(data.building && { building: data.building.features.length }), diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 476535e1b..c5767d7a7 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -68,7 +68,7 @@ import { import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend"; import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint"; import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl"; -import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight"; +import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light"; import { createSheetTiles, SHEET_TILE_NAMES, @@ -81,7 +81,7 @@ import { slopeGear, slopeValues, writeSlopeBands, -} from "./D03_PlanAnalysis_UI_SlopeBands"; +} from "../A00_Common/map/map_slope_bands"; import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map"; /** 옛 캔버스로 그림("1") — 처음 = GL. */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts index 1c501c919..5cc46a38d 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts @@ -28,7 +28,7 @@ import { type GlSource, } from "../A00_Common/map/map_layer_specs"; import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs"; -import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight"; +import { SAT_LIGHT_DEFAULT } from "../A00_Common/map/map_sat_light"; export type { GlImage }; diff --git a/resources/tester/test_584_3_plan_forest_road_zones.py b/resources/tester/test_584_3_plan_forest_road_zones.py index 7586f991c..2aabae7fa 100644 --- a/resources/tester/test_584_3_plan_forest_road_zones.py +++ b/resources/tester/test_584_3_plan_forest_road_zones.py @@ -332,6 +332,7 @@ def test_screen_defs() -> None: ) # 586-2 캔버스 그리기 따로 page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8") + legend += (ROOT / "A00_Common" / "map" / "map_legend.ts").read_text("utf-8") # 624-3 범례 줄 공용 for key, color in ( ("forestRoadFile", "forest-road-file"), ("landslideZones", "landslide-zone"), diff --git a/resources/tester/test_603_plan_slope_bands.py b/resources/tester/test_603_plan_slope_bands.py index f6d1e0935..1ba956239 100644 --- a/resources/tester/test_603_plan_slope_bands.py +++ b/resources/tester/test_603_plan_slope_bands.py @@ -1,5 +1,5 @@ """D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 + -`logic/D03_PlanAnalysis_SlopeBands.ts`. +`A00_Common/map/logic/map_slope_bands.ts`(624-3 공용으로 옮김). 1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 · 범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위. @@ -22,7 +22,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers from E07_StabArea.E07_StabArea_Engine import slope_rad ROOT = Path(__file__).resolve().parents[2] -TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts" +TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_slope_bands.ts" TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc" CELL = 2.0 OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS @@ -36,7 +36,7 @@ def _hills(n: int = 300) -> np.ndarray: _RUNNER = """ import { readFileSync, writeFileSync } from "node:fs"; -import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js"; +import * as bands from "./A00_Common/map/logic/map_slope_bands.js"; const cases = JSON.parse(readFileSync(process.argv[2], "utf8")); const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS }; // 인자 null = 기본 범위 diff --git a/resources/tester/test_606_1_sat_light.py b/resources/tester/test_606_1_sat_light.py index c4f0de6e5..7d6dd8906 100644 --- a/resources/tester/test_606_1_sat_light.py +++ b/resources/tester/test_606_1_sat_light.py @@ -10,7 +10,7 @@ ROOT = Path(__file__).resolve().parents[2] read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731 MAPGL = read("A00_Common/map/map_layer_specs.ts") # 624-2 GL 겹 꼴 = 공용 -LIGHT = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts") +LIGHT = read("A00_Common/map/map_sat_light.ts") # 624-3 공용 LAYERS = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")