Merge remote-tracking branch 'origin/sub_laptop_3' into main_laptop_1
This commit is contained in:
@@ -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[] {
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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" },
|
||||
|
||||
@@ -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"),
|
||||
];
|
||||
|
||||
@@ -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" }]
|
||||
: [];
|
||||
});
|
||||
}
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
],
|
||||
}
|
||||
@@ -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] {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
|
||||
@@ -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) # 출처 글은 있음
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user