Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8

This commit is contained in:
2026-10-31 21:28:42 +09:00
77 changed files with 3695 additions and 1248 deletions
+2
View File
@@ -138,6 +138,8 @@ export const KEY_ACTIONS: readonly KeyAction[] = [
A("plan.redo", "plan", ["Ctrl+Shift+Z", "Ctrl+Y"], ["다시", "Redo"]),
A("plan.drawFinish", "plan", ["Enter"], ["그리기 끝", "Finish drawing"]),
A("plan.drawBack", "plan", ["Backspace"], ["그리기 마지막 점 빼기", "Drawing: remove last point"]),
A("plan.drawKind", "plan", ["Tab"], ["긋는 중 방식 자유지정 ↔ 길이·기울기 고정", "Drawing: free ↔ length + slope"]),
A("plan.drawBetween", "plan", ["Shift 누른 채"], ["길이·기울기 고정 긋는 중 부채꼴 사이 기울기 자리(길이 그대로)", "Drawing: pick between-slope on fan (length kept)"], F),
A("plan.delete", "plan", ["Delete"], ["고른 노드 · 선 · 구역 · 전체 · 마커 지움", "Delete picked node · segment · region · all · marker"]),
A("plan.escape", "plan", ["Escape"], ["그리기 취소 · 고름 풀기 · 재기 끝", "Cancel drawing · clear selection · end measure"], F),
A("plan.zoom", "plan", ["휠"], ["커서 자리 확대 · 축소", "Zoom at cursor"], F),
@@ -1,10 +1,10 @@
/* =============================================================================
* D03_PlanAnalysis_SlopeBands.ts
* D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
* map_slope_bands.ts(logic)
* 공용 지도 급경사 범위(624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 기울기 % 범위 · 색 · 비율 셈(화면 · 서버 없이).
* 서버 값 그림 칸 = 기울기 % 내림(0~250 · 250 = 250 % 이상 · `D03_PlanAnalysis_Layers.slope_values_png`) ·
* 칸 v 는 가운데(v + 0.5)가 [아래, 위) 안이면 그 범위(위 = 250 이면 250 % 이상도) · 앞 범위 먼저.
* 기본 = 지식DB 15 · 35 · 40° 의 % — `임도망_편성.md:20` · `타당성평가.md:59` · `노선_배치.md:29`.
* 부르는 곳 = `D03_PlanAnalysis_UI_SlopeBands.ts` · `D03_PlanAnalysis_UI_LayerLegend.ts`.
* 부르는 곳 = `map_slope_bands.ts` · `map_legend.ts` · D03 `UI_LayerLegend`.
* ========================================================================== */
export interface SlopeBand {
@@ -1,24 +1,24 @@
/* =============================================================================
* D03_PlanAnalysis_UI_ForestLayers.ts
* D03 받은 산림 · 환경 자료 겹(PLAN 622-2 · 요구 529) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴).
* map_forest_layers.ts
* 공용 지도 — 받은 산림 · 환경 자료 겹(PLAN 622-2 · 624-2 · 요구 529 · 530) — 서버 줌별 조각(622-1)을 GL 고정 소스로(위성 · 등고선 615 꼴).
* 벡터 = 국유림 임도(forest_road · 간선 · 작업 · 산불 색 · 줌 따라 굵기) · 산림입지도(forest_soil · 토양 무리 색 ·
* 반투명 · z10 아래 숨김) · 경제림(eco_forest · 국유 · 사유 테두리) /
* 그림 = 생태자연도(ecology) · 산사태위험지도(landslide · 옛 캔버스는 받은 범위 한 장 그대로).
* 조각만 쓰는 겹(`TILE_ONLY`) = 받기 없음 · 옛 캔버스(그래픽 카드 끔)엔 안 보임.
* 토양 무리 색(`soilColor`) = 조각(`code` = SLTP_CD 두 자리) · 브이월드 요약(기호 「B₁」) 같은 색 — 범례 · 옛 캔버스도 이것.
* 부르는 곳 = `D03_PlanAnalysis_UI_MapGl`(꼴 · 소스) · `UI_Layers`(받기 거름) · `UI_LayerLegend`(범례).
* 부르는 곳 = `map_layer_specs`(꼴) · D03 `UI_MapGl`(소스) · `UI_Layers`(받기 거름) · `map_legend`(범례).
* ========================================================================== */
import type { ExpressionSpecification, SourceSpecification } from "maplibre-gl";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { tileSource, type GlSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { PlanLayerKey } from "./D03_PlanAnalysis_UI_LayerDefs";
import type { MapLayerKey } from "./map_layer_list";
/** 조각만 쓰는 겹 — 받을 자료 없음(위성 포함). */
const TILE_ONLY = ["satellite", "nationalRoad", "ecoForest", "ecology"] as const;
export type TileOnlyKey = (typeof TILE_ONLY)[number];
export const tileOnly = (key: PlanLayerKey): key is TileOnlyKey =>
export const tileOnly = (key: MapLayerKey): key is TileOnlyKey =>
(TILE_ONLY as readonly string[]).includes(key);
/** 받는 동안 서클에 세는 조각 소스(등고선 포함). */
@@ -89,7 +89,7 @@ const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const zoomWidth = (z8: number, z12: number, z15: number): ExpressionSpecification =>
["interpolate", ["linear"], ["zoom"], 8, z8, 12, z12, 15, z15] as ExpressionSpecification;
type Spec = GlSpec<PlanLayerKey>;
type Spec = GlSpec<MapLayerKey>;
const raster = (key: "landslide" | "ecology", opacity: number): Spec =>
({
key,
@@ -172,7 +172,7 @@ export function forestRoadSpecs(): Spec[] {
}
/** 범례 — 켠 조각 겹(산사태위험 = 범례 자리 `LANDSLIDE_LEGEND` 따로). */
export function forestLegendRows(on: Record<PlanLayerKey, boolean>): LegendItem[][] {
export function forestLegendRows(on: Record<MapLayerKey, boolean>): LegendItem[][] {
const item = (label: string, color: string, kind?: LegendItem["kind"]): LegendItem => ({
label,
color: `var(${color})`,
+221
View File
@@ -0,0 +1,221 @@
/* =============================================================================
* map_layer_api.ts
* 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김).
* 서버 길 = `/api/projects/{id}/plan/…`(624-1 · 임도 프로젝트도 · 띠 · 올린 임도망도 = 예정노선만).
* 지적도 · 도엽 겹 GeoJSON 경위도 · 도엽 조각 · 그림 겹(급경사 · 음영기복 · 산사태위험) · 보호구역 · 산림입지도 ·
* 선 겹(단층 · 지질구조선) · 0차곡 · 띠 · 올린 임도망도(올리기 · 지움) · 산사태취약지역.
* D03 = `D03_PlanAnalysis_Api` 가 다시 내놓음(부르는 곳 그대로).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
/** 경위도 GeoJSON 묶음 — B04 `GeoJsonCollection` 과 같은 꼴(공용 폴더는 페이지 파일을 안 부름). */
export type GeoJsonCollection = {
features?: Array<{
geometry?: { type: string; coordinates: unknown } | null;
properties?: Record<string, unknown> | null;
}>;
};
async function request<T>(path: string): Promise<T> {
const response = await fetch(`${API_BASE_URL}${path}`, {
credentials: "include",
headers: { "Content-Type": "application/json" },
});
const body = (await response.json().catch(() => ({}))) as T & { detail?: unknown };
if (!response.ok) {
const detail = typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`;
throw new Error(detail);
}
return body;
}
const base = (projectId: string): string => `/projects/${projectId}/plan`;
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
export interface PlanRaster {
box_m: [number, number, number, number];
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
box?: Array<[number, number]>;
image: string;
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
counts?: number[];
}
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/cadastral`);
/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */
export const fetchPlanSheetTiles = (
projectId: string,
bbox: [number, number, number, number],
): Promise<{ count: number; sheets: string[] }> =>
request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`);
/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */
export const fetchPlanSheetTile = (
projectId: string,
sheet: string,
name: string,
): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`);
/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면
* `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */
export const fetchPlanRaster = async (
projectId: string,
kind: "slope" | "hillshade" | "landslide",
): Promise<PlanRaster> => {
const path = `${base(projectId)}/layers/raster?kind=${kind}`;
if (kind !== "slope") return request(path);
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster;
return { ...meta, image: URL.createObjectURL(await response.blob()) };
};
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
export interface PlanZones extends GeoJsonCollection {
summary: Record<string, { label: string; count: number; area_m2: number; failed?: boolean }>;
}
export const fetchPlanZones = (projectId: string): Promise<PlanZones> =>
request(`${base(projectId)}/layers/zones`);
/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */
export interface PlanSoils extends GeoJsonCollection {
summary: Array<{ code: string; name: string; count: number; area_m2: number }>;
}
export const fetchPlanSoils = (projectId: string): Promise<PlanSoils> =>
request(`${base(projectId)}/layers/soils`);
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
export interface PlanBand {
dist_m: number;
lines: number;
area_m2: number;
ratio: number;
collection: GeoJsonCollection;
}
/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */
export interface PlanLines extends GeoJsonCollection {
summary: { count: number; length_m: number };
}
/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */
export const fetchPlanLines = (projectId: string, kind: string): Promise<PlanLines> =>
request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`);
/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */
export interface PlanZeroOrder {
min_m2: number;
max_m2: number;
cell_m: number;
count: number;
length_m: number;
collection: GeoJsonCollection;
}
export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
request(`${base(projectId)}/layers/zero-order`);
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */
export interface PlanRoadBundle {
id: string;
name: string;
files: string[];
bytes: number | null;
uploaded_at: string | null;
count: number;
length_m: number;
total: number;
bounds: [number, number, number, number] | null;
distance_m: number | null;
}
/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선
* (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 ·
* 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */
export interface PlanForestRoads {
bundles: PlanRoadBundle[];
count: number;
length_m: number;
total: number;
crs: string[];
bounds: [number, number, number, number] | null;
distance_m: number | null;
source: { files: string[]; bytes: number; uploaded_at: string } | null;
collection: GeoJsonCollection;
}
/** 올린 적 없으면 오류(404 글 그대로). */
export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> =>
request(`${base(projectId)}/layers/forest-roads`);
/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */
export async function deletePlanForestRoad(
projectId: string,
bundle: string,
): Promise<PlanForestRoads | null> {
const response = await fetch(
`${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`,
{ method: "DELETE", credentials: "include" },
);
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body.bundles.length ? body : null;
}
/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */
export async function uploadPlanForestRoads(
projectId: string,
files: File[],
): Promise<PlanForestRoads> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, {
method: "POST",
credentials: "include",
body: form,
});
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */
export interface PlanLandslideZones {
points: Array<{
lon: number;
lat: number;
kind: string | null;
area_m2: number | null;
place: string;
}>;
parcels: GeoJsonCollection;
sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>;
missing: string[];
/** 지적도를 받았나(못 받으면 점만). */
cadastral: boolean;
}
export const fetchPlanLandslideZones = (projectId: string): Promise<PlanLandslideZones> =>
request(`${base(projectId)}/layers/landslide-zones`);
+96
View File
@@ -0,0 +1,96 @@
/* =============================================================================
* map_layer_bar.ts
* 공용 지도 겹 줄(PLAN 569-2 · 586-2 · 624-2 · 요구 530) — 겹 단추 한 줄(공용 지도 도구줄 `createMapTools` · 묶음 사이 나눔선) +
* 끝에 그래픽 카드 단추(GL ↔ 옛 캔버스).
* 보일 겹 · 처음 켬 · 취향 열쇠 = 페이지 표(`map_layer_list` `MAP_LAYER_PAGES`) · 누르면 켬끔 뒤집고 취향에 적고 `onToggle`.
* 막힘 = 지형을 아직 안 받음(`sync({ ready: false })`) · 조각만 쓰는 겹은 옛 캔버스에서 「안 보임」 글.
* 받기 · 그리기 · 범례는 페이지 몫 — 켬끔 값(`on`)은 같은 객체를 같이 씀.
* 쓰는 법 = `createMapLayerBar({ page: "d03", glOn, onToggle, onGl })` → `{ root, bar, on, toggle, save, sync }`.
* ========================================================================== */
import { createIconButton } from "@ui/ui_template_button";
import { createMapTools } from "@ui/ui_template_map_tools";
import { writeByKey } from "../b_page_state";
import { tileOnly } from "./map_forest_layers";
import {
MAP_LAYER_PAGES,
pageGroups,
readLayerPref,
type MapLayerKey,
type MapPage,
} from "./map_layer_list";
export interface MapLayerBar {
/** 도구줄 감싼 것(자리는 페이지 CSS). */
root: HTMLElement;
/** 도구줄 자체(폭 재기). */
bar: HTMLElement;
/** 켬끔 — 페이지가 바꾸면 `save` · 받기 실패로 끄는 것은 안 적음. */
on: Record<MapLayerKey, boolean>;
/** 뒤집고 적고 `onToggle`. */
toggle: (key: MapLayerKey) => void;
save: () => void;
/** 단추 눌림 · 막힘 · 글을 켬끔 값에 맞춤. */
sync: (state: { ready: boolean; glOn: boolean }) => void;
}
export function createMapLayerBar(opts: {
page: MapPage;
glOn: boolean;
onToggle: (key: MapLayerKey) => void;
/** 그래픽 카드 단추 누름. */
onGl: () => void;
}): MapLayerBar {
const { page } = opts;
const on = readLayerPref(page);
const save = (): void => writeByKey(MAP_LAYER_PAGES[page].pref, JSON.stringify(on));
const toggle = (key: MapLayerKey): void => {
on[key] = !on[key];
save();
opts.onToggle(key);
};
const defs = pageGroups(page);
const buttons = new Map<MapLayerKey, HTMLButtonElement>();
const groups = defs.map((group) =>
group.map((def) => {
const button = createIconButton({
icon: def.icon,
title: def.title,
size: 32,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
buttons.set(def.key, button);
return button;
}),
);
const glButton = createIconButton({
icon: "gauge",
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
size: 32,
pressed: opts.glOn,
onClick: opts.onGl,
});
glButton.dataset.layer = "gl";
groups.push([glButton]);
const tools = createMapTools({ label: "지도 겹", groups });
function sync({ ready, glOn }: { ready: boolean; glOn: boolean }): void {
for (const def of defs.flat()) {
const button = buttons.get(def.key)!;
button.setAttribute("aria-pressed", String(on[def.key]));
button.disabled = !ready;
const title = !ready
? `${def.title} — 지형 받기 뒤 보임`
: tileOnly(def.key) && !glOn
? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`
: def.title;
button.title = title;
button.setAttribute("aria-label", title);
}
glButton.setAttribute("aria-pressed", String(glOn));
}
return { root: tools.root, bar: tools.bar, on, toggle, save, sync };
}
+61
View File
@@ -0,0 +1,61 @@
/* =============================================================================
* map_layer_data.ts
* 공용 지도 겹 자료 도움(PLAN 569-2 · 581 · 586-2 · 624-3) — D03 예정노선 · B05 평면이 같이 씀(D03 `UI_LayerDefs` 에서 옮김).
* 임도 짐작 구분 · 물 면 도엽 이름 · 넓이 글 · 그림 읽기 · GL 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
* ========================================================================== */
import type { GlImage } from "@ui/ui_template_gl_map";
import type { GeoJsonCollection, PlanRaster } from "./map_layer_api";
/** 도로 중 기존 임도로 짐작하는 구분. */
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
/** 물 면 = 도엽 수계(E) 면 셋. */
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`;
export const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((done, fail) => {
const image = new Image();
image.onload = () => done(image);
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
image.src = src;
});
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
export function glTagged(
collection: GeoJsonCollection,
color: (props: Record<string, unknown>) => string,
extra: Record<string, unknown> = {},
): GeoJsonCollection {
return {
...collection,
features: (collection.features ?? []).map((feature) => ({
...feature,
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
})),
};
}
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
const box = raster.box;
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
}
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
export function shadeImage(image: HTMLImageElement): string {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const px = pixels.data;
for (let i = 0; i < px.length; i += 4) {
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
px[i] = px[i + 1] = px[i + 2] = 0;
}
context.putImageData(pixels, 0, 0);
return canvas.toDataURL("image/png");
}
+221
View File
@@ -0,0 +1,221 @@
/* =============================================================================
* map_layer_list.ts
* 공용 지도 겹 목록(PLAN 624-2 · 요구 530 · 결정 A② · A⑧ · A⑨) — 예정노선(D03) · B05 평면이 같이 씀.
* 겹 = 열쇠 · 아이콘 · 이름 · 처음 값(`on` = 예정노선) · 단추 묶음(바탕 · 물 · 길 · 구역 · 사이 나눔선).
* 페이지 표(`MAP_LAYER_PAGES`) = 보일 겹(숨길 겹) · 처음 켬(없으면 겹의 `on`) · 켬끔 취향 열쇠.
* 선 겹 꼴 · 보호구역 색 · 산사태위험 범례 = GL 꼴(`map_layer_specs`) · 범례(`map_legend`) · 옛 캔버스가 같이 읽음.
* 쓰는 법 = 겹 줄 `createMapLayerBar({ page })`(`map_layer_bar`) · 새 겹 = `GROUPS` 한 줄 + 꼴 한 벌.
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { readByKey } from "../b_page_state";
export type MapLayerKey =
| "satellite"
| "hillshade"
| "contour"
| "slope"
| "stream"
| "water"
| "road"
| "forestRoad"
| "forestRoadFile"
| "nationalRoad"
| "ecoForest"
| "ecology"
| "cadastral"
| "zones"
| "soils"
| "landslide"
| "landslideZones"
| "faults"
| "lineaments"
| "zeroOrder"
| "bands"
| "building"
| "place";
/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
export interface RoadPick {
bundle: string;
line?: number;
}
export interface LayerDef {
key: MapLayerKey;
icon: IconName;
title: string;
/** 예정노선 처음 값 — 다른 페이지는 `MAP_LAYER_PAGES[page].on`. */
on: boolean;
}
/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */
export const GROUPS: readonly (readonly LayerDef[])[] = [
[
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false },
],
[
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
{ key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false },
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
{
key: "forestRoad",
icon: "route",
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
on: false,
},
{
key: "forestRoadFile",
icon: "map",
title:
"올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
on: false,
},
{
key: "nationalRoad",
icon: "flag",
title:
"국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)",
on: false,
},
],
[
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
{
key: "zones",
icon: "shield",
title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)",
on: false,
},
{
key: "soils",
icon: "leaf",
title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)",
on: false,
},
{
key: "ecoForest",
icon: "trees",
title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)",
on: false,
},
{
key: "ecology",
icon: "plant",
title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)",
on: false,
},
{
key: "landslide",
icon: "alert",
title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)",
on: false,
},
{
key: "landslideZones",
icon: "alertOctagon",
title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)",
on: false,
},
{ key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false },
{
key: "lineaments",
icon: "lineDashed",
title: "지질구조선(브이월드 지질 · 참고용)",
on: false,
},
{
key: "zeroOrder",
icon: "arrowsJoin",
title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)",
on: false,
},
{
key: "bands",
icon: "borderOuter",
title: "계류 100 m · 도로 300 m 띠(타당성평가)",
on: false,
},
{ key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false },
{ key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false },
],
];
export const LAYERS: readonly LayerDef[] = GROUPS.flat();
/** 지도가 있는 페이지. */
export type MapPage = "d03" | "b05";
/** 페이지 표 — 숨길 겹 · 처음 켬(없음 = 겹의 `on`) · 켬끔 취향 열쇠. */
export const MAP_LAYER_PAGES: Record<
MapPage,
{ hide: readonly MapLayerKey[]; on?: readonly MapLayerKey[]; pref: string }
> = {
d03: { hide: [], pref: "aislo:pref:d03.layers" },
// A② 처음 = 지금 그대로(등고선 · 하천) · A⑨ 띠(타당성) · 올린 임도망도 숨김
b05: {
hide: ["bands", "forestRoadFile"],
on: ["contour", "stream"],
pref: "aislo:pref:b05.layers",
},
};
/** 그 페이지에 보일 겹 묶음(빈 묶음 뺌). */
export const pageGroups = (page: MapPage): LayerDef[][] =>
GROUPS.map((group) =>
group.filter((def) => !MAP_LAYER_PAGES[page].hide.includes(def.key)),
).filter((group) => group.length);
/** 켬끔 취향 — 없거나 깨지면 페이지 처음 값 · 숨긴 겹 = 끔. */
export function readLayerPref(page: MapPage): Record<MapLayerKey, boolean> {
const { hide, on: first, pref } = MAP_LAYER_PAGES[page];
const on = Object.fromEntries(
LAYERS.map((def) => [def.key, first ? first.includes(def.key) : def.on]),
) as Record<MapLayerKey, boolean>;
try {
const saved = JSON.parse(readByKey(pref) ?? "{}") as Partial<Record<MapLayerKey, unknown>>;
for (const def of LAYERS)
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
} catch {
// 깨진 값 = 처음 값
}
for (const key of hide) on[key] = false;
return on;
}
/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */
export const ZONE_COLORS: Record<string, string> = {
forest: "--draw-map-zone-forest",
baekdu: "--draw-map-zone-baekdu",
water: "--draw-map-zone-water",
park: "--draw-map-zone-park",
heritage: "--draw-map-zone-heritage",
wetland: "--draw-map-zone-wetland",
wildlife: "--draw-map-zone-wildlife",
greenbelt: "--draw-map-zone-greenbelt",
};
/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */
export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({
label: `산사태 ${grade}등급`,
color: `var(--draw-map-landslide-grade-${grade})`,
}));
/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */
export type LineLayerKey = "faults" | "lineaments" | "zeroOrder";
export const LINE_LAYERS: Record<
LineLayerKey,
{ label: string; kind?: string; color: string; width: number; dash: number[] }
> = {
faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] },
lineaments: {
label: "지질구조선",
kind: "lineaments",
color: "--draw-map-lineament",
width: 1.8,
dash: [8, 4],
},
zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] },
};
export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[];
+317
View File
@@ -0,0 +1,317 @@
/* =============================================================================
* map_layer_specs.ts
* 공용 지도 GL 겹 꼴(PLAN 586-2 · 624-2 · 요구 530) — 바탕 겹 스물의 MapLibre 꼴 · 차례(아래 → 위):
* 위성 · 그림 겹(음영 · 급경사 · 산사태위험 · 생태자연도) · 면 겹(산림입지 · 경제림 · 보호구역 · 띠 · 물 면 · 건물) ·
* 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 · 국유림 임도 · 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지) ·
* 점 · 글(산사태취약 · 지명 · 등고 수치).
* 자료 = 받는 쪽이 지물에 붙인 `_color` · 고정 소스(위성 · 등고 · 산림 조각) 이름 = `map_forest_layers` · `ui_template_gl_map`.
* 카메라 · 지도 세우기는 페이지 몫(D03 `UI_MapGl.createGlBase` · B05 `createTmGl`) — 여기는 꼴만.
* ========================================================================== */
import type { ExpressionSpecification, FilterSpecification } from "maplibre-gl";
import { GL_FONT, type GlSpec as GlMapSpec } from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import { forestAreaSpecs, forestRasterSpecs, forestRoadSpecs } from "./map_forest_layers";
import {
LINE_KEYS,
LINE_LAYERS,
type LineLayerKey,
type MapLayerKey,
type RoadPick,
} from "./map_layer_list";
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
export type GlSource = Exclude<MapLayerKey, "satellite" | "forestRoad">;
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
type GlSpec = GlMapSpec<MapLayerKey>;
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const COLOR: ExpressionSpecification = ["get", "_color"];
/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */
const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification;
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */
function haloLine(
key: MapLayerKey,
source: GlSource,
color: string,
width: number,
pattern: number[] = [],
halo = { color: HALO, width: width + 1.6 },
filter?: FilterSpecification,
): GlSpec[] {
const line = (id: string, paint: Record<string, unknown>, w: number): GlSpec =>
({
key,
id,
type: "line",
source,
...(filter ? { filter } : {}),
layout: ROUND,
paint: {
...paint,
"line-width": w,
...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}),
},
}) as GlSpec;
return [
line(`${key}-halo`, { "line-color": halo.color }, halo.width),
line(key, { "line-color": color }, width),
];
}
/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */
function fillEdge(
key: MapLayerKey,
source: GlSource,
fill: string | ExpressionSpecification,
opacity: number | ExpressionSpecification,
edge: { color: string | ExpressionSpecification; width: number; dash?: number[] },
): GlSpec[] {
return [
{
key,
id: key,
type: "fill",
source,
paint: { "fill-color": fill, "fill-opacity": opacity },
} as GlSpec,
{
key,
id: `${key}-edge`,
type: "line",
source,
layout: ROUND,
paint: {
"line-color": edge.color,
"line-width": edge.width,
"line-opacity": 0.9,
...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}),
},
} as GlSpec,
];
}
/** 겹 차례(아래 → 위)와 꼴 — 옛 캔버스(D03 `UI_LayerPaint`)의 색 · 굵기 · 투명도 · 차례 그대로 · `light` = 위성 밝기 상한. */
export function mapLayerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
const raster = (
key: "satellite" | "hillshade" | "slope",
opacity: number,
extra: Record<string, number> = {},
): GlSpec =>
({
key,
id: key,
type: "raster",
source: key,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra },
}) as GlSpec;
const lineKind = (key: LineLayerKey): GlSpec[] => {
const { color, width, dash } = LINE_LAYERS[key];
return haloLine(key, key, themeColor(color, "#f43f5e"), width, dash);
};
return [
// 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절
raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }),
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
raster("slope", 0.55),
...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2)
...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각
...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }),
...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], {
color: COLOR,
width: 1.2,
dash: [6, 4],
}),
{
key: "cadastral",
id: "cadastral",
type: "line",
source: "cadastral",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-parcel", "#f0abfc"),
"line-width": 0.8,
"line-opacity": 0.8,
},
} as GlSpec,
...((): GlSpec[] => {
const water = themeColor("--draw-map-water", "#0ea5e9");
return fillEdge("water", "water", water, 0.45, { color: water, width: 1 });
})(),
...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, {
color: HALO,
width: 0.6,
}),
{
key: "contour",
id: "contour-minor",
type: "line",
source: "contour",
"source-layer": "contour",
layout: ROUND,
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-index",
type: "line",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-label",
type: "symbol",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: {
"symbol-placement": "line",
"symbol-spacing": 320,
"text-field": ["to-string", ["get", "h"]],
"text-font": [GL_FONT],
"text-size": 10,
"text-max-angle": 30,
"text-padding": 2,
},
paint: {
"text-color": contour,
"text-halo-color": "rgba(0, 0, 0, 0.6)",
"text-halo-width": 1.5,
},
} as GlSpec,
...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], {
color: HALO,
width: 3.2,
}),
...haloLine(
"forestRoad",
"road",
themeColor("--draw-map-forest-road", "#f59e0b"),
2,
[7, 4],
{ color: HALO, width: 3.6 },
[
"in",
["get", "도로구분"],
["literal", ["소로", "미분류"]],
] as unknown as FilterSpecification,
),
...haloLine(
"forestRoadFile",
"forestRoadFile",
themeColor("--draw-map-forest-road-file", "#ff2fd0"),
2.4,
[],
{ color: HALO, width: 4 },
),
...forestRoadSpecs(), // 국유림 임도 = 벡터 조각
// 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게
...["-pick-halo", "-pick"].map(
(tail, i) =>
({
key: "forestRoadFile",
id: `forestRoadFile${tail}`,
type: "line",
source: "forestRoadFile",
filter: pickFilter(pick),
layout: ROUND,
paint: {
"line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO,
"line-width": i ? 4.2 : 7.4,
},
}) as GlSpec,
),
{
key: "stream",
id: "stream",
type: "line",
source: "stream",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
},
} as GlSpec,
...LINE_KEYS.flatMap(lineKind),
// 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리
...haloLine(
"landslideZones",
"landslideZones",
landslideZone,
1.8,
[6, 3],
{ color: "rgba(255, 255, 255, 0.7)", width: 3.2 },
["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification,
),
{
key: "landslideZones",
id: "landslideZones-point",
type: "circle",
source: "landslideZones",
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 5,
"circle-color": landslideZone,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
} as GlSpec,
{
key: "place",
id: "place-dot",
type: "circle",
source: "place",
paint: { "circle-radius": 2.5, "circle-color": place },
} as GlSpec,
{
key: "place",
id: "place",
type: "symbol",
source: "place",
layout: {
"text-field": ["get", "name"],
"text-font": GL_FONT_BOLD,
"text-size": 11,
"text-anchor": "bottom",
"text-offset": [0, -0.36],
"text-padding": 2,
},
paint: {
"text-color": place,
"text-halo-color": "rgba(0, 0, 0, 0.7)",
"text-halo-width": 1.5,
},
} as GlSpec,
];
}
/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */
export function pickFilter(pick: RoadPick | null): FilterSpecification {
const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]];
if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]);
return all as unknown as FilterSpecification;
}
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
export const MAP_THEME_NAMES = [
"--draw-map-contour",
"--draw-map-road",
"--draw-map-stream",
"--draw-map-parcel",
] as const;
/** 위성 채도(−1~1) — 조금 연하게. */
const SAT_SATURATION = -0.3;
+206
View File
@@ -0,0 +1,206 @@
/* =============================================================================
* map_legend.ts
* 공용 지도 범례(PLAN 569-2 · 622-2 · 624-2 · 요구 530) — 켠 겹 범례 칸 하나(줄마다 `createLegend`) + 페이지가 붙이는 덧 줄.
* 조각 겹 범례(산사태위험 · 국유림 임도 · 경제림 · 생태자연도) = 받을 자료 없음 → 켬끔만 보고 여기서 만듦(`tileLegendRows`).
* 받은 자료 범례 줄(수 · 넓이 · 급경사 범위 · 624-3 D03 에서 옮김) = `legendRows(on, data)` — 받은 자료 꼴은 `LegendData`
* (D03 옛 캔버스 자료 · B05 GL 자료 둘 다 맞음 · 받는 일은 페이지 몫).
* 쓰는 법 = `const legend = createMapLegend("d03-plan__legend")` → `legend.show(rows, [덧 줄])` · 줄 · 덧 없으면 숨김.
* ========================================================================== */
import { createLegend, type LegendItem } from "@ui/ui_template_flow_legend";
import { forestLegendRows } from "./map_forest_layers";
import type {
PlanBand,
PlanForestRoads,
PlanLandslideZones,
PlanRaster,
PlanSoils,
PlanZones,
} from "./map_layer_api";
import { hectare } from "./map_layer_data";
import {
LANDSLIDE_LEGEND,
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
type MapLayerKey,
} from "./map_layer_list";
import { SLOPE_MAX_PCT, bandRatios, pctToDeg, type SlopeBand } from "./logic/map_slope_bands";
/** 지물 수만 보는 겹(옛 캔버스 자료 · GeoJSON 둘 다). */
interface Counted {
features?: ArrayLike<unknown>;
}
/** 범례가 읽는 받은 자료 — 받은 겹만. */
export interface LegendData {
slope: { raster: PlanRaster; bands: SlopeBand[] };
landslideZones: { info: PlanLandslideZones };
zones: { summary: PlanZones["summary"] };
/** 토양형(코드 차례) 색 값 · 요약. */
soils: { kinds: Array<{ color: string }>; summary: PlanSoils["summary"] };
bands: { info: { stream: PlanBand; road: PlanBand } };
water: Counted;
building: Counted;
road: { small: Counted };
forestRoadFile: { info: PlanForestRoads };
faults: { count: number; length_m: number };
lineaments: { count: number; length_m: number };
zeroOrder: { count: number; length_m: number };
}
export interface MapLegend {
root: HTMLElement;
show: (rows: LegendItem[][], tail?: HTMLElement[]) => void;
}
/** 켠 조각 겹 범례 — 산사태위험 다음 산림 셋. */
export const tileLegendRows = (on: Record<MapLayerKey, boolean>): LegendItem[][] => [
...(on.landslide ? [LANDSLIDE_LEGEND] : []),
...forestLegendRows(on),
];
/** `className` = 자리 · 꼴(페이지 CSS). */
export function createMapLegend(className: string): MapLegend {
const root = document.createElement("div");
root.className = className;
root.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
return {
root,
show(rows, tail = []) {
root.replaceChildren(...rows.map((row) => createLegend(row)), ...tail);
root.hidden = !rows.length && !tail.length;
},
};
}
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
function slopeRow({ raster, bands }: LegendData["slope"]): LegendItem[] {
const ratios = bandRatios(raster.counts ?? [], bands);
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
return bands.map((band, i) => {
const open = band.high >= SLOPE_MAX_PCT;
const range = open
? `${band.low} %↑(${deg(band.low)}°↑)`
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
});
}
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
function landslideZoneRow({ info }: LegendData["landslideZones"]): LegendItem[] {
const color = "var(--draw-map-landslide-zone)";
const items: LegendItem[] = [
{
label: `산사태취약지역 점 ${info.points.length}곳`,
color,
kind: "dot",
off: !info.points.length,
},
{
label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`,
color,
kind: "dash",
off: !info.parcels.features?.length,
},
];
for (const sido of info.sidos)
if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true });
for (const name of info.missing)
items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true });
if (!info.cadastral)
items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true });
return items;
}
/** 켠 겹의 받은 자료 범례 줄(급경사 · 조각 겹 · 산사태취약 · 보호구역 · 토양 · 띠 · 선 겹 · 물 면 · 건물 · 임도). */
export function legendRows(
on: Record<MapLayerKey, boolean>,
data: Partial<LegendData>,
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
rows.push(...tileLegendRows(on)); // 조각 겹 = 받기 없음(622-2 · 공용 범례)
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
if (on.zones && data.zones) {
const items = Object.entries(data.zones.summary)
.filter(([, kind]) => kind.count || kind.failed)
.map(([key, kind]) => ({
label: kind.failed
? `${kind.label} 받지 못함`
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
color: `var(${ZONE_COLORS[key]})`,
off: !!kind.failed,
}));
rows.push(
items.length
? items
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.soils && data.soils) {
const { kinds, summary } = data.soils;
rows.push(
summary.length
? summary.map((soil, i) => ({
label: `${soil.name} ${hectare(soil.area_m2)}`,
color: `var(${kinds[i].color})`,
}))
: [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.bands && data.bands) {
const { stream, road } = data.bands.info;
rows.push([
{
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-stream)",
},
{
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-road)",
},
]);
}
const lines = LINE_KEYS.flatMap((key) => {
const got = on[key] && data[key];
if (!got) return [];
const { label, color, dash } = LINE_LAYERS[key];
return [
{
label: `${label} ${got.count}가닥 ${km(got.length_m)}`,
color: `var(${color})`,
kind: dash.length ? ("dash" as const) : ("line" as const),
off: !got.count,
},
];
});
if (lines.length) rows.push(lines);
const tail: LegendItem[] = [];
if (on.water && data.water)
tail.push({
label: `물 면 ${data.water.features?.length ?? 0}곳`,
color: "var(--draw-map-water)",
});
if (on.building && data.building)
tail.push({
label: `건물 ${(data.building.features?.length ?? 0).toLocaleString()}동`,
color: "var(--draw-map-building)",
});
if (on.forestRoad && data.road)
tail.push({
label: `임도 짐작 ${data.road.small.features?.length ?? 0}가닥`,
color: "var(--draw-map-forest-road)",
kind: "dash",
});
if (on.forestRoadFile && data.forestRoadFile)
tail.push({
label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
color: "var(--draw-map-forest-road-file)",
kind: "line",
});
if (tail.length) rows.push(tail);
return rows;
}
@@ -1,11 +1,11 @@
/* =============================================================================
* D03_PlanAnalysis_UI_SatLight.ts
* D03 위성 바탕 밝기(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절.
* 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = `D03_PlanAnalysis_UI_MapGl` `setSatelliteLight`.
* map_sat_light.ts
* 공용 지도 위성 바탕 밝기(D03 · B05 · 624-3 공용으로 옮김)(요구 475) — 노선 선 대비를 위해 위성을 조금 어둡게 · 사용자가 범례에서 조절.
* 값 = 밝기 상한(0.4~1) · 계정 화면 취향으로 기억. 칠하기 = D03 `UI_MapGl` `setSatelliteLight` · B05 `UI_RouteEdit_Layers`.
* ========================================================================== */
import { createSlider } from "@ui/ui_template_input";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { readByKey, writeByKey } from "../b_page_state";
const PREF_KEY = "aislo:pref:d03.satellite-light";
export const SAT_LIGHT_MIN = 0.4;
@@ -1,10 +1,10 @@
/* =============================================================================
* D03_PlanAnalysis_UI_SlopeBands.ts
* D03 급경사 범위 옵션(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
* map_slope_bands.ts
* 공용 지도 급경사 범위 옵션(D03 · B05 · 624-3 공용으로 옮김)(PLAN 603 · 요구 466 · 467 · 468) — 범례 급경사 줄 ⚙ → 모달
* (범위 줄 = 아래 % · 위 % · 색 · 지움 · 칸 옆 ° 환산 · [범위 추가] · [기본값] · [적용] · 빈 값 · 겹침 막음).
* 기억 = 화면 취향(`aislo:pref:d03.slope-bands` · 계정에 붙음 · 프로젝트 무관).
* 칠하기 = 서버 값 그림(회색 · 칸 = 기울기 % 내림) → 범위 색 그림(서버 왕복 없음 · CLAUDE.md 5장).
* 범위 · 비율 셈 = `logic/D03_PlanAnalysis_SlopeBands.ts` · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`.
* 범위 · 비율 셈 = `logic/map_slope_bands.ts` · 부르는 곳 = D03 `UI_Layers` · B05 `UI_RouteEdit_Layers`.
* ========================================================================== */
import { createButton, createIconButton } from "@ui/ui_template_button";
@@ -13,8 +13,8 @@ import { createLegend } from "@ui/ui_template_flow_legend";
import { openModal } from "@ui/ui_template_modal";
import { themeColor } from "@ui/ui_template_palette";
import { createText } from "@ui/ui_template_text";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { loadImage } from "./D03_PlanAnalysis_UI_LayerDefs";
import { readByKey, writeByKey } from "../b_page_state";
import { loadImage } from "./map_layer_data";
import {
DEFAULT_SLOPE_BANDS,
SLOPE_COLORS,
@@ -25,7 +25,7 @@ import {
parseBands,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
} from "./logic/map_slope_bands";
const PREF_KEY = "aislo:pref:d03.slope-bands";
/** 칠한 칸 투명도(겹 투명도 0.55 는 따로). */
+66
View File
@@ -0,0 +1,66 @@
/* =============================================================================
* map_terrain_chip.ts
* 공용 지도 지형 칩(PLAN 624-2 · 요구 530 · 결정 A④ · A⑤ · D2) — 도구줄 끝 분할 고르기 하나: 수치지형도 · 라이다.
* 이름 = 예정노선 「분석 지형」 · B05 「설계 지형」(바꾸면 뒤 단계 모두 다시 셈 · 확인창은 B05 몫).
* 없는 원천 = 막힘 + 까닭 글(D03 라이다 = D 프로젝트에 LAS 올린 뒤 · 624-4).
* 화면 칩만 — 바꾸는 일(서버 원천 인자 624-1 · B05 설계 지형 바꿈 626-1)은 `onChange` 로 페이지가 붙임.
* 쓰는 법 = `createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })` → `{ root, set, setHave }` · root 를 도구줄 `terrain` 칸에.
* ========================================================================== */
import { createSegment } from "@ui/ui_template_segment";
import type { MapPage } from "./map_layer_list";
/** 서버 겹 길 `terrain=` 값과 같음(624-1 · 빈 값 = 설계 지형). */
export type TerrainSource = "sheet" | "lidar";
export const TERRAIN_NAMES: Record<TerrainSource, string> = {
sheet: "수치지형도",
lidar: "라이다",
};
const CAPTION: Record<MapPage, string> = {
d03: "분석 지형 — 급경사 · 음영 · 0차곡 · 긋기 높이에 쓰는 지형",
b05: "설계 지형 — 바꾸면 종단 · 횡단 · 배수 · 토공을 모두 다시 셈",
};
const MISSING: Record<TerrainSource, string> = {
sheet: "수치지형도 — 이 프로젝트 범위에 도엽 지형 없음",
lidar: "라이다 — 올린 LAS 없음",
};
export interface TerrainChip {
root: HTMLElement;
/** 고름을 바꿈(onChange 는 안 부름). */
set: (value: TerrainSource) => void;
/** 고를 수 있는 원천 — 없는 것은 막힘. */
setHave: (have: readonly TerrainSource[]) => void;
}
export function createTerrainChip(opts: {
page: MapPage;
value: TerrainSource;
have: readonly TerrainSource[];
onChange?: (value: TerrainSource) => void;
}): TerrainChip {
const sources = Object.keys(TERRAIN_NAMES) as TerrainSource[];
const segment = createSegment({
options: sources.map((value) => ({ value, label: TERRAIN_NAMES[value] })),
value: opts.value,
size: 32,
ariaLabel: CAPTION[opts.page],
onChange: (value) => opts.onChange?.(value as TerrainSource),
});
const root = segment.root;
root.dataset.act = "terrain";
root.title = CAPTION[opts.page];
const setHave = (have: readonly TerrainSource[]): void => {
for (const value of sources) {
const item = root.querySelector<HTMLButtonElement>(`[data-value="${value}"]`)!;
item.disabled = !have.includes(value);
item.title = item.disabled
? MISSING[value]
: `${CAPTION[opts.page]} · ${TERRAIN_NAMES[value]}`;
}
};
setHave(opts.have);
return { root, set: (value) => segment.set(value), setHave };
}
+66
View File
@@ -0,0 +1,66 @@
/* =============================================================================
* map_toolbar.ts
* 공용 지도 도구줄 표(PLAN 624-2 · 요구 530 「상단 아이콘 셋 같게 · 필요 없는 것 숨김」) — 예정노선(D03) · B05 평면이 같이 씀.
* 한 줄 = 열쇠(`ui_template_map_tools` 단추 이름 · 아이콘 · 마우스 글은 거기) · 할 일 · 보일 페이지.
* 묶음 = 나눔선 · 차례 = 도구줄 차례 · 단추 아닌 칸(간격 고르기 · 지형 칩)은 이름을 여기 둠.
* 누름 동작 · 페이지 문구는 페이지 몫 — 만든 요소를 `parts` 로 넘기면 그 자리에 · 안 넘긴 단추는 기본 단추(돌리기 둘 = 공용 돌림이 `data-act` 로 찾음).
* 거리 재기 = 단추 자리만 같고 동작은 페이지 몫(결정 A⑩) · 지형 칩 = `map_terrain_chip`.
* 쓰는 법 = `createMapTools({ label, hint, groups: mapToolGroups("d03", { undo: undoButton, terrain: chip.root }) })`.
* ========================================================================== */
import type { MapToolAct } from "@ui/ui_template_map_tools";
import type { MapPage } from "./map_layer_list";
export type MapToolKey = MapToolAct | "snap-step" | "terrain";
export interface MapToolRow {
key: MapToolKey;
/** 단추 아닌 칸 이름(단추는 `MAP_TOOLS` 이름). */
name?: string;
does: string;
pages: readonly MapPage[];
}
const BOTH: readonly MapPage[] = ["d03", "b05"];
const B05: readonly MapPage[] = ["b05"];
export const MAP_TOOLBAR: readonly (readonly MapToolRow[])[] = [
[
{ key: "undo", does: "편집 한 걸음 되돌림", pages: BOTH },
{ key: "redo", does: "되돌린 걸음 다시", pages: BOTH },
],
[
{ key: "view3d", does: "3D 큰 창(동작 = 페이지 몫)", pages: BOTH },
{ key: "rotate-ccw", does: "지도 반시계로 돌림(Ctrl+휠도)", pages: BOTH },
{ key: "rotate-cw", does: "지도 시계로 돌림", pages: BOTH },
],
[
{ key: "contour-minor", does: "라이다 1 m 등고선 켬끔(라이다일 때만 열림)", pages: B05 },
{ key: "stations", does: "측점 눈금 · 번호 켬끔", pages: BOTH },
{ key: "slopes", does: "구간 기울기 글 켬끔", pages: BOTH },
{ key: "footprint", does: "도로 끝 · 비탈 끝(절/성토선) 켬끔", pages: B05 },
{ key: "measure", does: "거리 재기(동작 = 페이지 몫 · A⑩)", pages: BOTH },
],
[
{ key: "station-snap", does: "곡선 가운데(MC)를 간격 배수에 맞춤", pages: B05 },
{ key: "snap-step", name: "측점 스냅 간격", does: "스냅 간격 고르기", pages: B05 },
],
[{ key: "terrain", name: "지형 칩", does: "지형 원천 보기 · 고르기", pages: BOTH }],
];
const isAct = (key: MapToolKey): key is MapToolAct => key !== "snap-step" && key !== "terrain";
/** 그 페이지 도구줄 묶음 — 넘긴 요소 · 기본 단추(단추 열쇠만) · 빈 묶음 뺌. */
export function mapToolGroups(
page: MapPage,
parts: Partial<Record<MapToolKey, HTMLElement>> = {},
): (MapToolAct | HTMLElement)[][] {
return MAP_TOOLBAR.map((group) =>
group
.filter((row) => row.pages.includes(page))
.flatMap((row): (MapToolAct | HTMLElement)[] => {
const made = parts[row.key];
return made ? [made] : isAct(row.key) ? [row.key] : [];
}),
).filter((group) => group.length);
}
+25 -15
View File
@@ -197,9 +197,12 @@ def run_surface_analysis(
methods: list[str],
force: bool = False,
on_progress: ProgressCallback | None = None,
maps: bool = True,
) -> dict[str, Any]:
"""구조화→필터→모델 빌드를 수행하고 산출 메타데이터를 반환한다.
`maps=False` = 지도 · GIS 받기와 도엽 서피스를 건너뜀(D 라이다 · PLAN 624-4).
`las_path`는 지형 파일 한 장 또는 여러 장이다 — 여러 장이면 합친 범위로 한 벌을
만든다(2026-09-06 사용자 확정).
@@ -349,31 +352,38 @@ def run_surface_analysis(
)
# 3-2·3-3. VWorld 지도·국가 GIS 벡터·수치지형도 도엽 (공용 블록 — 도엽 서피스도 사용)
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
las_bounds_dict = {
"x": [float(bounds[0, 0]), float(bounds[0, 1])],
"y": [float(bounds[1, 0]), float(bounds[1, 1])],
"z": [float(bounds[2, 0]), float(bounds[2, 1])],
}
download_geodata(
project_root, processed_dir, las_bounds_dict, las_paths[0].parent, rebuild, report=_report
)
if maps:
_report(90, "download_maps", "VWorld 지도 및 GIS 벡터 데이터 다운로드 중")
download_geodata(
project_root,
processed_dir,
las_bounds_dict,
las_paths[0].parent,
rebuild,
report=_report,
)
# 3-4. 도엽등고선 3D 서피스 — LAS가 있어도 참고용으로 같이 만들어 영구저장한다
# (2026-08-30 사용자 확정). 실패해도 분석은 계속한다.
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
sheet_models: list[dict[str, Any]] = []
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
if maps:
_report(94, "surface_model", "도엽등고선 3D 서피스 생성 중")
try:
from B04_PreProcess.B04_PreProcess_Engine_SheetSurface import (
build_sheet_surface_from_route,
)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
# 자동 전처리는 기본 방식 하나만 만든다 — 나머지는 관리자가 화면에서 고를 때.
sheet_models = build_sheet_surface_from_route(
project_root, processed_dir, models_dir, list(SHEET_SURFACE_AUTO_METHODS)
)
except Exception as exc:
logger.warning("도엽등고선 서피스 생성 실패: %s", exc)
_report(95, "saving", "결과 저장 중")
@@ -39,8 +39,10 @@ from .B04_PreProcess_Engine_MapSheet import latlon_to_sheet5k, sheet5k_to_bounds
logger = logging.getLogger(__name__)
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 도곽선 N3L_H0010000 이 없는 zip 은 옛 A0010000(도로 면)로 대신
# 도곽선 레이어 코드 (수치지형도 v2.0 도엽본) — 선 N3L_H0010000 · 면 N3A_H0010000(섬 · 해안 작은 도엽)
# · 둘 다 없는 zip 은 옛 A0010000(도로 면)로 대신
_SHEET_FRAME_CODE = "N3L_H0010000"
_SHEET_FRAME_AREA_CODE = "N3A_H0010000"
_SHEET_FRAME_FALLBACK_CODE = "A0010000"
_SHEET_NO_RE = re.compile(r"(\d{8})")
_SHARED_DIRNAME = "_shared" # storage/_shared 아래 = 여러 프로젝트가 같이 쓰는 창고
@@ -153,7 +155,11 @@ def download_sheet(store_dir: str | Path, sheet_no: str, version: int = 2) -> di
rows = search_sheet_files(sheet_no, version=version)
if not rows:
raise LookupError(f"도엽 {sheet_no}: 브이월드 검색 결과 없음")
row = rows[0]
# Ver2.0 이 zip 아닌 NGI · NDA 뿐인 도엽(작은 섬) — 받을 zip 없음
row = next((r for r in rows if str(r.get("file_nm", "")).lower().endswith(".zip")), None)
if row is None:
kinds = ", ".join(sorted({str(r.get("file_extsn", "")) for r in rows}))
raise LookupError(f"도엽 {sheet_no}: Ver2.0 zip 없음({kinds})")
cookie = _session_cookie_cache or VWORLD_SESSION_COOKIE
payload = None
@@ -318,7 +324,7 @@ def ingest_zip(store_dir: str | Path, src_path: str | Path, sheet_no: str | None
def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float, float], str]:
"""zip 내 도곽선(N3L_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS).
"""zip 내 도곽선(N3L_H0010000 · N3A_H0010000 · 없으면 A0010000) SHP bounds를 WGS84로 반환. (bounds, 사용된 CRS).
선언된 PRJ가 오라벨(중부/동부원점 뒤바뀜)인 실측 사례가 있어, 파일명 도엽번호
기준 예측 도곽과 대조해 정합한 CRS를 자동 판별한다.
@@ -328,7 +334,7 @@ def _read_frame_bounds_wgs84(zip_path: Path) -> tuple[tuple[float, float, float,
with zipfile.ZipFile(zip_path) as zf:
names = zf.namelist()
shp: list[str] = []
for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_FALLBACK_CODE):
for code in (_SHEET_FRAME_CODE, _SHEET_FRAME_AREA_CODE, _SHEET_FRAME_FALLBACK_CODE):
shp = [n for n in names if n.upper().endswith(f"{code}.SHP")]
if shp:
break
+12 -7
View File
@@ -257,21 +257,26 @@ def _apply_new_defaults(
if definition is None:
continue
options = dict(point.options or {})
given = set(options)
for field in definition.options:
if field.key not in options and field.default not in (None, ""):
options[field.key] = field.default
# 집수정 모양도 실제 값으로(54-39 · 54-36 기본 L형 — 화면 짝 `DEFAULT_BASIN_SHAPE`)
if options.get("inlet_type") == "집수정":
if "pipe_diameter_mm" not in given and isinstance(options.get("pipe_diameter_mm"), str):
options["pipe_diameter_mm"] = int(_float(options["pipe_diameter_mm"])) # 폼 = 수(623)
# 집수정 = 모양 L(54-39 · `DEFAULT_BASIN_SHAPE`) · 동물이동형 유 · 유입 흙막이 없음(623)
basin = options.get("inlet_type") == "집수정"
if basin:
options.setdefault("inlet_structure", "L")
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · 화면 짝 `PIPE_HEIGHT_KEYS`)
options.setdefault("inlet_basin_animal", "true")
for key in [k for k in options if k.startswith("inlet_revet_") and k not in given]:
del options[key]
# 보호공 · 집수정 높이 = 관경 + 0.5(139-1 · `PIPE_HEIGHT_KEYS`) · 유입 흙막이 + 0.7(211-1)
keys = {field.key for field in definition.options}
diameter_m = _float(options.get("pipe_diameter_mm")) / 1000
if diameter_m > 0:
for key in PIPE_HEIGHT_KEYS:
if key in keys:
side = (
"inlet" if key == "inlet_revet_height_m" else ""
) # 유입 흙막이 + 0.7(211-1)
if key in keys and not (basin and key.startswith("inlet_revet_")):
side = "inlet" if key == "inlet_revet_height_m" else ""
options.setdefault(key, pipe_wall_min_height_m(diameter_m, side))
if point.facility == "ford_bridge" and "ford_height_m" not in options:
flow = (flows or {}).get(round(point.chainage_m, 2))
@@ -146,6 +146,33 @@ def _write_planned_polyline(
섞여 있어 다시 단순화하면 꺾임점이 조금씩 지워지고, 그것을 반복하면 [확인]을 누를
때마다 선이 깎인다(2026-09-07 실측: 정점 136 → 134 → 119). 낳은 값을 그대로 보관한다.
"""
result, table = polyline_table(
points,
radius_m,
simplify=simplify,
curve_flags=curve_flags,
radii=radii,
station_snap=station_snap,
station_snap_step=station_snap_step,
locks=locks,
arcs=arcs,
)
return _write_table(path, result, table)
def polyline_table(
points: list[tuple[float, float]],
radius_m: float,
*,
simplify: bool = True,
curve_flags: list[bool] | None = None,
radii: list[float | None] | None = None,
station_snap: bool | None = None,
station_snap_step: float | None = None,
locks: list[str | None] | None = None,
arcs: list[float | None] | None = None,
) -> tuple[PlannedPolyline, dict[str, Any]]:
"""`_write_planned_polyline` 의 셈 몫 — (폴리라인, 곡선표) · 파일 안 씀(626-2)."""
edited = curve_flags is not None or radii is not None
made = (
node_polyline(points, radius_m, curve_flags, radii, locks, arcs)
@@ -163,8 +190,6 @@ def _write_planned_polyline(
radii=radii,
)
edits = node_edits(points, curve_flags, radii, locks, arcs) if edited else None
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
table: dict[str, Any] = {
"min_radius_m": round(radius_m, 3),
"curves": [c.as_dict() for c in result.curves],
@@ -175,6 +200,27 @@ def _write_planned_polyline(
table["station_snap_step"] = float(station_snap_step)
if edits is not None:
table["edits"] = edits
return result, table
def followed_table(
points: list[tuple[float, float]], radius_m: float
) -> tuple[PlannedPolyline, dict[str, Any]]:
"""예정노선 추적(383-1) — 점 묶음 → 곡선 MC 를 일반측점에 맞춘 폴리라인 + 곡선표(스냅 켬).
체인 초기 폴리라인(`_ensure_planned_initial`) · `/route/follow`(626-2)가 같이 씀 · 못 맞추면
지금 선.
"""
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
fit = snapped_initial(points, radius_m)
shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {}
return polyline_table(fit[0] if fit else points, radius_m, station_snap=True, **shape)
def _write_table(path: Path, result: PlannedPolyline, table: dict[str, Any]) -> dict:
write_route_csv(path, [{"x": x, "y": y} for x, y in result.vertices])
write_route_csv(_nodes_path(path), [{"x": node.x, "y": node.y} for node in result.nodes])
_curves_path(path).write_text(json.dumps(table, ensure_ascii=False), encoding="utf-8")
return {
"nodes": len(result.nodes),
+2
View File
@@ -75,6 +75,8 @@ class RouteDraftInput(BaseModel):
stationSnap: bool = False # noqa: N815
stationSnapStep: float | None = None # noqa: N815 — 390-5
terrain: Literal["las", "sheet"] | None = None
#: 고친 예정노선(626-2) — 없음 = 그대로 · [] = 지움 · 점 묶음 = `expected_route_edit.csv`
expected: list[tuple[float, float]] | None = None
class RouteSaveRequest(RouteReplanRequest):
@@ -272,6 +272,7 @@ async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None)
restore_snapshot_files,
wipe_edited_masters,
)
from common_util.common_util_route_geometry import expected_route_edit_path
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
pool = get_db_pool()
@@ -352,6 +353,8 @@ async def _reset_route_design(project_id: UUID, session: dict[str, Any] | None)
"B05 초기화: 편집 정본 제거 %s (project_id=%s)", removed, project_id
)
await run_auto_design_chain(project_id, surface_model_id=surface_model_id)
if project_root: # 고친 예정노선(626-2)은 초기값이 아님 — 스냅샷에 섞여 있어도 지움
expected_route_edit_path(project_root).unlink(missing_ok=True)
async with pool.acquire() as connection:
latest = await get_latest_route(connection, project_id)
+27 -8
View File
@@ -20,6 +20,7 @@
GET /api/projects/{id}/route/plan → 예상노선·계획노선 정점(사업지 좌표계)
POST /api/projects/{id}/route/replan → 노선 초안 저장(노선 저장 체인)
POST /api/projects/{id}/route/initial → 초기 계획노선 갈래 하나를 만들어 돌려줌(추천 저장본만 씀)
POST /api/projects/{id}/route/follow → 고친 예정노선 다시 추적(저장 0 · 626-2 · 나눈 파일)
"""
import asyncio
@@ -35,13 +36,14 @@ from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B05_Profile.B05_Profile_Engine_RouteInitial import read_initial_choice, write_initial_choice
from B05_Profile.B05_Profile_Engine_RouteInitial_Station import snapped_initial
from B05_Profile.B05_Profile_Engine_Sections import load_station_snap, load_station_snap_step
from B05_Profile.B05_Profile_Route_Polyline_Write import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
_curves_path,
_nodes_path,
_read_curves,
_write_planned_polyline,
_write_table,
followed_table,
)
from B05_Profile.B05_Profile_Route_Save import RouteSaveRequest
from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이 `replan.` 으로 씀
@@ -54,6 +56,7 @@ from B05_Profile.B05_Profile_Schema import ( # noqa: F401 — 부르는 곳이
from common_util.common_util_initial_snapshot import design_route_csv_path
from common_util.common_util_route_geometry import (
expected_route_csv_path,
expected_route_edit_path,
planned_route_initial_path,
planned_route_working_path,
read_planned_route_csv,
@@ -195,6 +198,15 @@ async def _road_settings(project_id: UUID) -> tuple[str, int | None, str]:
return grade_class, design_speed, terrain
def write_expected_edit(project_root: Path, points: list[tuple[float, float]]) -> None:
"""고친 예정노선 → `expected_route_edit.csv`(626-2 · 점 2 미만 = 지움)."""
target = expected_route_edit_path(project_root)
if len(points) < 2:
target.unlink(missing_ok=True)
return
write_route_csv(target, [{"x": x, "y": y} for x, y in points])
def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
"""계획노선 **초기 폴리라인**이 없거나 낡았으면 예상노선을 폴리라인화해 세운다.
@@ -211,11 +223,7 @@ def _ensure_planned_initial(project_root: Path, radius_m: float) -> dict | None:
if len(points) < 2:
return None
# 383-1 — 곡선 MC = 일반측점(기본 제약) + [측점 스냅] 켬 · 못 맞추면 지금 선 + 켬
fit = snapped_initial(points, radius_m)
shape = {"simplify": False, "curve_flags": fit[1], "radii": fit[2]} if fit else {}
summary = _write_planned_polyline(
target, fit[0] if fit else points, radius_m, station_snap=True, **shape
)
summary = _write_table(target, *followed_table(points, radius_m))
logger.info(
"계획노선 초기 폴리라인 생성: %s (노드 %d · 곡선 %d · 위반 %d · 정점 %d)",
target,
@@ -426,6 +434,10 @@ async def read_route_plan(project_id: UUID) -> dict[str, Any] | JSONResponse:
"status": "success",
"project_id": str(project_id),
"expected": expected,
# 고친 예정노선(626-2 · [저장] 때) — 없으면 빈 목록 · 원본 `expected` 는 그대로
"expected_edited": await asyncio.to_thread(
_vertices_of, expected_route_edit_path(project_root)
),
"planned": planned,
"nodes": [node.as_dict() for node in outline.nodes],
# 곡선 성분 — 화면이 **곡선 시작·끝점**을 손잡이로 그리고 반지름 칸을 띄우는 재료다
@@ -580,6 +592,10 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
return JSONResponse(
status_code=409, content={"status": "error", "message": result["error"]}
)
# 고친 예정노선(626-2) — 초안에 오면만 · [] = 지움 · `expected_route.csv` 는 안 씀
expected = getattr(draft, "expected", None)
if expected is not None:
await asyncio.to_thread(write_expected_edit, project_root, expected)
choice = request.initial
current = (
await asyncio.to_thread(
@@ -611,5 +627,8 @@ async def _save_route(project_id: UUID, body: RouteSaveRequest) -> dict[str, Any
}
# `POST /route/initial` 은 나눈 파일에서 같은 `router` 에 붙는다 — 끝에서 불러 등록.
from B05_Profile import B05_Profile_Router_RouteInitial # noqa: E402, F401
# `POST /route/initial` · `/route/follow` 는 나눈 파일에서 같은 `router` 에 붙음 — 끝에서 등록.
from B05_Profile import ( # noqa: E402
B05_Profile_Router_RouteFollow, # noqa: F401
B05_Profile_Router_RouteInitial, # noqa: F401
)
@@ -0,0 +1,81 @@
"""예정노선 다시 추적(626-2 · 요구 530) — 고친 예정노선 → 평면선(노드 · 곡선 · 편집표) · 저장 0.
계산 자리 ③ 서버 단독 — 체인 초기 폴리라인(`_ensure_planned_initial`)과 같은 함수
`followed_table`(지형 안 씀 · 순수 기하). 화면은 받은 노드를 편집기 한 걸음으로 싣고 페이지 [저장]
길로 보냄(고친 예정노선 = 초안 `expected` → `expected_route_edit.csv`). `/route/replan` ·
`as_initial` · `expected_route.csv` · 초기값 · 추천 저장본은 안 건드림.
POST /api/projects/{id}/route/follow
본문 {points: [[x, y], …], crs: "tm" | "lonlat"} ("lonlat" = [경도, 위도])
→ {status, planned, nodes, curves, node_edits, station_snap, min_radius_m,
curve_count, violation_count} (GET /route/plan 칸 이름 · 사업지 TM)
400 점 2 미만 · 404 프로젝트 없음 · 409 좌표계 모름(lonlat)
"""
from __future__ import annotations
import asyncio
from typing import Any, Literal
from uuid import UUID
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from B05_Profile.B05_Profile_Repository import get_surface_crs_epsg
from B05_Profile.B05_Profile_Route_Polyline_Write import followed_table
from B05_Profile.B05_Profile_Router_Replan import (
_PROJECT_PATH_MISSING,
_plan_criteria,
_project_paths,
router,
)
from common_util.common_util_crs import lonlat_to_tm
from config.config_db import get_db_pool
class RouteFollowRequest(BaseModel):
points: list[tuple[float, float]]
crs: Literal["tm", "lonlat"] = "tm"
def follow_route_line(points: list[tuple[float, float]], radius_m: float) -> dict[str, Any]:
"""점 묶음(TM) → 예정노선 추적 평면선 — 파일 꼴(csv 4자리)과 같은 값."""
result, table = followed_table(points, radius_m)
return {
"planned": [[round(x, 4), round(y, 4)] for x, y in result.vertices],
"nodes": [node.as_dict() for node in result.nodes],
"curves": table["curves"],
"node_edits": table.get("edits"),
"station_snap": table["station_snap"],
"min_radius_m": round(radius_m, 2),
"curve_count": result.curve_count,
"violation_count": result.violation_count,
}
@router.post("/{project_id}/route/follow", response_model=None)
async def follow_route(project_id: UUID, body: RouteFollowRequest) -> dict[str, Any] | JSONResponse:
"""고친 예정노선을 다시 추적한 평면선 — 아무것도 안 씀(626-2)."""
if len(body.points) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "예정노선은 점이 2개 이상이어야 합니다."},
)
if await _project_paths(project_id) is None:
return JSONResponse(status_code=404, content=_PROJECT_PATH_MISSING)
points = [(float(x), float(y)) for x, y in body.points]
if body.crs == "lonlat":
async with get_db_pool().acquire() as connection:
epsg = await get_surface_crs_epsg(connection, project_id, 0)
if epsg is None:
return JSONResponse(
status_code=409,
content={
"status": "error",
"message": "사업지 좌표계를 몰라 경위도를 바꿀 수 없습니다.",
},
)
points = [lonlat_to_tm(lon, lat, epsg) for lon, lat in points]
radius_m = (await _plan_criteria(project_id))[0]
line = await asyncio.to_thread(follow_route_line, points, radius_m)
return {"status": "success", "project_id": str(project_id), **line}
+28 -13
View File
@@ -19,7 +19,14 @@
산업안전보건관리비 = A(관급 순자재대 ÷ 1.1 포함) · B(관급 빼고 × 1.2) 중 작은 쪽 ·
이윤 = ROUNDDOWN((다 − 재료비) × 율, 0) − 보정 · 부가세 = ROUNDDOWN(공급가액 × 율, 0) + 조정.
빈칸 규칙 = 요율(사용자 · 자동 둘 다 없음) · 비목 합이 빈칸이면 그 줄 빈칸(0 을 채우지 않음) · 그 줄을 쓰는 소계도 빈칸 ·
손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0 · 엑셀에 없는 현행 비목 = 줄만(늘 빈칸).
손입력(기초액 · 보정 · 조정 · 간접재료비 · 작업설) 빈칸 = 0.
현행 비목 다섯(PLAN 708-2 · 엑셀 총괄에 없음 · 경쟁사 둘 다 셈) = STmate 일반형식 식 · 자리
(지식DB `STmate 분석/20_분석/12_원가계산_4형식과_RATE표_복원.md` 일반 · 수공 경비 차례 ·
`35_형식과_단계의_공통과_차이.md` 밑수 표) — 경비 묶음 안 · 나.소계(순공사원가)에 듦 ·
퇴직공제부금비 = 직접노무비 × 율 · 임금채권 · 석면 = (직접노무비 + 간접노무비) × 율 ·
하도급대금지급보증수수료 = 순공사비 × 율 · 공사이행보증수수료 = 금액 손입력(`performance_fee` ·
STmate 식 = 공종 · 기간 표라 뒤로) · 자동 요율 없음 · 요율 빈칸 = 해당 없음(0 · 빈칸 아님).
요율 출처 = 사용자 값 중 `rates_imported` 키 = 「import」(707-2 불러옴).
"""
from __future__ import annotations
@@ -29,7 +36,8 @@ from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown, roundup, total
from common_util.common_util_time import kst_today
#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 · 나.소계에 듦
#: 제비율 줄 (키, 이름, M01 요율 후보 키, 산식 글) — 엑셀 총괄 49~58 차례 +
#: 현행 비목(STmate 경비 차례) · 나.소계에 듦
RATE_LINES: tuple[tuple[str, str, str, str], ...] = (
("indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"),
("accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"),
@@ -37,19 +45,18 @@ RATE_LINES: tuple[tuple[str, str, str, str], ...] = (
("health", "건강보험료", "RT000003", "직접노무비 × 율"),
("care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"),
("pension", "연금보험료", "RT000004", "직접노무비 × 율"),
("retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"),
("safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"),
("other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"),
("performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"),
("subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"),
("environment", "환경보전비", "RT000010", "순공사비 × 율"),
("machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"),
("asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"),
("wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"),
)
#: 엑셀에 없는 현행 비목 — 줄만 두고 빈칸(밑수 · 고르는 값 미정 · 나.소계에 안 듦)
CURRENT_ONLY: tuple[tuple[str, str, str], ...] = (
("retirement", "퇴직공제부금비", "RT000011"),
("wage_claim", "임금채권보장분담금", "RT000012"),
("asbestos", "석면피해구제분담금", "RT000013"),
("subcontract", "하도급대금지급보증수수료", "RT000014"),
("performance", "공사이행보증수수료", "RT000016"),
)
#: 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음)
CURRENT_ONLY = ("retirement", "subcontract", "asbestos", "wage_claim", "performance")
#: 소계 아래 줄 (키, 이름, 후보 키, 산식 글)
TAIL_LINES: tuple[tuple[str, str, str, str], ...] = (
("overhead", "일반관리비", "RT000017", "나.소계 × 율"),
@@ -135,6 +142,7 @@ def compute_total(
sums = sums if isinstance(sums, dict) else {}
est = estimate if isinstance(estimate, dict) else {}
rates_in = est.get("rates") if isinstance(est.get("rates"), dict) else {}
imported = est.get("rates_imported") if isinstance(est.get("rates_imported"), list) else []
def hand(key: str) -> float:
return num(est.get(key)) or 0.0
@@ -164,7 +172,7 @@ def compute_total(
mine, got = num(rates_in.get(key)), auto.get(key) or {}
rates[key] = {
"rate": got.get("rate") if mine is None else mine,
"from": "user"
"from": ("import" if key in imported else "user")
if mine is not None
else ("auto" if got.get("rate") is not None else None),
"auto": got.get("rate"),
@@ -193,6 +201,14 @@ def compute_total(
line["other"] = _pct(total([labor, line["indirect_labor"], material]), rate["other"])
line["environment"] = _pct(direct, rate["environment"])
line["machine"] = _pct(direct, rate["machine"])
line["retirement"] = _pct(labor, rate["retirement"])
line["subcontract"] = _pct(direct, rate["subcontract"])
line["asbestos"] = _pct(total_labor, rate["asbestos"])
line["wage_claim"] = _pct(total_labor, rate["wage_claim"])
for key in CURRENT_ONLY: # 요율 빈칸 = 해당 없음
if rate[key] is None:
line[key] = 0.0
line["performance"] = hand("performance_fee")
net_cost = total([direct, *(line[key] for key, *_ in RATE_LINES)]) # 나. 순공사원가
line["overhead"] = _pct(net_cost, rate["overhead"])
da = total([net_cost, line["overhead"]]) # 다
@@ -220,8 +236,7 @@ def compute_total(
"indirect_labor": line["indirect_labor"],
"labor": total_labor,
"expense": expense,
**{key: line[key] for key in ("accident", "employment", "health", "care", "pension")},
**{key: line[key] for key in ("safety", "other", "environment", "machine")},
**{key: line[key] for key, *_ in RATE_LINES[1:]},
"expense_sum": total(
[expense, *(line[key] for key, *_ in RATE_LINES if key != "indirect_labor")]
),
+25 -19
View File
@@ -9,13 +9,14 @@
* 입력 sums = 설계내역서 비목 합(145-3 `sheets.bill.sums`) · estimate = 사용자 값(`user.estimate`) ·
* table = M01 요율 압축 표(payload `rate_table`) · year = 연금 해 — 자동 고르기(166-1) = 파이썬 짝 머리 글
* 식 · 끝수 = 엑셀 총괄 49~67 그대로 · 빈칸 규칙 = 파이썬 짝 머리 글 그대로.
* 현행 비목 다섯(708-2) = STmate 일반형식 식 · 자리(경비 묶음 안 · 나.소계에 듦) — 근거 = 파이썬 짝 머리 글.
* ========================================================================== */
import { num, rounddown, roundup, total, type Row } from "./B09_Estimation_Calc_Base";
type Num = number | null;
/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 · 나.소계에 듦 */
/** 제비율 줄 [키, 이름, M01 요율 후보 키, 산식 글] — 엑셀 총괄 49~58 차례 + 현행 비목 · 나.소계에 듦 */
export const RATE_LINES: Array<[string, string, string, string]> = [
["indirect_labor", "간접노무비", "RT000008", "직접노무비 × 율"],
["accident", "산재보험료", "RT000001", "(직접노무비 + 간접노무비) × 율"],
@@ -23,19 +24,18 @@ export const RATE_LINES: Array<[string, string, string, string]> = [
["health", "건강보험료", "RT000003", "직접노무비 × 율"],
["care", "노인장기요양보험료", "RT000005", "건강보험료 × 율"],
["pension", "연금보험료", "RT000004", "직접노무비 × 율"],
["retirement", "퇴직공제부금비", "RT000011", "직접노무비 × 율"],
["safety", "산업안전보건관리비", "RT000006", "A · B 중 작은 쪽 + 기초액"],
["other", "기타경비", "RT000009", "(직접노무비 + 간접노무비 + 재료비) × 율"],
["performance", "공사이행보증수수료", "RT000016", "금액 직접 입력"],
["subcontract", "하도급대금지급보증수수료", "RT000014", "순공사비 × 율"],
["environment", "환경보전비", "RT000010", "순공사비 × 율"],
["machine", "건설기계대여금지급보증", "RT000015", "순공사비 × 율"],
["asbestos", "석면피해구제분담금", "RT000013", "(직접노무비 + 간접노무비) × 율"],
["wage_claim", "임금채권보장분담금", "RT000012", "(직접노무비 + 간접노무비) × 율"],
];
/** 엑셀에 없는 현행 비목 — 줄만 두고 빈칸 */
export const CURRENT_ONLY: Array<[string, string, string]> = [
["retirement", "퇴직공제부금비", "RT000011"],
["wage_claim", "임금채권보장분담금", "RT000012"],
["asbestos", "석면피해구제분담금", "RT000013"],
["subcontract", "하도급대금지급보증수수료", "RT000014"],
["performance", "공사이행보증수수료", "RT000016"],
];
/** 현행 비목 — 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력(율 칸 없음) */
export const CURRENT_ONLY = ["retirement", "subcontract", "asbestos", "wage_claim", "performance"];
/** 소계 아래 줄 */
export const TAIL_LINES: Array<[string, string, string, string]> = [
["overhead", "일반관리비", "RT000017", "나.소계 × 율"],
@@ -113,6 +113,7 @@ export function computeTotal(
const s: Row = sums && typeof sums === "object" ? sums : {};
const est: Row = estimate && typeof estimate === "object" ? estimate : {};
const ratesIn: Row = est.rates && typeof est.rates === "object" ? est.rates : {};
const imported: unknown[] = Array.isArray(est.rates_imported) ? est.rates_imported : [];
const hand = (key: string): number => num(est[key]) || 0;
const material = num(s.material);
@@ -138,7 +139,14 @@ export function computeTotal(
const autoRate: Num = got.rate ?? null;
rates[key] = {
rate: mine === null ? autoRate : mine,
from: mine !== null ? "user" : autoRate !== null ? "auto" : null,
from:
mine !== null
? imported.includes(key)
? "import"
: "user"
: autoRate !== null
? "auto"
: null,
auto: autoRate,
why: got.why || "",
};
@@ -168,6 +176,12 @@ export function computeTotal(
line.other = pct(total([labor, line.indirect_labor, material]), rate.other);
line.environment = pct(direct, rate.environment);
line.machine = pct(direct, rate.machine);
line.retirement = pct(labor, rate.retirement);
line.subcontract = pct(direct, rate.subcontract);
line.asbestos = pct(totalLabor, rate.asbestos);
line.wage_claim = pct(totalLabor, rate.wage_claim);
for (const key of CURRENT_ONLY) if (rate[key] === null) line[key] = 0; // 요율 빈칸 = 해당 없음
line.performance = hand("performance_fee");
const netCost = total([direct, ...RATE_LINES.map(([key]) => line[key])]);
line.overhead = pct(netCost, rate.overhead);
const da = total([netCost, line.overhead]);
@@ -193,15 +207,7 @@ export function computeTotal(
indirect_labor: line.indirect_labor,
labor: totalLabor,
expense,
accident: line.accident,
employment: line.employment,
health: line.health,
care: line.care,
pension: line.pension,
safety: line.safety,
other: line.other,
environment: line.environment,
machine: line.machine,
...Object.fromEntries(RATE_LINES.slice(1).map(([key]) => [key, line[key]])),
expense_sum: total([
expense,
...RATE_LINES.filter(([key]) => key !== "indirect_labor").map(([key]) => line[key]),
+7 -11
View File
@@ -12,11 +12,11 @@
indirect_material · scrap = 그 줄 금액 ·
profit_adjust = ROUNDDOWN((순공사원가 − 재료비 + 일반관리비) × 율) − 엑셀 이윤 ·
vat_adjust = 엑셀 부가세 − ROUNDDOWN(총원가 × 율) ·
procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값).
procurement_fee_pct = 관급 조달수수료 ÷ 순자재대(소수 2~4 자리 중 수수료를 그대로 내는 첫 값) ·
performance_fee = 공사이행보증수수료 줄 금액(708-2 · 율 셈 없음).
대조(`check`) = 엑셀 비목 합 + 실은 값으로 우리 식(`compute_total`)을 돌려 줄마다 엑셀 금액과 견줌 —
1 원 넘게 다른 줄 = 「식 다름」(노인장기요양 · 산업안전보건관리비 A/B 등 식 꼴이 다른 비목) ·
우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) · 셈 없는 현행 비목(퇴직공제부금비 …)
= 「짝 없음」 · 「셈 없음」.
우리 표에 없는 행(폐기물처리 · 국가지점번호판 …) = 「짝 없음」.
"""
from __future__ import annotations
@@ -27,7 +27,6 @@ from typing import Any
from B09_Estimation.B09_Estimation_Calc_Base import num, rounddown
from B09_Estimation.B09_Estimation_Calc_Total import (
CURRENT_ONLY,
RATE_LINES,
TAIL_LINES,
compute_total,
@@ -68,7 +67,6 @@ ROWS: tuple[tuple[str, str], ...] = (
("소계", "subtotal"),
)
RATE_KEYS = {key: (name, rt) for key, name, rt, _ in (*RATE_LINES, *TAIL_LINES)}
NO_CALC = {key: (name, rt) for key, name, rt in CURRENT_ONLY}
_PCT = re.compile(r"[x×*]\s*([\d.]+)\s*%")
_BASE = re.compile(r"%\s*\+\s*([\d,]+)")
_HANGUL = re.compile(r"[가-힣]")
@@ -204,16 +202,14 @@ def read_rates(name: str, data: bytes) -> dict[str, Any]:
excel[key] = min(excel[key], amount or excel[key])
continue
excel[key] = amount if amount is not None else excel.get(key, 0.0)
if key in RATE_KEYS or key in NO_CALC:
if key == "performance": # 공사이행보증 = 금액 손입력(율 셈 없음)
if amount:
estimate["performance_fee"] = amount
elif key in RATE_KEYS:
if row["rate"] is not None:
rates[key] = row["rate"]
elif amount:
notes.append(f"율 없음 — 「{row['name']}」 산출근거 빈칸 · 지금 율 그대로")
if key in NO_CALC and amount:
notes.append(
f"셈 없음 — 「{row['name']}」 {amount:,.0f} 원 · 율 {row['rate']}% 만 실음"
"(우리 원가계산서 줄만 · 총액에 안 듦)"
)
if key == "safety" and row["text"]:
got = _BASE.search(row["text"])
estimate["safety_base"] = float(got.group(1).replace(",", "")) if got else 0.0
@@ -350,6 +350,7 @@ def _rate_patch(name: str, data: bytes, mine: dict[str, Any]) -> dict[str, Any]:
raise HTTPException(400, str(error)) from error
check = import_rates.check(read, mine, rate_table())
estimate = {**mine, **read["estimate"], "rates": {**(mine.get("rates") or {}), **read["rates"]}}
estimate["rates_imported"] = sorted({*(mine.get("rates_imported") or []), *read["rates"]})
keep = ("kind", "sheet", "rows", "rates", "notes")
return {**{k: read[k] for k in keep}, "check": check, "patch": {"estimate": estimate}}
+4
View File
@@ -62,6 +62,7 @@ ESTIMATE_FIELDS = (
"indirect_material", # 간접재료비(원)
"scrap", # 작업설 부산물(원 · 빼는 값)
"procurement_fee_pct", # 조달수수료율(%)
"performance_fee", # 공사이행보증수수료(원 · 708-2)
)
TEXT_FIELDS = ("work_type", "environment_kind")
@@ -345,6 +346,9 @@ def clean_user(raw: Any) -> dict[str, Any]:
rates = given.get("rates")
if isinstance(rates, dict):
estimate["rates"] = {str(k): _num(v) for k, v in rates.items()}
imported = given.get("rates_imported") # 707-2 불러온 요율 줄 키(출처 「불러옴」)
if isinstance(imported, list):
estimate["rates_imported"] = [str(k) for k in imported]
bill = clean_bill(raw.get("bill"))
out = {"materials": materials, "estimate": estimate, **({"bill": bill} if bill else {})}
if cover := clean_cover(raw.get("daega_cover")):
+36 -25
View File
@@ -7,7 +7,8 @@
* 맨 위 = 요율 고르는 값(추정가격 · 공사기간 · 환경보전비 공종 — 누르면 도로 ↔ 그 밖)(166-1)
* 요율 = 자동(M01 조달청 제비율 · 짝 셈이 고름) · 사용자가 적으면 앞섬 · 출처 칸 누름 = 자동으로
* 고칠 칸 = 요율 · 손입력(기초액 · 이윤 보정 · 부가세 조정 · 조달수수료율) ·
* 엑셀에 없는 현행 비목 = 줄만(빈칸)
* 현행 비목(708-2) = 경비 묶음 안 · 요율 빈칸 = 해당 없음(0) · 공사이행보증 = 금액 손입력 ·
* 요율 출처 = 사용자 · 불러옴(707-2 `rates_imported`) — 요율을 고치면 사용자
* 원가계산서 = 총괄 값 거울 + 구성비 · 고칠 칸 = 간접재료비 · 작업설 · 줄 누름 = 총괄 그 줄로
* 레벨(185-1 · 201-1) = 줄 `level`(화면이 정함 · 줄 짜임이 이 파일에 있음 · 셈 짝은 값만) →
* 공용 표 level 옵션(명칭 칸 들여쓰기 · 보기 전용) · 1단 = 굵게(is-title)
@@ -42,8 +43,22 @@ const HAND: Record<string, [string, string]> = {
supply: ["procurement_fee_pct", "조달수수료율(%)"],
indirect_material: ["indirect_material", "간접재료비(원)"],
scrap: ["scrap", "작업설 부산물(원)"],
performance: ["performance_fee", "공사이행보증수수료(원)"],
};
const RATE_KEYS = new Set([...RATE_LINES, ...TAIL_LINES].map(([key]) => key));
/** 요율 칸을 고치는 줄 — 공사이행보증 = 금액 손입력(율 칸 없음) */
const RATE_KEYS = new Set(
[...RATE_LINES, ...TAIL_LINES].map(([key]) => key).filter((key) => key !== "performance"),
);
/** 요율 줄 사용자 값 고침 · 지움 — 불러옴 표시도 뗌 */
function setRate(estimate: Row, key: string, v: number | null): void {
const rates: Row = { ...(estimate.rates || {}) };
if (v === null) delete rates[key];
else rates[key] = v;
estimate.rates = rates;
if (Array.isArray(estimate.rates_imported))
estimate.rates_imported = estimate.rates_imported.filter((k: string) => k !== key);
}
const ENV_NAME: Record<string, string> = { road: "도로", other: "그 밖의 토목" };
@@ -52,12 +67,8 @@ function editEstimate(ctx: TabContext, rowKey: string, column: string, value: st
const v = parseNumber(value);
if (v === undefined) return;
const estimate: Row = { ...(ctx.user.estimate || {}) };
if (column === "rate" && RATE_KEYS.has(rowKey)) {
const rates: Row = { ...(estimate.rates || {}) };
if (v === null) delete rates[rowKey];
else rates[rowKey] = v;
estimate.rates = rates;
} else if (HAND[rowKey]) {
if (column === "rate" && RATE_KEYS.has(rowKey)) setRate(estimate, rowKey, v);
else if (HAND[rowKey]) {
const field = HAND[rowKey][0];
if (v === null) delete estimate[field];
else estimate[field] = v;
@@ -71,11 +82,9 @@ function clickEstimate(ctx: TabContext, rowKey: string, column: string, from: un
if (rowKey === "in_env") {
if (estimate.environment_kind === "road") delete estimate.environment_kind;
else estimate.environment_kind = "road";
} else if (column === "candidate" && from === "user") {
const rates: Row = { ...(estimate.rates || {}) };
delete rates[rowKey];
estimate.rates = rates;
} else return;
} else if (column === "candidate" && (from === "user" || from === "import"))
setRate(estimate, rowKey, null);
else return;
ctx.setUser({ ...ctx.user, estimate });
}
@@ -122,8 +131,13 @@ function billRows(bill: Row | undefined): DataRow[] {
];
}
function rateText(formula: string, rate: Num): string {
return rate === null ? `${formula}(요율 빈칸)` : formula.replace("율", `${rate}%`);
function rateText(formula: string, rate: Num, key = ""): string {
if (key === "performance") return formula;
if (rate === null)
return CURRENT_ONLY.includes(key)
? `해당 없음(요율 빈칸) — ${formula}`
: `${formula}(요율 빈칸)`;
return formula.replace("율", `${rate}%`);
}
export const buildTotal: TabBuild = (ctx): TabTable => {
@@ -137,8 +151,10 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
/** 출처 칸 글 — 사용자 · 자동 까닭 · 자동 없음 */
const source = (key: string): string => {
const r: Row = picked[key] || {};
if (r.from === "user")
return `사용자 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`;
if (key === "performance") return "금액 직접 입력(율 셈 없음)";
if (r.from === "user" || r.from === "import")
return `${r.from === "user" ? "사용자" : "불러옴"} 값 · 누르면 자동${r.auto === null || r.auto === undefined ? "(없음)" : ` ${r.auto}%`}으로`;
if (CURRENT_ONLY.includes(key)) return "사용자 · 불러옴만(자동 없음)";
return r.from === "auto" ? `자동 — ${r.why}` : `자동 없음${r.why ? ` — ${r.why}` : ""}`;
};
const hand = (key: string): Num => {
@@ -165,7 +181,7 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
...extra,
});
const rateRow = ([key, name, rt, formula]: [string, string, string, string], no: number) =>
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key)), {
row(key, "", `${no}. ${name}`, line[key] ?? null, rateText(formula, rate(key), key), {
rate: rate(key),
candidate: source(key),
from: picked[key]?.from ?? null,
@@ -204,11 +220,6 @@ export const buildTotal: TabBuild = (ctx): TabTable => {
expense: sums.expense ?? null,
}),
...RATE_LINES.map((spec, i) => rateRow(spec, i + 1)),
...CURRENT_ONLY.map(([key, name, rt]) =>
row(key, "", name, null, "현행 비목 · 엑셀에 없음 — 밑수 · 고르는 값 미정(줄만)", {
candidate: cand[rt] ?? rt,
}),
),
row("net_cost", "나.", "소계(순공사원가)", sub.net_cost ?? null),
rateRow(TAIL_LINES[0], RATE_LINES.length + 1),
row("da", "다.", "소계", sub.da ?? null),
@@ -302,7 +313,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
const formula: Record<string, string> = Object.fromEntries(
[...RATE_LINES, ...TAIL_LINES].map(([key, , , f]) => [
key,
rateText(f, picked[key]?.rate ?? null),
rateText(f, picked[key]?.rate ?? null, key),
]),
);
const rows: DataRow[] = COST_ROWS.map(([key, group, part, name, link]) => ({
@@ -339,7 +350,7 @@ export const buildCost: TabBuild = (ctx): TabTable => {
level: nameLevel(rows),
mergeSame: ["group", "part"],
banner: unfinished(ctx.sheets) ?? undefined,
hint: "고칠 칸은 간접재료비 · 줄을 누르면 총괄 그 줄로 이동",
hint: "고칠 칸은 간접재료비 · 작업설 · 공사이행보증수수료 · 줄을 누르면 총괄 그 줄로 이동",
editable: (i, key) => key === "amount" && String(rows[i]?.key) in HAND,
onEdit: (i, key, value) => editEstimate(ctx, String(rows[i]?.key), key, value),
link: (i) => {
+3 -189
View File
@@ -3,13 +3,12 @@
* D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 ·
* 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 ·
* 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) ·
* 지도 겹(569 — 지적도 · 도엽 겹 GeoJSON 경위도 · 급경사 그림 · 581 — 음영기복 · 보호구역 · 띠 · 산림입지도).
* 지도 겹 받기 = 공용 `A00_Common/map/map_layer_api`(624-3 · 다시 내놓음).
* ========================================================================== */
import { API_BASE_URL } from "@config/config_frontend";
import { fetchCachedBytes } from "../A00_Common/b_asset_cache";
import { listSurfaceModels } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import { type DtmGrid, parseDtmGrid } from "../common_util/common_util_surface_sampler";
import type { PlanLine, PlanPiece, Vertex } from "./logic/D03_PlanAnalysis_Plan";
@@ -180,190 +179,5 @@ export async function fetchPlanSurface(projectId: string): Promise<PlanSurface |
return { modelId: model.id, grid: parseDtmGrid(await fetchCachedBytes(projectId, url)) };
}
/** 지도 겹 그림(569-1) — 작업 좌표계 m 상자(칸 가장자리) · data URL(위 = 북). */
export interface PlanRaster {
box_m: [number, number, number, number];
/** 경위도 네 귀퉁이(왼쪽 아래 · 오른쪽 아래 · 오른쪽 위 · 왼쪽 위) — GL 바탕(586-1). */
box?: Array<[number, number]>;
image: string;
/** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */
counts?: number[];
}
export const fetchPlanCadastral = (projectId: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/cadastral`);
/** 도엽 겹(`도로중심선` 등) — 없으면 서버가 받은 도엽에서 풀어 냄. */
export const fetchPlanSheetLayer = (projectId: string, key: string): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/layers/sheet?key=${encodeURIComponent(key)}`);
/** 보이는 도엽(606-2) — 지도 보기 경위도 상자 → 수 · 번호(가운데부터 · 30 장 넘음 = 빈 목록). */
export const fetchPlanSheetTiles = (
projectId: string,
bbox: [number, number, number, number],
): Promise<{ count: number; sheets: string[] }> =>
request(`${base(projectId)}/sheet-tiles?bbox=${bbox.map((v) => v.toFixed(5)).join(",")}`);
/** 도엽 조각 하나(606-1) — 공용 창고 · 없으면 서버가 만듦. */
export const fetchPlanSheetTile = (
projectId: string,
sheet: string,
name: string,
): Promise<GeoJsonCollection> =>
request(`${base(projectId)}/sheet-tiles/${sheet}/${encodeURIComponent(name)}`);
/** 그림 겹 — 급경사 = PNG 바이트 + 머리 `X-Plan-Raster`(616-4 · `image` = blob 주소 · 다 읽으면
* `slopeValues` 가 놓음) · 그 밖 = JSON(base64 그림). */
export const fetchPlanRaster = async (
projectId: string,
kind: "slope" | "hillshade" | "landslide",
): Promise<PlanRaster> => {
const path = `${base(projectId)}/layers/raster?kind=${kind}`;
if (kind !== "slope") return request(path);
const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" });
if (!response.ok) {
const body = (await response.json().catch(() => ({}))) as { detail?: unknown };
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
}
const meta = JSON.parse(response.headers.get("X-Plan-Raster") ?? "{}") as PlanRaster;
return { ...meta, image: URL.createObjectURL(await response.blob()) };
};
/** 보호 · 규제 구역(581-1) — 지물 `kind` · `name` · `area_m2` + 종류별 개수 · 넓이(실패 = `failed`). */
export interface PlanZones extends GeoJsonCollection {
summary: Record<string, { label: string; count: number; area_m2: number; failed?: boolean }>;
}
export const fetchPlanZones = (projectId: string): Promise<PlanZones> =>
request(`${base(projectId)}/layers/zones`);
/** 산림입지도(581-2) — 지물 `code` · `name` · `area_m2` + 토양형별 개수 · 넓이(코드 차례). */
export interface PlanSoils extends GeoJsonCollection {
summary: Array<{ code: string; name: string; count: number; area_m2: number }>;
}
export const fetchPlanSoils = (projectId: string): Promise<PlanSoils> =>
request(`${base(projectId)}/layers/soils`);
/** 계류 100 m · 도로 300 m 띠(581-1) — 받은 범위 안 넓이 · 비율. */
export interface PlanBand {
dist_m: number;
lines: number;
area_m2: number;
ratio: number;
collection: GeoJsonCollection;
}
/** 선 겹(584-1) — 받은 범위 안 선 + 가닥 · 길이 m. */
export interface PlanLines extends GeoJsonCollection {
summary: { count: number; length_m: number };
}
/** 단층 · 지질구조선(브이월드 WFS · 서버 `LINE_KINDS` 열쇠). */
export const fetchPlanLines = (projectId: string, kind: string): Promise<PlanLines> =>
request(`${base(projectId)}/layers/lines?kind=${encodeURIComponent(kind)}`);
/** 0차곡(584-1 · 지형 추정) — 위 집수 넓이 [min_m2, max_m2) 선 + 가닥 · 길이 m. */
export interface PlanZeroOrder {
min_m2: number;
max_m2: number;
cell_m: number;
count: number;
length_m: number;
collection: GeoJsonCollection;
}
export const fetchPlanZeroOrder = (projectId: string): Promise<PlanZeroOrder> =>
request(`${base(projectId)}/layers/zero-order`);
export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> =>
request(`${base(projectId)}/layers/bands`);
/** 올린 묶음 한 줄(605-1) — 올릴 때마다 하나 · 자른 가닥 · 길이 m · 원본 선 수 · 잘린 상자 · 기준점 거리. */
export interface PlanRoadBundle {
id: string;
name: string;
files: string[];
bytes: number | null;
uploaded_at: string | null;
count: number;
length_m: number;
total: number;
bounds: [number, number, number, number] | null;
distance_m: number | null;
}
/** 올린 임도망도(584-3 · 595-4 · 605-1) — 묶음 모두를 합친 값: 기준점 ±5 km ∪ 받은 지형으로 자른 선
* (선마다 `__bundle` 묶음 번호 · `__line` 묶음 안 차례) + 가닥 · 길이 m · 원본 선 수 · 좌표계 ·
* 잘린 상자([서, 남, 동, 북] 경위도) · 기준점 ~ 가장 가까운 임도 m · 마지막 올린 파일 · 묶음 줄. */
export interface PlanForestRoads {
bundles: PlanRoadBundle[];
count: number;
length_m: number;
total: number;
crs: string[];
bounds: [number, number, number, number] | null;
distance_m: number | null;
source: { files: string[]; bytes: number; uploaded_at: string } | null;
collection: GeoJsonCollection;
}
/** 올린 적 없으면 오류(404 글 그대로). */
export const fetchPlanForestRoads = (projectId: string): Promise<PlanForestRoads> =>
request(`${base(projectId)}/layers/forest-roads`);
/** 묶음 하나 지움(605-1) — 남은 묶음 합친 값 · 하나도 안 남으면 null. */
export async function deletePlanForestRoad(
projectId: string,
bundle: string,
): Promise<PlanForestRoads | null> {
const response = await fetch(
`${API_BASE_URL}${base(projectId)}/layers/forest-roads/${encodeURIComponent(bundle)}`,
{ method: "DELETE", credentials: "include" },
);
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body.bundles.length ? body : null;
}
/** 임도망도 SHP 올리기(zip 하나 · shp · shx · dbf · prj 묶음) — 못 읽으면 서버 글 그대로 오류. */
export async function uploadPlanForestRoads(
projectId: string,
files: File[],
): Promise<PlanForestRoads> {
const form = new FormData();
for (const file of files) form.append("files", file);
const response = await fetch(`${API_BASE_URL}${base(projectId)}/layers/forest-roads`, {
method: "POST",
credentials: "include",
body: form,
});
const body = (await response.json().catch(() => ({}))) as PlanForestRoads & {
detail?: unknown;
};
if (!response.ok)
throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`);
return body;
}
/** 산사태취약지역(584-3) — 받은 범위 안 점 · 지번 맞는 필지 테두리 · 시도별 표 · 표 없는 시도. */
export interface PlanLandslideZones {
points: Array<{
lon: number;
lat: number;
kind: string | null;
area_m2: number | null;
place: string;
}>;
parcels: GeoJsonCollection;
sidos: Array<{ code: string; name: string; rows: number; located: number; basis?: string }>;
missing: string[];
/** 지적도를 받았나(못 받으면 점만). */
cadastral: boolean;
}
export const fetchPlanLandslideZones = (projectId: string): Promise<PlanLandslideZones> =>
request(`${base(projectId)}/layers/landslide-zones`);
/** 지도 겹 받기 = 공용(624-3 · B05 평면도 씀). */
export * from "../A00_Common/map/map_layer_api";
+9 -7
View File
@@ -3,19 +3,20 @@
- 도엽 겹(하천중심선 · 도로중심선 …) = 지형 받기 병합본 · 없으면 지난 병합 도엽에서 그 겹만 풀어 냄
- 지적도 = 브이월드 WFS LX맵(`lt_c_landinfobasemap`) · B04 `request_vworld_wfs` 를 1 km 조각으로 ·
한 번 1,000 필지 한도 → 꽉 차면 넷으로 다시 쪼갬 · `pnu` 로 겹친 필지 거름 ·
저장 = `processed/연속지적도_bounds.geojson`(B04 `/geojson?layer=지적도` 자리) + 곁 열쇠
저장 = `<받은 겹 자리>/연속지적도_bounds.geojson` + 곁 열쇠 · 받은 겹 자리 = D03 몫
`D03_PlanAnalysis/layers/`(B04 `processed/` 와 이름 겹침 0 · `_TerrainSource` · 624-1)
- 급경사 = 평활 안 한 사업 DEM(`models_plan/dtm_sheet_<방법>.npz`) · E07 `slope_rad` ·
기울기 % 값 그림 한 장(1 % 단계 · 회색) + 1 % 칸 수 · 범위 색칠 · 비율 = 화면(PLAN 603) ·
파생 저장본(열쇠 = 격자 지문)
- 음영기복 = 같은 DEM · B07 `hillshade_png`(PLAN 581-1)
- 보호 · 규제 구역 = 브이월드 WFS 여덟 종(겹 이름 = `_분석_440-C` 표 1) ·
받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `processed/보호구역_bounds.geojson` + 곁 열쇠 ·
받은 범위로 잘라 종류 · 이름 · 넓이만 · 저장 = `layers/보호구역_bounds.geojson` + 곁 열쇠 ·
종류 하나 실패 = 그 종류만 빠짐
- 산림입지도 = 브이월드 WFS `lt_c_fsdifrsts`(원자료 산림청 산림입지토양도 · PLAN 581-2) ·
받은 범위로 잘라 토양형 코드 · 이름 · 넓이만 ·
저장 = `processed/산림입지도_bounds.geojson` + 곁 열쇠
저장 = `layers/산림입지도_bounds.geojson` + 곁 열쇠
- 선 겹 = 브이월드 WFS 단층 `lt_l_gimsfault` · 지질구조선 `lt_l_gimslinea`(PLAN 584-1) ·
받은 범위로 잘라 가닥 · 길이만 · 저장 = `processed/<이름>_bounds.geojson` + 곁 열쇠
받은 범위로 잘라 가닥 · 길이만 · 저장 = `layers/<이름>_bounds.geojson` + 곁 열쇠
- 0차곡 = `D03_PlanAnalysis_ZeroOrder`
- 계류 100 m · 도로 300 m 띠 = 도엽 하천중심선(세류 뺌) · 도로중심선(미분류 · 소로 ·
면리간도로 뺌) 합친 띠 · 받은 범위로 자름 · 파생 저장본(열쇠 = 두 파일 지문)
@@ -253,10 +254,11 @@ def hillshade_layer_png(z: np.ndarray, valid: np.ndarray, cell_m: float) -> dict
def slope_layer(
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope"
project_root: Path, models_dir: Path, epsg: int, kind: str = "slope", npz: Path | None = None
) -> dict[str, Any]:
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본."""
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
"""급경사(`slope`) · 음영기복(`hillshade`) — 지문(격자 파일 · 판) 같으면 저장본 ·
`npz` = 지형 원천 격자(624-1 · 열쇠의 파일 이름 = 원천) · 없으면 D 수치지형도."""
npz = npz or models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
stamp = file_stamp(npz)
if stamp is None:
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
+194
View File
@@ -0,0 +1,194 @@
"""D 예정노선 라이다 — LAS 올리기 → B04 엔진 그대로 지형(요구 530 · 531 · PLAN 624-4).
- `POST /api/projects/{id}/plan/lidar` — LAS(+ .prj) 올리기 · 뒤에서 지형 짓기
(진행 = `/plan/progress` 의 `lidar`) · 다시 올리면 옛 LAS 를 갈음
- `GET …/plan/lidar` — 지은 라이다 지형(없으면 `ready: false`)
자리 = `B04_PreProcess/lidar/`(D 몫 · 안은 B 와 같은 꼴 — `B03_FileInput/input/las/` ·
`B04_PreProcess/models/`).
LAS 좌표계(.prj · LAS 머리 · 없으면 프로젝트 좌표계)가 프로젝트와 다르면 옮겨 적음(높이 그대로).
지도 · GIS 받기 · 도엽 서피스는 안 함(`maps=False`) · 지은 DTM = `surface_models` 행
(높이 표본 격자).
라이다 범위 밖 = 빈칸(격자 `valid_mask`) · 수치지형도로 메우지 않음(결정 A⑥).
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import asyncio
import json
import shutil
from pathlib import Path
from typing import Any, Callable
from uuid import UUID
from fastapi import APIRouter, File, HTTPException, UploadFile
from common_util.common_util_json import atomic_write_json
router = APIRouter(prefix="/api/projects", tags=["D03 Plan Lidar"])
LIDAR = Path("B04_PreProcess") / "lidar" # 표면 모델 행 경로 = B04_PreProcess 아래
UPLOAD = "upload"
LAS_DIR = Path("B03_FileInput") / "input" / "las"
MODELS = Path("B04_PreProcess") / "models"
META = "lidar_meta.json"
LAS_SUFFIXES = (".las", ".laz")
CHUNK = 2_000_000
def lidar_root(root: Path) -> Path:
return Path(root) / LIDAR
def read_meta(root: Path) -> dict[str, Any]:
try:
return json.loads((lidar_root(root) / META).read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
def lidar_dem(root: Path) -> Path | None:
"""지은 라이다 DTM(평활 안 함) — 없으면 None."""
name = read_meta(root).get("model_file")
path = Path(root) / name if name else None
return path if path is not None and path.is_file() else None
def check_names(names: list[str]) -> None:
"""LAS · LAZ 하나 이상 · 그 밖은 .prj 만 · 같은 이름 없음."""
suffixes = [Path(name).suffix.lower() for name in names]
if not any(s in LAS_SUFFIXES for s in suffixes):
raise HTTPException(status_code=400, detail="LAS 또는 LAZ 파일을 하나 이상 올리세요.")
if any(s not in (*LAS_SUFFIXES, ".prj") for s in suffixes):
raise HTTPException(status_code=400, detail="LAS · LAZ · PRJ 만 올릴 수 있습니다.")
if len({Path(name).name.casefold() for name in names}) != len(names):
raise HTTPException(status_code=400, detail="같은 이름 파일이 있습니다.")
def las_epsg(path: Path, prj_text: str | None) -> int | None:
"""LAS 좌표계 — .prj 먼저 · 없으면 LAS 머리 · 모르면 None."""
import laspy
from pyproj import CRS
from common_util.common_util_crs import identify_epsg
if prj_text:
return identify_epsg(CRS.from_wkt(prj_text), prj_text)
with laspy.open(path) as reader:
crs = reader.header.parse_crs()
return identify_epsg(crs, crs.to_wkt()) if crs is not None else None
def place_las(src: Path, dst_dir: Path, src_epsg: int | None, epsg: int) -> Path:
"""올린 LAS → 지형 입력 자리 — 좌표계가 같거나 모르면 옮김 · 다르면 x · y 만 옮겨 적음(.las)."""
import laspy
from pyproj import CRS, Transformer
dst_dir.mkdir(parents=True, exist_ok=True)
if src_epsg in (None, int(epsg)):
return Path(shutil.move(src, dst_dir / src.name))
dst = dst_dir / f"{src.stem}.las"
move = Transformer.from_crs(src_epsg, int(epsg), always_xy=True)
with laspy.open(src) as reader:
old = reader.header
header = laspy.LasHeader(point_format=old.point_format, version=old.version)
header.scales = old.scales
x0, y0 = move.transform(old.mins[0], old.mins[1])
header.offsets = [round(x0), round(y0), old.offsets[2]]
header.add_crs(CRS.from_epsg(int(epsg)))
with laspy.open(dst, mode="w", header=header) as writer:
for points in reader.chunk_iterator(CHUNK):
x, y = move.transform(points.x, points.y)
record = laspy.ScaleAwarePointRecord.zeros(len(points), header=header)
record.array[:] = points.array
record.x, record.y, record.z = x, y, points.z
writer.write_points(record)
src.unlink()
return dst
def build_lidar(root: Path, epsg: int, report: Callable[[int, str], None]) -> dict[str, Any]:
"""올린 자리(`upload/`)의 LAS → 입력 자리 갈음 → B04 엔진(지면 필터 · DTM) → 메타."""
from B04_PreProcess.B04_PreProcess_Engine import run_surface_analysis
from config.config_system import SURFACE_AUTO_METHODS
base = lidar_root(root)
upload, las_dir = base / UPLOAD, base / LAS_DIR
prj = next(iter(sorted(upload.glob("*.prj"))), None)
prj_text = prj.read_text(encoding="utf-8", errors="ignore") if prj else None
sources = sorted(p for p in upload.iterdir() if p.suffix.lower() in LAS_SUFFIXES)
if sources: # 새로 올림 = 옛 LAS 갈음
shutil.rmtree(las_dir, ignore_errors=True)
report(5, "LAS 좌표계 맞추는 중")
for path in sources:
place_las(path, las_dir, las_epsg(path, prj_text), epsg)
shutil.rmtree(upload, ignore_errors=True)
las_paths = sorted(p for p in las_dir.glob("*") if p.suffix.lower() in LAS_SUFFIXES)
if not las_paths:
raise FileNotFoundError("올린 LAS 가 없습니다.")
result = run_surface_analysis(
base,
las_paths,
source_filters=None,
methods=list(SURFACE_AUTO_METHODS),
on_progress=lambda percent, _stage, message: report(10 + int(percent * 0.85), message),
maps=False,
)
prefix = LIDAR.as_posix()
models = [
{**model, "model_file_path": f"{prefix}/{model['model_file_path']}"}
for model in result["models"]
if model.get("model_file_path")
]
picked = result["source_filters"][0]
meta = {
"epsg": int(epsg),
"files": [p.name for p in las_paths],
"source_filter": picked,
"model_file": f"{prefix}/{(MODELS / f'dtm_{picked}.npz').as_posix()}",
"point_count": result["processed"]["point_count"],
"bounds": result["processed"]["bounds"],
}
atomic_write_json(base / META, meta)
return {"meta": meta, "models": models}
@router.post("/{project_id}/plan/lidar", response_model=None)
async def upload_plan_lidar(project_id: UUID, files: list[UploadFile] = File(...)) -> dict:
"""D 에 LAS 올리기 → 뒤에서 라이다 지형(진행 = `/plan/progress` 의 `lidar`)."""
from B03_FileInput.B03_FileInput_Engine import save_upload_stream
from D03_PlanAnalysis import D03_PlanAnalysis_Router as base
check_names([upload.filename or "" for upload in files])
root, epsg, _, _ = await base._plan_ends(project_id) # 예정노선 아님 400
if (base._JOBS.get((str(project_id), "lidar")) or {}).get("running"):
raise HTTPException(status_code=409, detail="이미 돌고 있습니다.")
upload = lidar_root(root) / UPLOAD
shutil.rmtree(upload, ignore_errors=True)
upload.mkdir(parents=True)
try:
for item in files:
await save_upload_stream(item, upload / Path(item.filename or "").name)
except ValueError as error:
shutil.rmtree(upload, ignore_errors=True)
raise HTTPException(status_code=413, detail=str(error)) from error
async def work(report: Any) -> None:
got = await asyncio.to_thread(build_lidar, root, epsg, report)
await base._save_models(project_id, got["models"], epsg)
report(100, f"라이다 지형 끝 — 점 {got['meta']['point_count']:,}개")
return base._start_job(project_id, "lidar", work, root)
@router.get("/{project_id}/plan/lidar", response_model=None)
async def plan_lidar(project_id: UUID) -> dict[str, Any]:
"""지은 라이다 지형 — `ready` · 메타(좌표계 · 파일 · 필터 · 점 수 · 범위)."""
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
root, _ = await store.load_plan(project_id)
meta = await asyncio.to_thread(read_meta, root)
ready = await asyncio.to_thread(lidar_dem, root) is not None
return {"status": "success", "ready": ready, **meta}
+34 -37
View File
@@ -30,7 +30,7 @@ from pathlib import Path
from typing import Any
from uuid import UUID, uuid4
from fastapi import APIRouter, File, HTTPException, Request, Response, UploadFile
from fastapi import APIRouter, File, HTTPException, Query, Request, Response, UploadFile
from pydantic import BaseModel
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
@@ -48,6 +48,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Network as network
from D03_PlanAnalysis import D03_PlanAnalysis_SheetTiles as sheet_tiles
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as terrain_source
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
logger = logging.getLogger(__name__)
@@ -56,7 +57,7 @@ router = APIRouter(prefix="/api/projects", tags=["D03 Plan Analysis"])
# 진행 — (프로젝트, 일) → {running, percent, message, error} · 프로세스 메모리
_JOBS: dict[tuple[str, str], dict[str, Any]] = {}
_TASKS: set[asyncio.Task] = set()
KINDS = ("terrain", "analyze")
KINDS = ("terrain", "analyze", "lidar")
class Center(BaseModel):
@@ -282,13 +283,9 @@ async def plan_progress(project_id: UUID) -> dict[str, Any]:
return {"status": "success", **jobs}
async def _layer_root(project_id: UUID) -> tuple[Path, int, terrain.Box]:
"""(실경로, 좌표계, 받은 범위) — 지형 받기 전이면 404."""
root, epsg, _, _ = await _plan_ends(project_id)
box = terrain.received_box(root, epsg)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return root, epsg, box
async def _layer_root(project_id: UUID, source: str | None = None) -> terrain_source.Frame:
"""겹 틀 — 임도 프로젝트도 · 지형 원천(624-1 · `TerrainSource.layer_frame`)."""
return await terrain_source.layer_frame(project_id, source, _plan_ends)
SHEET_CLIP_KIND = "plan_sheet"
@@ -335,7 +332,7 @@ async def plan_layer_sheet(project_id: UUID, key: str, request: Request) -> Resp
받은 범위로 자름(475 · 범위가 늘면 새 사본)."""
from B04_PreProcess.B04_PreProcess_Router_GIS import _display_copy
root, epsg, box = await _layer_root(project_id)
root, epsg, box, *_ = await _layer_root(project_id)
try:
path = await asyncio.to_thread(layers.ensure_sheet_layer, root, key)
except KeyError as error:
@@ -389,8 +386,7 @@ async def plan_contour_tile(project_id: UUID, z: int, x: int, y: int) -> Respons
@router.get("/{project_id}/plan/layers/cadastral", response_model=None)
async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
"""지적도 GeoJSON(브이월드 LX맵 · 참고용) — 받은 범위를 덮지 않으면 지금 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
_, epsg, box, processed, *_ = await _layer_root(project_id)
try:
got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg)
except RuntimeError as error:
@@ -399,13 +395,16 @@ async def plan_layer_cadastral(project_id: UUID, request: Request) -> Response:
@router.get("/{project_id}/plan/layers/raster", response_model=None)
async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str, Any] | Response:
async def plan_layer_raster(
project_id: UUID, kind: str = "slope", source: str | None = Query(None, alias="terrain")
) -> dict[str, Any] | Response:
"""급경사 기울기 % 값 그림 + 1 % 칸 수(603 · PNG 바이트 + 머리 `X-Plan-Raster` 616-4) ·
음영기복(`hillshade`) — 격자 지문 같으면 저장본 ·
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본."""
산사태위험지도(`landslide` · 행안부 WMS) — 받은 범위를 덮으면 저장본 ·
`terrain` = 지형 원천(sheet · lidar · design · 624-1)."""
frame = await _layer_root(project_id, source)
if kind == "landslide":
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
_, epsg, box, processed, *_ = frame
try:
value = await asyncio.to_thread(landslide.ensure_landslide, processed, box, epsg)
except landslide.MissingKeyError as error:
@@ -416,25 +415,24 @@ async def plan_layer_raster(project_id: UUID, kind: str = "slope") -> dict[str,
return {"status": "success", "kind": kind, **value, "box": box_lonlat}
if kind not in layers.RASTER_KINDS:
raise HTTPException(status_code=400, detail=f"없는 그림 겹: {kind}")
root, epsg, _ = await _layer_root(project_id)
root, epsg, _, _, source, _ = frame
npz = terrain_source.need_dem(frame)
try:
value = await asyncio.to_thread(
layers.slope_layer, root, terrain.models_dir_of(root), epsg, kind
)
value = await asyncio.to_thread(layers.slope_layer, root, npz.parent, epsg, kind, npz)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
if kind == "slope":
png, meta = layers.raster_bytes(value)
head = {"X-Plan-Raster": json.dumps({"kind": kind, **meta}), "Content-Encoding": "identity"}
meta = {"kind": kind, "source": source, **meta}
head = {"X-Plan-Raster": json.dumps(meta), "Content-Encoding": "identity"}
return Response(png, media_type="image/png", headers=head) # PNG = 다시 압축 안 함
return {"status": "success", "kind": kind, **value}
return {"status": "success", "kind": kind, "source": source, **value}
@router.get("/{project_id}/plan/layers/zones", response_model=None)
async def plan_layer_zones(project_id: UUID, request: Request) -> Response:
"""보호 · 규제 구역 GeoJSON(여덟 종 · `summary` = 종류별 개수 · 넓이) — 범위 밖이면 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
_, epsg, box, processed, *_ = await _layer_root(project_id)
try:
got = await asyncio.to_thread(layers.ensure_zones, processed, box, epsg)
except RuntimeError as error:
@@ -445,8 +443,7 @@ async def plan_layer_zones(project_id: UUID, request: Request) -> Response:
@router.get("/{project_id}/plan/layers/soils", response_model=None)
async def plan_layer_soils(project_id: UUID, request: Request) -> Response:
"""산림입지도 GeoJSON(토양형 · `summary` = 토양형별 개수 · 넓이) — 범위 밖이면 받음."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
_, epsg, box, processed, *_ = await _layer_root(project_id)
try:
got = await asyncio.to_thread(layers.ensure_soils, processed, box, epsg)
except RuntimeError as error:
@@ -457,7 +454,7 @@ async def plan_layer_soils(project_id: UUID, request: Request) -> Response:
@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 띠(넓이 · 받은 범위 비율) — 두 도엽 겹 지문 같으면 저장본."""
root, epsg, box = await _layer_root(project_id)
root, epsg, box, *_ = await _layer_root(project_id)
try:
value = await asyncio.to_thread(layers.band_layer, root, epsg, box)
except FileNotFoundError as error:
@@ -470,8 +467,7 @@ async def plan_layer_lines(project_id: UUID, kind: str, request: Request) -> Res
"""선 겹 GeoJSON(단층 · 지질구조선 · `summary` = 가닥 · 길이 m) — 범위 밖이면 받음."""
if kind not in layers.LINE_KINDS:
raise HTTPException(status_code=400, detail=f"없는 선 겹: {kind}")
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
_, epsg, box, processed, *_ = await _layer_root(project_id)
try:
got = await asyncio.to_thread(layers.ensure_lines, processed, box, epsg, kind)
except RuntimeError as error:
@@ -484,22 +480,24 @@ async def plan_layer_zero_order(
project_id: UUID,
min_m2: float = zero_order.ZERO_ORDER_MIN_M2,
max_m2: float = zero_order.ZERO_ORDER_MAX_M2,
source: str | None = Query(None, alias="terrain"),
) -> Response:
"""0차곡 선(지형 추정 · 위 집수 `min_m2` ~ `max_m2`) — 창고 도엽 선 · 다른 임계는 저장본 ·
창고에 없어 셈이 필요하면 서버 본체 밖 일꾼(616-3 · 다른 겹 요청을 안 막음)."""
if not 0 < min_m2 < max_m2:
raise HTTPException(status_code=400, detail="0 < min_m2 < max_m2 여야 합니다.")
root, epsg, _ = await _layer_root(project_id)
args = (root, terrain.models_dir_of(root), epsg, min_m2, max_m2)
frame = await _layer_root(project_id, source)
work, light, full = terrain_source.zero_order_calls(frame, min_m2, max_m2)
pool = zero_order.heavy_pool()
try:
value = await asyncio.to_thread(zero_order.zero_order_layer, *args, False)
value = await asyncio.to_thread(work, *light)
if value is None:
loop = asyncio.get_running_loop()
value = await loop.run_in_executor(pool, zero_order.zero_order_layer, *args)
value = await asyncio.get_running_loop().run_in_executor(pool, work, *full)
except FileNotFoundError as error:
raise HTTPException(status_code=404, detail=str(error)) from error
body = json.dumps({"status": "success", **value}, ensure_ascii=False) # 큰 선 묶음 = 바로 글로
body = json.dumps(
{"status": "success", "source": frame.source, **value}, ensure_ascii=False
) # 큰 선 묶음 = 바로 글로
return Response(body, media_type="application/json")
@@ -591,8 +589,7 @@ async def plan_layer_forest_roads_upload(
async def plan_layer_landslide_zones(project_id: UUID) -> dict[str, Any]:
"""산사태취약지역(시도 표 · 점 + 지번 맞는 필지 테두리 · 표 없는 시도) — 지적도 못 받으면
점만."""
root, epsg, box = await _layer_root(project_id)
processed = root / terrain.STAGE / "processed"
root, epsg, box, processed, *_ = await _layer_root(project_id)
try:
got = await asyncio.to_thread(layers.ensure_cadastral, processed, box, epsg)
cadastral = got["path"]
+12 -2
View File
@@ -64,7 +64,8 @@ def save_plan(root: str | Path, plan: dict[str, Any]) -> None:
atomic_write_json(plan_path(root), _clean(plan))
async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]:
async def _project(cursor: Any, project_id: UUID | str, plan_only: bool = True) -> dict[str, Any]:
"""프로젝트 줄 — `plan_only` = 예정노선 아니면 400(지도 겹 길은 종류 안 가림 · 624-1)."""
await cursor.execute(
"SELECT project_kind, project_year, storage_path FROM projects"
" WHERE id = %s AND deleted_at IS NULL",
@@ -73,7 +74,7 @@ async def _project(cursor: Any, project_id: UUID | str) -> dict[str, Any]:
project = await cursor.fetchone()
if not project:
raise HTTPException(status_code=404, detail="프로젝트를 찾을 수 없습니다.")
if project["project_kind"] != "PLAN" or project["project_year"] is None:
if plan_only and (project["project_kind"] != "PLAN" or project["project_year"] is None):
raise HTTPException(status_code=400, detail="예정노선 프로젝트가 아닙니다.")
if not project["storage_path"]:
raise HTTPException(status_code=404, detail="프로젝트 저장 경로를 찾을 수 없습니다.")
@@ -91,6 +92,15 @@ async def load_plan(project_id: UUID | str) -> tuple[Path, dict[str, Any]]:
return root, read_plan(root) or empty_plan()
async def project_row(project_id: UUID | str) -> dict[str, Any]:
"""{project_kind, project_year, storage_path} — 종류 안 가림 · 없음 404."""
async with (
get_db_pool().acquire() as connection,
connection.cursor(aiomysql.DictCursor) as cursor,
):
return await _project(cursor, project_id, plan_only=False)
def plan_units(plan: dict[str, Any]) -> list[dict[str, Any]]:
"""조각마다 {id · line · line_name · year · epsg · from_m · to_m · length_m · planned ·
stations}
@@ -0,0 +1,180 @@
"""D03 지도 겹의 프로젝트 틀 · 지형 원천 (요구 530 · 531 · PLAN 624-1).
겹 길(`/plan/layers/*`)은 프로젝트 종류를 안 가림:
- 예정노선(PLAN) = 받은 범위 · 지형 = 수치지형도(`models_plan/dtm_sheet_<방법>.npz`) ·
`lidar` = D 에 올린 LAS 로 지은 DTM(624-4 · `D03_PlanAnalysis_Lidar` · 안 올렸으면 404 ·
범위 밖 = 빈칸)
- 그 밖(임도 등) = 범위 = B04 배경 지도 상자(`vworld_white_meta.json` · 없으면 원천 격자 상자) ·
지형 = 설계 지형(stage 1 params · 기본) · `lidar` = 라이다 확정 지표면 ·
`sheet` = 수치지형도 서피스 ·
평활 고르기 = 서버 표본기와 같음(`B05_Profile_Router_Terrain.dtm_grid_npz`)
- 원천 인자 `terrain=sheet|lidar|design`(빈 값 = design) · 파생 저장본 열쇠 = 격자 파일 이름(원천)
- 받은 겹 저장 = 종류 상관없이 `D03_PlanAnalysis/layers/`(B04 `processed/` 파일과 이름 겹침 0) ·
D 옛 자리(`B04_PreProcess/processed/`) 파일은 새 자리에 없을 때 한 번 복사(다시 받지 않게)
계산 자리 = ③ 서버 단독.
"""
from __future__ import annotations
import json
import shutil
from pathlib import Path
from typing import Any, Callable, NamedTuple
import numpy as np
from fastapi import HTTPException
from common_util.common_util_storage import resolve_stored_project_path
from config.config_system_terrain import SHEET_SURFACE_DEFAULT_METHOD
from D03_PlanAnalysis import D03_PlanAnalysis_Landslide as landslide
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar
from D03_PlanAnalysis import D03_PlanAnalysis_Store as store
from D03_PlanAnalysis import D03_PlanAnalysis_Terrain as terrain
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
Box = tuple[float, float, float, float]
SOURCES = ("sheet", "lidar", "design")
LAYER_DIR = Path("D03_PlanAnalysis") / "layers"
OLD_DIR = Path("B04_PreProcess") / "processed"
ROAD_MODELS = Path("B04_PreProcess") / "models"
ROAD_META = ("vworld_white_meta.json", "vworld_satellite_meta.json")
def _legacy_names() -> list[str]:
names = [layers.CADASTRAL_FILE, layers.CADASTRAL_KEY, layers.ZONE_FILE, layers.ZONE_KEY]
names += [layers.SOIL_FILE, layers.SOIL_KEY, landslide.LANDSLIDE_FILE]
for label, _ in layers.LINE_KINDS.values():
names += [f"{label}_bounds.geojson", f"{label}_bounds.key.json"]
return names
class Frame(NamedTuple):
root: Path
epsg: int
box: Box
store: Path # 받은 겹 자리(D03 몫)
source: str # 실제 원천 — sheet · lidar
dem: Path | None # 원천 격자(없으면 None)
def check_source(choice: str | None) -> str:
if choice in (None, ""):
return "design"
if choice not in SOURCES:
raise HTTPException(status_code=400, detail=f"없는 지형 원천: {choice}")
return choice
def layer_store(root: Path, plan: bool) -> Path:
"""받은 겹 자리 — D 는 옛 자리 파일을 처음 한 번 복사(덮지 않음)."""
store = Path(root) / LAYER_DIR
store.mkdir(parents=True, exist_ok=True)
if plan:
for name in _legacy_names():
old, new = Path(root) / OLD_DIR / name, store / name
if old.is_file() and not new.exists():
shutil.copy2(old, new)
return store
def plan_frame(root: Path, epsg: int, box: Box, choice: str | None) -> Frame:
"""예정노선 — 수치지형도 하나(design = sheet) · lidar = 올린 LAS 의 DTM(없으면 None)."""
source = check_source(choice)
source = "sheet" if source == "design" else source
npz = terrain.models_dir_of(Path(root)) / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
dem = npz if source == "sheet" else lidar.lidar_dem(root)
return Frame(Path(root), epsg, box, layer_store(root, True), source, dem)
def road_dem(models: Path, params: dict[str, Any], source: str) -> tuple[str, Path | None]:
"""(실제 원천, 격자) — 고른 원천 params 의 `dtm_<필터>`(평활이면 `_smooth` 먼저) ·
라이다 없는 프로젝트의 lidar = None."""
from common_util.common_util_surface_confirmation import (
analysis_terrain_params,
design_terrain,
)
want = None if source == "design" else ("las" if source == "lidar" else "sheet")
picked = analysis_terrain_params(params, want)
got = "sheet" if design_terrain(picked) == "sheet" else "lidar"
if source != "design" and got != source:
return source, None
npz = Path(models) / f"dtm_{picked.get('source_filter') or ''}.npz"
smooth = npz.with_name(f"{npz.stem}_smooth.npz")
npz = smooth if picked.get("smooth") and smooth.is_file() else npz
return got, npz if npz.is_file() else None
def grid_box(npz: Path) -> Box:
with np.load(npz) as dem:
x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float)
half = abs(float(x[1] - x[0])) / 2 if len(x) > 1 else 0.5
return (x[0] - half, y[0] - half, x[-1] + half, y[-1] + half)
def road_box(root: Path, dem: Path | None) -> Box | None:
"""B04 배경 지도 상자(작업 좌표계 m) · 없으면 격자 상자."""
for name in ROAD_META:
try:
meta = json.loads((Path(root) / OLD_DIR / name).read_text(encoding="utf-8"))
return (meta["x_min"], meta["y_min"], meta["x_max"], meta["y_max"])
except (OSError, ValueError, KeyError, TypeError):
continue
return grid_box(dem) if dem is not None else None
async def road_frame(row: dict[str, Any], project_id: Any, choice: str | None) -> Frame:
"""임도 등 — 범위 · 좌표계 · 설계 지형 params 로 원천 격자."""
from pyproj import CRS
from common_util.common_util_crs import identify_epsg, resolve_project_crs
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
from config.config_db import get_db_pool
source = check_source(choice)
root = Path(resolve_stored_project_path(row["storage_path"]))
async with get_db_pool().acquire() as connection:
params = await get_surface_confirmation_params(connection, str(project_id))
got, dem = road_dem(root / ROAD_MODELS, params, source)
box = road_box(root, dem)
if box is None:
raise HTTPException(status_code=404, detail="지형 전처리 뒤 보입니다.")
text = resolve_project_crs(root)
epsg = identify_epsg(CRS.from_user_input(text), text)
if epsg is None:
raise HTTPException(status_code=404, detail="프로젝트 좌표계를 모릅니다.")
return Frame(root, epsg, tuple(float(v) for v in box), layer_store(root, False), got, dem)
def need_dem(frame: Frame) -> Path:
if frame.dem is None:
what = "라이다" if frame.source == "lidar" else "수치지형도"
raise HTTPException(status_code=404, detail=f"{what} 지형이 없습니다.")
return frame.dem
async def layer_frame(project_id: Any, source: str | None, plan_ends: Callable) -> Frame:
"""겹 틀 — 예정노선 = 받은 범위(받기 전 404) · 그 밖 = `road_frame`."""
row = await store.project_row(project_id)
if row["project_kind"] != "PLAN":
return await road_frame(row, project_id, source)
root, epsg, _, _ = await plan_ends(project_id)
box = terrain.received_box(root, epsg)
if box is None:
raise HTTPException(status_code=404, detail="지형 받기 뒤 보입니다.")
return plan_frame(root, epsg, box, source)
def zero_order_calls(frame: Frame, min_m2: float, max_m2: float) -> tuple[Callable, tuple, tuple]:
"""(셈, 가벼운 인자 = 저장본만, 무거운 인자) — 수치지형도 = 창고 도엽 선(범위 = 원천 격자
상자) · 라이다 = 격자 통째."""
npz = need_dem(frame)
if frame.source == "sheet":
args = (frame.root, npz.parent, frame.epsg, min_m2, max_m2)
return zero_order.zero_order_layer, (*args, False, npz), (*args, True, npz)
args = (frame.root, npz, frame.epsg, min_m2, max_m2)
return zero_order.dem_zero_order_layer, (*args, False), args
+67 -23
View File
@@ -13,10 +13,12 @@
* 미리보기 = 마지막 점 → 마우스 길이(m) · 가상 종단 기울기(원지반 % · ° · + 오르막) — 제약 칸 넷:
* 자유 · 길이 고정 · 기울기 고정(기울기 고정 · 길이 자유 · 마우스 방향 첫 자리) · 길이·기울기 고정(양각기 원 위 후보 중
* 마우스에 가까운 것) · 제약 기울기 = 오르내림 같은 크기(562-1 짝이 |기울기|로 셈).
* 길이·기울기 고정 부채꼴(625-2 · `logic/D03_PlanAnalysis_Fan`) — 정방향 = 앞 구간(갈래 첫 구간 = 붙은 선) ·
* 완만한 땅 = 어느 방향이나 · Shift 누른 채 = 허용 호 위 마우스 방위(길이 그대로 · 기록 = 길이 고정).
* 긋기는 해를 안 물음(585-3) — 새 선 = 사업연도 시작 해 · 갈래 = 붙은 자리 구간 해 · 이어 긋기 = 끝 구간이 늘어남 ·
* 여러 해 = 다 그은 뒤 그리기 칸 「연도 구역 마커」를 선 위로 끌어 구역을 나누고 속성 판에서 해를 고름.
* 고르기 · 고치기 · 마커(쉴 때) = `D03_PlanAnalysis_UI_Edit`.
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기(설정 창 단축키 표 D03 줄).
* 키 = Enter 끝 · Esc 이번 긋기 취소 · Backspace 마지막 점 빼기 · Tab 방식 바꿈(설정 창 단축키 표 D03 줄).
* 셈 = 562-1 짝(`logic/D03_PlanAnalysis_Draw`) · 꼴 다루기 = `logic/D03_PlanAnalysis_Plan` ·
* 값을 바꾸면 `commit(다음 꼴)` 한 번 = 되돌리기 한 걸음(스택 · 캐시 = 페이지).
* ========================================================================== */
@@ -24,13 +26,12 @@
import { showToast } from "@ui/ui_template_elements";
import { keyIs } from "../A00_Common/key_registry";
import {
compassCandidates,
compassPoint,
fixedLengthPoint,
fixedSlopePoint,
groundSlopePct,
segmentLength,
} from "./logic/D03_PlanAnalysis_Draw";
import { compassFan, fanBetween, fanPick, type Fan } from "./logic/D03_PlanAnalysis_Fan";
import {
addBranch,
appendVertex,
@@ -75,6 +76,8 @@ export interface DrawPreview {
note: string | null;
blocked: boolean;
snapped: boolean;
/** 길이만 지킨 점(Shift 사이 고르기 · 완만한 땅) — 노드 기록 = 길이 고정(B2). */
lengthOnly: boolean;
/** 다른 선 위(꺾임점 아님)에 붙음 — 점을 찍을 때 그 선에 꺾임점을 끼움. */
onLine: { line: number; segment: number } | null;
}
@@ -119,8 +122,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
let flip = false; // 끝에서 긋는 중 — 끝낼 때 뒤집음
let strokeStart: PlanNetwork | null = null; // Esc 로 돌아갈 꼴
let preview: DrawPreview | null = null;
let candidates: Vertex[] = [];
let kind: DrawConstraint = "free";
let fan: Fan | null = null;
let kind: DrawConstraint = "both"; // 625-1 · 기억 안 함(B9)
let hoverAt: { px: number; py: number; map: ScreenMap; shift: boolean } | null = null; // Tab · Shift 뒤 미리보기 다시
let lengthM = DRAW_DEFAULT_LENGTH_M;
let slopePct = DRAW_DEFAULT_SLOPE_PCT;
// 다시 긋기 — 선 id · 앞 · 뒤 꺾임점 · 새로 찍은 속 점 · 그 기록
@@ -186,8 +190,34 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
: null;
}
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저. */
function previewAt(px: number, py: number, map: ScreenMap): DrawPreview | null {
/** 앞 구간 시작 — 정방향 부채꼴(625-2 B5) · 첫 구간 = null · 갈래 첫 구간 = 붙은 선 들어온 쪽. */
function prevOf(): Vertex | null {
const network = params.network();
if (span) {
const vertices = network.lines[spanLine()]?.vertices ?? [];
const through = span.through;
return through.length > 1
? through[through.length - 2]
: through.length
? vertices[span.from]
: (vertices[span.from - 1] ?? null);
}
const line = active === null ? undefined : network.lines[active];
if (!line) return null;
if (line.vertices.length > 1) return line.vertices[line.vertices.length - 2];
const parent = network.lines.find((other) => other.id === line.branch?.line)?.vertices;
if (!parent || !line.branch) return null;
const [node, before, after] = [
parent[line.branch.vertex],
parent[line.branch.vertex - 1],
parent[line.branch.vertex + 1],
];
if (before) return before;
return node && after ? [2 * node[0] - after[0], 2 * node[1] - after[1]] : null;
}
/** 마우스 자리 → 찍을 점 · 값 — 붙기가 제약보다 먼저 · `shift` = 부채꼴 사이 고르기. */
function previewAt(px: number, py: number, map: ScreenMap, shift = false): DrawPreview | null {
const spanVertices = span ? params.network().lines[spanLine()]?.vertices : undefined;
if (active === null && !spanVertices) return null;
const from = spanVertices
@@ -196,7 +226,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
if (!from) return null;
const z = params.heightAt();
const cursor = map.toMetric(px, py);
candidates = kind === "both" ? compassCandidates(from, lengthM, slopePct, z) : [];
fan = kind === "both" ? compassFan(from, lengthM, slopePct, z, prevOf(), cursor) : null;
// 다시 긋기 = 원래 뒤 노드에만 붙음
const end = spanVertices?.[span!.to];
const snap = end
@@ -206,6 +236,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
: snapAt(px, py, map);
let point: Vertex | null = cursor;
let note: string | null = null;
let lengthOnly = false;
if (snap) {
[point, note] = [snap.point, snap.note];
} else if (kind === "length") {
@@ -213,8 +244,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
} else if (kind === "slope") {
point = fixedSlopePoint(from, cursor, slopePct, z);
if (!point) note = `이 방향엔 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
} else if (kind === "both") {
point = compassPoint(from, cursor, lengthM, slopePct, z);
} else if (fan) {
point = shift ? fanBetween(fan, from, cursor, lengthM) : fanPick(fan, from, cursor, lengthM);
lengthOnly = shift || fan.flat;
if (!point) note = `반지름 ${fmt(lengthM)} m 원 위에 ±${fmt(Math.abs(slopePct))} % 자리 없음`;
}
const at = point ?? cursor;
@@ -226,6 +258,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
note,
blocked: !point || length <= 0,
snapped: !!snap,
lengthOnly: lengthOnly && !snap,
onLine: snap?.onLine ?? null,
};
}
@@ -266,7 +299,7 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
flip = false;
strokeStart = null;
preview = null;
candidates = [];
fan = null;
span = null;
}
@@ -389,7 +422,9 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
return false;
}
// 노드 기록 = 그때 방식 · 값(붙은 점 = 자유 · 599-2)
const record = preview.snapped ? nodeRecord("free", 0, 0) : nodeRecord(kind, lengthM, slopePct);
const record = preview.snapped
? nodeRecord("free", 0, 0)
: nodeRecord(preview.lengthOnly ? "length" : kind, lengthM, slopePct);
if (span) {
if (!preview.snapped) {
span.through.push(preview.point);
@@ -446,18 +481,12 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
() => {
const from = stroke === null ? null : network.lines[stroke]?.vertices.at(-1);
if (from && preview)
paintPreview(
context,
map,
from,
preview,
kind === "both" ? { lengthM, candidates } : null,
);
paintPreview(context, map, from, preview, fan ? { lengthM, fan } : null);
},
dim,
);
},
press(px, py, map, button) {
press(px, py, map, button, shift) {
if (!editable) return false;
if (mode === "draw" && button === 0) {
if (active === null && !span) {
@@ -466,15 +495,17 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
params.onMode();
return started;
}
preview = previewAt(px, py, map);
preview = previewAt(px, py, map, shift);
return addPoint();
}
return false;
},
hover(px, py, map) {
preview = mode === "draw" ? previewAt(px, py, map) : null;
hover(px, py, map, shift) {
hoverAt = { px, py, map, shift };
preview = mode === "draw" ? previewAt(px, py, map, shift) : null;
},
leave() {
hoverAt = null;
preview = null;
},
};
@@ -501,6 +532,19 @@ export function createPlanDraw(params: PlanDrawParams): PlanDraw {
panelState();
},
key(event) {
// Tab = 자유지정 ↔ 길이·기울기 고정(단독 고정이면 길이·기울기로 · 625-1) · 긋기 아닐 때 = 초점 이동
if (mode === "draw" && keyIs(event, "plan.drawKind")) {
kind = kind === "both" ? "free" : "both";
kindFields.set({ kind });
if (hoverAt) preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, hoverAt.shift);
return true;
}
// Shift 누름 = 마우스 안 움직여도 사이 고르기로(뗌은 다음 움직임 · 누름이 Shift 를 다시 봄)
if (mode === "draw" && event.key === "Shift" && hoverAt) {
hoverAt.shift = true;
preview = previewAt(hoverAt.px, hoverAt.py, hoverAt.map, true);
return true;
}
if (mode === "draw" && keyIs(event, "plan.drawFinish")) {
if (span) showToast("다시 긋기는 뒤 노드에 붙어야 끝 — 취소했습니다.", "info");
setMode("idle");
@@ -3,10 +3,13 @@
* D03 손 긋기 겹 그림 · 집기(PLAN 562-3 · 580) — `D03_PlanAnalysis_UI_Draw`(긋기) ·
* `D03_PlanAnalysis_UI_Edit`(고르기 · 고치기)가 같이 씀. 그림 = 선 · 측점 · 구간 글 · 꺾임점 · 마크 · 연도 ·
* 미리보기(거리 · 가상 종단 기울기 % · °) · 집기 = 화면 px 둘레 선 위 자리.
* 길이·기울기 고정 부채꼴(625-2) — 정방향 = 채움 · 역방향 = 테두리 · 노드 · 놓인 꺾임점 · 미리보기 고리 =
* 기울기 부호 색(+ 오르막 `--draw-map-slope-up` 따뜻한 색 · − 내리막 `--draw-map-slope-down` 찬 색 · 막힘 = 경고 빨강).
* `paintFan` = ScreenMap 의 `toScreen` 만 씀 — B05 평면(626-3)이 그대로 씀.
* 지점 핀(585-1) = 따로 겹(`pointLayer` · 겹 목록 맨 끝 = 늘 맨 위) · 색 = 목록 줄과 같은 `pointColor(i)` · 끌기 · 메뉴 = `D03_PlanAnalysis_UI_Points`.
* ========================================================================== */
import { pointColor, routeColor } from "@ui/ui_template_palette";
import { pointColor, routeColor, themeColor } from "@ui/ui_template_palette";
import { drawStationTicks } from "../B04_PreProcess/B04_PreProcess_UI_MapOverlays";
import {
distanceAlong,
@@ -17,6 +20,7 @@ import {
stationChainages,
type HeightAt,
} from "./logic/D03_PlanAnalysis_Draw";
import { fanPoint, type Fan } from "./logic/D03_PlanAnalysis_Fan";
import type { PlanNetwork, Vertex } from "./logic/D03_PlanAnalysis_Plan";
import { pointLabel } from "./logic/D03_PlanAnalysis_PointEdit";
import { nodeOff } from "./logic/D03_PlanAnalysis_Span";
@@ -52,6 +56,54 @@ export function strokeCased(
context.setLineDash([]);
}
/** 기울기 부호 색(625-2 B3 · B4) — + 오르막 따뜻한 색 · − 내리막 찬 색 · null(지형 밖) = 흰색. */
export function slopeColor(pct: number | null): string {
if (pct === null) return "#ffffff";
return pct >= 0
? themeColor("--draw-map-slope-up", "#ff9e2c")
: themeColor("--draw-map-slope-down", "#29e0ff");
}
/** 부채꼴 호 그림 걸음(라디안 · 약 3°). */
const FAN_STEP = Math.PI / 60;
/** 길이·기울기 고정 부채꼴 — 허용 호(정방향 = 반투명 채움 · 역방향 = 테두리만) · 노드 = 부호 색. */
export function paintFan(
context: CanvasRenderingContext2D,
map: Pick<ScreenMap, "toScreen">,
from: Vertex,
lengthM: number,
fan: Fan,
): void {
const colors = planColors();
const center = map.toScreen(from);
for (const arc of fan.arcs) {
context.beginPath();
context.moveTo(center[0], center[1]);
const steps = Math.max(2, Math.ceil((arc.to - arc.from) / FAN_STEP));
for (let k = 0; k <= steps; k += 1) {
const [sx, sy] = map.toScreen(
fanPoint(from, lengthM, arc.from + ((arc.to - arc.from) * k) / steps),
);
context.lineTo(sx, sy);
}
context.closePath();
if (arc.forward) {
context.globalAlpha = 0.3;
context.fillStyle = colors.active;
context.fill();
context.globalAlpha = 1;
}
strokeCased(context, colors.active, arc.forward ? 2 : 1.5);
}
for (const node of fan.nodes) {
const [cx, cy] = map.toScreen(node.point);
context.beginPath();
context.arc(cx, cy, 4, 0, Math.PI * 2);
fillCased(context, slopeColor(node.sign));
}
}
/** 지도 위 점 — 색 채움 + 검은 테. */
function fillCased(context: CanvasRenderingContext2D, color: string): void {
context.fillStyle = color;
@@ -161,8 +213,7 @@ export function paintNetwork(
);
if (map.slopes) segmentLabels(context, map, line.vertices, z);
});
// 꺾임점 · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
context.fillStyle = "#ffffff";
// 꺾임점 = 들어온 구간 기울기 부호 색(625-2 B4) · 기록과 어긋난 노드(옮김) = 경고 고리(599-2)
context.strokeStyle = colors.warn;
context.lineWidth = 2;
for (const line of network.lines)
@@ -171,6 +222,7 @@ export function paintNetwork(
const [sx, sy] = map.toScreen(v);
context.beginPath();
context.arc(sx, sy, 3, 0, Math.PI * 2);
context.fillStyle = slopeColor(groundSlopePct(line.vertices[i - 1], v, z));
context.fill();
if (!nodeOff(line, i, z)) return;
context.beginPath();
@@ -274,13 +326,14 @@ export function paintMark(
context.stroke();
}
/** 미리보기 — 점선 · 둘 다(양각기) 원 · 후보 · 글(거리 · 가상 종단 기울기 % · °). */
/** 미리보기 — 점선 · 길이·기울기 고정 원 · 부채꼴 · 노드 · 글(거리 · 가상 종단 기울기 % · °) ·
* 고리 = 고른 점 기울기 부호 색(놓인 뒤 꺾임점과 같은 색). */
export function paintPreview(
context: CanvasRenderingContext2D,
map: ScreenMap,
from: Vertex,
preview: DrawPreview,
compass: { lengthM: number; candidates: Vertex[] } | null,
compass: { lengthM: number; fan: Fan } | null,
): void {
const colors = planColors();
const [ax, ay] = map.toScreen(from);
@@ -289,12 +342,7 @@ export function paintPreview(
context.beginPath();
context.arc(ax, ay, compass.lengthM * map.pxPerMeter, 0, Math.PI * 2);
strokeCased(context, colors.active, 1.5, [3, 4]);
for (const c of compass.candidates) {
const [cx, cy] = map.toScreen(c);
context.beginPath();
context.arc(cx, cy, 3.5, 0, Math.PI * 2);
fillCased(context, colors.active);
}
paintFan(context, map, from, compass.lengthM, compass.fan);
}
const color = preview.blocked ? colors.warn : colors.active;
context.beginPath();
@@ -303,7 +351,7 @@ export function paintPreview(
strokeCased(context, color, 2.5, [6, 4]);
context.beginPath();
context.arc(bx, by, 5, 0, Math.PI * 2);
strokeCased(context, color, 2.5);
strokeCased(context, preview.blocked ? colors.warn : slopeColor(preview.slopePct), 2.5);
const value =
`${fmt(preview.lengthM)} m · ` +
(preview.slopePct === null ? "지형 밖" : slopeText(preview.slopePct));
@@ -2,8 +2,9 @@
* D03_PlanAnalysis_UI_DrawPanel.ts
* D03 좌측 「그리기」 칸 몸(PLAN 599-1 · 599-4 · 요구 463-10 · 22) — [긋기] · 연도 구역 마커 · 긋기 방식 칸.
*
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유 · 길이 고정 · 기울기 고정 · 길이·기울기 고정) +
* 길이 · 기울기 두 열(기울기 아래 ≈ °) — 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
* 방식 칸 `createDrawKindFields` = 마디 고름 2 × 2(자유지정 · 길이·기울기 고정 / 길이 고정 · 기울기 고정 ·
* 625-1) + 길이 · 기울기 두 열(기울기 아래 ≈ °) — 방식에 안 쓰는 칸은 숨김(막힘 = 권한 없음만) ·
* 그리기 칸 · 노드 판(598-4)이 같은 부품을 씀.
* 연도 구역 마커 = 끌어 선 위에 놓기(`YEAR_MARK_DRAG` · 받음 = `D03_PlanAnalysis_UI_Edit` drop).
* 설명 글 없음(요구 463-6) — 칸 오류 · ≈ ° 값만.
* ========================================================================== */
@@ -29,10 +30,10 @@ export interface DrawKindValue {
}
export const DRAW_KIND_LABELS: Record<DrawConstraint, string> = {
free: "자유",
free: "자유지정",
both: "길이·기울기 고정",
length: "길이 고정",
slope: "기울기 고정",
both: "길이·기울기 고정",
};
export interface DrawKindFields {
@@ -118,8 +119,10 @@ export function createDrawKindFields(opts: {
function fieldsState(): void {
for (const button of segment.root.querySelectorAll("button")) button.disabled = disabled;
lengthField.input.disabled = disabled || value.kind === "free" || value.kind === "slope";
slopeField.input.disabled = disabled || value.kind === "free" || value.kind === "length";
lengthField.input.disabled = slopeField.input.disabled = disabled;
fields.hidden = value.kind === "free";
lengthField.root.hidden = value.kind === "slope";
slopeColumn.hidden = value.kind === "length";
}
degText();
fieldsState();
+21 -242
View File
@@ -1,197 +1,38 @@
/* =============================================================================
* D03_PlanAnalysis_UI_LayerDefs.ts
* D03 바탕 겹 정의(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2) — 겹 단추 묶음 · 처음 값 · 보호구역 · 토양형 색 ·
* 산사태위험지도 범례 · 선 겹(단층 · 지질구조선 · 0차곡) 꼴 · 켬끔 취향 읽기 · 면 겹 채우기 · 선 긋기 · 그림 겹 놓기 ·
* GL 바탕 자료(지물 색 붙이기 · 그림 귀퉁이 · 음영 그늘 그림).
* D03 바탕 겹 옛 캔버스 도움(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 586-2 · 624-2 · 624-3) — 면 겹 채우기 · 선 긋기 · 그림 겹 놓기.
* 임도 짐작 구분 · 물 면 · 그림 읽기 · GL 자료 = 공용 `A00_Common/map/map_layer_data`(624-3 · 다시 내놓음).
* 겹 목록 · 처음 값 · 켬끔 취향 · 선 겹 꼴 · 보호구역 색 = 공용 `A00_Common/map/map_layer_list`(624-2).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기 차례 · 범례).
* ========================================================================== */
import type { IconName } from "@ui/ui_template_icons";
import type { LegendItem } from "@ui/ui_template_flow_legend";
import { readByKey } from "../A00_Common/b_page_state";
import type { MapLayerKey } from "../A00_Common/map/map_layer_list";
import {
drawPreparedLayer,
normalizedToScreen,
type GeoJsonCollection,
type PreparedLayer,
type ViewState,
} from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanRaster } from "./D03_PlanAnalysis_Api";
import type { ScreenMap } from "./D03_PlanAnalysis_UI_Map";
import type { GlImage } from "./D03_PlanAnalysis_UI_MapGl";
export type PlanLayerKey =
| "satellite"
| "hillshade"
| "contour"
| "slope"
| "stream"
| "water"
| "road"
| "forestRoad"
| "forestRoadFile"
| "nationalRoad"
| "ecoForest"
| "ecology"
| "cadastral"
| "zones"
| "soils"
| "landslide"
| "landslideZones"
| "faults"
| "lineaments"
| "zeroOrder"
| "bands"
| "building"
| "place";
/** 올린 임도망 고름(605-2) — 묶음 하나 또는 그 안의 선 하나(지도 강조). */
export interface RoadPick {
bundle: string;
line?: number;
}
export interface LayerDef {
key: PlanLayerKey;
icon: IconName;
title: string;
on: boolean;
}
/** 단추 묶음(사이 나눔선) — 바탕 · 물 · 길 · 구역. 「지금」 겹 처음 값 = 끔(`_분석_440-C` 1-4). */
export const GROUPS: readonly (readonly LayerDef[])[] = [
[
{ key: "satellite", icon: "satellite", title: "위성사진", on: true },
{ key: "hillshade", icon: "sun", title: "음영기복(2 m 지형 · 산줄기 · 골)", on: false },
{ key: "contour", icon: "mountain", title: "도엽 등고선(굵은 선 = 계곡선 25 m)", on: true },
{ key: "slope", icon: "angle", title: "급경사 색(기울기 % 범위 · 범례 ⚙ 로 바꿈)", on: false },
],
[
{ key: "stream", icon: "droplet", title: "세류선(가는 파랑) · 하천(굵은 파랑)", on: true },
{ key: "water", icon: "ripple", title: "물 면(도엽 실폭하천 · 저수지 등)", on: false },
{ key: "road", icon: "road", title: "기존 도로(도엽 도로중심선)", on: false },
{
key: "forestRoad",
icon: "route",
title: "기존 임도 짐작(도엽 도로 소로 · 미분류 = 주황 점선 · 정식 임도망도 아님)",
on: false,
},
{
key: "forestRoadFile",
icon: "map",
title:
"올린 임도망도(산림청 시군구 SHP = 밝은 분홍 실선 · 왼쪽 「위치」 칸에서 올리고 묶음 고름 · 지움)",
on: false,
},
{
key: "nationalRoad",
icon: "flag",
title:
"국유림 임도(산림청 전국 국가임도 · 간선 · 작업 · 산불 색 · 지방 · 민간임도 없음 · 참고용)",
on: false,
},
],
[
{ key: "cadastral", icon: "borderAll", title: "지적도(참고용 · 법적 효력 없음)", on: false },
{
key: "zones",
icon: "shield",
title: "보호 · 규제 구역(브이월드 여덟 종 · 참고용)",
on: false,
},
{
key: "soils",
icon: "leaf",
title: "산림입지도(토양형 · 산림청 산림입지토양도 1:25,000 · 줌 10 이상 · 참고용)",
on: false,
},
{
key: "ecoForest",
icon: "trees",
title: "경제림육성단지(산림청 2014 · 국유 · 사유 테두리 · 참고용)",
on: false,
},
{
key: "ecology",
icon: "plant",
title: "생태자연도(환경부 · 1~3 등급 · 별도관리 · 참고용)",
on: false,
},
{
key: "landslide",
icon: "alert",
title: "산사태위험지도(1~5 등급 · 행안부 생활안전지도 · 참고용)",
on: false,
},
{
key: "landslideZones",
icon: "alertOctagon",
title: "산사태취약지역(시도 지정 현황 · 점 + 지번 필지 테두리 · 참고용)",
on: false,
},
{ key: "faults", icon: "bolt", title: "단층(브이월드 지질 · 참고용)", on: false },
{
key: "lineaments",
icon: "lineDashed",
title: "지질구조선(브이월드 지질 · 참고용)",
on: false,
},
{
key: "zeroOrder",
icon: "arrowsJoin",
title: "0차곡(지형 추정 골짜기 머리 · 위 집수 0.1~1 ha · 곡두 통과 피함)",
on: false,
},
{
key: "bands",
icon: "borderOuter",
title: "계류 100 m · 도로 300 m 띠(타당성평가)",
on: false,
},
{ key: "building", icon: "building", title: "건물(도엽 · 하류 민가 · 인가)", on: false },
{ key: "place", icon: "mapPin", title: "지명(도엽 계곡 · 령 · 부락)", on: false },
],
];
export const LAYERS: readonly LayerDef[] = GROUPS.flat();
/** 보호구역 종류 → 그림 색 값(서버 `ZONE_KINDS` 열쇠). */
export const ZONE_COLORS: Record<string, string> = {
forest: "--draw-map-zone-forest",
baekdu: "--draw-map-zone-baekdu",
water: "--draw-map-zone-water",
park: "--draw-map-zone-park",
heritage: "--draw-map-zone-heritage",
wetland: "--draw-map-zone-wetland",
wildlife: "--draw-map-zone-wildlife",
greenbelt: "--draw-map-zone-greenbelt",
};
/** 산사태위험지도 범례(1 등급 = 가장 위험 · 색 = WMS 그림 색). */
export const LANDSLIDE_LEGEND: LegendItem[] = [1, 2, 3, 4, 5].map((grade) => ({
label: `${grade}등급`,
color: `var(--draw-map-landslide-grade-${grade})`,
}));
/** 선 겹 — 이름 · 그림 꼴(서버 `LINE_KINDS` 열쇠 = `kind` · 없으면 0차곡). 새 선 겹 = 여기 한 줄. */
export type LineLayerKey = "faults" | "lineaments" | "zeroOrder";
export const LINE_LAYERS: Record<
LineLayerKey,
{ label: string; kind?: string; color: string; width: number; dash: number[] }
> = {
faults: { label: "단층", kind: "faults", color: "--draw-map-fault", width: 2.4, dash: [] },
lineaments: {
label: "지질구조선",
kind: "lineaments",
color: "--draw-map-lineament",
width: 1.8,
dash: [8, 4],
},
zeroOrder: { label: "0차곡", color: "--draw-map-zero-order", width: 1.8, dash: [4, 3] },
};
export const LINE_KEYS = Object.keys(LINE_LAYERS) as LineLayerKey[];
/** 도로 중 기존 임도로 짐작하는 구분. */
export const FOREST_ROAD_KINDS = new Set(["소로", "미분류"]);
/** 물 면 = 도엽 수계(E) 면 셋. */
export const WATER_KEYS = ["실폭하천", "수계면E003", "수계면E005"];
export const hectare = (m2: number): string => `${(m2 / 10000).toFixed(1)} ha`;
/** 겹 목록 · 페이지 표 = 공용 `map_layer_list`(624-2) — 옛 이름으로 다시 내놓음(캔버스 · 조각 · 임도망 목록이 읽음). */
export type PlanLayerKey = MapLayerKey;
export {
FOREST_ROAD_KINDS,
WATER_KEYS,
glImage,
glTagged,
hectare,
loadImage,
shadeImage,
} from "../A00_Common/map/map_layer_data";
export {
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
type RoadPick,
} from "../A00_Common/map/map_layer_list";
/** 면 겹 채움 — 지물마다 한 길(evenodd = 구멍) · 화면 밖 지물 거름(돌린 지도 귀퉁이까지 여유). */
export function fillLayer(
@@ -238,14 +79,6 @@ export function fillLayer(
context.setLineDash([]);
}
export const loadImage = (src: string): Promise<HTMLImageElement> =>
new Promise((done, fail) => {
const image = new Image();
image.onload = () => done(image);
image.onerror = () => fail(new Error("그림을 읽지 못했습니다"));
image.src = src;
});
/** 선 겹 긋기(점 솎음 `dot` · `everyM` = 등고 간격 거름). */
export function stroke(
context: CanvasRenderingContext2D,
@@ -282,57 +115,3 @@ export function drawRaster(
);
context.drawImage(raster.image, 0, 0);
}
/** 켬끔 취향(`key`) — 없거나 깨지면 처음 값. */
export function readPref(key: string): Record<PlanLayerKey, boolean> {
const on = Object.fromEntries(LAYERS.map((def) => [def.key, def.on])) as Record<
PlanLayerKey,
boolean
>;
try {
const saved = JSON.parse(readByKey(key) ?? "{}") as Partial<Record<PlanLayerKey, unknown>>;
for (const def of LAYERS)
if (typeof saved[def.key] === "boolean") on[def.key] = saved[def.key] as boolean;
} catch {
// 깨진 값 = 처음 값
}
return on;
}
/** GL 자료 — 지물마다 그릴 색(`_color`) · 덧 속성을 붙인 사본. */
export function glTagged(
collection: GeoJsonCollection,
color: (props: Record<string, unknown>) => string,
extra: Record<string, unknown> = {},
): GeoJsonCollection {
return {
...collection,
features: (collection.features ?? []).map((feature) => ({
...feature,
properties: { ...feature.properties, ...extra, _color: color(feature.properties ?? {}) },
})),
};
}
/** GL 그림 자리 — 서버 경위도 귀퉁이(왼쪽 아래부터 돎) → 왼쪽 위부터 · 없으면 null. */
export function glImage(raster: PlanRaster, url = raster.image): GlImage | null {
const box = raster.box;
return box?.length === 4 ? { url, corners: [box[3], box[2], box[1], box[0]] } : null;
}
/** 음영 그늘 그림 — 위성 위 「곱하기 0.75」와 같은 검은 반투명(밝기 g → 투명도 0.75 · (1 − g)). */
export function shadeImage(image: HTMLImageElement): string {
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d")!;
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height);
const px = pixels.data;
for (let i = 0; i < px.length; i += 4) {
px[i + 3] = Math.round(0.75 * (255 - px[i]) * (px[i + 3] / 255));
px[i] = px[i + 1] = px[i + 2] = 0;
}
context.putImageData(pixels, 0, 0);
return canvas.toDataURL("image/png");
}
@@ -2,35 +2,24 @@
* D03_PlanAnalysis_UI_LayerLegend.ts
* D03 바탕 겹 받은 자료 꼴 · 범례 줄 · 겹별 수(PLAN 569-2 · 581 · 584-1 · 584-3) — 켠 겹의 받은 범위 안 수 · 넓이.
* 급경사 = 고른 범위(603)마다 % · ° · 비율(1 % 칸 수로 바로 셈).
* 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
* 범례 칸 · 조각 겹 범례 = 공용 `A00_Common/map/map_legend`(624-2) · 여기는 받은 자료 줄만.
* 범례 줄 셈 = 공용 `map_legend` `legendRows`(624-3) · 여기는 자료 꼴 · 겹별 수 · 부르는 곳 = `D03_PlanAnalysis_UI_Layers.ts`(받기 · 그리기).
* ========================================================================== */
import type { LegendItem } from "@ui/ui_template_flow_legend";
import type { PreparedLayer } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type {
PlanBand,
PlanForestRoads,
PlanLandslideZones,
PlanRaster,
PlanSoils,
PlanZones,
PlanBand,
} from "./D03_PlanAnalysis_Api";
import {
LANDSLIDE_LEGEND,
LINE_KEYS,
LINE_LAYERS,
ZONE_COLORS,
hectare,
type LineLayerKey,
type PlanLayerKey,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { forestLegendRows } from "./D03_PlanAnalysis_UI_ForestLayers";
import {
SLOPE_MAX_PCT,
bandRatios,
pctToDeg,
type SlopeBand,
} from "./logic/D03_PlanAnalysis_SlopeBands";
import { LINE_KEYS, type LineLayerKey } from "../A00_Common/map/map_layer_list";
import { bandRatios, type SlopeBand } from "../A00_Common/map/logic/map_slope_bands";
/** 범례 줄 = 공용(624-3 · B05 평면도 씀) — 옛 이름으로 다시 내놓음. */
export { legendRows } from "../A00_Common/map/map_legend";
/** 선 겹 — 그릴 선 · 받은 범위 안 가닥 · 길이 m. */
export interface PreparedLines {
@@ -69,134 +58,6 @@ export type Prepared = Record<LineLayerKey, PreparedLines> & {
place: Array<{ lon: number; lat: number; name: string }>;
};
const km = (m: number): string => `${(m / 1000).toFixed(2)} km`;
/** 급경사 범례 — 범위마다 「아래~위 %(°) · 비율」(위 = 끝 = 「↑」). */
function slopeRow({ raster, bands }: Prepared["slope"]): LegendItem[] {
const ratios = bandRatios(raster.counts ?? [], bands);
const deg = (pct: number): string => pctToDeg(pct).toFixed(0);
return bands.map((band, i) => {
const open = band.high >= SLOPE_MAX_PCT;
const range = open
? `${band.low} %↑(${deg(band.low)}°↑)`
: `${band.low}~${band.high} %(${deg(band.low)}~${deg(band.high)}°)`;
return { label: `${range} · ${(ratios[i] * 100).toFixed(1)} %`, color: `var(${band.color})` };
});
}
/** 산사태취약지역 범례 — 점 · 필지 · 위치 없는 시도 · 표 없는 시도 · 지적 못 받음. */
function landslideZoneRow({ info }: Prepared["landslideZones"]): LegendItem[] {
const color = "var(--draw-map-landslide-zone)";
const items: LegendItem[] = [
{
label: `산사태취약지역 점 ${info.points.length}곳`,
color,
kind: "dot",
off: !info.points.length,
},
{
label: `지번 맞는 필지 ${info.parcels.features?.length ?? 0}곳(테두리)`,
color,
kind: "dash",
off: !info.parcels.features?.length,
},
];
for (const sido of info.sidos)
if (!sido.located) items.push({ label: `${sido.name} 위치 없음 — 필지만`, color, off: true });
for (const name of info.missing)
items.push({ label: `${name} — 이 시도 자료 없음`, color: "transparent", off: true });
if (!info.cadastral)
items.push({ label: "지적도 못 받음 — 필지 테두리 없음", color: "transparent", off: true });
return items;
}
export function legendRows(
on: Record<PlanLayerKey, boolean>,
data: Partial<Prepared>,
): LegendItem[][] {
const rows: LegendItem[][] = [];
if (on.slope && data.slope) rows.push(slopeRow(data.slope));
if (on.landslide) rows.push(LANDSLIDE_LEGEND); // GL = 조각(받기 없음 · 622-2)
rows.push(...forestLegendRows(on));
if (on.landslideZones && data.landslideZones) rows.push(landslideZoneRow(data.landslideZones));
if (on.zones && data.zones) {
const items = Object.entries(data.zones.summary)
.filter(([, kind]) => kind.count || kind.failed)
.map(([key, kind]) => ({
label: kind.failed
? `${kind.label} 받지 못함`
: `${kind.label} ${kind.count}곳 ${hectare(kind.area_m2)}`,
color: `var(${ZONE_COLORS[key]})`,
off: !!kind.failed,
}));
rows.push(
items.length
? items
: [{ label: "보호 · 규제 구역 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.soils && data.soils) {
const { kinds, summary } = data.soils;
rows.push(
summary.length
? summary.map((soil, i) => ({
label: `${soil.name} ${hectare(soil.area_m2)}`,
color: `var(${kinds[i].color})`,
}))
: [{ label: "산림입지도 없음(받은 범위 안)", color: "transparent", off: true }],
);
}
if (on.bands && data.bands) {
const { stream, road } = data.bands.info;
rows.push([
{
label: `계류 ${stream.dist_m} m 띠 ${hectare(stream.area_m2)} (${(stream.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-stream)",
},
{
label: `도로 ${road.dist_m} m 띠 ${hectare(road.area_m2)} (${(road.ratio * 100).toFixed(1)} %)`,
color: "var(--draw-map-band-road)",
},
]);
}
const lines = LINE_KEYS.flatMap((key) => {
const got = on[key] && data[key];
if (!got) return [];
const { label, color, dash } = LINE_LAYERS[key];
return [
{
label: `${label} ${got.count}가닥 ${km(got.length_m)}`,
color: `var(${color})`,
kind: dash.length ? ("dash" as const) : ("line" as const),
off: !got.count,
},
];
});
if (lines.length) rows.push(lines);
const tail: LegendItem[] = [];
if (on.water && data.water)
tail.push({ label: `물 면 ${data.water.features.length}곳`, color: "var(--draw-map-water)" });
if (on.building && data.building)
tail.push({
label: `건물 ${data.building.features.length.toLocaleString()}동`,
color: "var(--draw-map-building)",
});
if (on.forestRoad && data.road)
tail.push({
label: `임도 짐작 ${data.road.small.features.length}가닥`,
color: "var(--draw-map-forest-road)",
kind: "dash",
});
if (on.forestRoadFile && data.forestRoadFile)
tail.push({
label: `올린 임도망도 ${data.forestRoadFile.info.count}가닥 ${km(data.forestRoadFile.info.length_m)}`,
color: "var(--draw-map-forest-road-file)",
kind: "line",
});
if (tail.length) rows.push(tail);
return rows;
}
/** 검증 창구 — 겹별 수(지물 · 넓이 m² · 길이 m). */
export const layerCounts = (data: Partial<Prepared>): Record<string, number> => ({
...(data.building && { building: data.building.features.length }),
+38 -79
View File
@@ -1,7 +1,7 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Layers.ts
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
* (공용 지도 도구줄 `createMapTools` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
* D03 평면 바탕 겹 켜고 끔(PLAN 569-2 · 581-1 · 581-2 · 584-1 · 584-3 · 624-2 · 요구 395-2 · 440-8 · 442 · 443) — 지도 왼쪽 위 아이콘 한 줄
* (공용 겹 줄 `A00_Common/map/map_layer_bar` · 넘치면 가로 밀기 571-1 · 오른쪽 평면 도구줄과 따로).
*
* 겹 = 바탕(위성 · 음영기복 · 도엽 등고선(계곡선 25 m 굵게 · 수치) · 급경사 색) ·
* 물 · 길(세류선 · 하천 · 물 면(실폭하천 · 저수지) · 기존 도로 · 기존 임도 짐작(소로 · 미분류) ·
@@ -9,28 +9,29 @@
* 구역(지적도 · 보호 · 규제 구역 여덟 종 · 산림입지도(토양형) · 산사태위험지도 · 산사태취약지역(시도 표 점 · 지번 필지 테두리) ·
* 선 겹(단층 · 지질구조선 · 0차곡) ·
* 계류 100 m · 도로 300 m 띠 · 건물 · 지명).
* 처음 켬 = 위성 · 등고선 · 세류 · 고른 값 = 화면 취향(`aislo:pref:d03.layers`) 기억.
* 겹 목록 · 처음 켬(위성 · 등고선 · 세류) · 취향(`aislo:pref:d03.layers`) = 공용 `map_layer_list` 페이지 표 「d03」.
* 받기 = 켤 때 · 받은 지형 범위 지문(`load(have)` · 페이지가 지형면을 다 읽은 뒤 넘김)이 바뀌면 차례 +1 · 켠 겹 모두 다시 ·
* 옛 차례 답은 버림(475) · 도엽 선 겹(등고 · 세류 · 물 면 · 도로 · 건물 · 지명) = 보이는 도엽 조각(606 · `UI_SheetTiles`) · 지형을 안 받았으면 단추를 막음 ·
* 받기 실패 = 그 겹만 꺼짐 + 알림 · 받는 일은 페이지 서클에 셈(`track`).
* 그리기 차례(아래 → 위) = 위성(GL 조각만 · 옛 캔버스 = 위성 없음 · 590-2) → 음영기복 → 급경사 → 산사태위험지도 → 산림입지도 → 보호구역 → 띠 → 지적 → 물 면 →
* 건물 → 등고선 → 도로 → 임도 짐작 → 올린 임도망도 → 세류 → 선 겹 → 산사태취약지역 → 지명 → 그은 선.
* 겹 모두 = 바탕 캔버스(`base` · 582) · 등고선 = 줌 단계 색인 · 글 폭 기억.
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · `D03_PlanAnalysis_UI_LayerLegend`).
* 범례 = 켠 겹의 받은 범위 안 수 · 넓이(왼쪽 아래 · 칸 = 공용 `map_legend` · 줄 = `D03_PlanAnalysis_UI_LayerLegend`).
* 급경사 = 서버 값 그림을 고른 범위대로 칠함 · 범례 ⚙ → 범위 모달(603 · `D03_PlanAnalysis_UI_SlopeBands`).
* GL 바탕(586-2 · 처음 = 켬 · 끝 단추 끔 = 옛 캔버스 · `aislo:pref:d03.canvas`) — 겹 스물 · 등고 글 · 지명을
* MapLibre 가 그림(`D03_PlanAnalysis_UI_MapGl`) · 캔버스 바탕 겹은 쉼(등고 간격 셈만) · 받기 · 범례는 같음 ·
* WebGL 못 씀 · 잃음 = 저절로 옛 캔버스 + 알림(취향은 안 바꿈).
* 받은 산림 · 환경 자료(622-2) = 국유림 임도 · 경제림 · 생태자연도(조각만 · 받기 없음) · 산림입지도 · 산사태위험(GL = 조각 ·
* 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = `D03_PlanAnalysis_UI_ForestLayers`.
* 옛 캔버스 = 지금 받기 그대로) — 꼴 · 범례 = 공용 `A00_Common/map/map_forest_layers`.
* ========================================================================== */
import { showToast } from "@ui/ui_template_elements";
import { createLegend } from "@ui/ui_template_flow_legend";
import { createIconButton } from "@ui/ui_template_button";
import { createMapTools } from "@ui/ui_template_map_tools";
import { themeColor } from "@ui/ui_template_palette";
import { readByKey, writeByKey } from "../A00_Common/b_page_state";
import { soilColor, tileOnly, type TileOnlyKey } from "../A00_Common/map/map_forest_layers";
import { createMapLayerBar } from "../A00_Common/map/map_layer_bar";
import { LAYERS, LINE_LAYERS, ZONE_COLORS, type RoadPick } from "../A00_Common/map/map_layer_list";
import { createMapLegend } from "../A00_Common/map/map_legend";
import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
createNormalizer,
@@ -57,24 +58,17 @@ import {
} from "./D03_PlanAnalysis_Api";
import {
FOREST_ROAD_KINDS,
GROUPS,
LAYERS,
LINE_LAYERS,
WATER_KEYS,
ZONE_COLORS,
glImage,
glTagged,
loadImage,
readPref,
shadeImage,
type PlanLayerKey,
type RoadPick,
} from "./D03_PlanAnalysis_UI_LayerDefs";
import { layerCounts, legendRows, type Prepared } from "./D03_PlanAnalysis_UI_LayerLegend";
import { createCanvasLayers } from "./D03_PlanAnalysis_UI_LayerPaint";
import { createGlBase, glFont, type GlImage } from "./D03_PlanAnalysis_UI_MapGl";
import { soilColor, tileOnly, type TileOnlyKey } from "./D03_PlanAnalysis_UI_ForestLayers";
import { createSatLightRow, readSatLight } from "./D03_PlanAnalysis_UI_SatLight";
import { createSatLightRow, readSatLight } from "../A00_Common/map/map_sat_light";
import {
createSheetTiles,
SHEET_TILE_NAMES,
@@ -87,10 +81,9 @@ import {
slopeGear,
slopeValues,
writeSlopeBands,
} from "./D03_PlanAnalysis_UI_SlopeBands";
} from "../A00_Common/map/map_slope_bands";
import type { PlanMap, PlanMapLayer } from "./D03_PlanAnalysis_UI_Map";
const PREF_KEY = "aislo:pref:d03.layers";
/** 옛 캔버스로 그림("1") — 처음 = GL. */
const CANVAS_PREF_KEY = "aislo:pref:d03.canvas";
/** 옛 캔버스 알림 — 위성 = GL 조각만(590-2). */
@@ -121,6 +114,8 @@ export interface PlanLayers {
counts: Record<string, number>;
/** GL 겹마다 화면에 그린 지물 수(보임만 · GL 끔 = 빈 것). */
gl: Record<string, number>;
/** GL 줌(GL 끔 = null · 622-2 검증). */
zoom: number | null;
/** 겹이 받은 범위(마지막 `load` 값) · 차례 번호(475 검증). */
area: [number, number, number, number] | null;
turn: number;
@@ -135,7 +130,6 @@ export function createPlanLayers(opts: {
track: <T>(job: Promise<T>) => Promise<T>;
}): PlanLayers {
const { projectId, map, track } = opts;
const on = readPref(PREF_KEY);
const data: Partial<Prepared> = {};
const pending = new Map<DataKey | "satellite", Promise<void>>();
let meta: VWorldMeta | null = null;
@@ -150,6 +144,22 @@ export function createPlanLayers(opts: {
useGl(false);
});
let glOn = readByKey(CANVAS_PREF_KEY) !== "1";
/** 겹 줄 · 켬끔 · 취향 = 공용 겹 목록(624-2 · 페이지 표 「d03」). */
const layerBar = createMapLayerBar({
page: "d03",
glOn,
onToggle: (key) => {
void ensure(key);
sync();
map.redrawBase();
},
onGl: () => {
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
useGl(!glOn);
if (!glOn) showToast(NO_SATELLITE_NOTE, "info");
},
});
const { on, toggle } = layerBar;
/** 도엽 조각(606) — 조각이 오면 그 겹만 합친 자료로 다시 얹음. */
const tiles = createSheetTiles({
projectId,
@@ -164,78 +174,34 @@ export function createPlanLayers(opts: {
},
});
const buttons = new Map<PlanLayerKey, HTMLButtonElement>();
const groups = GROUPS.map((group) =>
group.map((def) => {
const button = createIconButton({
icon: def.icon,
title: def.title,
size: 32,
pressed: on[def.key],
onClick: () => toggle(def.key),
});
button.dataset.layer = def.key;
buttons.set(def.key, button);
return button;
}),
);
const glButton = createIconButton({
icon: "gauge",
title: "그래픽 카드 그림(바탕 겹 · 글을 GPU 로 · 끔 = 옛 캔버스 그림)",
size: 32,
pressed: glOn,
onClick: () => {
writeByKey(CANVAS_PREF_KEY, glOn ? "1" : "0");
useGl(!glOn);
if (!glOn) showToast(NO_SATELLITE_NOTE, "info");
},
});
glButton.dataset.layer = "gl";
/** GL ↔ 옛 캔버스 — 못 켜면 캔버스(알림은 `onFail`). */
function useGl(want: boolean): void {
glOn = want && map.setGl(gl);
if (!glOn) map.setGl(null);
glButton.setAttribute("aria-pressed", String(glOn));
if (!glOn) void Promise.all(LAYERS.map((def) => ensure(def.key))); // 산사태위험 한 장(622-2)
sync();
map.redrawBase();
}
groups.push([glButton]);
const tools = createMapTools({ label: "지도 겹", groups });
const bar = tools.root;
const bar = layerBar.root;
bar.classList.add("d03-plan__layers");
// 571-1 — 오른쪽 평면 도구줄이 이 줄 오른쪽부터(CSS `--d03-layers-w`)
new ResizeObserver(() =>
map.root.style.setProperty("--d03-layers-w", `${tools.bar.offsetWidth}px`),
).observe(tools.bar);
const legend = document.createElement("div");
legend.className = "d03-plan__legend";
legend.title = "켠 겹 범례 — 받은 지형 범위 안 수 · 넓이";
map.root.style.setProperty("--d03-layers-w", `${layerBar.bar.offsetWidth}px`),
).observe(layerBar.bar);
const legendBox = createMapLegend("d03-plan__legend");
const legend = legendBox.root;
/** 받음 — 위성 · 산림 조각 = GL 조각(받을 것 없음 · 옛 캔버스 = 안 그림) · 산사태위험 = GL 이면 조각(622-2). */
const loadedOf = (key: PlanLayerKey): boolean =>
tileOnly(key) || (glOn && key === "landslide") || !!data[dataKey(key)];
function sync(): void {
for (const def of LAYERS) {
const button = buttons.get(def.key)!;
button.setAttribute("aria-pressed", String(on[def.key]));
button.disabled = !meta;
const title = !meta
? `${def.title} — 지형 받기 뒤 보임`
: tileOnly(def.key) && !glOn
? `${def.title} — 옛 그림(그래픽 카드 끔)엔 안 보임`
: def.title;
button.title = title;
button.setAttribute("aria-label", title);
}
layerBar.sync({ ready: !!meta, glOn });
for (const def of LAYERS) gl.setVisible(def.key, on[def.key]);
gl.setData("hillshade", hillshade && (on.satellite ? hillshade.shade : hillshade.plain));
const rows = meta ? legendRows(on, data) : [];
const light = meta && glOn && on.satellite; // 옛 캔버스 = 위성 없음
legend.replaceChildren(...rows.map((row) => createLegend(row)), ...(light ? [lightRow] : []));
legendBox.show(meta ? legendRows(on, data) : [], light ? [lightRow] : []);
if (meta && on.slope && data.slope) legend.firstElementChild?.append(gear);
legend.hidden = !rows.length && !light;
}
/** 위성 밝기(요구 475) — 위성 켠 동안 범례 맨 아래 한 줄 · 바로 반영. */
@@ -277,14 +243,6 @@ export function createPlanLayers(opts: {
sync();
useGl(glOn);
function toggle(key: PlanLayerKey): void {
on[key] = !on[key];
writeByKey(PREF_KEY, JSON.stringify(on));
void ensure(key);
sync();
map.redrawBase();
}
/** 켜진 겹의 자료를 받음(한 번) — 실패 = 그 겹만 끔(기억은 안 바꿈 · 다음에 다시 받음) + 알림. */
function ensure(key: PlanLayerKey): Promise<void> {
if (!meta || !on[key] || loadedOf(key)) return Promise.resolve();
@@ -364,7 +322,7 @@ export function createPlanLayers(opts: {
gl.setRoadPick(null);
}
on.forestRoadFile = !!info;
writeByKey(PREF_KEY, JSON.stringify(on));
layerBar.save();
}
async function deleteRoad(bundle: string): Promise<boolean> {
@@ -672,6 +630,7 @@ export function createPlanLayers(opts: {
: gl.map()!.queryRenderedFeatures({ layers: [layer.id] }).length,
]),
),
zoom: gl.map()?.getZoom() ?? null,
area,
turn: areaTurn,
tiles: tiles.state(),
+19 -9
View File
@@ -1,7 +1,8 @@
/* =============================================================================
* D03_PlanAnalysis_UI_Map.ts
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉
* (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기).
* D03 메인 평면(PLAN 530-3 · 562-3) — 위성 바탕 · 위에 겹(바탕 겹 569-2 · 그리기 · 거리재기) · 도구줄 아홉 + 지형 칩
* (공용 `createMapTools` 568-3 — [?] · 되돌리기 · 다시하기 · 3D · 좌회전 · 우회전 · 측점 · 기울기 · 거리재기 ·
* 차례 · 보임 = 공용 도구줄 표 `A00_Common/map/map_toolbar` 624-2 · 지형 칩 `map_terrain_chip`).
*
* 셈 · 그리기는 B 부품 그대로(import 만) — 좌표 `createMetricProjector` · `screenToMetric` · 맞춤
* `computeRouteView` · 휠 확대 · 가운데 단추 옮기기 `bindRouteEditNavigation` ·
@@ -19,6 +20,8 @@
import { perfTime } from "@ui/ui_template_gl_map";
import { createMapTools, mapToolButton } from "@ui/ui_template_map_tools";
import { routeColor, themeColor } from "@ui/ui_template_palette";
import { createTerrainChip } from "../A00_Common/map/map_terrain_chip";
import { mapToolGroups } from "../A00_Common/map/map_toolbar";
import type { VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch";
import {
computeMapRect,
@@ -101,7 +104,7 @@ export interface PlanMapLayer {
base?: boolean;
draw: (context: CanvasRenderingContext2D, map: ScreenMap) => void;
press?: (px: number, py: number, map: ScreenMap, button: 0 | 2, shift: boolean) => boolean;
hover?: (px: number, py: number, map: ScreenMap) => void;
hover?: (px: number, py: number, map: ScreenMap, shift: boolean) => void;
release?: (map: ScreenMap) => void;
/** 「연도 마커」를 끌고 지나감(참 = 받을 겹) · 놓음(참 = 받음) — 585-3. */
dragOver?: (px: number, py: number, map: ScreenMap) => boolean;
@@ -210,6 +213,8 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Enter = 긋기 끝",
"Esc = 긋기 취소",
"Backspace = 마지막 점 빼기",
"Tab(긋는 중) = 자유지정 ↔ 길이·기울기 고정",
"Shift 누른 채(길이·기울기 고정) = 부채꼴 사이 기울기 자리 · 길이 그대로",
"[다시 긋기] = 앞 노드부터 긋고 뒤 노드에 붙으면 끝",
"누름 = 노드 · 선 고름 · 다시 누름 = 선 → 구역 → 전체로 넓힘",
"Shift+누름 = 선 더함 · 뺌",
@@ -224,11 +229,16 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
"Ctrl+휠 = 돌리기",
],
},
groups: [
[undoButton, redoButton],
[viewButton, "rotate-ccw", "rotate-cw"],
[markButton("stations", "측점"), markButton("slopes", "구간 길이 · 기울기"), measureButton],
],
// 624-2 — 단추 차례 · 보임 = 공용 도구줄 표(페이지 「d03」) · 지형 칩 = 수치지형도(라이다 = 624-4)
groups: mapToolGroups("d03", {
undo: undoButton,
redo: redoButton,
view3d: viewButton,
stations: markButton("stations", "측점"),
slopes: markButton("slopes", "구간 길이 · 기울기"),
measure: measureButton,
terrain: createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] }).root,
}),
});
const toolbar = tools.root;
toolbar.classList.add("d03-plan__tools");
@@ -458,7 +468,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap {
perfTime("plan:pointermove", () => {
const [px, py] = at(event);
const map = screenMap();
for (const layer of layers) layer.hover?.(px, py, map);
for (const layer of layers) layer.hover?.(px, py, map, event.shiftKey);
drawTop();
});
});
+17 -325
View File
@@ -1,47 +1,37 @@
/* =============================================================================
* D03_PlanAnalysis_UI_MapGl.ts
* D03 GL 바탕(PLAN 586-1 · 586-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로:
* 위성 조각(서버 대리 · 서버 기억 z6~19) · 그림 겹(음영 · 급경사 · 산사태위험 = image) ·
* 면 겹(산림입지 · 보호구역 · 띠 · 물 면 · 건물 = fill) · 선 겹(지적 · 등고선 · 도로 · 임도 짐작 · 올린 임도망도 ·
* 세류 · 단층 · 지질구조선 · 0차곡 · 산사태취약 필지 = line · 점선 포함) · 점(산사태취약 · 지명 = circle) ·
* 글(등고 수치 · 지명 = symbol · 겹치면 뺌 · 글꼴 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 —
* 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로 그림 · 글꼴 조각 파일 없음).
* D03 GL 바탕(PLAN 586-1 · 586-2 · 624-2 · 요구 445 B · 448) — 바탕 겹 스물을 MapLibre(WebGL · BSD-3)로.
* 겹 꼴 · 차례 = 공용 `A00_Common/map/map_layer_specs`(624-2 · B05 평면과 같은 꼴) · 여기는 D03 지도 세우기만:
* 고정 소스(위성 조각 z6~19 · 등고선 615 줌별 벡터 조각 · 산림 조각 다섯 622-2) · 받는 동안 서클 · 임도망 고름 · 위성 밝기.
* 글 = 화면 Noto Sans KR(OFL · 자체 보관)을 그 자리에서 그림 — 꼴에 글꼴 주소가 없으면 MapLibre 가 모든 글자를 화면 글꼴로.
* 그은 선 · 재기 · 고르기 · 누름 = 위 캔버스 그대로(위에 겹침).
* 카메라 주인 = D03 화면(`ViewState` · 돌림) 하나 — 여기는 따라가기만 · 조작은 안 받음.
* 자료 = 지금 받는 GeoJSON 그대로(조각내기는 MapLibre 일꾼 스레드) · 색 = 받는 쪽이 지물에 붙인 `_color`.
* 등고선 = 줌별 벡터 조각(615 · 위성처럼 고정 소스 · 가는 선 = 모두 · 굵은 선 · 글 = `k` 1) · 받는 동안 서클.
* 받은 산림 · 환경 자료(622-2) = 같은 꼴 고정 소스 다섯(`D03_PlanAnalysis_UI_ForestLayers`) · 받는 동안 서클.
* 그래픽 카드 · WebGL 이 없거나 잃으면 `onFail` — 부르는 쪽이 옛 캔버스로.
* 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2) · 여기는 겹 꼴만.
* 지도 세우기 · 자료 올리기 · 카메라 = 공용 부품 `ui_template_gl_map`(589-2).
* ========================================================================== */
import type { ExpressionSpecification, FilterSpecification, Map as MapLibreMap } from "maplibre-gl";
import type { Map as MapLibreMap } from "maplibre-gl";
import {
contourTileSource,
createGlMap,
GL_FONT,
satelliteTileSource,
type GlImage,
type GlSpec as GlMapSpec,
} from "@ui/ui_template_gl_map";
import { themeColor } from "@ui/ui_template_palette";
import type { GeoJsonCollection } from "../B04_PreProcess/B04_PreProcess_UI_MapRender";
import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs";
import { forestSources, TRACKED_SOURCES } from "../A00_Common/map/map_forest_layers";
import {
forestAreaSpecs,
forestRasterSpecs,
forestRoadSpecs,
forestSources,
TRACKED_SOURCES,
} from "./D03_PlanAnalysis_UI_ForestLayers";
import { SAT_LIGHT_DEFAULT } from "./D03_PlanAnalysis_UI_SatLight";
MAP_THEME_NAMES,
mapLayerSpecs,
pickFilter,
type GlSource,
} from "../A00_Common/map/map_layer_specs";
import type { PlanLayerKey, RoadPick } from "./D03_PlanAnalysis_UI_LayerDefs";
import { SAT_LIGHT_DEFAULT } from "../A00_Common/map/map_sat_light";
export { GL_FONT, type GlImage };
export type { GlImage };
/** 자료 소스(임도 짐작 = 도로 소스를 거름 · 위성 = 조각). */
export type GlSource = Exclude<PlanLayerKey, "satellite" | "forestRoad">;
const GL_FONT_BOLD = [`${GL_FONT} SemiBold`, GL_FONT];
/** GL 글꼴 미리 받음 — 받기 전에 그린 글자는 대체 글꼴로 굳음(못 받아도 그림). */
export const glFont = (weight: string, text: string): Promise<unknown> =>
document.fonts.load(`${weight} 24px "${GL_FONT}"`, text || "가").catch(() => null);
@@ -62,304 +52,6 @@ export interface GlBase {
resize: () => void;
}
type GlSpec = GlMapSpec<PlanLayerKey>;
const HALO = "rgba(0, 0, 0, 0.55)";
const ROUND = { "line-join": "round", "line-cap": "round" } as const;
const COLOR: ExpressionSpecification = ["get", "_color"];
/** 등고 조각 굵은 선(서버가 줌마다 정함) — 가는 선은 모두라 굵은 선은 두 번 겹쳐 짙음(캔버스처럼). */
const BOLD = ["==", ["get", "k"], 1] as unknown as FilterSpecification;
/** 캔버스 점선(px) → MapLibre 점선(선 굵기 배수). */
const dash = (px: number[], width: number): number[] => px.map((v) => v / width);
/** 선 겹 — 검은 테두리 + 선(점선이면 둘 다 같은 px 무늬). */
function haloLine(
key: PlanLayerKey,
source: GlSource,
color: string,
width: number,
pattern: number[] = [],
halo = { color: HALO, width: width + 1.6 },
filter?: FilterSpecification,
): GlSpec[] {
const line = (id: string, paint: Record<string, unknown>, w: number): GlSpec =>
({
key,
id,
type: "line",
source,
...(filter ? { filter } : {}),
layout: ROUND,
paint: {
...paint,
"line-width": w,
...(pattern.length ? { "line-dasharray": dash(pattern, w) } : {}),
},
}) as GlSpec;
return [
line(`${key}-halo`, { "line-color": halo.color }, halo.width),
line(key, { "line-color": color }, width),
];
}
/** 면 겹 — 채움 + 테두리(캔버스 `fillLayer` 처럼 테두리 투명도 0.9). */
function fillEdge(
key: PlanLayerKey,
source: GlSource,
fill: string | ExpressionSpecification,
opacity: number | ExpressionSpecification,
edge: { color: string | ExpressionSpecification; width: number; dash?: number[] },
): GlSpec[] {
return [
{
key,
id: key,
type: "fill",
source,
paint: { "fill-color": fill, "fill-opacity": opacity },
} as GlSpec,
{
key,
id: `${key}-edge`,
type: "line",
source,
layout: ROUND,
paint: {
"line-color": edge.color,
"line-width": edge.width,
"line-opacity": 0.9,
...(edge.dash ? { "line-dasharray": dash(edge.dash, edge.width) } : {}),
},
} as GlSpec,
];
}
/** 겹 차례(아래 → 위)와 꼴 — 캔버스 `D03_PlanAnalysis_UI_Layers` 의 색 · 굵기 · 투명도 · 차례 그대로. */
function layerSpecs(light: number, pick: RoadPick | null): GlSpec[] {
const contour = themeColor("--draw-map-contour", "#ffd68c");
const place = themeColor("--draw-map-place", "#f8fafc");
const landslideZone = themeColor("--draw-map-landslide-zone", "#e11d48");
const raster = (
key: "satellite" | "hillshade" | "slope",
opacity: number,
extra: Record<string, number> = {},
): GlSpec =>
({
key,
id: key,
type: "raster",
source: key,
paint: { "raster-opacity": opacity, "raster-fade-duration": 0, ...extra },
}) as GlSpec;
const lineKind = (key: "faults" | "lineaments" | "zeroOrder", width: number, pattern: number[]) =>
haloLine(key, key, themeColor(LINE_COLOR[key], "#f43f5e"), width, pattern);
return [
// 위성 = 조금 어둡고 연하게(요구 475 · 노선 선 대비) — 밝기 상한은 사용자 조절
raster("satellite", 1, { "raster-brightness-max": light, "raster-saturation": SAT_SATURATION }),
raster("hillshade", 1), // 위성 위 = 「곱하기」와 같은 검은 그늘 그림(받는 쪽이 바꿈)
raster("slope", 0.55),
...forestRasterSpecs(), // 산사태위험 · 생태자연도 = 조각 그림(622-2)
...forestAreaSpecs(), // 산림입지도 · 경제림 = 벡터 조각
...fillEdge("zones", "zones", COLOR, 0.22, { color: COLOR, width: 1.6 }),
...fillEdge("bands", "bands", COLOR, ["case", ["==", ["get", "_band"], "road"], 0.16, 0.18], {
color: COLOR,
width: 1.2,
dash: [6, 4],
}),
{
key: "cadastral",
id: "cadastral",
type: "line",
source: "cadastral",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-parcel", "#f0abfc"),
"line-width": 0.8,
"line-opacity": 0.8,
},
} as GlSpec,
...((): GlSpec[] => {
const water = themeColor("--draw-map-water", "#0ea5e9");
return fillEdge("water", "water", water, 0.45, { color: water, width: 1 });
})(),
...fillEdge("building", "building", themeColor("--draw-map-building", "#fb7185"), 0.7, {
color: HALO,
width: 0.6,
}),
{
key: "contour",
id: "contour-minor",
type: "line",
source: "contour",
"source-layer": "contour",
layout: ROUND,
paint: { "line-color": contour, "line-width": 0.8, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-index",
type: "line",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: ROUND,
paint: { "line-color": contour, "line-width": 1.6, "line-opacity": 0.7 },
} as GlSpec,
{
key: "contour",
id: "contour-label",
type: "symbol",
source: "contour",
"source-layer": "contour",
filter: BOLD,
layout: {
"symbol-placement": "line",
"symbol-spacing": 320,
"text-field": ["to-string", ["get", "h"]],
"text-font": [GL_FONT],
"text-size": 10,
"text-max-angle": 30,
"text-padding": 2,
},
paint: {
"text-color": contour,
"text-halo-color": "rgba(0, 0, 0, 0.6)",
"text-halo-width": 1.5,
},
} as GlSpec,
...haloLine("road", "road", themeColor("--draw-map-road", "#f8fafc"), 1.6, [], {
color: HALO,
width: 3.2,
}),
...haloLine(
"forestRoad",
"road",
themeColor("--draw-map-forest-road", "#f59e0b"),
2,
[7, 4],
{ color: HALO, width: 3.6 },
[
"in",
["get", "도로구분"],
["literal", ["소로", "미분류"]],
] as unknown as FilterSpecification,
),
...haloLine(
"forestRoadFile",
"forestRoadFile",
themeColor("--draw-map-forest-road-file", "#ff2fd0"),
2.4,
[],
{ color: HALO, width: 4 },
),
...forestRoadSpecs(), // 국유림 임도 = 벡터 조각
// 고른 묶음 · 선 강조(605-2) — 같은 자료에서 걸러 한 겹 더 굵게
...["-pick-halo", "-pick"].map(
(tail, i) =>
({
key: "forestRoadFile",
id: `forestRoadFile${tail}`,
type: "line",
source: "forestRoadFile",
filter: pickFilter(pick),
layout: ROUND,
paint: {
"line-color": i ? themeColor("--draw-map-sketch", "#ffe94d") : HALO,
"line-width": i ? 4.2 : 7.4,
},
}) as GlSpec,
),
{
key: "stream",
id: "stream",
type: "line",
source: "stream",
layout: ROUND,
paint: {
"line-color": themeColor("--draw-map-stream", "#38bdf8"),
"line-width": ["case", ["==", ["get", "구분"], "세류"], 1, 2.4],
},
} as GlSpec,
...lineKind("faults", 2.4, []),
...lineKind("lineaments", 1.8, [8, 4]),
...lineKind("zeroOrder", 1.8, [4, 3]),
// 산사태취약 — 필지 = 「이 필지 안 어딘가」 테두리만 · 점 = 지정 자리
...haloLine(
"landslideZones",
"landslideZones",
landslideZone,
1.8,
[6, 3],
{ color: "rgba(255, 255, 255, 0.7)", width: 3.2 },
["!=", ["geometry-type"], "Point"] as unknown as FilterSpecification,
),
{
key: "landslideZones",
id: "landslideZones-point",
type: "circle",
source: "landslideZones",
filter: ["==", ["geometry-type"], "Point"],
paint: {
"circle-radius": 5,
"circle-color": landslideZone,
"circle-stroke-color": "#ffffff",
"circle-stroke-width": 1.5,
},
} as GlSpec,
{
key: "place",
id: "place-dot",
type: "circle",
source: "place",
paint: { "circle-radius": 2.5, "circle-color": place },
} as GlSpec,
{
key: "place",
id: "place",
type: "symbol",
source: "place",
layout: {
"text-field": ["get", "name"],
"text-font": GL_FONT_BOLD,
"text-size": 11,
"text-anchor": "bottom",
"text-offset": [0, -0.36],
"text-padding": 2,
},
paint: {
"text-color": place,
"text-halo-color": "rgba(0, 0, 0, 0.7)",
"text-halo-width": 1.5,
},
} as GlSpec,
];
}
/** 고른 묶음 · 선만 거름(고름 없음 = 아무것도 안 맞는 거름). */
function pickFilter(pick: RoadPick | null): FilterSpecification {
const all: unknown[] = ["all", ["==", ["get", "__bundle"], pick?.bundle ?? ""]];
if (pick?.line !== undefined) all.push(["==", ["get", "__line"], pick.line]);
return all as unknown as FilterSpecification;
}
/** 선 겹 색 값(캔버스 `LINE_LAYERS` 와 같음). */
const LINE_COLOR = {
faults: "--draw-map-fault",
lineaments: "--draw-map-lineament",
zeroOrder: "--draw-map-zero-order",
} as const;
/** 겹 색 지문 — 밝음 · 어두움이 바뀌면 색을 다시. */
const THEME_NAMES = [
"--draw-map-contour",
"--draw-map-road",
"--draw-map-stream",
"--draw-map-parcel",
] as const;
/** 위성 채도(−1~1) — 조금 연하게. */
const SAT_SATURATION = -0.3;
/** `track` = 받는 일을 진행 서클에 셈(등고 · 산림 조각 받는 동안). */
export function createGlBase(
projectId: string,
@@ -369,13 +61,13 @@ export function createGlBase(
let light = SAT_LIGHT_DEFAULT;
let pick: RoadPick | null = null;
const base = createGlMap<PlanLayerKey>({
specs: () => layerSpecs(light, pick),
specs: () => mapLayerSpecs(light, pick),
fixed: {
satellite: satelliteTileSource(projectId),
contour: contourTileSource(projectId),
...forestSources(projectId),
},
themeNames: THEME_NAMES,
themeNames: MAP_THEME_NAMES,
className: "d03-plan__gl",
onFail,
});
@@ -192,6 +192,10 @@
align-items: start;
}
.d03-plan__draw-fields[hidden] {
display: none;
}
.d03-plan__draw-fields .ui-field {
margin: 0;
min-width: 0;
+56 -2
View File
@@ -450,10 +450,12 @@ def zero_order_layer(
min_m2: float = ZERO_ORDER_MIN_M2,
max_m2: float = ZERO_ORDER_MAX_M2,
compute: bool = True,
npz: Path | None = None,
) -> dict[str, Any] | None:
"""0차곡 겹 — 받은 범위의 창고 도엽 선(기본 임계) 또는 누적(다른 임계 · 지문 같으면 저장본) ·
경위도 선 + 가닥 · 길이 합 m. `compute=False` = 셈이 필요하면 None(일꾼으로 넘김)."""
npz = models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
경위도 선 + 가닥 · 길이 합 m. `compute=False` = 셈이 필요하면 None(일꾼으로 넘김) ·
`npz` = 범위를 줄 수치지형도 격자(624-1 · 임도 프로젝트) · 없으면 D `models_plan`."""
npz = npz or models_dir / f"dtm_sheet_{SHEET_SURFACE_DEFAULT_METHOD}.npz"
if file_stamp(npz) is None:
raise FileNotFoundError("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
box = _project_box(npz)
@@ -497,6 +499,58 @@ def zero_order_layer(
return value
DEM_ZERO_ORDER_KIND = "plan_zero_order_dem"
def dem_zero_order_layer(
project_root: Path,
npz: Path,
epsg: int,
min_m2: float = ZERO_ORDER_MIN_M2,
max_m2: float = ZERO_ORDER_MAX_M2,
compute: bool = True,
) -> dict[str, Any] | None:
"""0차곡 겹 — 라이다 지형 격자 통째(624-1 · 창고 없음) · 2 m 로 솎아 흐름누적 · 선 ·
파생 저장본(열쇠 = 격자 파일 이름 · 지문 · 임계). `compute=False` = 저장본 없으면 None."""
import shapely
stamp = file_stamp(npz)
if stamp is None:
raise FileNotFoundError("지형 격자가 없습니다.")
key = derived_key(
DEM_ZERO_ORDER_KIND,
ZERO_ORDER_VERSION,
npz.name,
stamp,
int(epsg),
float(min_m2),
float(max_m2),
ZERO_ORDER_CELL_M,
ZERO_ORDER_MIN_LEN_M,
SIMPLIFY_M,
)
saved = read_derived(project_root, DEM_ZERO_ORDER_KIND, key)
if saved is not None or not compute:
return saved["value"] if saved is not None else None
with np.load(npz) as dem:
x, y = np.asarray(dem["x"], float), np.asarray(dem["y"], float)
cell = float(dem["resolution"][0]) if "resolution" in dem.files else float(x[1] - x[0])
step = max(1, round(ZERO_ORDER_CELL_M / cell))
z = np.asarray(dem["z"], float)[::step, ::step]
valid = np.asarray(dem["valid_mask"], bool)[::step, ::step]
x, y, cell = x[::step], y[::step], cell * step
lines = zero_order_lines(z, valid, x, y, cell, min_m2, max_m2)
shapes: list[Any] = []
if lines:
index = np.repeat(np.arange(len(lines)), [len(line) for line in lines])
geoms = shapely.linestrings(np.concatenate(lines), indices=index)
shapes = list(shapely.simplify(geoms, SIMPLIFY_M))
value = _layer_value(shapes, epsg, min_m2, max_m2)
value["cell_m"] = cell
write_derived(project_root, DEM_ZERO_ORDER_KIND, key, value)
return value
def _clipped_lines(paths: list[Path], box: tuple[float, ...]) -> list[Any]:
"""도엽 선 이어 붙여 받은 범위로 자름 — 잘려 짧아진 토막(`ZERO_ORDER_MIN_LEN_M` 미만) 거름."""
import shapely
@@ -0,0 +1,129 @@
/* =============================================================================
* D03_PlanAnalysis_Fan.ts
* D 예정노선 길이·기울기 고정 긋기 — 부채꼴 고르기 셈(PLAN 625-2 · 요구 530 · 531 B1~B7).
* 노드 = 반지름 L 원 위 |기울기| = s 자리(양각기 후보 · 562-1 짝 `compassCandidates`) · 부호 = 오르막 + · 내리막 −.
* 부채꼴 = 각도 차례 이웃 두 노드 사이 호 중 가운데가 |기울기| ≤ s 인 호(노드 2 · 4 · 6 · 8 개 모두 같은 규칙).
* 정방향 = 앞 구간 방향과 가장 가까운 호(앞 구간 없음 = 없음 · 거의 같으면 마우스 쪽) · 나머지 = 역방향.
* 완만한 땅(노드 0 · 원 전체 ≤ s) = 어느 방향이나(길이 고정처럼).
* Shift 사이 고르기 = 마우스 방위를 허용 호 안으로 묶음 · 길이 그대로 · 기울기 = 그 자리 실제 지형.
* 화면 고르기 전용 — 서버는 [저장] 때 꼭짓점으로 다시 셈(파이썬 짝 없음) · DOM · fetch · three 없음.
* B05 평면(626-3)이 그대로 씀.
* ========================================================================== */
import {
compassCandidates,
segmentLength,
type HeightAt,
type Vertex,
} from "./D03_PlanAnalysis_Draw";
const TAU = 2 * Math.PI;
/** 정방향 두 호 점수 차가 이보다 작으면 마우스 쪽 호(cos 차 · 약 3°). */
const FORWARD_TIE = 0.05;
/** 원 위 노드 — 방위(라디안 · 동쪽 0 · 반시계 · 작업 좌표 y 위) · 부호(+1 오르막 · −1 내리막). */
export interface FanNode {
point: Vertex;
angle: number;
sign: 1 | -1;
}
/** 허용 호 — `from` 에서 반시계로 `to`(to > from · 2π 넘을 수 있음). */
export interface FanArc {
from: number;
to: number;
forward: boolean;
}
export interface Fan {
nodes: FanNode[];
arcs: FanArc[];
/** 원 전체가 |기울기| ≤ s(노드 0) — 어느 방향이나. */
flat: boolean;
}
const norm = (angle: number): number => ((angle % TAU) + TAU) % TAU;
const bearing = (from: Vertex, to: Vertex): number =>
norm(Math.atan2(to[1] - from[1], to[0] - from[0]));
/** 두 방위 사이 각(0 ~ π). */
const gap = (a: number, b: number): number => Math.min(norm(a - b), norm(b - a));
/** 원 위 방위 자리. */
export function fanPoint(from: Vertex, lengthM: number, angle: number): Vertex {
return [from[0] + Math.cos(angle) * lengthM, from[1] + Math.sin(angle) * lengthM];
}
/** 부채꼴 — `prev` = 앞 구간 시작(정방향 · 없으면 null) · `cursor` = 정방향이 거의 같을 때 가름. */
export function compassFan(
from: Vertex,
lengthM: number,
slopePct: number,
heightAt: HeightAt,
prev: Vertex | null,
cursor: Vertex | null = null,
): Fan {
const z0 = heightAt(from[0], from[1]);
if (!(lengthM > 0) || !Number.isFinite(z0)) return { nodes: [], arcs: [], flat: false };
const rise = (Math.abs(slopePct) / 100) * lengthM;
const allowed = (angle: number): boolean => {
const p = fanPoint(from, lengthM, angle);
return Math.abs(heightAt(p[0], p[1]) - z0) <= rise; // NaN = 지형 밖 = 거짓
};
const nodes: FanNode[] = compassCandidates(from, lengthM, slopePct, heightAt)
.map((point): FanNode => ({
point,
angle: bearing(from, point),
sign: heightAt(point[0], point[1]) >= z0 ? 1 : -1,
}))
.sort((a, b) => a.angle - b.angle);
if (!nodes.length) return { nodes, arcs: [], flat: allowed(0) };
const arcs: FanArc[] = [];
nodes.forEach((node, i) => {
const to = i + 1 < nodes.length ? nodes[i + 1].angle : nodes[0].angle + TAU;
if (allowed((node.angle + to) / 2)) arcs.push({ from: node.angle, to, forward: false });
});
if (prev && arcs.length && segmentLength(prev, from) > 1e-9) {
const heading = bearing(prev, from);
const mid = (arc: FanArc): number => (arc.from + arc.to) / 2;
const ranked = arcs
.map((arc) => ({ arc, score: Math.cos(mid(arc) - heading) }))
.sort((a, b) => b.score - a.score);
let pick = ranked[0].arc;
if (cursor && ranked.length > 1 && ranked[0].score - ranked[1].score < FORWARD_TIE) {
const toward = bearing(from, cursor);
if (gap(mid(ranked[1].arc), toward) < gap(mid(pick), toward)) pick = ranked[1].arc;
}
pick.forward = true;
}
return { nodes, arcs, flat: false };
}
/** 고르기 — 완만한 땅 = 마우스 방향 원 위(길이 고정처럼) · 아니면 마우스에 가장 가까운 노드 · 없으면 null. */
export function fanPick(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
if (fan.flat)
return segmentLength(from, cursor) > 1e-9
? fanPoint(from, lengthM, bearing(from, cursor))
: null;
let best: Vertex | null = null;
let bestD = Infinity;
for (const node of fan.nodes) {
const d = segmentLength(node.point, cursor);
if (d < bestD) [best, bestD] = [node.point, d];
}
return best;
}
/** Shift 사이 고르기 — 마우스 방위가 허용 호 안이면 그 방위 · 밖이면 가장 가까운 호 끝(= 노드) · 길이 그대로. */
export function fanBetween(fan: Fan, from: Vertex, cursor: Vertex, lengthM: number): Vertex | null {
if (segmentLength(from, cursor) < 1e-9) return null;
const toward = bearing(from, cursor);
if (fan.flat) return fanPoint(from, lengthM, toward);
let best: number | null = null;
let bestGap = Infinity;
for (const arc of fan.arcs) {
if (norm(toward - arc.from) <= arc.to - arc.from) return fanPoint(from, lengthM, toward);
for (const end of [arc.from, arc.to])
if (gap(end, toward) < bestGap) [best, bestGap] = [end, gap(end, toward)];
}
return best === null ? null : fanPoint(from, lengthM, best);
}
@@ -224,6 +224,11 @@ def expected_route_csv_path(project_root: Path) -> Path:
return Path(project_root) / "B05_Profile" / "route" / "expected_route.csv"
def expected_route_edit_path(project_root: Path) -> Path:
"""고친 예정노선 CSV 자리(626-2) — 페이지 [저장] 때만 씀 · [초기화]면 지움 · 원본 그대로."""
return Path(project_root) / "B05_Profile" / "route" / "expected_route_edit.csv"
def write_route_csv(path: Path, points: list[dict[str, float]]) -> None:
"""노선 정점을 CSV로 적는다. 열 이름은 `read_planned_route_csv()`가 아는 것."""
path.parent.mkdir(parents=True, exist_ok=True)
+6 -2
View File
@@ -273,8 +273,12 @@ def _fetch_zips(sheet_no: str, report: Callable[[int, str], None]) -> tuple[dict
zips[no] = _sha256(TERRAIN_ROOT / no / f"{no}.zip")
elif no == sheet_no:
reason = result["failed"].get(no, "")
# 검색 결과 없음 = 바다 등 진짜 빈 도엽 · 그 밖(로그인 · 망)은 다음에 다시
return zips, None if reason.startswith("LookupError") else reason
# 검색 결과 · zip 없음 = 바다 · 작은 섬 · zip 꼴 흠(ValueError — 도곽이 번호 자리와 어긋남 =
# 제주 · 울릉 등 다른 번호 체계) = 그 자리 등고선 없음 · 그 밖(로그인 · 망)은 다음에 다시
if reason.startswith(("LookupError", "ValueError")):
logger.warning("도엽 %s zip 못 씀 — 빈 지형: %s", no, reason[:200])
return zips, None
return zips, reason
return zips, None
+22
View File
@@ -0,0 +1,22 @@
# PLAN 616 · 620 이관 — 급경사 · 0차곡 첫 계산 빠르게 · B06 관 옵션 칸 까닭 분석(요구 497 · 529)
## 616 급경사 · 0차곡 첫 계산 빠르게 (요구 497 · 529 승인 · 진행)
- 근거: `docs/raw/요구_1025/_분석_497_급경사_0차곡_속도.md` — 급경사 = 이미 빠름(처음 0.2~0.7 s · 화면 0.75 s) · 0차곡 = 범위 바뀐 첫 번만 느림(D 3.5 s · 용화 11.9 s · 둘째 0.3~0.5 s) · 옛 지형 프로젝트(10-30 21:55 앞 받음)는 창고 안 씀 → 미리 계산 끝나도 안 빨라짐 · 0차곡 첫 셈이 서버 한 프로세스를 붙잡아 급경사 0.2 → 2.6 s · 화면 쪽 느림 없음.
- [x] 616-1 0차곡 셈 = 창고 격자에서(옛 격자 갈래 걷음 · `D03_PlanAnalysis_ZeroOrder.py:218 · 293`) — 확인 = 용화 11.9 → 약 3.7 s · 결과 선 전후 대조
- 검증완료 — 용화(창고 격자) 4,585 / 4,584 가닥 · 230.89 / 230.84 km · 검증 D(옛 격자 통째 15.0 s) 5,619 / 5,650 가닥(−0.5 %) · 273.1 / 277.9 km(−1.7 % · 창고 지형 대 사업 laplace 원천 차) · 누적 열쇠 판 그대로(전국 누적 다시 셈 없음) · 파생 판 3 (66f9c68a1)
- [x] 616-2 0차곡 선을 도엽마다 미리 뽑아 창고에(`zero_lines_<epsg>`) · 요청 = 이어 붙여 자름 · 전국 미리 계산 단계에 덧 — 확인 = 선 뽑기 1.9 s → 0.1~0.3 s
- 검증완료 — 용화 둘레 100 도엽 zero_lines(일꾼 3 · 155 s · 장당 가운데 4.8 s · 29 KB → 전국 약 0.4 GB) · 함수 용화 0.18 s · D 0.24~0.29 s · HTTP 0차곡 첫 번 용화 0.41 · D 0.61 s · 둘째 0.30 · 0.41 s · 기본 임계는 파생 저장본 안 씀 · 시험 = 도엽 선 이어 붙임 대 상자 통째 1 % 안 · 겹침 0 (66f9c68a1)
- 쓰는 법 — 전국 = `tools/precompute_terrain.py --steps zero_lines --workers N`(두 PC zero 단계 다 끝난 뒤 · 이웃 누적 없으면 그 이웃 빼고 셈 · 나중에 이웃 누적이 생기면 요청 때 일꾼이 그 도엽 선 다시)
- [x] 616-3 무거운 첫 셈을 서버 본체 밖(프로세스 풀) — 확인 = 0차곡 계산 중 급경사 요청 2.6 → 0.2 s
- 검증완료 — 0차곡 일꾼 셈(임계 1003 · 5.06 s) 도중 급경사 0.23 · 0.09 · 0.07 · 0.10 s · 일꾼 하나 113 MB(main.py 안 읽음) · 일꾼 죽임 → 다음 요청 새로 띄움(6.6 s) · 서버 다시 켬 → 옛 일꾼 따라 끝남 · 급경사 첫 셈은 스레드 그대로(0.2~0.7 s · numpy · PIL 이 GIL 놓음 · 일꾼에 0차곡 뒤 줄 서면 더 느림) (5d6d7bbb8)
- [x] 616-4 급경사 칠하기 = 기다림 없는 길(toBlob 대신) · 값 그림 바이너리 — 확인 = 최대 1 s 대기 없음 · 전송 −23 %
- 검증완료 — 멈춘 화면 범위 [적용] 다시 칠함 158~197 ms(옛 toBlob 최대 1,046 ms) · 급경사 응답 = PNG 바이트 용화 1.81 MB · D 2.50 MB(옛 base64 JSON 본문 −25 % · 전송은 PNG 가 이미 압축이라 옛 gzip JSON 과 거의 같음 −1 % · 「−23 %」는 base64 를 gzip 이 되돌린 몫) · 서버 gzip 0.1 s 덜 · 칸 수 4,432,752 서버와 같음 · ORCA 검증_sub2_D 급경사 · 0차곡 5,619 가닥 보임 · 시험 608 · 612 · 617-1 · 618 · 584-1 · 616 51 통과 · 569-1 · 581-1 19 통과 · typecheck 0 (5d6d7bbb8)
- 이음 — `GET …/plan/layers/raster?kind=slope` = `image/png` + 머리 `X-Plan-Raster`(JSON · counts · box · box_m · width · height) · `fetchPlanRaster` 가 `image` = blob 주소로 돌려줌(겉 꼴 그대로) · 음영기복 · 산사태 = JSON 그대로
- 뒤로: 0차곡 · 급경사 보는 화면 조각(넓은 범위용) = 615 전후 결과 보고 정함.
## 620 B06 [저장] 때 관 지점 옛 옵션 칸 지워짐 (sub8 알림 · 분석 끝 = `_분석_620` · 고침은 623 으로)
- 근거: `docs/raw/요구_내역서/_점검_통합길.md` 3장 ⑩ — 집수정 `inlet_revet_*`(돌쌓기 10 m)가 [저장] 뒤 사라지고 동물이동형 기본 true 들어감 · B 그룹(요구 491 조심) · 사용자 승인 뒤 착수.
- [x] 620-1 까닭 찾기 — 데이터 손실 아님 · 까닭 79ca22082 + d5d9c5f66 · 고칠 자리 `B04_PreProcess_Router_Basins.py` `_apply_new_defaults` → 623
+15
View File
@@ -0,0 +1,15 @@
# PLAN 622 이관 — 받은 산림 · 환경 자료 = 예정노선 지도 겹(요구 529)
## 622 받은 산림 · 환경 자료 = 예정노선 지도 겹(줌별 조각) (요구 529 · 진행)
- 근거: PLAN 621 확보 목록 · 615 등고선 줌별 조각 꼴(`common_util/common_util_contour_tiles.py` · 창고 · 잠금 · 지문 · 미리 만들기) · 산사태 WMS `D03_PlanAnalysis_Landslide.py`(지금 = 받은 범위 한 장).
- 이음: 벡터 `GET /api/projects/{id}/plan/vt/{name}/{z}/{x}/{y}.pbf`(name = forest_road · forest_soil · eco_forest) · 그림 `GET /api/projects/{id}/plan/wmt/{name}/{z}/{x}/{y}.png`(name = ecology · landslide · 서버가 대리 받아 기억) · 창고 `storage/_shared/vector_tiles/<name>/<지문>/` · `storage/_shared/wms_tiles/<name>/`.
- 이음(조각 칸 · sub3 화면이 읽음): 벡터 겹 이름(source-layer) = name 그대로 · forest_road `kind`(정수 1 간선 · 2 작업 · 3 산불 = FRRD_DV_B) `name`(FRRD_NM) · forest_soil `code`(글 두 자리 = SLTP_CD 「02」) · eco_forest `own`(글 「국유」 · 「사유」 = ecoFrst_01 · 02) · 벡터 z8~12 서버 · 13 이상 = 12 늘려 씀 · 그림 z6~19 256 px 투명 PNG.
- 원자료: `resources/data_forest/임도망도/<시도>/` 국가임도 선 · `산림입지토양도_1-25000/` 면 · `경제림육성단지/` 면(국유 · 사유) · 생태자연도 오픈API(`.env` `DATA_GO_KR_API_KEY`) · 산사태 생활안전지도 WMS(`SAFEMAP_API_KEY`).
- 정함: 새 라우터 파일(`D03_PlanAnalysis_Router.py` 651 줄 · 늘리지 않음) · 화면 새 줄은 `UI_MapGl.ts` 또는 새 파일(`UI_Layers.ts` 688 줄 늘리지 않음) · 산림입지도 화면 = 받은 전국 파일 조각(브이월드 받기 요약은 621-3 정할 때까지 그대로) · 색 `--draw-map-*` · 지방 · 민간임도 = 사용자 입력(이번 범위 밖).
- 이음 확정(622-1 · 위 줄 따름 + 덧): forest_road 선 {kind 1|2|3 · name · width m · year} · forest_soil 면 {code · name 토양형 이름} · eco_forest 면 {own 국유|사유 · name 단지명} · MVT 512 px · 범위 4096 · 벡터 서버 z8~15(줌별 단순화 · 13 이상도 셈 — 화면 maxzoom 15 가능) · z8~15 밖 = 400 · 자료 줌 밖(forest_soil z8~9) = 빈 조각 · 모르는 name = 404 / 그림 z6~19 · 키 없음 = 404 detail 까닭 · 빈 · 오류 조각 = 투명 PNG + 머리 `X-Tile-Empty: empty|error` · Cache-Control 300 s
- [x] 622-1 서버(sub1) — 공용 벡터 조각(SHP 원자료 · 줌별 단순화 · 남길 칸 · 미리 z8~12) · WMS 조각 대리(생태자연도 · 산사태 · 기억) · 시험
- 검증완료 — 시험 13 · 615 시험 9 통과 · typecheck 0 · 조각 셈(z10 · z13 · z15) 용화: 임도 0.59 s 12 KB · 0.02 s 1 KB · 0.01 s / 토양 0.95 s 192 KB · 0.04 s 13 KB · 0.02 s 2 KB / 경제림 0.37 s 52 KB · 0.18 s 2 KB · 0.11 s 0.5 KB · 강원 평창 산지 같은 꼴(토양 z10 0.77 s 137 KB) · 둘째 = 파일 31~38 ms(8001) · 색인 처음 한 번 임도 4 s 27 MB · 경제림 18 s 203 MB · 토양 91 s 1.7 GB · 전국 낮은 줌 조각 어림 임도 1,078 · 토양 2,659(z10~12) · 경제림 1,960 ≈ 5,700 개 · 약 170 MB(시험 지역 용화 106 조각 만듦) · 그림 처음 · 둘째 = 산사태 0.25~0.51 s · 11~53 ms · 생태자연도 2~3 s(가끔 8~15 s · 대기 30 s) · 64 ms · 키 없음 404 · 로그인 없음 401 · 커밋 251dfd9a5
- [x] 622-2 화면(sub3) — 예정노선 겹 다섯(국유림 임도 · 산림토양도 · 경제림 · 생태자연도 · 산사태) 고정 소스 · 범례 · 켬끔 · 밝음 · 어두움 · ORCA 수치
- 검증완료 — typecheck 0 · 시험 88 통과 · ORCA 용화 다섯 겹 켬 → 0.3 s 안 그림(z12 임도 53 · 토양 477~539 · 경제림 128 지물) · 서버 조각 화면 하나 z9 2.9 MB / z12 3.8 MB / z15 1.0 MB(생태자연도 첫 대리 최대 7.6 s · 뒤 기억) · 켬 때 가장 긴 틈 54 ms · 산사태 조각 = 옛 한 장 같은 등급 94.6 %(밀면 80 % — 자리 같음) · 옛 캔버스 한 장 1,375 px 그대로 · 밝음 · 어두움 · 범례 색 = 조각 색
+7
View File
@@ -0,0 +1,7 @@
# PLAN 627 이관 — 깨진 시험 6 = 낡은 시험(609-1 crs 칸 · 498-2 계획고 검사) 가짜값 고침
## 627 메인에서 깨진 시험 6 — test_283 · test_347_2 (sub2 알림 · 진행)
- `test_283_route_change_reset.py::test_한_본문_손_고침은_보낸_칸을_씀` · `test_347_2_drainage_live.py` 다섯(세대 · 저장 노선 답 · 잠금) — 623 · 626-2 전부터 깨짐(sub2) · 메인에서도 같음(10-31 브레인).
- [x] 627-1 시험마다 까닭 가름(낡은 시험 = 고침 · 지움 · 까닭은 커밋 글 / 진짜 흠 = 코드 안 고치고 question) — B 그룹 조심
- 검증완료 — 6 모두 낡은 시험(진짜 흠 0) · test_347_2 다섯 = 59ae735ed(609-1 _prepare 에 crs 칸) · test_283 하나 = b92fa06f3(498-2 _save_alignment 꼴 검사) · 가짜값만 고침 · 두 파일 23 통과 · db45b0542
@@ -1110,3 +1110,7 @@ CPU는 더늘리기는 힘들어보이고 GPU 사용을 위해 부품사용에
- 노선이 변경된이후 다시 예정노선 추적 로직을 다시 계산함. 초기화값 변경 없음. 파일입력 페이지에서 받은 예정노선이 초기화 기준임.
- 노선추천의 재계산하는 경우가 없음.
- 평면도의 노선변경은 상당히 복잡한 로직으로 B05/B06이 연계되어 있음. 그러니 수정을 할때는 평면노선 값만 변경이 되도록 잘 조사할 것.
## 요구 531 (10-31 · 사용자)
요구서 530 진행해줘.
+2
View File
@@ -75,6 +75,7 @@ from B08_Quantity.B08_Quantity_Router_Milestone import router as b08_milestone_r
from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router
from D01_PlanRegister.D01_PlanRegister_Router import dashboard_router as d01_dashboard_router
from D01_PlanRegister.D01_PlanRegister_Router import router as d01_plan_register_router
from D03_PlanAnalysis.D03_PlanAnalysis_Lidar import router as d03_plan_lidar_router
from D03_PlanAnalysis.D03_PlanAnalysis_Network import router as d03_plan_network_router
from D03_PlanAnalysis.D03_PlanAnalysis_Router import router as d03_plan_analysis_router
from D03_PlanAnalysis.D03_PlanAnalysis_Router_Tiles import router as d03_plan_tiles_router
@@ -497,6 +498,7 @@ app.include_router(b05_route_lifecycle_router, dependencies=protected_with_compa
app.include_router(d03_plan_analysis_router, dependencies=protected_with_company)
app.include_router(d03_plan_tiles_router, dependencies=protected_with_company)
app.include_router(d03_plan_network_router, dependencies=protected_with_company)
app.include_router(d03_plan_lidar_router, dependencies=protected_with_company)
app.include_router(d04_plan_confirm_router, dependencies=protected_with_company)
app.include_router(d04_plan_link_router, dependencies=protected_with_company)
app.include_router(e02_stab_ground_router, dependencies=protected_with_company)
@@ -0,0 +1,134 @@
/* 623 헬퍼 — 서버가 새로 만든 관 옵션(`_apply_new_defaults`)을 공용 명세 폼 `write → read` 에 돌리고
* 같은 종류 병합(`mergeFacilityOptions`)까지 — 폼을 처음 거칠 때 파일이 바뀌는 칸(지워짐 · 더함 · 바뀜).
* 입력 = 표준입력 JSON(옵션 목록) · 출력 = 표준출력 JSON 목록 · 짝 = test_623_inlet_defaults.py.
* 가짜 DOM 틀 = helper_173_7_inlet_animal.cjs. */
const fs = require("fs");
const path = require("path");
const { makeLoader } = require("./helper_ts_stub_loader.cjs");
const CATALOG = require("./helper_322_catalog_module.cjs"); // 구조물 표(322-1)
const ROOT = path.join(__dirname, "..", "..");
class FakeNode {
constructor(tag) {
this.tagName = tag.toUpperCase();
this.children = [];
this.dataset = {};
this.style = {};
this._classes = new Set();
this._on = {};
this._text = "";
this.value = "";
const self = this;
this.classList = {
add: (...n) => n.forEach((c) => self._classes.add(c)),
remove: (...n) => n.forEach((c) => self._classes.delete(c)),
toggle: (c, f) =>
(f ?? !self._classes.has(c)) ? self._classes.add(c) : self._classes.delete(c),
contains: (c) => self._classes.has(c),
};
}
set className(v) {
this._classes = new Set(String(v).split(/\s+/).filter(Boolean));
}
set textContent(v) {
this._text = String(v);
this.children = [];
}
get textContent() {
return this._text + this.children.map((c) => c.textContent ?? "").join("");
}
append(...kids) {
kids.forEach((k) => this.children.push(typeof k === "string" ? { textContent: k } : k));
}
replaceChildren(...kids) {
this.children = [];
this.append(...kids);
}
add(kid) {
this.children.push(kid);
}
setAttribute(n, v) {
this[n] = v;
}
addEventListener(type, fn) {
(this._on[type] ??= []).push(fn);
}
fire(type) {
(this._on[type] ?? []).forEach((fn) => fn({ target: this }));
}
closest() {
return null;
}
matches(sel) {
return sel.split(",").some((s) => {
s = s.trim();
return s.startsWith(".") ? this._classes.has(s.slice(1)) : this.tagName === s.toUpperCase();
});
}
querySelectorAll(sel) {
const found = [];
const walk = (n) =>
(n.children ?? []).forEach((k) => {
if (k.matches?.(sel)) found.push(k);
walk(k);
});
walk(this);
return found;
}
querySelector(sel) {
return this.querySelectorAll(sel)[0] ?? null;
}
}
globalThis.document = { createElement: (t) => new FakeNode(t) };
globalThis.queueMicrotask = (fn) => fn();
globalThis.Option = function (text, value) {
const o = new FakeNode("option");
o.textContent = text;
o.value = value;
return o;
};
const STUBS = {
"@ui/ui_template_elements": { showToast: () => {} },
"@config/config_frontend": { FORD_MANNING_N: 0.03, FORD_SLOPE: 0.02 },
"./ui_template_checkbox.css": {},
"../../B05_Profile/B05_Profile_UI_Structures_Summary_Row": { buildSummaryRow: () => null },
};
const { loadTs } = makeLoader({
root: ROOT,
requireFor: (full) => (request) => {
if (CATALOG.isCatalogRequest(request)) return CATALOG;
if (STUBS[request]) return STUBS[request];
if (request.startsWith("@util/"))
return loadTs(path.join(ROOT, "common_util", `${request.slice(6)}.ts`));
return loadTs(path.join(path.dirname(full), `${request}.ts`));
},
});
const { createSpecForm } = loadTs(
path.join(ROOT, "A00_Common", "design", "design_structure_form.ts"),
);
const { PIPE_SPEC } = loadTs(
path.join(ROOT, "A00_Common", "design", "logic", "design_structure_pipe.ts"),
);
const { mergeFacilityOptions } = loadTs(
path.join(ROOT, "B05_Profile", "logic", "B05_Profile_UI_Drainage_Facility_Merge.ts"),
);
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
const inputs = JSON.parse(fs.readFileSync(0, "utf-8"));
const out = inputs.map((options) => {
const form = createSpecForm(null, {});
form.write(PIPE_SPEC, options);
const merged =
mergeFacilityOptions({ facility: "pipe", options }, { facility: "pipe", options: form.read() })
.options ?? {};
return {
removed: Object.keys(options).filter((k) => !(k in merged)),
added: Object.keys(merged).filter((k) => !(k in options)),
changed: Object.keys(options).filter((k) => k in merged && !same(options[k], merged[k])),
};
});
process.stdout.write(JSON.stringify(out));
+52 -3
View File
@@ -17,7 +17,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_candidates # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -60,6 +60,7 @@ EXCEL_LINES = { # 총괄 D49~D58 · D60 · D62 · D64 · D66
"profit": 108109955,
"vat": 102911727,
"supply": 69850000,
**{key: 0 for key in CURRENT_ONLY}, # 708-2 현행 비목 — 엑셀(기번3)에 없음 = 요율 빈칸 = 0
}
EXCEL_SUBTOTALS = { # 총괄 D59 · D61 · D63 · D65 · D67
"net_cost": 868874829,
@@ -103,7 +104,8 @@ def test_간접재료비_작업설은_직접재료비만_바꿈():
def test_빈칸_규칙():
blank = compute_total(SUMS, {})
assert all(v is None for k, v in blank["lines"].items() if k != "supply")
assert all(v is None for k, v in blank["lines"].items() if k not in ("supply", *CURRENT_ONLY))
assert all(blank["lines"][k] == 0 for k in CURRENT_ONLY) # 708-2 요율 빈칸 = 해당 없음
assert blank["subtotals"]["net_cost"] is None and blank["subtotals"]["grand"] is None
assert blank["lines"]["supply"] is None # 조달수수료율 빈칸
# 요율 하나만 빈칸 — 그 줄 · 소계 빈칸 · 다른 줄은 셈
@@ -157,6 +159,8 @@ def test_거울_TS_가_같은_값():
[{**SUMS, "supply_net": 2_000_000_000}, ESTIMATE],
[{**SUMS, "labor": None}, ESTIMATE],
[SUMS, {**ESTIMATE, "rates": {**RATES, "care": None, "profit": 12.5}}],
[SUMS, CURRENT],
[{**SUMS, "labor": None}, CURRENT],
]
assert _run_ts(cases) == [compute_total(s, e) for s, e in cases]
@@ -208,8 +212,53 @@ console.log(JSON.stringify(out));
) # fmt: skip
assert result.returncode == 0, result.stderr
out = json.loads(result.stdout)
assert out["editable"] == [True, False, True, False, False]
assert out["editable"] == [True, False, True, True, False] # 708-2 퇴직공제 요율 칸
assert out["care"] == 13.14 and out["careGone"] is False
assert out["grandBlank"] is None # 요율 지우면 그 줄 · 소계 빈칸
assert out["base"] == 1000 and out["scrap"] == 300
assert out["link"] == {"tab": "total", "key": "safety"}
CURRENT = { # 708-2 현행 비목 — 율 넷 · 공사이행보증 금액 · 불러온 율 표시
**ESTIMATE,
"rates": {
**RATES,
"retirement": 2.3,
"wage_claim": 0.09,
"asbestos": 0.006,
"subcontract": 0.1,
},
"rates_imported": ["retirement", "safety"],
"performance_fee": 1234567,
}
def test_708_2_현행_비목_다섯():
out = compute_total(SUMS, CURRENT)
ln, total_labor, direct = out["lines"], 346800554 + 50286080, 710359136
assert ln["retirement"] == int(346800554 * 2.3 / 100) # 직접노무비 × 율
assert ln["wage_claim"] == int(total_labor * 0.09 / 100) # (직노 + 간노) × 율
assert ln["asbestos"] == int(total_labor * 0.006 / 100)
assert ln["subcontract"] == int(direct * 0.1 / 100) # (재료 + 직노 + 산출경비) × 율
assert ln["performance"] == 1234567 # 금액 손입력
added = sum(ln[k] for k in CURRENT_ONLY)
assert out["subtotals"]["net_cost"] == EXCEL_SUBTOTALS["net_cost"] + added # 나.소계에 듦
assert out["cost"]["expense_sum"] == 277553778 + added # 경비 묶음 안
assert out["cost"]["retirement"] == ln["retirement"]
assert (
out["rates"]["retirement"]["from"] == "import"
and out["rates"]["safety"]["from"] == "import"
)
assert out["rates"]["wage_claim"]["from"] == "user"
# 요율 빈칸 = 해당 없음(0 · 소계 빈칸 아님) — 옛 공사 금액 그대로
old = compute_total(SUMS, ESTIMATE)
assert all(old["lines"][k] == 0 for k in CURRENT_ONLY)
assert old["subtotals"] == EXCEL_SUBTOTALS
def test_708_2_저장_칸():
from B09_Estimation import B09_Estimation_Store as store
raw = {"estimate": {"performance_fee": 1234567, "rates_imported": ["retirement", 3]}}
got = store.clean_user(raw)["estimate"]
assert got["performance_fee"] == 1234567 and got["rates_imported"] == ["retirement", "3"]
+2 -2
View File
@@ -15,7 +15,7 @@ ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from B09_Estimation.B09_Estimation_Calc_Total import compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Calc_Total import CURRENT_ONLY, compute_total # noqa: E402
from B09_Estimation.B09_Estimation_Rates import rate_table # noqa: E402
IMPORTS = re.compile(r"^import[^;]*;\n", re.MULTILINE)
@@ -142,7 +142,7 @@ def test_엑셀_검산_손값이면_그대로():
out = total(SUMS, EXCEL, 2026)
assert out["subtotals"]["grand"] == 1201879000
assert out["lines"]["safety"] == 16586996 and out["lines"]["profit"] == 108109955
assert all(r["from"] == "user" for r in out["rates"].values())
assert all(r["from"] == "user" for k, r in out["rates"].items() if k not in CURRENT_ONLY)
def _node(script: str) -> str:
@@ -47,7 +47,12 @@ def test_파이썬_기본_높이_유입만_0_7():
def test_새_관_기본값_유입_흙막이만_0_7():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 1200})
# 유입 기슭막이 관만 유입 흙막이 칸을 받음 — 기본 집수정이면 안 채움(623)
fresh = PipePoint(
chainage_m=10.0,
source="stream",
options={"pipe_diameter_mm": 1200, "inlet_type": "기슭막이"},
)
basins._apply_new_defaults([fresh])
assert fresh.options["inlet_revet_height_m"] == pytest.approx(1.9)
assert fresh.options["outlet_revet_height_m"] == pytest.approx(1.7)
@@ -310,7 +310,11 @@ def test_한_본문_손_고침은_보낸_칸을_씀(monkeypatch):
pipes={"points": [{"chainage_m": 50.0}]},
structures=SimpleNamespace(),
plus_stations=SimpleNamespace(),
alignment=SimpleNamespace(model_copy=lambda update: SimpleNamespace(**update)),
alignment=SimpleNamespace(
model_copy=lambda update: design_save.ProfileAlignmentSaveRequest.model_construct(
**update
)
),
culvert_options=None,
uphill=None,
cross=None,
+1 -1
View File
@@ -77,7 +77,7 @@ def test_build_detail_uses_memory_routing(tmp_path):
def _patch_live(monkeypatch, preview_fn):
async def prepare(project_id):
return {"contours": [], "streams": [], "contour_key": "k"}
return {"contours": [], "streams": [], "contour_key": "k", "crs": None}
async def load(project_id):
return DrainageContext(stored_path="x", project_root=Path("."), vertices=VERTICES), ""
+4 -2
View File
@@ -173,9 +173,11 @@ def test_map_drawing_colors_fixed_and_readable_on_dark_map():
"""600-1 지도 위 그림 색 — 테마 안 따름(어두움 블록에 없음) · 어두운 바탕 `#1f2937` · 검은 테 둘 다 ≥ 3 : 1."""
blocks = _blocks(THEME.read_text(encoding="utf-8"))
names = [
k for k in blocks["light"] if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d)", k)
k
for k in blocks["light"]
if re.fullmatch(r"--draw-map-(sketch|measure|warn|route-\d|slope-(up|down))", k)
]
assert len(names) == 8, names
assert len(names) == 10, names
ground = _lum("#1f2937")
for name in names:
for mode in ("dark", "auto"):
+17 -6
View File
@@ -35,7 +35,9 @@ def test_d03_parts():
tools = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts")
layers = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
assert 'createElement("progress")' not in ts and "createProgressBar(" not in ts
assert "createProgressCircle({ size: 16, inline: true })" in ts # 604-3 받기 = 위치 칸 제목 서클
assert (
"createProgressCircle({ size: 16, inline: true })" in ts
) # 604-3 받기 = 위치 칸 제목 서클
site = _src(
"D03_PlanAnalysis/D03_PlanAnalysis_UI_Site.ts"
) # 596-2 위치 칸 · 596-5 등록 찾기 = 같은 부품
@@ -52,16 +54,22 @@ def test_d03_parts():
assert "createToolbar(" not in tools and "createIconButton(" not in tools
for act in ("undo", "redo", "view3d", "measure", "stations", "slopes"):
assert f'mapToolButton("{act}"' in tools or f'markButton("{act}"' in tools, act
assert '"rotate-ccw", "rotate-cw"' in tools
table = _src("A00_Common/map/map_toolbar.ts") # 624-2 — 차례 · 보임 = 공용 도구줄 표
assert 'mapToolGroups("d03"' in tools and re.search(r'key: "rotate-ccw".*pages: BOTH', table)
for b05_only in ("footprint", "station-snap", "contour-minor"):
assert f'"{b05_only}"' not in tools, b05_only
paint = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_DrawPaint.ts") # 580 — 그림 나눔
assert "map.stations" in paint and "map.slopes" in paint
# 569-2 · 581-1 · 581-2 — 바탕 겹 단추(공용 지도 도구줄 한 줄 · 묶음 셋) · 처음 켬 셋 · 취향 기억
defs = _src("D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerDefs.ts")
assert "createMapTools({" in layers and "createIconButton({" in layers
assert "createToolbar(" not in layers
assert '"aislo:pref:d03.layers"' in layers and "writeByKey(PREF_KEY" in layers
defs = _src("A00_Common/map/map_layer_list.ts") # 624-2 공용 겹 목록
bar = _src("A00_Common/map/map_layer_bar.ts")
assert (
"createMapTools({" in bar
and "createIconButton({" in bar
and "createMapLayerBar({" in layers
)
assert "createToolbar(" not in layers and "createToolbar(" not in bar
assert '"aislo:pref:d03.layers"' in defs and "writeByKey(MAP_LAYER_PAGES[page].pref" in bar
keys = re.findall(r'key: "(\w+)",\s+icon: "\w+",\s+title:\s+"[^"]+",\s+on: (true|false)', defs)
assert keys == [
("satellite", "true"),
@@ -73,9 +81,12 @@ def test_d03_parts():
("road", "false"),
("forestRoad", "false"),
("forestRoadFile", "false"),
("nationalRoad", "false"),
("cadastral", "false"),
("zones", "false"),
("soils", "false"),
("ecoForest", "false"),
("ecology", "false"),
("landslide", "false"),
("landslideZones", "false"),
("faults", "false"),
+1 -1
View File
@@ -85,7 +85,7 @@ def test_soils_fail_raises(tmp_path, monkeypatch) -> None:
def test_screen_def_and_colors() -> None:
defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2
entry = re.search(r'key: "soils",\s*icon: "(\w+)",[^}]*on: (\w+)', defs)
assert entry and entry.group(2) == "false" # 처음 값 끔
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
@@ -191,7 +191,7 @@ def test_zero_order_no_dem(tmp_path) -> None:
def test_screen_defs_icons_colors() -> None:
defs = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
for key, color in (
@@ -324,7 +324,7 @@ def test_zone_layer_saved_and_no_cadastral(tmp_path, monkeypatch) -> None:
def test_screen_defs() -> None:
folder = ROOT / "D03_PlanAnalysis"
defs = (folder / "D03_PlanAnalysis_UI_LayerDefs.ts").read_text("utf-8")
defs = (ROOT / "A00_Common" / "map" / "map_layer_list.ts").read_text("utf-8") # 624-2
icons = (ROOT / "ui_template" / "ui_template_icons.ts").read_text("utf-8")
theme = (ROOT / "ui_template" / "ui_template_theme.css").read_text("utf-8")
paint = (folder / "D03_PlanAnalysis_UI_LayerPaint.ts").read_text(
@@ -332,6 +332,7 @@ def test_screen_defs() -> None:
) # 586-2 캔버스 그리기 따로
page = (folder / "D03_PlanAnalysis_UI_Layers.ts").read_text("utf-8") + paint
legend = (folder / "D03_PlanAnalysis_UI_LayerLegend.ts").read_text("utf-8")
legend += (ROOT / "A00_Common" / "map" / "map_legend.ts").read_text("utf-8") # 624-3 범례 줄 공용
for key, color in (
("forestRoadFile", "forest-road-file"),
("landslideZones", "landslide-zone"),
@@ -1,5 +1,5 @@
"""D03 급경사 범위(PLAN 603 · 요구 466 · 467 · 468) — 서버 1 % 칸 수 +
`logic/D03_PlanAnalysis_SlopeBands.ts`.
`A00_Common/map/logic/map_slope_bands.ts`(624-3 공용으로 옮김).
1 % 칸 수 합 = 유효 칸 · 기본 범위(26.8 · 70.0 · 83.9 %) 비율 = 옛 15 · 35 · 40° 비율 ·
범위 막음(빈 값 · 0~250 밖 · 아래 ≥ 위 · 겹침 · 없음) · 깨진 취향 = 기본 · 250 % 이상 칸 = 끝 범위.
@@ -22,7 +22,7 @@ from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from E07_StabArea.E07_StabArea_Engine import slope_rad
ROOT = Path(__file__).resolve().parents[2]
TS_FILE = ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_SlopeBands.ts"
TS_FILE = ROOT / "A00_Common" / "map" / "logic" / "map_slope_bands.ts"
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
CELL = 2.0
OLD_DEG = ((15.0, 35.0), (35.0, 40.0), (40.0, 90.0)) # 옛 SLOPE_BANDS
@@ -36,7 +36,7 @@ def _hills(n: int = 300) -> np.ndarray:
_RUNNER = """
import { readFileSync, writeFileSync } from "node:fs";
import * as bands from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_SlopeBands.js";
import * as bands from "./A00_Common/map/logic/map_slope_bands.js";
const cases = JSON.parse(readFileSync(process.argv[2], "utf8"));
const out = { DEFAULT: bands.DEFAULT_SLOPE_BANDS };
// 인자 null = 기본 범위
+2 -2
View File
@@ -9,8 +9,8 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
read = lambda rel: (ROOT / rel).read_text(encoding="utf-8") # noqa: E731
MAPGL = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_MapGl.ts")
LIGHT = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_SatLight.ts")
MAPGL = read("A00_Common/map/map_layer_specs.ts") # 624-2 GL 겹 꼴 = 공용
LIGHT = read("A00_Common/map/map_sat_light.ts") # 624-3 공용
LAYERS = read("D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts")
+2 -1
View File
@@ -94,4 +94,5 @@ def test_screen_uses_fixed_vector_source() -> None:
assert "/plan/contour/{z}/{x}/{y}.pbf" in gl
base = (ROOT / "D03_PlanAnalysis" / "D03_PlanAnalysis_UI_MapGl.ts").read_text(encoding="utf-8")
assert "contour: contourTileSource(projectId)" in base
assert base.count('"source-layer": "contour"') == 3
specs = (ROOT / "A00_Common" / "map" / "map_layer_specs.ts").read_text(encoding="utf-8")
assert specs.count('"source-layer": "contour"') == 3 # 624-2 GL 겹 꼴 = 공용
+4 -3
View File
@@ -8,6 +8,7 @@ from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
D03 = ROOT / "D03_PlanAnalysis"
MAP = ROOT / "A00_Common" / "map" # 624-2 공용 지도 부품
def _read(path: Path) -> str:
@@ -18,7 +19,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None:
gl = _read(ROOT / "ui_template" / "ui_template_gl_map.ts")
assert "export function tileSource" in gl
assert "/plan/${path}/{z}/{x}/{y}.${ext}" in gl
forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts")
forest = _read(MAP / "map_forest_layers.ts")
for name in ("forest_road", "forest_soil", "eco_forest"):
assert f'vt("{name}")' in forest
assert f'...vector("{name}")' in forest or f'"source-layer": "{name}"' in forest
@@ -33,7 +34,7 @@ def test_fixed_sources_follow_plan_622_routes() -> None:
def test_layer_list_five_off_by_default() -> None:
defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts")
defs = _read(MAP / "map_layer_list.ts")
icons = _read(ROOT / "ui_template" / "ui_template_icons.ts")
for key in ("nationalRoad", "soils", "ecoForest", "ecology", "landslide"):
entry = re.search(rf'key: "{key}",\s*icon: "(\w+)",[^}}]*on: (\w+)', defs)
@@ -43,7 +44,7 @@ def test_layer_list_five_off_by_default() -> None:
def test_colors_are_draw_map_tokens() -> None:
theme = _read(ROOT / "ui_template" / "ui_template_theme.css")
forest = _read(D03 / "D03_PlanAnalysis_UI_ForestLayers.ts")
forest = _read(MAP / "map_forest_layers.ts")
for token in set(re.findall(r'"(--draw-map-[\w-]+)"', forest)):
assert f"{token}:" in theme, token
assert all(f"--draw-map-soil-{n}:" in theme for n in range(1, 7))
@@ -0,0 +1,59 @@
"""623 새 관 기본값 — 유입 집수정이면 유입 흙막이 칸 안 채움 · 동물이동형 유(요구 530 E1 · 분석 620).
못박는 것
- 서버가 새로 만든 관(`_apply_new_defaults`) = 집수정 갈래 · `inlet_revet_*` 없음 · `inlet_basin_animal` "true".
- 그 옵션을 공용 명세 폼 `write → read` + 병합(`mergeFacilityOptions`)에 돌리면 차이 0
(지워짐 0 · 더함 0 · 바뀜 0) — 폼을 처음 거쳐도 파일이 안 바뀜(`helper_623_inlet_defaults.cjs`).
- 유입 기슭막이로 받은 관 · 이미 적힌 유입 흙막이 칸은 그대로.
"""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import pytest
from B04_PreProcess import B04_PreProcess_Router_Basins as basins
from common_util.common_util_drainage_pipes import PipePoint
HERE = Path(__file__).parent
ROOT = HERE.parents[1]
def _fresh(**options) -> dict:
pipe = PipePoint(chainage_m=10.0, source="stream", options=options or None)
basins._apply_new_defaults([pipe])
return pipe.options
def test_집수정이면_유입_흙막이_없음_동물이동형_유():
for options in (_fresh(), _fresh(pipe_diameter_mm=800)):
assert options["inlet_type"] == "집수정"
assert not [k for k in options if k.startswith("inlet_revet_")]
assert options["inlet_basin_animal"] == "true" and options["inlet_structure"] == "L"
assert _fresh()["pipe_diameter_mm"] == 1000 # 등록부 글 "1000" → 수(폼과 같음)
def test_기슭막이_관과_적힌_칸은_그대로():
soil = _fresh(inlet_type="기슭막이")
assert soil["inlet_revet_form"] == "돌쌓기(찰)" and soil["inlet_revet_height_m"] == 1.7
assert "inlet_basin_animal" not in soil
kept = _fresh(inlet_revet_form="돌쌓기(메)", inlet_basin_animal="false")
assert kept["inlet_revet_form"] == "돌쌓기(메)" and kept["inlet_basin_animal"] == "false"
def test_서버가_만든_관_폼_왕복_차이_0():
if not (ROOT / "config" / "node_modules").is_dir():
pytest.skip("의존성 미설치")
inputs = [_fresh(), _fresh(pipe_diameter_mm=800), _fresh(pipe_diameter_mm=1200)]
done = subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(HERE / "helper_623_inlet_defaults.cjs")],
input=json.dumps(inputs, ensure_ascii=False),
capture_output=True,
text=True,
encoding="utf-8",
)
assert done.returncode == 0, done.stderr
assert json.loads(done.stdout) == [{"removed": [], "added": [], "changed": []}] * 3
@@ -0,0 +1,196 @@
"""D03 지도 겹 길 — 임도 프로젝트도 받음 · 지형 원천 인자 · 받은 겹 자리(요구 530 · PLAN 624-1).
- 예정노선(D) · 임도(B) 둘 다 급경사 · 음영 · 지적 받음 · 원천 머리(`source`)
- 원천별 파생 저장본 열쇠 다름 · 없는 원천 404 · 틀린 원천 400
- 받은 겹 = `D03_PlanAnalysis/layers/` — B04 `processed/` 파일 안 덮음 · 새 파일 0
- D 옛 자리 파일은 한 번 복사(다시 안 받음) · D 급경사 값 = 옛 길 값
- 라이다 0차곡 = 격자 통째 · 저장본
./venv/Scripts/python.exe -m pytest resources/tester/test_624_1_layers_any_project.py -q
"""
import json
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
import numpy as np
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Router as router_mod
from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source
from D03_PlanAnalysis import D03_PlanAnalysis_ZeroOrder as zero_order
PID = "12953f5a-5ba1-42ad-a427-8ac857a58bc2"
EPSG = 5186
X0, Y0 = 200000.0, 450000.0
BOX = (X0, Y0, X0 + 400.0, Y0 + 400.0)
B04_CADASTRAL = b'{"type": "FeatureCollection", "features": [], "b04": true}'
def _dem(path, tilt: float, n: int = 60, cell: float = 2.0) -> None:
x = X0 + np.arange(n) * cell
y = Y0 + np.arange(n) * cell
z = np.add.outer(np.zeros(n), np.arange(n) * cell * tilt)
path.parent.mkdir(parents=True, exist_ok=True)
np.savez(path, x=x, y=y, z=z, valid_mask=np.ones(z.shape, bool), resolution=[cell])
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
class FakeWfs:
def __init__(self) -> None:
self.calls = 0
def __call__(self, box, epsg, typename):
self.calls += 1
lon, lat = 127.0, 37.0
ring = [[lon, lat], [lon + 1e-4, lat], [lon + 1e-4, lat + 1e-4], [lon, lat]]
geom = {"type": "Polygon", "coordinates": [ring]}
return [{"type": "Feature", "geometry": geom, "properties": {"pnu": str(box)}}]
@pytest.fixture
def world(tmp_path, monkeypatch):
"""D 프로젝트(수치지형도) · B 프로젝트(라이다 csf 확정 + 수치지형도 서피스 · B04 지적도)."""
import common_util.common_util_crs as crs
import common_util.common_util_surface_confirmation as confirm
import config.config_db as config_db
d_root, b_root = tmp_path / "D", tmp_path / "B"
_dem(d_root / "B04_PreProcess" / "models_plan" / "dtm_sheet_laplace.npz", 0.3)
b_models = b_root / "B04_PreProcess" / "models"
_dem(b_models / "dtm_csf.npz", 0.6)
_dem(b_models / "dtm_csf_smooth.npz", 0.5)
_dem(b_models / "dtm_sheet_laplace_smooth.npz", 0.2)
processed = b_root / "B04_PreProcess" / "processed"
processed.mkdir(parents=True)
(processed / layers.CADASTRAL_FILE).write_bytes(B04_CADASTRAL)
meta = {"x_min": BOX[0], "y_min": BOX[1], "x_max": BOX[2], "y_max": BOX[3]}
(processed / "vworld_white_meta.json").write_text(json.dumps(meta), "utf-8")
kinds = {"D": "PLAN", "B": "ROAD"}
state = {"pid": "D"}
params = {"source_filter": "csf", "method": "dtm", "smooth": True}
async def project_row(project_id):
return {"project_kind": kinds[state["pid"]], "storage_path": state["pid"]}
async def plan_ends(project_id, center=None, reset=False):
return d_root, EPSG, [], None
async def surface_params(connection, project_id):
return dict(params)
monkeypatch.setattr(router_mod.store, "project_row", project_row)
monkeypatch.setattr(router_mod, "_plan_ends", plan_ends)
monkeypatch.setattr(router_mod.terrain, "received_box", lambda root, epsg: BOX)
monkeypatch.setattr(source, "resolve_stored_project_path", lambda p: str(tmp_path / p))
monkeypatch.setattr(confirm, "get_surface_confirmation_params", surface_params)
monkeypatch.setattr(config_db, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(crs, "resolve_project_crs", lambda root: f"EPSG:{EPSG}")
wfs = FakeWfs()
monkeypatch.setattr(layers, "_wfs", wfs)
monkeypatch.setattr(zero_order, "heavy_pool", lambda: ThreadPoolExecutor(1))
app = FastAPI()
app.include_router(router_mod.router)
client = TestClient(app)
def get(pid: str, path: str, **query):
state["pid"] = pid
return client.get(f"/api/projects/{PID}/plan/layers/{path}", params=query)
return {"get": get, "D": d_root, "B": b_root, "wfs": wfs, "params": params}
def _raster(res) -> dict:
assert res.status_code == 200, res.text
return json.loads(res.headers["X-Plan-Raster"])
def test_both_kinds_get_slope_with_source(world) -> None:
get = world["get"]
assert _raster(get("D", "raster", kind="slope"))["source"] == "sheet"
assert _raster(get("B", "raster", kind="slope"))["source"] == "lidar" # 설계 지형 = 라이다
assert _raster(get("B", "raster", kind="slope", terrain="sheet"))["source"] == "sheet"
shade = get("B", "raster", kind="hillshade", terrain="lidar").json()
assert shade["source"] == "lidar" and len(shade["box"]) == 4
assert get("D", "raster", kind="slope", terrain="lidar").status_code == 404 # 624-4 전
assert get("B", "raster", kind="slope", terrain="las").status_code == 400
def test_source_keys_differ(world) -> None:
get = world["get"]
for terrain in ("lidar", "sheet", "design"):
get("B", "raster", kind="slope", terrain=terrain)
saved = sorted((world["B"] / "_derived" / layers.SLOPE_KIND).glob("*.json"))
assert len(saved) == 2 # design = lidar 같은 격자 → 같은 열쇠
lidar = _raster(get("B", "raster", kind="slope", terrain="lidar"))
sheet = _raster(get("B", "raster", kind="slope", terrain="sheet"))
assert lidar["counts"] != sheet["counts"]
def test_road_lidar_missing_when_sheet_only(world) -> None:
world["params"].update(source_filter="sheet_laplace") # 라이다 없는 임도
assert get_status(world, "lidar") == 404
assert get_status(world, "design") == 200
def get_status(world, terrain: str) -> int:
return world["get"]("B", "raster", kind="slope", terrain=terrain).status_code
def test_road_layers_never_touch_b04_files(world) -> None:
processed = world["B"] / "B04_PreProcess" / "processed"
before = {p.name: p.read_bytes() for p in processed.iterdir()}
res = world["get"]("B", "cadastral")
assert res.status_code == 200 and res.json()["features"]
after = {p.name: p.read_bytes() for p in processed.iterdir()}
assert after == before # 같은 이름 덮음 0 · 새 파일 0
mine = world["B"] / source.LAYER_DIR / layers.CADASTRAL_FILE
assert mine.is_file() and mine.read_bytes() != B04_CADASTRAL
def test_plan_old_files_copied_once(world) -> None:
"""D 옛 자리(B04 processed) 지적도 + 열쇠 = 새 자리로 복사 · 다시 받지 않음 · 값 같음."""
old = world["D"] / "B04_PreProcess" / "processed"
old.mkdir(parents=True)
body = {"type": "FeatureCollection", "features": [{"type": "Feature", "properties": {}}]}
(old / layers.CADASTRAL_FILE).write_text(json.dumps(body), "utf-8")
key = {"box": list(BOX), "epsg": EPSG, "count": 1, "failed": 0, "failed_tiles": []}
(old / layers.CADASTRAL_KEY).write_text(json.dumps(key), "utf-8")
res = world["get"]("D", "cadastral")
assert res.status_code == 200 and res.json() == body and world["wfs"].calls == 0
assert (world["D"] / source.LAYER_DIR / layers.CADASTRAL_KEY).is_file()
def test_plan_slope_same_as_before(world, tmp_path) -> None:
"""D 급경사 = 옛 길(`slope_layer(root, models_plan, epsg)`) 값 그대로."""
res = world["get"]("D", "raster", kind="slope")
meta = _raster(res)
old = layers.slope_layer(world["D"], world["D"] / "B04_PreProcess" / "models_plan", EPSG)
png, old_meta = layers.raster_bytes(old)
assert res.content == png and meta == {"kind": "slope", "source": "sheet", **old_meta}
def test_lidar_zero_order_whole_grid(world) -> None:
"""라이다 0차곡 = 격자 통째(1 m → 2 m 솎음) · 저장본 · 수치지형도 = 창고 길."""
n, cell = 200, 1.0
x, y = X0 + np.arange(n) * cell, Y0 + np.arange(n) * cell
valley = np.abs(x - x.mean())[None, :] * 0.3 + (y - Y0)[:, None] * 0.2
npz = world["B"] / "B04_PreProcess" / "models" / "dtm_csf_smooth.npz"
np.savez(npz, x=x, y=y, z=valley, valid_mask=np.ones(valley.shape, bool), resolution=[cell])
res = world["get"]("B", "zero-order", terrain="lidar")
got = res.json()
assert res.status_code == 200 and got["source"] == "lidar" and got["cell_m"] == 2.0
assert got["count"] >= 1 and got["length_m"] > 0
again = zero_order.dem_zero_order_layer(world["B"], npz, EPSG, compute=False)
assert again is not None and again["count"] == got["count"]
frame = source.plan_frame(world["D"], EPSG, BOX, None)
work, light, _ = source.zero_order_calls(frame, 1.0, 2.0)
assert work is zero_order.zero_order_layer and light[-1] == frame.dem
+84
View File
@@ -0,0 +1,84 @@
"""PLAN 624-2 — 공용 지도 부품(A00_Common/map) 지킴: 도구줄 표 · 겹 목록 페이지 표 · 범례 · 지형 칩 · D03 은 부르기만.
./venv/Scripts/python.exe -m pytest resources/tester/test_624_2_map_parts.py -q
"""
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
MAP = ROOT / "A00_Common" / "map"
D03 = ROOT / "D03_PlanAnalysis"
def _read(path: Path) -> str:
return path.read_text(encoding="utf-8")
def _toolbar_rows() -> list[list[tuple[str, set[str]]]]:
"""표 → 묶음마다 (열쇠, 보일 페이지)."""
src = _read(MAP / "map_toolbar.ts")
pages = {"BOTH": {"d03", "b05"}, "B05": {"b05"}}
body = src[src.index("MAP_TOOLBAR") : src.index("const isAct")]
groups = re.findall(r"\[\s*(\{.*?\})\s*,?\s*\]", body, re.S)
return [
[(k, pages[p]) for k, p in re.findall(r'key: "([\w-]+)".*?pages: (\w+)', g, re.S)]
for g in groups
]
def test_toolbar_table_d03_same_as_before_plus_terrain() -> None:
d03 = [[k for k, p in g if "d03" in p] for g in _toolbar_rows()]
d03 = [g for g in d03 if g]
# 옛 D03 도구줄 세 묶음 그대로 + 끝에 지형 칩
assert d03 == [
["undo", "redo"],
["view3d", "rotate-ccw", "rotate-cw"],
["stations", "slopes", "measure"],
["terrain"],
], d03
b05 = {k for g in _toolbar_rows() for k, p in g if "b05" in p}
assert {"contour-minor", "footprint", "station-snap", "snap-step", "terrain"} <= b05
tools = _read(ROOT / "ui_template" / "ui_template_map_tools.ts")
for key in {k for g in _toolbar_rows() for k, _ in g} - {"snap-step", "terrain"}:
assert f'"{key}": {{' in tools or f"{key}: {{" in tools, key # 아이콘 · 이름 = MAP_TOOLS
def test_layer_pages() -> None:
src = _read(MAP / "map_layer_list.ts")
assert 'd03: { hide: [], pref: "aislo:pref:d03.layers" }' in src
b05 = re.search(r"b05: \{(.*?)\},", src, re.S).group(1)
assert 'hide: ["bands", "forestRoadFile"]' in b05 # A⑨
assert 'on: ["contour", "stream"]' in b05 # A② 지금 그대로
assert "for (const key of hide) on[key] = false;" in src
def test_common_parts_use_kit_and_no_page_imports() -> None:
for path in MAP.glob("*.ts"):
src = _read(path)
assert len(src.splitlines()) <= 700, path.name
for page in ("D03_PlanAnalysis", "B05_Profile", "B04_PreProcess"):
assert f"../../{page}" not in src and f"../{page}" not in src, (path.name, page)
for raw in ('createElement("button")', 'createElement("input")', 'createElement("select")'):
assert raw not in src, (path.name, raw)
chip = _read(MAP / "map_terrain_chip.ts")
assert "createSegment({" in chip and 'root.dataset.act = "terrain"' in chip
assert "createLegend(row)" in _read(MAP / "map_legend.ts")
assert "createMapTools({" in _read(MAP / "map_layer_bar.ts")
def test_d03_calls_common_only() -> None:
layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts")
assert (
'createMapLayerBar({\n page: "d03"' in layers
and 'createMapLegend("d03-plan__legend")' in layers
)
assert len(layers.splitlines()) < 683 # 줄이기만
gl = _read(D03 / "D03_PlanAnalysis_UI_MapGl.ts")
assert "mapLayerSpecs(light, pick)" in gl and "function haloLine" not in gl
defs = _read(D03 / "D03_PlanAnalysis_UI_LayerDefs.ts")
assert "export const GROUPS" not in defs and "readPref" not in defs
ui_map = _read(D03 / "D03_PlanAnalysis_UI_Map.ts")
assert 'mapToolGroups("d03", {' in ui_map
assert 'createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] })' in ui_map
assert not (D03 / "D03_PlanAnalysis_UI_ForestLayers.ts").exists()
+128
View File
@@ -0,0 +1,128 @@
"""PLAN 624-4 — D 예정노선 라이다: LAS 올리기 → B04 엔진 지형 → `terrain=lidar` 겹(요구 530 · 531).
D 몫 길 = `D03_PlanAnalysis_Lidar` · B04 엔진은 `maps=False` 인자만 덧(기본 = B 그대로).
"""
from __future__ import annotations
from pathlib import Path
import numpy as np
import pytest
from fastapi import HTTPException
from D03_PlanAnalysis import D03_PlanAnalysis_Layers as layers
from D03_PlanAnalysis import D03_PlanAnalysis_Lidar as lidar
from D03_PlanAnalysis import D03_PlanAnalysis_TerrainSource as source
D_EPSG, LAS_EPSG = 5187, 5176 # D = GRS80 동부 · 업체 LAS = 베셀 동부(검증 용화와 같음)
def _write_las(path: Path, epsg: int | None, x0: float, y0: float) -> None:
"""두 덩이(가운데 빈 땅) 지면점 LAS — 비탈 0.3."""
import laspy
from pyproj import CRS
rng = np.random.default_rng(7)
xs, ys = [], []
for dx in (0.0, 160.0):
xs.append(rng.uniform(x0 + dx, x0 + dx + 60, 6000))
ys.append(rng.uniform(y0, y0 + 60, 6000))
x, y = np.concatenate(xs), np.concatenate(ys)
header = laspy.LasHeader(point_format=3, version="1.2")
header.offsets = [round(x0), round(y0), 0]
header.scales = [0.01, 0.01, 0.01]
if epsg is not None:
header.add_crs(CRS.from_epsg(epsg))
data = laspy.LasData(header)
data.x, data.y, data.z = x, y, 500 + 0.3 * (x - x0)
data.classification = np.full(len(x), 2, np.uint8)
path.parent.mkdir(parents=True, exist_ok=True)
data.write(path)
def _built(tmp_path: Path, monkeypatch, epsg: int | None = LAS_EPSG) -> dict: # type: ignore[no-untyped-def]
import B04_PreProcess.B04_PreProcess_Engine as engine
called: list[int] = []
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: called.append(1))
_write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "t.las", epsg, 208300.0, 367500.0)
got = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None)
assert called == [] # 지도 · GIS 받기 안 함
return got
def test_이름_고르기() -> None:
lidar.check_names(["a.las", "a.prj", "b.LAZ"])
for names in (["a.prj"], ["a.las", "a.txt"], ["a.las", "A.las"]):
with pytest.raises(HTTPException) as caught:
lidar.check_names(names)
assert caught.value.status_code == 400
def test_올리기_뒤_라이다_지형_B_꼴(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
got = _built(tmp_path, monkeypatch)
meta = got["meta"]
dem = lidar.lidar_dem(tmp_path)
assert dem is not None and dem.name == f"dtm_{meta['source_filter']}.npz"
assert dem.parent == lidar.lidar_root(tmp_path) / "B04_PreProcess" / "models"
assert meta["files"] == ["t.las"] and meta["point_count"] == 12000
assert not (lidar.lidar_root(tmp_path) / lidar.UPLOAD).exists()
# surface_models 행 경로 = 프로젝트 뿌리 기준
from B04_PreProcess.B04_PreProcess_Repository import _validate_stage_path
assert any(m["model_file_path"] == meta["model_file"] for m in got["models"])
assert all(_validate_stage_path(m["model_file_path"]) for m in got["models"])
# 좌표계 옮김 — 베셀 → GRS80 북 +10 만 m 언저리
from pyproj import Transformer
want = Transformer.from_crs(LAS_EPSG, D_EPSG, always_xy=True).transform(208300.0, 367500.0)
assert abs(meta["bounds"]["x_min"] - want[0]) < 1.0
assert abs(meta["bounds"]["y_min"] - want[1]) < 1.0
# 다시 올리면 갈음
_write_las(lidar.lidar_root(tmp_path) / lidar.UPLOAD / "u.las", None, 208300.0, 467500.0)
again = lidar.build_lidar(tmp_path, D_EPSG, lambda *_: None)["meta"]
assert again["files"] == ["u.las"] and abs(again["bounds"]["y_min"] - 467500.0) < 1.0
def test_terrain_lidar_급경사_0차곡_범위_밖_빈칸(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
with pytest.raises(HTTPException) as caught: # 안 올림 = 404
source.need_dem(source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar"))
assert caught.value.status_code == 404
_built(tmp_path, monkeypatch)
frame = source.plan_frame(tmp_path, D_EPSG, (0, 0, 1, 1), "lidar")
npz = source.need_dem(frame)
assert frame.source == "lidar" and npz == lidar.lidar_dem(tmp_path)
value = layers.slope_layer(tmp_path, npz.parent, D_EPSG, "slope", npz)
counts = value["counts"]
assert sum(counts) > 0 and max(range(len(counts)), key=counts.__getitem__) in (29, 30)
# 두 덩이 사이 = 빈칸(수치지형도로 안 메움)
with np.load(npz) as dem:
valid = np.asarray(dem["valid_mask"], bool)
x = np.asarray(dem["x"], float)
x0 = float(x[0])
gap = (x > x0 + 90) & (x < x0 + 130)
assert gap.any() and not valid[:, gap].any()
work, light, full = source.zero_order_calls(frame, 50.0, 5000.0)
assert work(*light) is None # 저장본 없음
value = work(*full)
assert value["cell_m"] > 0 and work(*light) == value # 다음 = 저장본
def test_B04_기본은_지도_받기_그대로(tmp_path: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""B 프로젝트 전처리(기본 인자) = 지도 · GIS 받기 · 도엽 서피스 그대로 부름."""
import B04_PreProcess.B04_PreProcess_Engine as engine
import B04_PreProcess.B04_PreProcess_Engine_SheetSurface as sheet
calls: list[str] = []
monkeypatch.setattr(engine, "download_geodata", lambda *a, **k: calls.append("maps"))
monkeypatch.setattr(
sheet, "build_sheet_surface_from_route", lambda *a, **k: calls.append("sheet") or []
)
las = tmp_path / "B03_FileInput" / "input" / "las" / "t.las"
_write_las(las, None, 200000.0, 500000.0)
engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"])
assert calls == ["maps", "sheet"]
calls.clear()
engine.run_surface_analysis(tmp_path, las, source_filters=None, methods=["dtm"], maps=False)
assert calls == []
+161
View File
@@ -0,0 +1,161 @@
"""D03 길이·기울기 고정 부채꼴 셈(PLAN 625-2 · 요구 530 · 531 B1~B7) — TS `logic/D03_PlanAnalysis_Fan.ts` 순수 시험.
화면 고르기 전용(파이썬 짝 없음) — tsc 로 옮겨 Node 로 돌림.
- 경사면 65 % · 반지름 20 m · 9 % = 노드 4 · 호 2 · 호마다 + 하나 − 하나 · 부채꼴 폭 ≈ 2·arcsin(9/65).
- 꽃잎 땅 cos(nθ) = 노드 2 · 4 · 6 · 8 · 호 n.
- 정방향 = 앞 구간 쪽 호 · 첫 구간 없음 · 거의 같으면 마우스 쪽.
- 완만한 땅 = 노드 0 · 어느 방향이나 · Shift 사이 = 길이 그대로 · 기울기 = 실제 지형 · 호 밖 = 호 끝.
./venv/Scripts/python.exe -m pytest resources/tester/test_625_draw.py -q
"""
from __future__ import annotations
import json
import math
import re
import subprocess
from pathlib import Path
import pytest
ROOT = Path(__file__).resolve().parents[2]
TS_FILES = [
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Draw.ts",
ROOT / "D03_PlanAnalysis" / "logic" / "D03_PlanAnalysis_Fan.ts",
]
TSC = ROOT / "config" / "node_modules" / "typescript" / "bin" / "tsc"
_RUNNER = """
import { writeFileSync } from "node:fs";
import * as fan from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Fan.js";
import { groundSlopePct, segmentLength } from "./D03_PlanAnalysis/logic/D03_PlanAnalysis_Draw.js";
const L = 20, S = 9, O = [0, 0];
const plane = (x, y) => 0.65 * x;
const gentle = (x, y) => 0.01 * x;
// 꽃잎 — 원 위 높이 차 = 10·(cos nθ − 1)/2 · 시작점 0
const petal = (n) => (x, y) => (Math.hypot(x, y) / L) * 10 * (Math.cos(n * Math.atan2(y, x)) - 1) / 2;
const at = (deg) => [Math.cos((deg * Math.PI) / 180) * 50, Math.sin((deg * Math.PI) / 180) * 50];
const deg = (p) => ((Math.atan2(p[1], p[0]) * 180) / Math.PI + 360) % 360;
const view = (f) => ({
nodes: f.nodes.map((n) => ({ deg: (n.angle * 180) / Math.PI, sign: n.sign })),
arcs: f.arcs.map((a) => ({ from: (a.from * 180) / Math.PI, to: (a.to * 180) / Math.PI, forward: a.forward })),
flat: f.flat,
});
const pick = (f, z, p) => p && { deg: deg(p), length: segmentLength(O, p), slope: groundSlopePct(O, p, z) };
const out = {};
// 경사면 — 앞 구간 북쪽으로(앞 점 = 남쪽) · 첫 구간 · 동쪽(오르막) 앞 구간 = 마우스 쪽
out.plane = view(fan.compassFan(O, L, S, plane, [0, -20]));
out.planeFirst = view(fan.compassFan(O, L, S, plane, null));
out.planeTieSouth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(-80)));
out.planeTieNorth = view(fan.compassFan(O, L, S, plane, [-20, 0], at(80)));
const f = fan.compassFan(O, L, S, plane, [0, -20]);
out.between = pick(f, plane, fan.fanBetween(f, O, at(93), L));
out.betweenOut = pick(f, plane, fan.fanBetween(f, O, at(60), L));
out.pick = pick(f, plane, fan.fanPick(f, O, at(60), L));
out.pickReverse = pick(f, plane, fan.fanPick(f, O, at(-95), L));
for (const n of [1, 2, 3, 4]) out[`petal${n}`] = view(fan.compassFan(O, L, S, petal(n), null));
const g = fan.compassFan(O, L, S, gentle, [0, -20]);
out.gentle = view(g);
out.gentlePick = pick(g, gentle, fan.fanPick(g, O, at(200), L));
out.gentleBetween = pick(g, gentle, fan.fanBetween(g, O, at(200), L));
out.steep = view(fan.compassFan(O, L, S, (x, y) => 0.5 * Math.hypot(x, y), null));
out.offTerrain = view(fan.compassFan(O, L, S, () => NaN, null));
writeFileSync(process.argv[2], JSON.stringify(out));
"""
@pytest.fixture(scope="module")
def r(tmp_path_factory: pytest.TempPathFactory) -> dict:
tmp = tmp_path_factory.mktemp("fan")
out = tmp / "js"
subprocess.run( # noqa: S603 — 고정 실행 파일
["node", str(TSC), *map(str, TS_FILES), "--outDir", str(out), "--rootDir", str(ROOT)]
+ ["--module", "esnext", "--target", "es2022", "--moduleResolution", "bundler"]
+ ["--ignoreConfig"],
cwd=str(ROOT),
check=True,
capture_output=True,
)
for emitted in out.rglob("*.js"):
emitted.write_text(
re.sub(r'(from\s+"\.\.?/[^"]+)"', r'\1.js"', emitted.read_text(encoding="utf-8")),
encoding="utf-8",
)
(out / "runner.mjs").write_text(_RUNNER, encoding="utf-8")
result = tmp / "out.json"
subprocess.run( # noqa: S603
["node", str(out / "runner.mjs"), str(result)],
cwd=str(ROOT),
check=True,
capture_output=True,
)
return json.loads(result.read_text(encoding="utf-8"))
def _inside(arc: dict, deg: float) -> bool:
return (deg - arc["from"]) % 360 <= arc["to"] - arc["from"]
def test_경사면_노드_넷_호_둘_부호_짝(r):
f = r["plane"]
assert len(f["nodes"]) == 4 and len(f["arcs"]) == 2 and not f["flat"]
width = 2 * math.degrees(math.asin(9 / 65))
by_deg = {round(n["deg"], 3): n["sign"] for n in f["nodes"]}
for arc in f["arcs"]:
assert abs((arc["to"] - arc["from"]) - width) < 0.05
ends = {by_deg[round(arc["from"], 3)], by_deg[round(arc["to"] % 360, 3)]}
assert ends == {1, -1} # 호마다 + 하나 − 하나
assert _inside(arc, 90) or _inside(arc, 270) # 등고선 방향
# + = 오르막(동쪽 반) · − = 서쪽 반
for n in f["nodes"]:
assert (n["sign"] == 1) == (math.cos(math.radians(n["deg"])) > 0)
def test_정방향_앞_구간_쪽_첫_구간_없음_같으면_마우스_쪽(r):
forward = [a for a in r["plane"]["arcs"] if a["forward"]]
assert len(forward) == 1 and _inside(forward[0], 90)
assert not any(a["forward"] for a in r["planeFirst"]["arcs"])
south = [a for a in r["planeTieSouth"]["arcs"] if a["forward"]]
north = [a for a in r["planeTieNorth"]["arcs"] if a["forward"]]
assert _inside(south[0], 270) and _inside(north[0], 90)
@pytest.mark.parametrize("n", [1, 2, 3, 4])
def test_꽃잎_땅_노드_2_4_6_8(r, n):
f = r[f"petal{n}"]
assert len(f["nodes"]) == 2 * n and len(f["arcs"]) == n
for k in range(n): # 허용 호 = 꽃잎 꼭대기(cos nθ = 1)마다
assert sum(_inside(a, 360 * k / n) for a in f["arcs"]) == 1
assert all(node["sign"] == -1 for node in f["nodes"])
def test_shift_사이_길이_그대로_기울기_실제_지형(r):
b = r["between"]
assert abs(b["deg"] - 93) < 1e-6 and abs(b["length"] - 20) < 1e-9
assert abs(b["slope"] - 65 * math.cos(math.radians(93))) < 1e-6 and abs(b["slope"]) < 9
# 호 밖 = 가까운 호 끝(= ±9 % 노드)
o = r["betweenOut"]
assert abs(o["length"] - 20) < 1e-9 and abs(abs(o["slope"]) - 9) < 0.01
assert abs(o["deg"] - (90 - math.degrees(math.asin(9 / 65)))) < 0.05
def test_보통_고르기_가까운_노드_역방향도(r):
assert abs(abs(r["pick"]["slope"]) - 9) < 0.01
rev = r["pickReverse"]
assert abs(abs(rev["slope"]) - 9) < 0.01 and 260 < rev["deg"] < 280 # 역방향 호 노드도 고름
def test_완만한_땅_어느_방향이나(r):
g = r["gentle"]
assert g["flat"] and not g["nodes"] and not g["arcs"]
for key in ("gentlePick", "gentleBetween"):
p = r[key]
assert abs(p["deg"] - 200) < 1e-6 and abs(p["length"] - 20) < 1e-9
assert abs(p["slope"] - 1 * math.cos(math.radians(200))) < 1e-6
def test_가파른_땅_지형_밖_고를_자리_없음(r):
for key in ("steep", "offTerrain"):
f = r[key]
assert not f["flat"] and not f["nodes"] and not f["arcs"]
+221
View File
@@ -0,0 +1,221 @@
"""626-2 예정노선 다시 추적 `POST /route/follow` · 고친 예정노선 `expected_route_edit.csv`.
요구 530 D3 · D4 · D8. 못박는 것
- follow = 체인 초기 폴리라인(`_ensure_planned_initial` 파일)과 같은 입력이면 같은 선 · 노드 ·
곡선 · 편집표.
- follow 는 아무것도 안 씀(프로젝트 폴더 그대로) · 경위도 입력 = TM 입력과 같은 선.
- [저장](노선 초안 `expected`) = `expected_route_edit.csv` 씀 · `expected_route.csv` ·
초기 폴리라인 · `initial_snapshot/` 그대로 · 없음 = 그대로 · [] = 지움 ·
GET `/route/plan` `expected_edited`.
- [초기화] = 고친 예정노선 지움(스냅샷에 섞여 있어도).
- follow 길은 `/route/replan` · `as_initial` 을 안 부름.
"""
from __future__ import annotations
import asyncio
import csv
import hashlib
import json
import math
from contextlib import asynccontextmanager
from pathlib import Path
from uuid import uuid4
import pytest
from B05_Profile import B05_Profile_Router_Replan as replan
from B05_Profile import B05_Profile_Router_RouteFollow as follow
from common_util.common_util_crs import tm_to_lonlat
from common_util.common_util_initial_snapshot import snapshot_dir
from common_util.common_util_route_geometry import (
expected_route_csv_path,
expected_route_edit_path,
planned_route_initial_path,
write_route_csv,
)
ROOT = Path(__file__).resolve().parents[2]
RADIUS_M = 30.0
EPSG = 5186
# 예정노선 점 묶음 꼴(3 m 간격 · 굽은 산길) — 사업지 TM
LINE = [(200000.0 + 3 * i, 450000.0 + 40 * math.sin(i / 20)) for i in range(160)]
def _patch(monkeypatch, root: Path) -> None:
async def paths(_pid):
return root, "stored"
async def criteria(_pid):
return RADIUS_M, 0.0, 0.0
monkeypatch.setattr(replan, "_project_paths", paths)
monkeypatch.setattr(replan, "_plan_criteria", criteria)
monkeypatch.setattr(follow, "_project_paths", paths)
monkeypatch.setattr(follow, "_plan_criteria", criteria)
def _rows(path: Path) -> list[list[float]]:
with path.open(encoding="utf-8") as file:
return [[float(r["x"]), float(r["y"])] for r in csv.DictReader(file)]
def _tree(root: Path) -> dict[str, str]:
return {
str(p.relative_to(root)): hashlib.sha1(p.read_bytes()).hexdigest()
for p in sorted(root.rglob("*"))
if p.is_file()
}
def test_follow_은_체인_초기선과_같은_선(tmp_path: Path) -> None:
write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE])
assert replan._ensure_planned_initial(tmp_path, RADIUS_M)
target = planned_route_initial_path(tmp_path)
table = json.loads(replan._curves_path(target).read_text(encoding="utf-8"))
# 같은 입력 = 체인이 읽는 예정노선 파일 점(csv 4자리)
same = [tuple(p) for p in replan._vertices_of(expected_route_csv_path(tmp_path))]
line = follow.follow_route_line(same, RADIUS_M)
assert line["planned"] == _rows(target)
assert [[round(n["x"], 4), round(n["y"], 4)] for n in line["nodes"]] == _rows(
replan._nodes_path(target)
)
assert line["curves"] == table["curves"] and line["curves"]
assert line["node_edits"] == table.get("edits")
assert line["station_snap"] is True and table["station_snap"] is True
def test_follow_은_안_씀_경위도도_같은_선(tmp_path: Path, monkeypatch) -> None:
_patch(monkeypatch, tmp_path)
(tmp_path / "keep.txt").write_text("x", encoding="utf-8")
before = _tree(tmp_path)
body = follow.RouteFollowRequest(points=LINE)
tm = asyncio.run(follow.follow_route(uuid4(), body))
assert tm["status"] == "success" and len(tm["nodes"]) >= 3
class _Pool:
@asynccontextmanager
async def acquire(self):
yield None
async def epsg(*_a):
return EPSG
monkeypatch.setattr(follow, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(follow, "get_surface_crs_epsg", epsg)
lonlat = [tm_to_lonlat(x, y, EPSG) for x, y in LINE]
geo = asyncio.run(
follow.follow_route(uuid4(), follow.RouteFollowRequest(points=lonlat, crs="lonlat"))
)
assert len(geo["nodes"]) == len(tm["nodes"])
assert all(math.dist(a, b) < 0.01 for a, b in zip(geo["planned"], tm["planned"], strict=True))
assert _tree(tmp_path) == before # 저장 0
short = asyncio.run(follow.follow_route(uuid4(), follow.RouteFollowRequest(points=LINE[:1])))
assert short.status_code == 400
def _save(root: Path, expected) -> None:
nodes = [LINE[0], LINE[80], LINE[-1]]
draft = {"nodes": nodes, "curveOn": [True] * 3, "stationSnap": True, "expected": expected}
body = replan.RouteSaveRequest(vertices=[], draft=draft)
out = asyncio.run(replan.replan_route(uuid4(), body))
assert out["status"] == "success"
def test_저장은_고친_예정노선만_씀(tmp_path: Path, monkeypatch) -> None:
_patch(monkeypatch, tmp_path)
async def recompute(*_a, **_k):
return {"route_id": 1}
monkeypatch.setattr(replan, "_recompute", recompute)
write_route_csv(expected_route_csv_path(tmp_path), [{"x": x, "y": y} for x, y in LINE])
replan._ensure_planned_initial(tmp_path, RADIUS_M)
(snapshot_dir(tmp_path) / "B05_Profile__route").mkdir(parents=True)
(snapshot_dir(tmp_path) / "B05_Profile__route" / "planned_route.csv").write_text("x")
keep = [expected_route_csv_path(tmp_path), planned_route_initial_path(tmp_path)]
fixed = {p: p.read_bytes() for p in keep}
shot = _tree(snapshot_dir(tmp_path))
edited = [list(p) for p in LINE[::10]]
_save(tmp_path, edited)
assert _rows(expected_route_edit_path(tmp_path)) == [
[round(x, 4), round(y, 4)] for x, y in edited
]
assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == _rows(
expected_route_edit_path(tmp_path)
)
_save(tmp_path, None) # 없음 = 그대로
assert expected_route_edit_path(tmp_path).is_file()
_save(tmp_path, []) # 지움
assert not expected_route_edit_path(tmp_path).exists()
assert asyncio.run(replan.read_route_plan(uuid4()))["expected_edited"] == []
assert {p: p.read_bytes() for p in keep} == fixed
assert _tree(snapshot_dir(tmp_path)) == shot # 초기값 그대로
def test_초기화는_고친_예정노선_지움(tmp_path: Path, monkeypatch) -> None:
from B05_Profile import B05_Profile_Router_Corridor as corridor
from B05_Profile import B05_Profile_Router_Lifecycle as lifecycle
from common_util import common_util_initial_snapshot as snapshot
class _Cursor:
rowcount = 1
async def __aenter__(self):
return self
async def __aexit__(self, *_):
return False
async def execute(self, *_a):
pass
class _Connection:
def cursor(self, *_a, **_k):
return _Cursor()
async def begin(self):
pass
async def commit(self):
pass
async def rollback(self):
pass
class _Pool:
@asynccontextmanager
async def acquire(self):
yield _Connection()
def _async(value):
async def run(*_a, **_k):
return value
return run
# 추천 [적용]이 고친 예정노선째 스냅샷을 뜬 경우도 — 복원 뒤 지움
stored = snapshot_dir(tmp_path) / "B05_Profile__route"
stored.mkdir(parents=True)
(stored / "expected_route_edit.csv").write_text("sequence,x,y\n", encoding="utf-8")
(snapshot_dir(tmp_path) / "db.json").write_text(json.dumps({"routes": [{"id": 1}]}))
write_route_csv(expected_route_edit_path(tmp_path), [{"x": 0, "y": 0}, {"x": 1, "y": 1}])
monkeypatch.setattr(lifecycle, "get_db_pool", lambda: _Pool())
monkeypatch.setattr(lifecycle, "get_project_storage_relative_path", _async("p"))
monkeypatch.setattr(lifecycle, "resolve_stored_project_path", lambda _p: str(tmp_path))
monkeypatch.setattr(lifecycle, "get_latest_route", _async({"id": 9}))
monkeypatch.setattr(lifecycle, "start_stage", _async(None))
monkeypatch.setattr(snapshot, "restore_initial_snapshot", _async(9))
monkeypatch.setattr(corridor, "prune_corridor_files", lambda *_a: 0)
response = asyncio.run(lifecycle.reset_route_design(uuid4(), None))
assert json.loads(response.body)["restored"] is True
assert not expected_route_edit_path(tmp_path).exists()
@pytest.mark.parametrize("name", ["B05_Profile_Router_RouteFollow.py"])
def test_follow_길은_replan_as_initial_안_부름(name: str) -> None:
text = (ROOT / "B05_Profile" / name).read_text(encoding="utf-8")
code = text.split('"""', 2)[2] # 머리글 뺀 몸통
assert "replan_route" not in code and "as_initial" not in code
assert "expected_route_csv_path" not in code and "write_" not in code
@@ -0,0 +1,81 @@
"""628-3 — 전국 0차곡 이웃 도엽 받기 실패 셋: 면 도곽(N3A_H0010000) · zip 없는 도엽(NGI 뿐) ·
도곽이 번호 자리와 어긋난 zip(제주 · 울릉) = 빈 지형 · 로그인 · 망 실패는 그대로 실패."""
import sys
import tempfile
import zipfile
from pathlib import Path
import geopandas as gpd
import pytest
from shapely.geometry import box
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
from B04_PreProcess import B04_PreProcess_Engine_SheetStore as sheet_store # noqa: E402
from B04_PreProcess.B04_PreProcess_Engine_MapSheet import sheet5k_to_bounds # noqa: E402
from common_util import common_util_terrain_store as store # noqa: E402
SHEET = "34616006"
CRS = "EPSG:5186"
def _make_zip(path: Path, code: str, bb: tuple[float, float, float, float]) -> None:
with tempfile.TemporaryDirectory() as td, zipfile.ZipFile(path, "w") as zf:
gdf = gpd.GeoDataFrame(geometry=[box(*bb)], crs="EPSG:4326").to_crs(CRS)
gdf.to_file(Path(td) / f"{code}.shp")
for f in Path(td).glob(f"{code}.*"):
zf.write(f, f.name)
def test_면_도곽만_있는_섬_도엽_받기_통과(tmp_path):
z = tmp_path / f"{SHEET}.zip"
_make_zip(z, "N3A_H0010000", sheet5k_to_bounds(SHEET))
entry = sheet_store.ingest_zip(tmp_path / "store", z, sheet_no=SHEET)
for got, want in zip(entry["bounds_wgs84"], sheet5k_to_bounds(SHEET)):
assert got == pytest.approx(want, abs=1e-6)
def test_도곽이_번호_자리와_어긋나면_ValueError(tmp_path):
w, s, e, n = sheet5k_to_bounds(SHEET)
z = tmp_path / f"{SHEET}.zip"
_make_zip(z, "N3A_H0010000", (w, s - 0.1, e, n - 0.1)) # 제주식 0.1° 어긋남
with pytest.raises(ValueError, match="정합하지 않음"):
sheet_store.ingest_zip(tmp_path / "store", z, sheet_no=SHEET)
def test_zip_없는_도엽은_LookupError_받기_안_함(tmp_path, monkeypatch):
rows = [
{"ds_sq": 1, "file_no": 1, "file_nm": f"{SHEET}.nda", "file_extsn": "nda"},
{"ds_sq": 1, "file_no": 2, "file_nm": f"{SHEET}.ngi", "file_extsn": "ngi"},
]
monkeypatch.setattr(sheet_store, "search_sheet_files", lambda no, version=2: rows)
def no_fetch(*_a, **_k):
raise AssertionError("받기 안 함")
monkeypatch.setattr(sheet_store, "_fetch_sheet_payload", no_fetch)
monkeypatch.setattr(sheet_store, "vworld_login", no_fetch)
with pytest.raises(LookupError, match="zip 없음"):
sheet_store.download_sheet(tmp_path, SHEET)
result = sheet_store.ensure_sheets(tmp_path, [SHEET])
assert result["failed"][SHEET].startswith("LookupError")
@pytest.mark.parametrize(
("reason", "empty"),
[
("LookupError: 검색 결과 없음", True),
("LookupError: Ver2.0 zip 없음(nda, ngi)", True),
("ValueError: _dl_x.zip: 어떤 좌표계 후보로도 도엽번호 도곽과 정합하지 않음", True),
("PermissionError: 재로그인 후에도 실패", False),
("URLError: timed out", False),
],
)
def test_가운데_도엽_받기_실패_가름(monkeypatch, reason, empty):
monkeypatch.setattr(
sheet_store, "ensure_sheets", lambda d, nos: {"available": {}, "failed": {nos[0]: reason}}
)
zips, failure = store._fetch_zips(SHEET, lambda p, s: None)
assert zips == {}
assert failure == (None if empty else reason)
@@ -3,7 +3,7 @@
총액 대조 = 엑셀 비목 합 + 실은 요율 · 손입력으로 우리 식(`compute_total`) → 엑셀 총공사비:
대흥 = 같음 · 영월 = 「국가지점번호판검증수수료」 762,000 원만 다름(우리 표에 줄 없음) ·
거창 = 퇴직공제부금비(셈 없는 현행 비목)만큼 · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄.
거창 = 같음(708-2 퇴직공제부금비 셈 뒤) · 소광 = 산출근거 빈칸 율 넷 · 폐기물처리 줄.
"""
import sys
@@ -61,7 +61,7 @@ def test_707_2_요율_짝(rates):
2.3,
)
assert gc["estimate"]["safety_base"] == 0 # 글에 + 기초액 없음
assert any("퇴직공제부금비" in n and "셈 없음" in n for n in gc["notes"])
assert gc["notes"] == [] # 708-2 퇴직공제부금비 = 셈(「셈 없음」 없음)
sk = rates["소광"]
assert sum("율 없음" in n for n in sk["notes"]) == 4 # 산출근거 빈칸 — 지금 율 그대로
assert any("페기물처리" in n for n in sk["notes"])
@@ -73,11 +73,11 @@ def test_707_2_총액_대조(rates):
assert got["대흥"]["differ"] == []
assert got["영월"]["excel_grand"] - got["영월"]["grand"] == 762000 # 국가지점번호판 줄 몫
assert set(got["영월"]["differ"]) == {"도급공사비", "총공사비"}
# 708-2 — 퇴직공제부금비 5,603,907(직노 × 2.3 %)가 순공사원가에 듦 → 총공사비 = 엑셀
assert got["거창"]["grand"] == got["거창"]["excel_grand"] == 744653000
assert got["거창"]["differ"] == []
lines = {ln["key"]: ln for ln in got["거창"]["lines"]}
rate_keys = ("indirect_labor", "accident", "employment", "health", "care", "pension")
assert all(lines[k]["same"] for k in (*rate_keys, "safety", "other", "environment"))
assert not lines["overhead"]["same"] # 퇴직공제부금비 5,603,907 이 순공사원가에 없음
assert lines["overhead"]["excel"] - lines["overhead"]["ours"] == 448312 # = 5,603,907 × 8 %
assert lines["retirement"]["ours"] == lines["retirement"]["excel"] == 5603907
def test_707_3_단가표_읽기():
@@ -99,7 +99,8 @@ def test_new_pipe_gets_master_default_not_recommendation(monkeypatch, facility,
"""새로 놓인 관 = 배관 · 마스터 기본 관경 — 추천(관경 · BOX암거 · 세월교)은 안 얹는다."""
carried = _run_build(monkeypatch, _basin(100.0, facility, diameter), None)
assert carried[0].facility == PIPE_FACILITY_PIPE
assert carried[0].options["pipe_diameter_mm"] == _master_pipe_diameter()
# 등록부 글 "1000" → 수로 적힘(623 · 폼 왕복과 같음)
assert carried[0].options["pipe_diameter_mm"] == int(_master_pipe_diameter())
def test_existing_auto_pipe_is_untouched(monkeypatch):
@@ -30,13 +30,19 @@ def _defaults(type_id: str) -> dict:
}
def _basin_defaults() -> dict:
"""새 관(유입 집수정) — 유입 흙막이 칸 없음 · 모양 L · 동물이동형 유 · 관경 수(623)."""
kept = {k: v for k, v in _defaults("pipe").items() if not k.startswith("inlet_revet_")}
return {**kept, "pipe_diameter_mm": 1000, "inlet_structure": "L", "inlet_basin_animal": "true"}
def test_새_관만_기본값_빈_키에만():
fresh = PipePoint(chainage_m=10.0, source="stream", options={"pipe_diameter_mm": 800})
basins._apply_new_defaults([fresh])
# 추천(먼저 들어간 값)이 이김 · 집수정 모양 L 도 실제 값(54-39)
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 유입 흙막이만 + 0.7(211-1 → 1.5)
heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.3), "inlet_revet_height_m": 1.5}
expected = {**_defaults("pipe"), "pipe_diameter_mm": 800, "inlet_structure": "L", **heights}
# 관 보호공 · 유입 집수정 높이 = 관경 + 0.5(139-1 · Φ800 → 1.3) · 집수정이면 유입 흙막이 없음(623)
heights = {"outlet_revet_height_m": 1.3, "inlet_basin_height_m": 1.3}
expected = {**_basin_defaults(), "pipe_diameter_mm": 800, **heights}
assert fresh.options == expected
assert fresh.options["pipe_kind"] == "파형강관" and fresh.options["inlet_type"] == "집수정"
box = PipePoint(chainage_m=20.0, source="stream", facility="box_culvert")
@@ -65,10 +71,10 @@ def test_원본에_있던_관은_빈칸_그대로(monkeypatch):
_, points = basins._build("p", Ctx(), [old])
by = {p.chainage_m: p for p in points}
assert by[100.0].options is None # 옛 빈칸 그대로
# 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39)
# 기본 관경 1000 → 1.5(139-1) · 유입 흙막이 1.7(211-1)
heights = {**dict.fromkeys(basins.PIPE_HEIGHT_KEYS, 1.5), "inlet_revet_height_m": 1.7}
assert by[300.0].options == {**_defaults("pipe"), "inlet_structure": "L", **heights}
# 새로 보충된 관 = 기본값 + 집수정 모양 L(54-39) · 동물이동형 유 · 유입 흙막이 없음(623)
# 기본 관경 1000 → 1.5(139-1)
heights = {"outlet_revet_height_m": 1.5, "inlet_basin_height_m": 1.5}
assert by[300.0].options == {**_basin_defaults(), **heights}
def test_월류_폭_기본값은_등록부_엔진과_같음():
+2
View File
@@ -601,6 +601,8 @@ html.is-bm-route .m01lab-src__body * {
--draw-map-sketch: #ffe94d; /* 지도 위 그림(테마 안 따름 · 검은 테와 함께) — 긋기 미리보기 · 그리는 선 · 고름 강조 · 마커 */
--draw-map-measure: #ffffff; /* 거리재기 */
--draw-map-warn: #ff5a4d; /* 긋기 막힘(경고) */
--draw-map-slope-up: #ff9e2c; /* 긋기 기울기 부호 — + 오르막(따뜻한 색 · 노드 · 꺾임점) */
--draw-map-slope-down: #29e0ff; /* − 내리막(찬 색) */
--draw-map-route-0: #3df5c4; /* 예정노선 선마다 차례 색(개발제한 · 산사태 주황과 가름) */
--draw-map-route-1: #6db8ff;
--draw-map-route-2: #cf9dff;