Merge remote-tracking branch 'origin/sub_laptop_7' into sub_laptop_8
This commit is contained in:
@@ -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),
|
||||
|
||||
+3
-3
@@ -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 {
|
||||
+7
-7
@@ -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})`,
|
||||
@@ -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`);
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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[];
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+6
-6
@@ -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 는 따로). */
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}
|
||||
@@ -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")]
|
||||
),
|
||||
|
||||
@@ -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]),
|
||||
|
||||
@@ -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}}
|
||||
|
||||
|
||||
@@ -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")):
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
@@ -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("사업 지형면이 없습니다 — 지형 받기 뒤 보입니다.")
|
||||
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 그대로 · 밝음 · 어두움 · 범례 색 = 조각 색
|
||||
|
||||
@@ -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 진행해줘.
|
||||
|
||||
@@ -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));
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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), ""
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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 = 기본 범위
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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 겹 꼴 = 공용
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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 == []
|
||||
@@ -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"]
|
||||
@@ -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_월류_폭_기본값은_등록부_엔진과_같음():
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user