Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1

This commit is contained in:
2026-11-03 08:33:30 +09:00
21 changed files with 863 additions and 16 deletions
+16 -6
View File
@@ -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<string, SourceSpecifica
eco_forest: vt("eco_forest"),
ecology: wmt("ecology"),
landslide: wmt("landslide"),
geology_map: wmt("geology"),
};
}
@@ -90,17 +92,25 @@ const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecificatio
["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification;
type Spec = GlSpec<MapLayerKey>;
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[] {
+161
View File
@@ -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<PlanGeology> {
const got = await request<PlanGeology>(`/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<MapLayerKey>[] {
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<MapLayerKey>[];
}
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<MapLayerKey, boolean>, 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];
}
+1 -1
View File
@@ -19,7 +19,7 @@ export type GeoJsonCollection = {
}>;
};
async function request<T>(path: string): Promise<T> {
export async function request<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
+2
View File
@@ -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"),
];
+31
View File
@@ -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<MapLayerKey, boolean>): 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" }]
: [];
});
}
+2
View File
@@ -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"]),
+6
View File
@@ -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;
}
+10 -1
View File
@@ -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<MapLayerKey, boolean>);
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 };
@@ -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],
@@ -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
],
}
+17 -1
View File
@@ -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 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본."""
@@ -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` 등록 때 회사 보호).
@@ -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<LineLayerKey, PreparedLines> & {
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). */
@@ -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 = {
@@ -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] {
+22 -4
View File
@@ -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/<name>/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:
+1 -1
View File
@@ -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)과 같은 함수
@@ -34,6 +34,7 @@ GROUP1 = [
("ecology", "생태자연도", "limit"),
("landslide", "산사태위험지도", "limit"),
("landslideZones", "산사태취약지역", "limit"),
("geology", "암상 지질도", "limit"), # 760-1 더함
("faults", "단층", "limit"),
("lineaments", "지질구조선", "limit"),
]
+172
View File
@@ -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"<html>500</html>")
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) # 출처 글은 있음
+1
View File
@@ -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;
+6
View File
@@ -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;