From f6cfaf446c6ed4d33eb0a1f051f05302edc42539 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Tue, 3 Nov 2026 08:32:53 +0900 Subject: [PATCH] =?UTF-8?q?feat(760-1):=20=EC=95=94=EC=83=81=20=EC=A7=80?= =?UTF-8?q?=EC=A7=88=EB=8F=84=20=EA=B2=B9=20=C2=B7=20=EB=85=B8=EC=84=A0=20?= =?UTF-8?q?=EB=94=B0=EB=9D=BC=20=EB=B0=94=EC=9C=84=20=EC=A2=85=EB=A5=98=20?= =?UTF-8?q?=EA=B5=AC=EA=B0=84=20=C2=B7=20=EB=B0=96=20=EA=B2=B9=20=EC=B6=9C?= =?UTF-8?q?=EC=B2=98=20=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 서버: 지오빅데이터(KIGAM) 수치지질도 1:50,000 — WFS 암상 면(깨진 한글 칸은 기호별 점 조회로 채움) · 받은 범위 ∪ 노선으로 자름 · 저장본 · 노선 따라 1 m 표본 구간 · 키는 서버만(.env GEOBIGDATA_API_KEY · 쿼리 key) - 그림 조각 대리에 geology(키 쿼리 이름 칸 key_param · 옛 겹 serviceKey 그대로) - 화면(D03 · B05): 「제한·재해」에 「암상 지질도」 — 그림 조각 + 갈래 색 면 · 「기호 · 암상」 글 · 범례 · 노선 구간 표(참고 — 토사 · 암 판정 아님) - 켠 밖 겹마다 범례 맨 아래 출처 줄(브이월드 · 국토지리정보원 · 산림청 · 환경부 · 생활안전지도 공공누리 · 한국지질자원연구원 CC BY-NC) - 범례 칸 높이 한도 · 밀기(낮은 화면) - 시험: test_760_1 새로 · test_634_2 · test_553_4 겹 차례 표에 geology 한 줄 더함(새 겹 · 낡은 표) Co-Authored-By: Claude Opus 5.5 --- A00_Common/map/map_forest_layers.ts | 22 +- A00_Common/map/map_geology.ts | 161 +++++++ A00_Common/map/map_layer_api.ts | 2 +- A00_Common/map/map_layer_list.ts | 2 + A00_Common/map/map_layer_source.ts | 31 ++ A00_Common/map/map_layer_specs.ts | 2 + A00_Common/map/map_legend.ts | 6 + .../B05_Profile_UI_RouteEdit_Layers.ts | 11 +- .../B05_Profile_UI_Style_RouteEdit.css | 2 + D03_PlanAnalysis/D03_PlanAnalysis_Geology.py | 400 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_Router.py | 18 +- .../D03_PlanAnalysis_Router_Tiles.py | 2 +- .../D03_PlanAnalysis_UI_LayerLegend.ts | 3 + .../D03_PlanAnalysis_UI_Layers.ts | 7 +- .../D03_PlanAnalysis_UI_Style.css | 2 + common_util/common_util_wms_tiles.py | 26 +- resources/tester/test_553_4_de_parts.py | 2 +- resources/tester/test_634_2_hover_table.py | 1 + resources/tester/test_760_1_geology.py | 172 ++++++++ ui_template/ui_template_icons.ts | 1 + ui_template/ui_template_theme.css | 6 + 21 files changed, 863 insertions(+), 16 deletions(-) create mode 100644 A00_Common/map/map_geology.ts create mode 100644 A00_Common/map/map_layer_source.ts create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_Geology.py create mode 100644 resources/tester/test_760_1_geology.py diff --git a/A00_Common/map/map_forest_layers.ts b/A00_Common/map/map_forest_layers.ts index 8e0e00206..293eac670 100644 --- a/A00_Common/map/map_forest_layers.ts +++ b/A00_Common/map/map_forest_layers.ts @@ -3,7 +3,7 @@ * 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴). * 벡터 = 임도망도(forest_road · 전국 국가임도 · 간선 · 작업 · 산불 색 · 줌 따라 굵기 · 줌 14 부터 연도 · 종류 글) · 산림입지도(forest_soil · 토양 무리 색 · * 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) / - * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로). + * 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로) · 암상 지질도(geology_map · 760-1 · 도형 = `map_geology`). * 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임. * 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것. * 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례). @@ -29,6 +29,7 @@ export const TRACKED_SOURCES = [ "eco_forest", "ecology", "landslide", + "geology_map", ]; /** 고정 소스 — 벡터 z8~15(서버 줌별 단순화) · 그림 z6~19. */ @@ -41,6 +42,7 @@ export function forestSources(projectId: string): Record; -const raster = (key: "landslide" | "ecology", opacity: number): Spec => +const raster = ( + key: "landslide" | "ecology" | "geology", + opacity: number, + source: string = key, +): Spec => ({ key, - id: key, + id: source === key ? key : source, type: "raster", - source: key, + source, paint: { "raster-opacity": opacity, "raster-fade-duration": 0 }, }) as Spec; -/** 그림 겹(산사태위험 · 생태자연도) — 아래 차례(급경사 위). */ -export const forestRasterSpecs = (): Spec[] => [raster("landslide", 0.6), raster("ecology", 0.55)]; +/** 그림 겹(암상 지질도 · 산사태위험 · 생태자연도) — 아래 차례(급경사 위). */ +export const forestRasterSpecs = (): Spec[] => [ + raster("geology", 0.5, "geology_map"), + raster("landslide", 0.6), + raster("ecology", 0.55), +]; /** 면 겹(산림입지도 · 경제림) — 보호구역 아래. */ export function forestAreaSpecs(): Spec[] { diff --git a/A00_Common/map/map_geology.ts b/A00_Common/map/map_geology.ts new file mode 100644 index 000000000..481c8a8c4 --- /dev/null +++ b/A00_Common/map/map_geology.ts @@ -0,0 +1,161 @@ +/* ============================================================================= + * map_geology.ts + * 공용 지도 암상 지질도 겹(PLAN 760-1 · 요구 597-3 · 598-3) — D03 예정노선 · B05 평면이 같이 씀. + * 그림 = KIGAM 5만 WMS 조각(`map_forest_layers` 「geology_map」) · 도형 = 서버 `/plan/layers/geology` + * (기호별 요약 · 경위도 면 · 노선마다 암상 구간 · 키는 서버만). + * 색 = 바위 갈래(서버 `ROCK_GROUPS` 차례) → `--draw-map-geology-1..6` · 면 위 글 = 「기호 · 첫 암상 낱말」. + * 범례 줄(`geologyRow`) · 노선 구간 표(`geologyTail` · 참고 — 토사 · 암 판정 아님) = 범례 칸. + * ========================================================================== */ + +import type { LegendItem } from "@ui/ui_template_flow_legend"; +import { GL_FONT, type GlSpec } from "@ui/ui_template_gl_map"; +import { themeColor } from "@ui/ui_template_palette"; +import { createTable } from "@ui/ui_template_table"; +import { el } from "@ui/ui_template_elements_base"; +import { stationLabel } from "@util/common_util_station"; +import { request, type GeoJsonCollection } from "./map_layer_api"; +import { hectare } from "./map_layer_data"; +import type { MapLayerKey } from "./map_layer_list"; + +/** 기호 하나(받은 범위 안). */ +export interface GeologyUnit { + symnum: string; + symbol: string; + litho: string; + strata: string; + age: string; + sheet: string; + group: number; + count: number; + area_m2: number; +} + +/** 노선 구간 — `i` = 요약 차례 · -1 = 자료 없음. */ +export interface GeologySegment { + from_m: number; + to_m: number; + i: number; +} + +export interface PlanGeology extends GeoJsonCollection { + source: string; + summary: GeologyUnit[]; + groups: string[]; + routes: Array<{ name: string; segments: GeologySegment[] }>; +} + +const GROUP_FALLBACK = ["#f472b6", "#fb923c", "#fde047", "#818cf8", "#e5e7eb", "#5eead4"]; +const groupToken = (group: number): string => `--draw-map-geology-${Math.min(group, 5) + 1}`; +const firstWord = (text: string): string => text.split(/\s+/)[0] ?? ""; +/** 긴 암상 글 줄임(범례 · 표 폭). */ +const short = (text: string, max = 22): string => + text.length > max ? `${text.slice(0, max)}…` : text; + +/** 받기 — 면 위 글 꼴을 먼저 읽어 둠(한글 글자). */ +export async function fetchPlanGeology(projectId: string): Promise { + const got = await request(`/projects/${projectId}/plan/layers/geology`); + const text = got.summary.map((unit) => unit.symbol + unit.litho).join("") || "가"; + await document.fonts.load(`600 24px "${GL_FONT}"`, text).catch(() => null); + return got; +} + +/** GL 자료 — 지물마다 색(`_color`) · 글(`_label`). */ +export function geologyCollection(info: PlanGeology): GeoJsonCollection { + return { + features: (info.features ?? []).map((feature) => { + const unit = info.summary[Number(feature.properties?.["i"])]; + const group = unit?.group ?? 5; + return { + ...feature, + properties: { + ...feature.properties, + _color: themeColor(groupToken(group), GROUP_FALLBACK[group] ?? GROUP_FALLBACK[5]), + _label: unit ? `${unit.symbol} · ${firstWord(unit.litho)}` : "", + }, + }; + }), + }; +} + +/** GL 꼴 — 연한 채움 · 갈래 색 테두리 · 면 위 글(겹치면 뺌). */ +export function geologySpecs(): GlSpec[] { + const color = ["get", "_color"]; + return [ + { + key: "geology", + id: "geology", + type: "fill", + source: "geology", + paint: { "fill-color": color, "fill-opacity": 0.2 }, + }, + { + key: "geology", + id: "geology-edge", + type: "line", + source: "geology", + layout: { "line-join": "round" }, + paint: { "line-color": color, "line-width": 1.6, "line-opacity": 0.95 }, + }, + { + key: "geology", + id: "geology-label", + type: "symbol", + source: "geology", + layout: { + "text-field": ["get", "_label"], + "text-font": [GL_FONT], + "text-size": 12, + "text-padding": 4, + }, + paint: { + "text-color": color, + "text-halo-color": "rgba(0, 0, 0, 0.75)", + "text-halo-width": 1.5, + }, + }, + ] as unknown as GlSpec[]; +} + +const unitColor = (unit: GeologyUnit | undefined): string => + unit ? `var(${groupToken(unit.group)})` : "transparent"; + +/** 범례 줄 — 기호마다 「기호 대표암상 넓이」(갈래 색) · 없으면 한 줄. */ +export function geologyRow(info: PlanGeology): LegendItem[] { + if (!info.summary.length) + return [{ label: "암상 지질도 없음(받은 범위 안)", color: "transparent", off: true }]; + return info.summary.map((unit) => ({ + label: `${unit.symbol} ${short(unit.litho || unit.strata)} ${hectare(unit.area_m2)}`, + color: unitColor(unit), + })); +} + +/** 노선 따라 바위 종류 구간 표(노선마다) + 참고 글 — 켬 · 받음 · 노선 있을 때만. */ +export function geologyTail(on: Record, info?: PlanGeology): HTMLElement[] { + if (!on.geology || !info?.routes.length) return []; + const columns = [ + { label: "측점" }, + { label: "길이", align: "end" as const }, + { label: "기호" }, + { label: "대표암상" }, + ]; + const tables = info.routes.map((route) => { + const rows = route.segments.map((seg) => { + const unit = info.summary[seg.i]; + return [ + `${stationLabel(seg.from_m, 20)} ~ ${stationLabel(seg.to_m, 20)}`, + `${(seg.to_m - seg.from_m).toFixed(0)} m`, + unit?.symbol ?? "—", + unit ? short(unit.litho || unit.strata, 30) : "자료 없음", + ]; + }); + return el("div", { + className: "ui-legend ui-legend--vertical", + children: [`암상 구간 — ${route.name}`, createTable(columns, rows, { density: "data" })], + }); + }); + const note = el("div", { + className: "ui-legend", + children: ["참고 — 토사 · 암 판정 아님(1:50,000 바위 종류 · 경계 수십 m 어긋날 수 있음)"], + }); + return [...tables, note]; +} diff --git a/A00_Common/map/map_layer_api.ts b/A00_Common/map/map_layer_api.ts index 32e7aad5b..ae64c6414 100644 --- a/A00_Common/map/map_layer_api.ts +++ b/A00_Common/map/map_layer_api.ts @@ -19,7 +19,7 @@ export type GeoJsonCollection = { }>; }; -async function request(path: string): Promise { +export async function request(path: string): Promise { const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include", headers: { "Content-Type": "application/json" }, diff --git a/A00_Common/map/map_layer_list.ts b/A00_Common/map/map_layer_list.ts index 580db287e..82b0baa56 100644 --- a/A00_Common/map/map_layer_list.ts +++ b/A00_Common/map/map_layer_list.ts @@ -29,6 +29,7 @@ export type MapLayerKey = | "soils" | "landslide" | "landslideZones" + | "geology" // 암상 지질도(760-1 · KIGAM 5만 · 그림 조각 + 도형) | "faults" | "lineaments" | "zeroOrder" @@ -100,6 +101,7 @@ export const LAYERS: readonly LayerDef[] = [ row("ecology", "plant", "생태자연도", "limit"), row("landslide", "alert", "산사태위험지도", "limit"), row("landslideZones", "alertOctagon", "산사태취약지역", "limit"), + row("geology", "strata", "암상 지질도", "limit"), row("faults", "bolt", "단층", "limit"), row("lineaments", "lineament", "지질구조선", "limit"), ]; diff --git a/A00_Common/map/map_layer_source.ts b/A00_Common/map/map_layer_source.ts new file mode 100644 index 000000000..127ee9136 --- /dev/null +++ b/A00_Common/map/map_layer_source.ts @@ -0,0 +1,31 @@ +/* ============================================================================= + * map_layer_source.ts + * 공용 지도 밖 겹 출처(PLAN 760-1 · 요구 598-4 「받아온 곳의 정보를 표기」) — 켠 겹의 받아온 곳을 범례 맨 아래 한 줄씩. + * 받아온 곳 = 서버가 부르는 곳 그대로(브이월드 WFS · 위성 · 산림청 원자료 · 생활안전지도 WMS · 지오빅데이터 …) · + * 자체 셈 겹(음영 · 급경사 · 0차곡 · 띠) · 사용자 올림(임도(업로드)) · 자기 지형(등고선 · 세류)은 안 적음. + * ========================================================================== */ + +import type { LegendItem } from "@ui/ui_template_flow_legend"; +import { LAYERS, type MapLayerKey } from "./map_layer_list"; + +/** 받아온 곳 → 겹(차례 = 범례 차례). */ +export const LAYER_SOURCES: ReadonlyArray<[string, readonly MapLayerKey[]]> = [ + ["브이월드", ["satellite", "cadastral", "zones", "faults", "lineaments"]], + ["국토지리정보원 수치지형도", ["water", "road", "building", "place"]], + ["산림청", ["nationalRoad", "ecoForest", "soils"]], + ["시도 산사태취약지역 현황(공공데이터포털)", ["landslideZones"]], + ["환경부 생태자연도(공공데이터포털)", ["ecology"]], + ["행정안전부 생활안전지도(공공누리 제4유형)", ["landslide"]], + ["한국지질자원연구원 수치지질도 1:50,000(CC BY-NC)", ["geology"]], +]; + +/** 켠 밖 겹의 출처 줄 — 「출처: 곳 — 겹 · 겹」 · 없으면 빈 것. */ +export function sourceRow(on: Record): LegendItem[] { + const title = (key: MapLayerKey): string => LAYERS.find((def) => def.key === key)?.title ?? key; + return LAYER_SOURCES.flatMap(([where, keys]) => { + const shown = keys.filter((key) => on[key]); + return shown.length + ? [{ label: `출처: ${where} — ${shown.map(title).join(" · ")}`, color: "transparent" }] + : []; + }); +} diff --git a/A00_Common/map/map_layer_specs.ts b/A00_Common/map/map_layer_specs.ts index e8c7c3648..ad431d786 100644 --- a/A00_Common/map/map_layer_specs.ts +++ b/A00_Common/map/map_layer_specs.ts @@ -17,6 +17,7 @@ import { forestRoadLabelSpec, forestRoadSpecs, } from "./map_forest_layers"; +import { geologySpecs } from "./map_geology"; import { LINE_KEYS, LINE_LAYERS, @@ -137,6 +138,7 @@ export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] { raster("slope", 0.55), ...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2) ...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각 + ...geologySpecs(), // 암상 지질도 면 · 글(760-1) ...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }), // 띠 = 한 자료(`_band`) · 겹 둘(634-2 · 도로 300 m 아래 · 계류 100 m 위 — 옛 한 겹 차례 그대로) ...fillEdge("roadBands", "bands", COLOR, 0.16, BAND_EDGE, ["==", ["get", "_band"], "road"]), diff --git a/A00_Common/map/map_legend.ts b/A00_Common/map/map_legend.ts index 7ae0461d9..326ff5206 100644 --- a/A00_Common/map/map_legend.ts +++ b/A00_Common/map/map_legend.ts @@ -4,11 +4,13 @@ * 조각 겹 범례(산사태위험 · 임도망도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`). * 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData` * (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫). + * 암상 지질도 줄 · 노선 구간 표 = `map_geology`(760-1) · 켠 밖 겹 출처 줄 = 맨 아래(`map_layer_source` · 598-4). * 쓰는 법 = `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 { geologyRow, type PlanGeology } from "./map_geology"; import type { PlanBand, PlanForestRoads, @@ -18,6 +20,7 @@ import type { PlanZones, } from "./map_layer_api"; import { hectare } from "./map_layer_data"; +import { sourceRow } from "./map_layer_source"; import { LANDSLIDE_LEGEND, LINE_KEYS, @@ -47,6 +50,7 @@ export interface LegendData { faults: { count: number; length_m: number }; lineaments: { count: number; length_m: number }; zeroOrder: { count: number; length_m: number }; + geology: PlanGeology; } export interface MapLegend { @@ -187,6 +191,7 @@ export function legendRows( ]; }); if (lines.length) rows.push(lines); + if (on.geology && data.geology) rows.push(geologyRow(data.geology)); const tail: LegendItem[] = []; if (on.water && data.water) tail.push({ @@ -205,5 +210,6 @@ export function legendRows( kind: "line", }); if (tail.length) rows.push(tail); + rows.push(...sourceRow(on).map((item) => [item])); return rows; } diff --git a/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts b/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts index 5e0346958..7a839bfdf 100644 --- a/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts +++ b/B05_Profile/B05_Profile_UI_RouteEdit_Layers.ts @@ -48,6 +48,7 @@ import { type MapLayerKey, } from "../A00_Common/map/map_layer_list"; import { createMapLegend, legendRows, type LegendData } from "../A00_Common/map/map_legend"; +import { fetchPlanGeology, geologyCollection, geologyTail } from "../A00_Common/map/map_geology"; import { MAP_THEME_NAMES, mapLayerSpecs } from "../A00_Common/map/map_layer_specs"; import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light"; import { @@ -190,7 +191,11 @@ export function createRouteEditLayers(opts: { layerBar.sync({ ready: !!meta, glOn, why }); if (hillshade) gl.setData("hillshade", on.satellite ? hillshade.shade : hillshade.plain); const shown = glOn ? on : ({} as Record); - legendBox.show(meta ? legendRows(shown, data) : [], glOn && on.satellite ? [lightRow] : []); + const tail = [ + ...(meta ? geologyTail(shown, data.geology) : []), + ...(glOn && on.satellite ? [lightRow] : []), + ]; + legendBox.show(meta ? legendRows(shown, data) : [], tail); if (glOn && on.slope && data.slope) legendBox.root.firstElementChild?.append(gear); if (first) void Promise.all(LAYERS.map((def) => ensure(def.key))); } @@ -322,6 +327,10 @@ export function createRouteEditLayers(opts: { }; return; } + if (key === "geology") { + data.geology = await fetchPlanGeology(projectId); // 암상(760-1) + return gl.setData("geology", geologyCollection(data.geology)); + } if (key === "landslideZones") { const info = await fetchPlanLandslideZones(projectId); data.landslideZones = { info }; diff --git a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css index 5143ce7c3..5e59466c4 100644 --- a/B05_Profile/B05_Profile_UI_Style_RouteEdit.css +++ b/B05_Profile/B05_Profile_UI_Style_RouteEdit.css @@ -463,6 +463,8 @@ html.is-bm-route .b05-routeedit__tools { gap: var(--spacing-4); margin-bottom: var(--spacing-4); pointer-events: auto; + max-height: 50vh; /* 760-1 암상 구간 표 · 출처 줄 — 낮은 화면에서 밀기 */ + overflow-y: auto; } .b05-routeedit__maplegend[hidden], diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Geology.py b/D03_PlanAnalysis/D03_PlanAnalysis_Geology.py new file mode 100644 index 000000000..67549855f --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Geology.py @@ -0,0 +1,400 @@ +"""D 노선분석 지도 암상 지질도 겹(PLAN 760-1 · 요구 597-3 · 598-3 · 근거 `_분석_597-3`). + +- 출처 = 한국지질자원연구원 지오빅데이터 오픈API(수치지질도 1:50,000 · CC BY-NC) · 키 = `.env` + `GEOBIGDATA_API_KEY`(쿼리 `key` · 없으면 `MissingKeyError` · 값은 어떤 로그 · 응답 · 오류 글에도 안 냄) +- 그림 = WMS `L_50K_Geology_Map` 조각 대리(`common_util_wms_tiles` 「geology」) +- 도형 = WFS `geoOpen:l_50k_geology_litho_latest`(GeoJSON · 경위도로 받아 작업 좌표계로 옮김) · + 한글 칸이 「?」로 깨짐 → 기호(`symnum`)마다 그 면 안 점 하나 GetFeatureInfo 로 시대 · 지층 · + 대표암상 · 기호를 채움(대문자 전체 인자 · 1.1.1) +- 받은 범위로 자르고 기호별 넓이 · 바위 갈래(`ROCK_GROUPS` · 색 = 화면 `--draw-map-geology-*`) · + 저장 = `<받은 겹 자리>/암상지질도_bounds.json`(상자가 덮으면 다시 안 받음) +- 노선 따라 구간 = `route_segments`(1 m 걸음 표본 · 참고 표시 — 토사 · 암 판정 아님) + +계산 자리 = ③ 서버 단독(브라우저는 KIGAM 을 부르지 않음). +""" + +from __future__ import annotations + +import json +import logging +import os +import re +import time +import urllib.parse +import urllib.request +from pathlib import Path +from typing import Any + +import numpy as np + +from common_util.common_util_json import atomic_write_json + +logger = logging.getLogger(__name__) + +Box = tuple[float, float, float, float] + +KEY_ENV = "GEOBIGDATA_API_KEY" +WMS_URL = "https://data.kigam.re.kr/openapi/wms" +WFS_URL = "https://data.kigam.re.kr/openapi/wfs" +MAP_LAYER = "L_50K_Geology_Map" +LITHO_TYPE = "geoOpen:l_50k_geology_litho_latest" +FILE = "암상지질도_bounds.json" +VERSION = 1 +MAX_FEATURES = 1000 +TIMEOUT_S = 30 +STEP_M = 1.0 +SOURCE = "한국지질자원연구원 지오빅데이터 · 수치지질도 1:50,000 (CC BY-NC)" + +# 바위 갈래(차례 = 화면 `--draw-map-geology-1..6`) — 대표암상의 첫 「…암」 낱말로 고름(없으면 글 통째) +ROCK_GROUPS: list[tuple[str, tuple[str, ...]]] = [ + ("심성암(화강암류)", ("화강", "섬록", "반려", "섬장", "몬조", "페그마")), + ("화산암 · 맥암", ("안산", "유문", "현무", "응회", "조면", "반암", "맥암", "화산", "석영맥")), + ("퇴적암", ("사암", "셰일", "세일", "이암", "역암", "석회", "미사", "이질", "처트", "백운")), + ( + "변성암", + ("편마", "편암", "규암", "천매", "대리", "혼펠", "각섬암", "점판", "미그마", "결정질"), + ), + ("미고결 퇴적층", ("충적", "자갈", "모래", "점토", "퇴적층", "붕적", "선상지")), +] +OTHER = len(ROCK_GROUPS) # 그 밖 +META = 3 # 변성암 — 글 어디든 낱말이 있으면 먼저(「화강편마암」 · 「결정질 석회암」) +ROCK_GROUP_NAMES = [name for name, _ in ROCK_GROUPS] + ["그 밖"] +SYMBOL = re.compile(r"^[A-Z][A-Za-z0-9-]*$") + + +class MissingKeyError(RuntimeError): + """`.env` 에 키 없음.""" + + +def rock_group(litho: str) -> int: + """대표암상 → 바위 갈래 번호(0 ~ `OTHER`).""" + words = litho.split() + head = next((w for w in words if w.endswith(("암", "층")) and not w.endswith("암질")), litho) + if any(key in litho for key in ROCK_GROUPS[META][1]): + return META + for text in (head, litho): + for index, (_, keys) in enumerate(ROCK_GROUPS): + if any(key in text for key in keys): + return index + return OTHER + + +def _get(url: str, params: dict[str, Any]) -> bytes: + """KIGAM 요청 — 키 없음 = `MissingKeyError` · 실패 = `RuntimeError`(오류 글에 주소 · 키 없음).""" + key = os.environ.get(KEY_ENV, "").strip() + if not key: + raise MissingKeyError(f"암상 지질도 키 없음 — .env {KEY_ENV}") + query = urllib.parse.urlencode({"key": key, **params}) + request = urllib.request.Request(f"{url}?{query}", headers={"User-Agent": "Mozilla/5.0"}) + try: + with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response: + return response.read() + except OSError as error: + raise RuntimeError(f"암상 지질도를 받지 못했습니다 — {type(error).__name__}") from None + + +def _json(body: bytes) -> Any: + """JSON 몸(같은 이름 칸이 겹쳐도 다 남김 — 깨진 한글 칸 「??」) · 아니면 `RuntimeError`.""" + try: + return json.loads(body.decode("utf-8"), object_pairs_hook=list) + except ValueError: + raise RuntimeError("암상 지질도를 받지 못했습니다 — JSON 이 아닌 응답") from None + + +def _pairs_get(pairs: Any, name: str) -> Any: + return next((v for k, v in pairs if k == name), None) if isinstance(pairs, list) else None + + +def parse_litho(body: bytes) -> list[dict[str, Any]]: + """WFS GeoJSON → [{symnum, symbol, geometry(경위도 GeoJSON)}] — 기호 = 깨진 칸 중 영문 기호 값.""" + features = _pairs_get(_json(body), "features") or [] + out = [] + for feature in features: + props = _pairs_get(feature, "properties") or [] + geometry = _pairs_get(feature, "geometry") + if not geometry: + continue + symnum = str(_pairs_get(props, "symnum") or "") + symbol = next( + ( + str(v) + for k, v in props + if k not in ("symnum", "mapname", "mapidx") and SYMBOL.match(str(v)) + ), + "", + ) + out.append({"symnum": symnum or symbol, "symbol": symbol, "geometry": _plain(geometry)}) + return out + + +def _plain(value: Any) -> Any: + """`object_pairs_hook=list` 로 읽은 값 → 보통 dict · list.""" + if ( + isinstance(value, list) + and value + and all(isinstance(p, tuple) and len(p) == 2 for p in value) + ): + return {k: _plain(v) for k, v in value} + if isinstance(value, list): + return [_plain(v) for v in value] + return value + + +def fetch_litho(bounds: dict[str, float]) -> list[dict[str, Any]]: + """받은 범위(경위도 상자) 안 암상 면.""" + body = _get( + WFS_URL, + { + "service": "WFS", + "version": "1.1.0", + "request": "GetFeature", + "typeName": LITHO_TYPE, + "maxFeatures": MAX_FEATURES, + "outputFormat": "application/json", + "srsName": "EPSG:4326", + "bbox": f"{bounds['min_lon']},{bounds['min_lat']},{bounds['max_lon']},{bounds['max_lat']},EPSG:4326", + }, + ) + return parse_litho(body) + + +def fetch_names(lon: float, lat: float) -> list[dict[str, Any]]: + """점 하나의 암상 칸(한글 정상) — 그 점을 덮는 면들의 속성.""" + d = 1e-4 + body = _get( + WMS_URL, + { + "SERVICE": "WMS", + "VERSION": "1.1.1", + "REQUEST": "GetFeatureInfo", + "LAYERS": MAP_LAYER, + "QUERY_LAYERS": MAP_LAYER, + "SRS": "EPSG:4326", + "BBOX": f"{lon - d},{lat - d},{lon + d},{lat + d}", + "WIDTH": 11, + "HEIGHT": 11, + "X": 5, + "Y": 5, + "INFO_FORMAT": "application/json", + "FORMAT": "image/png", + "STYLES": "", + }, + ) + got = _plain(_json(body)) + return [f.get("properties") or {} for f in (got or {}).get("features", [])] + + +def _names_for(symnum: str, symbol: str, lon: float, lat: float) -> dict[str, str]: + """기호 하나의 이름 칸 — 못 받으면 기호만.""" + try: + rows = fetch_names(lon, lat) + except RuntimeError as error: + logger.warning("%s", error) + rows = [] + row = next((r for r in rows if str(r.get("symnum")) == symnum), rows[0] if rows else {}) + return { + "symbol": str(row.get("기호") or symbol), + "litho": str(row.get("대표암상") or ""), + "strata": str(row.get("지층") or ""), + "age": str(row.get("시대") or ""), + "sheet": str(row.get("mapname") or ""), + } + + +def build_layer( + raw: list[dict[str, Any]], box: Box, epsg: int, names: Any = _names_for +) -> dict[str, Any]: + """받은 면 → 받은 범위로 자른 겹(경위도 지물 · 기호별 요약 · 작업 좌표계 면).""" + from pyproj import Transformer + from shapely.geometry import box as shape_box + from shapely.geometry import mapping, shape + from shapely.ops import transform + + to_m = Transformer.from_crs("EPSG:4326", f"EPSG:{epsg}", always_xy=True).transform + to_ll = Transformer.from_crs(f"EPSG:{epsg}", "EPSG:4326", always_xy=True).transform + frame = shape_box(*box) + by_sym: dict[str, dict[str, Any]] = {} + parts: list[tuple[str, Any]] = [] + for item in raw: + try: + geom = transform(to_m, shape(item["geometry"])).buffer(0).intersection(frame) + except (ValueError, TypeError, AttributeError): + continue + if geom.is_empty or geom.area <= 0: + continue + parts.append((item["symnum"], geom)) + unit = by_sym.setdefault( + item["symnum"], {"symnum": item["symnum"], "symbol": item["symbol"], "big": geom} + ) + if geom.area > unit["big"].area: + unit["big"] = geom + summary = [] + for symnum, unit in by_sym.items(): + lon, lat = to_ll(*unit["big"].representative_point().coords[0]) + info = names(symnum, unit["symbol"], lon, lat) + area = sum(g.area for s, g in parts if s == symnum) + count = sum(1 for s, _ in parts if s == symnum) + summary.append( + { + "symnum": symnum, + **info, + "group": rock_group(info["litho"]), + "count": count, + "area_m2": round(area, 1), + } + ) + summary.sort(key=lambda s: -s["area_m2"]) + index = {s["symnum"]: i for i, s in enumerate(summary)} + features = [] + for symnum, geom in parts: + unit = summary[index[symnum]] + features.append( + { + "type": "Feature", + "properties": { + "i": index[symnum], + "symbol": unit["symbol"], + "litho": unit["litho"], + "group": unit["group"], + "area_m2": round(geom.area, 1), + }, + "geometry": mapping(transform(to_ll, geom.simplify(1.0))), + } + ) + return { + "version": VERSION, + "box_m": [float(v) for v in box], + "source": SOURCE, + "summary": summary, + "features": features, + "parts_m": [[index[s], mapping(g)] for s, g in parts], + } + + +def ensure_geology(processed: Path, box: Box, epsg: int) -> dict[str, Any]: + """암상 겹 — 저장 상자가 덮으면 그대로 · 아니면 받아 지음.""" + from D03_PlanAnalysis.D03_PlanAnalysis_Layers import _covers, _lonlat_bounds + + path = processed / FILE + try: + saved = json.loads(path.read_text(encoding="utf-8")) + except (OSError, ValueError): + saved = None + if ( + isinstance(saved, dict) + and saved.get("version") == VERSION + and _covers(saved.get("box_m"), box) + ): + return saved + started = time.perf_counter() + raw = fetch_litho(_lonlat_bounds(box, epsg)) + value = build_layer(raw, box, epsg) + processed.mkdir(parents=True, exist_ok=True) + atomic_write_json(path, value) + logger.info( + "D 암상 지질도 받기: 면 %d · 기호 %d · %.2f s", + len(raw), + len(value["summary"]), + time.perf_counter() - started, + ) + return value + + +def route_segments( + parts_m: list[list[Any]], points: list[list[float]], step: float = STEP_M +) -> list[dict[str, Any]]: + """노선(작업 좌표계 꺾은선) 따라 암상 구간 — [{from_m, to_m, i}] · i = 요약 차례 · -1 = 자료 없음. + + ponytail: 1 m 걸음 표본 — 경계 오차 ≤ 1 m(1:50,000 지질 경계 어긋남 수십 m 보다 작음).""" + from shapely import contains_xy + from shapely.geometry import LineString, shape + + if len(points) < 2: + return [] + line = LineString([(float(p[0]), float(p[1])) for p in points]) + length = line.length + if length <= 0: + return [] + count = max(1, int(np.ceil(length / step))) + edges = np.minimum(np.arange(count + 1) * step, length) + mids = (edges[:-1] + edges[1:]) / 2 + xy = np.array([line.interpolate(float(m)).coords[0] for m in mids]) + owner = np.full(count, -1) + for i, geometry in parts_m: + hit = (owner < 0) & contains_xy(shape(geometry), xy[:, 0], xy[:, 1]) + owner[hit] = int(i) + segments: list[dict[str, Any]] = [] + for k in range(count): + if segments and segments[-1]["i"] == owner[k]: + segments[-1]["to_m"] = round(float(edges[k + 1]), 1) + else: + segments.append( + { + "from_m": round(float(edges[k]), 1), + "to_m": round(float(edges[k + 1]), 1), + "i": int(owner[k]), + } + ) + return segments + + +def project_routes(root: Path) -> list[dict[str, Any]]: + """프로젝트 노선 — 예정노선 = 세트마다 이은 선 · 그 밖 = 계획노선(수정본 → 초기 폴리라인).""" + from common_util.common_util_route_geometry import ( + planned_route_initial_path, + planned_route_working_path, + read_planned_route_csv, + ) + from D03_PlanAnalysis import D03_PlanAnalysis_Store as store + + plan = store.read_plan(root) + if plan and plan.get("lines"): + try: + _, units = store.plan_parts(plan) + except Exception as error: # 그물 오류 — 구간 표만 빠짐 + logger.warning("암상 구간 — 예정노선을 못 읽음: %s", error) + return [] + return [ + {"name": f"{u['line_name']} {u['id']}", "points": u["planned"]} + for u in units + if len(u["planned"]) >= 2 + ] + for path in (planned_route_working_path(root), planned_route_initial_path(root)): + if path.is_file(): + route = read_planned_route_csv(path) + if route is not None and len(route.vertices) >= 2: + return [{"name": "계획노선", "points": [[v.x, v.y] for v in route.vertices]}] + return [] + + +def cover_box(box: Box, routes: list[dict[str, Any]], pad: float = 100.0) -> Box: + """받을 상자 = 받은 범위 ∪ 노선(+ 여유) — 노선 끝이 지형 범위 밖이어도 구간이 빈칸 안 됨.""" + xs = [float(p[0]) for r in routes for p in r["points"]] + ys = [float(p[1]) for r in routes for p in r["points"]] + if not xs: + return box + return ( + min(box[0], min(xs) - pad), + min(box[1], min(ys) - pad), + max(box[2], max(xs) + pad), + max(box[3], max(ys) + pad), + ) + + +def layer_response(value: dict[str, Any], routes: list[dict[str, Any]]) -> dict[str, Any]: + """화면 답 — 경위도 지물 · 요약 · 노선마다 구간(작업 좌표계 면은 안 내보냄).""" + parts = value.get("parts_m", []) + return { + "source": value.get("source", SOURCE), + "summary": value["summary"], + "groups": ROCK_GROUP_NAMES, + "type": "FeatureCollection", + "features": value["features"], + "routes": [ + { + "name": r["name"], + "segments": route_segments(parts, r["points"]), + } + for r in routes + ], + } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py index 74dfc0d70..06cd3f235 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router.py @@ -5,7 +5,7 @@ - `GET …/plan/progress` — 일의 진행률 - `GET …/plan/tile/{z}/{x}/{y}` — 위성 조각 대리 · 서버 기억(586 GL 바탕) - `GET …/plan/layers/sheet?key=` · `…/cadastral` · `…/raster?kind=slope|hillshade|landslide` · - `…/zones` · `…/soils` · `…/bands` · `…/lines?kind=` · `…/zero-order` · + `…/zones` · `…/soils` · `…/geology`(760-1) · `…/bands` · `…/lines?kind=` · `…/zero-order` · `…/forest-roads`(GET · POST 올리기 · `/bundles` 목록 · DELETE `/<번호>`) · `…/landslide-zones` — 지도 겹 (PLAN 569-1 · 581-1 · 581-2 · 584-1 · 584-3 · 605-1) - `GET …/plan/sheet-tiles?bbox=` · `…/sheet-tiles/{도엽}/{겹}` — 보이는 도엽 · 도엽 조각(606) @@ -41,6 +41,7 @@ from common_util.common_util_http_cache import cached_file_response from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool from D03_PlanAnalysis import D03_PlanAnalysis_ForestRoad as forest_road +from D03_PlanAnalysis import D03_PlanAnalysis_Geology as geology from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide from D03_PlanAnalysis import D03_PlanAnalysis_LandslideZone as landslide_zone from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers @@ -392,6 +393,21 @@ async def plan_layer_soils(project_id: UUID, request: Request) -> Response: return cached_file_response(request, got["path"], "application/geo+json") +@router.get("/{project_id}/plan/layers/geology", response_model=None) +async def plan_layer_geology(project_id: UUID) -> dict[str, Any]: + """암상 지질도(KIGAM 5만 · 760-1) — 기호별 요약 · 경위도 면 · 노선마다 암상 구간(참고).""" + root, epsg, box, processed, *_ = await _layer_root(project_id) + try: + routes = await asyncio.to_thread(geology.project_routes, root) + box = geology.cover_box(box, routes) + value = await asyncio.to_thread(geology.ensure_geology, processed, box, epsg) + except geology.MissingKeyError as error: + raise HTTPException(status_code=503, detail=str(error)) from error + except RuntimeError as error: + raise HTTPException(status_code=502, detail=str(error)) from error + return {"status": "success", **geology.layer_response(value, routes)} + + @router.get("/{project_id}/plan/layers/bands", response_model=None) async def plan_layer_bands(project_id: UUID) -> dict[str, Any]: """계류 100 m · 도로 300 m 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본.""" diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Router_Tiles.py b/D03_PlanAnalysis/D03_PlanAnalysis_Router_Tiles.py index 732e038b2..eb2ef5103 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Router_Tiles.py +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Router_Tiles.py @@ -2,7 +2,7 @@ - `GET /api/projects/{id}/plan/vt/{name}/{z}/{x}/{y}.pbf` — 벡터 조각(forest_road · forest_soil · eco_forest · 공용 창고 · `common_util_vector_tiles`) -- `GET …/plan/wmt/{name}/{z}/{x}/{y}.png` — 그림 조각 대리(ecology · landslide · 처음 받고 기억 · +- `GET …/plan/wmt/{name}/{z}/{x}/{y}.png` — 그림 조각 대리(ecology · landslide · geology 760-1 · 처음 받고 기억 · `common_util_wms_tiles`) · 빈 · 오류 조각 = 투명 PNG + `X-Tile-Empty` · 짧게 기억 권한 = 다른 plan 길과 같음(`main.py` 등록 때 회사 보호). diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index 823fb34de..a951a9a06 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -16,6 +16,7 @@ import type { PlanBand, } from "./D03_PlanAnalysis_Api"; import { LINE_KEYS, type LineLayerKey } from "../A00_Common/map/map_layer_list"; +import type { PlanGeology } from "../A00_Common/map/map_geology"; import { bandRatios, type SlopeBand } from "../A00_Common/map/logic/map_slope_bands"; /** 범례 줄 = 공용(624-3 · B05 평면도 씀) — 옛 이름으로 다시 내놓음. */ @@ -58,6 +59,8 @@ export type Prepared = Record & { bands: { stream: PreparedLayer; road: PreparedLayer; info: { stream: PlanBand; road: PlanBand } }; building: PreparedLayer; place: Array<{ lon: number; lat: number; name: string }>; + /** 암상 지질도(760-1) — 받은 그대로(GL 만 그림). */ + geology: PlanGeology; }; /** 검증 창구 — 겹별 수(지물 · 넓이 m² · 길이 m). */ diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index 6087abcbd..5574937ab 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -35,6 +35,7 @@ import { bandCollection } from "../A00_Common/map/map_layer_data"; import { LAYERS, LINE_LAYERS, ZONE_COLORS, type RoadPick } from "../A00_Common/map/map_layer_list"; import type { TerrainSource } from "../A00_Common/map/map_terrain_chip"; import { createMapLegend } from "../A00_Common/map/map_legend"; +import { fetchPlanGeology, geologyCollection, geologyTail } from "../A00_Common/map/map_geology"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { createNormalizer, @@ -207,7 +208,8 @@ export function createPlanLayers(opts: { for (const def of LAYERS) gl.setVisible(def.key, on[def.key]); gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain)); const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음 - legendBox.show(meta ? legendRows(on, data) : [], light ? [lightRow] : []); + const tail = [...(meta ? geologyTail(on, data.geology) : []), ...(light ? [lightRow] : [])]; + legendBox.show(meta ? legendRows(on, data) : [], tail); if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear); } @@ -543,6 +545,9 @@ export function createPlanLayers(opts: { } finally { window.clearTimeout(slow); } + } else if (key === "geology") { + got.geology = await fetchPlanGeology(projectId); // 암상(760-1) — GL 만 그림 + put("geology", geologyCollection(got.geology)); } else if (key === "bands" || key === "roadBands") { const info = await fetchPlanBands(projectId); got.bands = { diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 470fd3e1b..b557bf711 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -70,6 +70,8 @@ flex-direction: column; gap: var(--spacing-4); max-width: calc(100% - 2 * var(--spacing-8)); + max-height: 60%; /* 760-1 암상 구간 표 · 출처 줄 — 낮은 화면에서 밀기 */ + overflow-y: auto; } .d03-plan__legend[hidden] { diff --git a/common_util/common_util_wms_tiles.py b/common_util/common_util_wms_tiles.py index 75535329e..1142d079b 100644 --- a/common_util/common_util_wms_tiles.py +++ b/common_util/common_util_wms_tiles.py @@ -1,7 +1,7 @@ """그림 조각 대리 — 바깥 WMS 를 웹 메르카토르 z/x/y 256 px PNG 로(PLAN 622-1 · 요구 529). -- 겹 = `SOURCES`(ecology 생태자연도 오픈API · landslide 생활안전지도 산사태위험지도) · 조각 상자 - = EPSG:3857 bbox 그대로 요청(두 곳 모두 3857 받음 · 옮겨 그리기 없음). +- 겹 = `SOURCES`(ecology 생태자연도 오픈API · landslide 생활안전지도 산사태위험지도 · geology 지오빅데이터 + 수치지질도 1:50,000 · 760-1) · 조각 상자 = EPSG:3857 bbox 그대로 요청(모두 3857 받음 · 옮겨 그리기 없음). - 처음 받고 기억 = `storage/_shared/wms_tiles//z/x/y.png`(영구 · `.part` → 바꿈) · 빈 조각 (전부 투명) · 오류 조각 = 서버 메모리에 `SHORT_S` 초만 기억하고 투명 PNG. - 키 = `.env`(`SOURCES` 의 key_env) · 없으면 `MissingKeyError` · 키 값은 어떤 로그 · 응답에도 안 냄 @@ -25,6 +25,9 @@ from uuid import uuid4 from common_util.common_util_contour_tiles import tile_bounds from config.config_system import STORAGE_BASE_DIR +from D03_PlanAnalysis.D03_PlanAnalysis_Geology import KEY_ENV as GEOLOGY_KEY_ENV +from D03_PlanAnalysis.D03_PlanAnalysis_Geology import MAP_LAYER as GEOLOGY_LAYER +from D03_PlanAnalysis.D03_PlanAnalysis_Geology import WMS_URL as GEOLOGY_WMS_URL from D03_PlanAnalysis.D03_PlanAnalysis_Landslide import ( LANDSLIDE_KEY_ENV, LANDSLIDE_LAYER, @@ -43,12 +46,13 @@ TIMEOUT_S = 30 # 생태자연도 한 조각이 8~15 s 걸릴 때 있음 @dataclass(frozen=True) class Source: - """WMS 하나 — 주소 · 키 칸 · 고정 인자 · 상자 좌표계 칸 이름.""" + """WMS 하나 — 주소 · 키 칸 · 고정 인자 · 상자 좌표계 칸 이름 · 키 쿼리 이름.""" url: str key_env: str params: dict[str, str] = field(default_factory=dict) srs_param: str = "srs" + key_param: str = "serviceKey" SOURCES: dict[str, Source] = { @@ -63,6 +67,20 @@ SOURCES: dict[str, Source] = { LANDSLIDE_KEY_ENV, {"layers": LANDSLIDE_LAYER, "styles": "", "format": "image/png", "transparent": "TRUE"}, ), + "geology": Source( + GEOLOGY_WMS_URL, + GEOLOGY_KEY_ENV, + { + "service": "WMS", + "version": "1.1.1", + "request": "GetMap", + "layers": GEOLOGY_LAYER, + "styles": "", + "format": "image/png", + "transparent": "true", + }, + key_param="key", + ), } @@ -102,7 +120,7 @@ def fetch(name: str, z: int, x: int, y: int) -> bytes: "width": str(TILE_PX), "height": str(TILE_PX), } - url = f"{source.url}?serviceKey={_key(source)}&{urllib.parse.urlencode(query)}" + url = f"{source.url}?{source.key_param}={_key(source)}&{urllib.parse.urlencode(query)}" request = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"}) try: with urllib.request.urlopen(request, timeout=TIMEOUT_S) as response: diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index ed8a8b01c..54b12490e 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -76,7 +76,7 @@ def test_d03_parts(): on = {"satellite", "contour", "stream"} order = "satellite hillshade contour slope stream water zeroOrder bands road nationalRoad" order += " forestRoadFile roadBands cadastral building place zones soils ecoForest ecology" - order += " landslide landslideZones faults lineaments" + order += " landslide landslideZones geology faults lineaments" # 760-1 암상 더함 assert keys == [(key, str(key in on).lower()) for key in order.split()], keys assert ".d03-plan__icons" in css and ".d03-plan__legend" in css # 634-3 한 행 # 599-1 — 방식 칸 = 마디 고름 2 × 2 · 길이 · 기울기 두 열 · 노드 판(598-4)과 같은 함수 diff --git a/resources/tester/test_634_2_hover_table.py b/resources/tester/test_634_2_hover_table.py index ed0fa4d08..76e790f7d 100644 --- a/resources/tester/test_634_2_hover_table.py +++ b/resources/tester/test_634_2_hover_table.py @@ -34,6 +34,7 @@ GROUP1 = [ ("ecology", "생태자연도", "limit"), ("landslide", "산사태위험지도", "limit"), ("landslideZones", "산사태취약지역", "limit"), + ("geology", "암상 지질도", "limit"), # 760-1 더함 ("faults", "단층", "limit"), ("lineaments", "지질구조선", "limit"), ] diff --git a/resources/tester/test_760_1_geology.py b/resources/tester/test_760_1_geology.py new file mode 100644 index 000000000..666444f94 --- /dev/null +++ b/resources/tester/test_760_1_geology.py @@ -0,0 +1,172 @@ +"""760-1 암상 지질도 겹 — 바위 갈래 · WFS 깨진 칸 읽기 · 받은 범위 자름 · 노선 구간 · 키 숨김 · 출처 줄.""" + +import json +import re +from pathlib import Path + +import pytest + +from common_util import common_util_wms_tiles as wms_tiles +from D03_PlanAnalysis import D03_PlanAnalysis_Geology as geology + +ROOT = Path(__file__).resolve().parents[2] +MAP = ROOT / "A00_Common" / "map" +EPSG = 5179 +FAKE_KEY = "TESTKEY-760-SECRET" + + +def _square(x0, y0, size): + from pyproj import Transformer + + to_ll = Transformer.from_crs(f"EPSG:{EPSG}", "EPSG:4326", always_xy=True).transform + ring = [(x0, y0), (x0 + size, y0), (x0 + size, y0 + size), (x0, y0 + size), (x0, y0)] + return {"type": "Polygon", "coordinates": [[list(to_ll(x, y)) for x, y in ring]]} + + +def test_rock_group(): + assert geology.rock_group("알코스사암 역질 사암 셰일 역암") == 2 + assert geology.rock_group("흑운모 화강암") == 0 + assert geology.rock_group("우백질 화강편마암") == geology.META + assert geology.rock_group("결정질 석회암 및 각섬암") == geology.META + assert geology.rock_group("안산암질 응회암") == 1 + assert geology.rock_group("충적층") == 4 + assert geology.rock_group("") == geology.OTHER + assert len(geology.ROCK_GROUP_NAMES) == 6 + + +def test_parse_litho_broken_korean_keys(): + # 서버 흠 — 한글 칸 이름 · 값이 「??」로 겹침 · 기호만 영문 + body = ( + '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":' + '{"type":"Polygon","coordinates":[[[0,0],[1,0],[1,1],[0,0]]]},' + '"properties":{"??":"???? ???","??":"PCEp","????":"?? ??","symnum":"010106",' + '"mapname":"???","mapidx":"IF04"}}]}' + ).encode() + got = geology.parse_litho(body) + assert got == [ + { + "symnum": "010106", + "symbol": "PCEp", + "geometry": {"type": "Polygon", "coordinates": [[[0, 0], [1, 0], [1, 1], [0, 0]]]}, + } + ] + with pytest.raises(RuntimeError): + geology.parse_litho(b"500") + + +def _layer(): + raw = [ + {"symnum": "A1", "symbol": "Kd", "geometry": _square(1000000, 2000000, 100)}, + {"symnum": "B2", "symbol": "Kg", "geometry": _square(1000100, 2000000, 100)}, + {"symnum": "A1", "symbol": "Kd", "geometry": _square(1000900, 2000900, 50)}, # 범위 밖 + ] + names = {"A1": "알코스사암 셰일", "B2": "흑운모 화강암"} + calls = [] + + def fake_names(symnum, symbol, lon, lat): + calls.append(symnum) + return { + "symbol": symbol, + "litho": names[symnum], + "strata": "지층", + "age": "백악기", + "sheet": "도계동", + } + + box = (1000000.0, 2000000.0, 1000200.0, 2000100.0) + return geology.build_layer(raw, box, EPSG, fake_names), calls + + +def test_build_layer_clips_and_names_once_per_symbol(): + value, calls = _layer() + assert sorted(calls) == ["A1", "B2"] # 기호마다 점 조회 한 번 + assert [(s["symbol"], s["group"], s["count"]) for s in value["summary"]] == [ + ("Kd", 2, 1), + ("Kg", 0, 1), + ] + assert value["summary"][0]["area_m2"] == pytest.approx(10000, rel=1e-3) + assert len(value["features"]) == 2 and len(value["parts_m"]) == 2 + lon, lat = value["features"][0]["geometry"]["coordinates"][0][0] + assert 120 < lon < 135 and 30 < lat < 45 # 화면 지물 = 경위도 + + +def test_route_segments(): + value, _ = _layer() + # 서 → 동 200 m 직선 — 0~100 m Kd · 100~200 m Kg + segs = geology.route_segments(value["parts_m"], [[1000000, 2000050], [1000200, 2000050]]) + assert [(s["from_m"], s["to_m"], s["i"]) for s in segs] == [(0.0, 100.0, 0), (100.0, 200.0, 1)] + # 범위 밖으로 50 m 더 = 자료 없음(-1) + segs = geology.route_segments(value["parts_m"], [[1000000, 2000050], [1000250.5, 2000050]]) + assert segs[-1] == {"from_m": 200.0, "to_m": 250.5, "i": -1} + assert geology.route_segments(value["parts_m"], [[0, 0]]) == [] + out = geology.layer_response( + value, [{"name": "계획노선", "points": [[1000000, 2000050], [1000200, 2000050]]}] + ) + assert "parts_m" not in out and out["routes"][0]["segments"][1]["i"] == 1 + assert "CC BY-NC" in out["source"] + + +def test_cover_box_takes_route(): + box = (0.0, 0.0, 100.0, 100.0) + assert geology.cover_box(box, []) == box + got = geology.cover_box(box, [{"name": "a", "points": [[50, 50], [300, 40]]}], pad=10) + assert got == (0.0, 0.0, 310.0, 100.0) + + +def test_key_only_on_server(monkeypatch): + """키 = 쿼리 `key` 로만 · 오류 글에 주소 · 키 없음 · 없으면 MissingKeyError.""" + seen = [] + + def fake_urlopen(request, timeout): + seen.append(request.full_url) + raise OSError("boom") + + monkeypatch.setattr("urllib.request.urlopen", fake_urlopen) + monkeypatch.setenv(geology.KEY_ENV, FAKE_KEY) + with pytest.raises(RuntimeError) as error: + geology.fetch_litho({"min_lon": 129, "min_lat": 36, "max_lon": 129.1, "max_lat": 36.1}) + assert FAKE_KEY not in str(error.value) + assert f"key={FAKE_KEY}" in seen[0] and "typeName=geoOpen" in seen[0] + # 그림 조각 대리 — 같은 키 · 쿼리 이름 key · 오류 글에 키 없음 + source = wms_tiles.SOURCES["geology"] + assert source.key_param == "key" and source.key_env == geology.KEY_ENV + with pytest.raises(RuntimeError) as error: + wms_tiles.fetch("geology", 14, 14000, 6400) + assert FAKE_KEY not in str(error.value) + assert seen[1].startswith(f"{geology.WMS_URL}?key={FAKE_KEY}&") and "GetMap" in seen[1] + assert "serviceKey" in wms_tiles.SOURCES["landslide"].key_param # 옛 겹 그대로 + monkeypatch.setenv(geology.KEY_ENV, "") + with pytest.raises(geology.MissingKeyError): + geology.fetch_litho({"min_lon": 129, "min_lat": 36, "max_lon": 129.1, "max_lat": 36.1}) + + +def test_screen_layer_and_sources(): + lst = (MAP / "map_layer_list.ts").read_text(encoding="utf-8") + assert 'row("geology", "strata", "암상 지질도", "limit")' in lst + src = (MAP / "map_layer_source.ts").read_text(encoding="utf-8") + assert re.search(r'\["브이월드", \[[^\]]*"faults", "lineaments"\]\]', src) + assert re.search(r'CC BY-NC\)", \["geology"\]\]', src) + assert '공공누리 제4유형)", ["landslide"]' in src + forest = (MAP / "map_forest_layers.ts").read_text(encoding="utf-8") + assert 'geology_map: wmt("geology")' in forest + geo = (MAP / "map_geology.ts").read_text(encoding="utf-8") + assert "참고 — 토사 · 암 판정 아님" in geo and "/plan/layers/geology" in geo + assert "data.kigam.re.kr" not in geo # 브라우저는 KIGAM 을 부르지 않음 + theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text(encoding="utf-8") + assert all(f"--draw-map-geology-{i}:" in theme for i in range(1, 7)) + + +def test_no_key_in_code(): + env = (ROOT / ".env").read_text(encoding="utf-8") if (ROOT / ".env").is_file() else "" + got = re.search(r"^GEOBIGDATA_API_KEY=(.+)$", env, re.M) + if not got or not got.group(1).strip(): + pytest.skip("키 없는 PC") + key = got.group(1).strip() + paths = [ + ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_Geology.py", + MAP / "map_geology.ts", + MAP / "map_layer_source.ts", + ] + leaked = [p.name for p in paths if key in p.read_text(encoding="utf-8")] # 실패 글에 키 안 냄 + assert leaked == [] + assert json.dumps(geology.SOURCE) # 출처 글은 있음 diff --git a/ui_template/ui_template_icons.ts b/ui_template/ui_template_icons.ts index 871ed89c9..cee5c209a 100644 --- a/ui_template/ui_template_icons.ts +++ b/ui_template/ui_template_icons.ts @@ -170,6 +170,7 @@ const PATHS = { parcelHouse: "M3 21h18|M5 21v-9l7 -6l7 6v9|M10 21v-5h4v5", roadBand: "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|M10 4l-2 16|M14 4l2 16", + strata: "M12 4l-8 4l8 4l8 -4l-8 -4|M4 12l8 4l8 -4|M4 16l8 4l8 -4", ban: "M3 12a9 9 0 1 0 18 0a9 9 0 1 0 -18 0|M5.7 5.7l12.6 12.6", } as const; diff --git a/ui_template/ui_template_theme.css b/ui_template/ui_template_theme.css index 15148e8e0..04cd70a2b 100644 --- a/ui_template/ui_template_theme.css +++ b/ui_template/ui_template_theme.css @@ -598,6 +598,12 @@ html.is-bm-route .m01lab-src__body * { --draw-map-ecology-1: #1aa800; /* 생태자연도 1등급(환경부 WMS 그림 색 그대로 — 범례만 · 3등급 = 칠 없음) */ --draw-map-ecology-2: #92d050; /* 2등급 */ --draw-map-ecology-x: #ff8000; /* 별도관리지역 */ + --draw-map-geology-1: #f472b6; /* 암상 지질도 바위 갈래(760-1) — 심성암 · 화산암 맥암 · 퇴적암 · 변성암 · 미고결 퇴적층 · 그 밖 */ + --draw-map-geology-2: #fb923c; + --draw-map-geology-3: #fde047; + --draw-map-geology-4: #818cf8; + --draw-map-geology-5: #e5e7eb; + --draw-map-geology-6: #5eead4; --draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */ --draw-map-rec-1: #ffe94d; /* 추천노선 보기 선 넷(여럿 보기 색 구분 · 검은 테와 함께) */ --draw-map-rec-2: #d58bff;