From 92fdf334b238ad049a6d6039fc56f2cba73b37c1 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 31 Oct 2026 21:15:14 +0900 Subject: [PATCH] =?UTF-8?q?feat(D03):=20=EC=98=88=EC=A0=95=EB=85=B8?= =?UTF-8?q?=EC=84=A0=20=EC=A7=80=ED=98=95=20=EC=B9=A9=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=8B=A4=20=C2=B7=20LAS=20=EC=98=AC=EB=A6=AC=EA=B8=B0=20?= =?UTF-8?q?=E2=80=94=20=EC=B9=A9=20=EB=B0=94=EA=BE=B8=EB=A9=B4=20=EA=B8=89?= =?UTF-8?q?=EA=B2=BD=EC=82=AC=20=C2=B7=20=EC=9D=8C=EC=98=81=20=C2=B7=200?= =?UTF-8?q?=EC=B0=A8=EA=B3=A1=20=C2=B7=20=EB=86=92=EC=9D=B4=20=EB=8B=A4?= =?UTF-8?q?=EC=8B=9C=20=EB=B0=9B=EC=9D=8C=20(PLAN=20624-4=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=C2=B7=20=EC=9A=94=EA=B5=AC=20530=20=C2=B7=20531)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 새 D03_PlanAnalysis_UI_LidarUpload — 공용 칩(안 고침) + 라이다 없으면 [LAS 올리기](부품 단추 · LAS · LAZ · .prj) · 진행 = 위치 칸 서클 「라이다」 · 고른 원천 = 세션 d03-terrain(등록부 한 줄) - 공용 겹 받기 fetchPlanRaster · fetchPlanZeroOrder 에 terrain 인자(안 넘김 = 옛 그대로 · B05 영향 없음) - 겹 reterrain = 지형 겹 셋만 비우고 다시 · 높이 = 고른 원천 격자(라이다 범위 밖 = 빈칸) - 지도는 지형 칸을 넘겨받기만 · 낡은 시험 둘(475 · 624_2)은 글 대조 줄만 새 꼴로 Co-Authored-By: Claude Opus 5.5 --- A00_Common/b_page_state.ts | 2 + A00_Common/map/map_layer_api.ts | 21 ++- D03_PlanAnalysis/D03_PlanAnalysis_Api.ts | 44 ++++- .../D03_PlanAnalysis_UI_LayerLegend.ts | 2 + .../D03_PlanAnalysis_UI_Layers.ts | 41 +++- .../D03_PlanAnalysis_UI_LidarUpload.ts | 176 ++++++++++++++++++ D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts | 7 +- D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts | 24 ++- .../D03_PlanAnalysis_UI_Style.css | 7 + .../D03_PlanAnalysis_UI_Terrain.ts | 13 +- .../tester/test_475_plan_area_refresh.py | 4 +- resources/tester/test_624_2_map_parts.py | 4 +- resources/tester/test_624_4b_plan_lidar_ui.py | 72 +++++++ 13 files changed, 387 insertions(+), 30 deletions(-) create mode 100644 D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts create mode 100644 resources/tester/test_624_4b_plan_lidar_ui.py diff --git a/A00_Common/b_page_state.ts b/A00_Common/b_page_state.ts index e04aeca2e..6594940ab 100644 --- a/A00_Common/b_page_state.ts +++ b/A00_Common/b_page_state.ts @@ -186,6 +186,8 @@ export const STATE_REGISTRY = { "b08-milestone": { bucket: "draft", scope: "project" }, /** D03 예정노선 노선분석 `{version: 2, network: 그은 선 · 갈래 · 조각}` — [저장] 때 plan/network 로(563-1). */ "d03-plan": { bucket: "draft", scope: "project" }, + /** D03 예정노선 분석 지형 원천 `"sheet" | "lidar"`(624-4 · 지형 칩) — 화면 캐시만 · 서버로 안 감. */ + "d03-terrain": { bucket: "draft", scope: "project" }, /** B09 원가 사용자 값 `{materials, estimate}`(고친 칸만) — [저장] 때 프로젝트 B09 폴더로(144). */ "b09-estimation": { bucket: "draft", scope: "project" }, /** 미저장 계획고 편집(▲▼ · 곡선반경) — 설계 가게 `alignmentDraft` 의 칸(계획서 24-2c). diff --git a/A00_Common/map/map_layer_api.ts b/A00_Common/map/map_layer_api.ts index 94ab56ac8..32e7aad5b 100644 --- a/A00_Common/map/map_layer_api.ts +++ b/A00_Common/map/map_layer_api.ts @@ -1,13 +1,15 @@ /* ============================================================================= * map_layer_api.ts - * 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김). + * 공용 지도 겹 받기(PLAN 569 · 581 · 584 · 603 · 605 · 606 · 624-3 · 624-4 · 요구 530) — D03 예정노선 · B05 평면이 같이 씀(D03_Api 에서 옮김). * 서버 길 = `/api/projects/{id}/plan/…`(624-1 · 임도 프로젝트도 · 띠 · 올린 임도망도 = 예정노선만). + * 급경사 · 음영 · 0차곡 = 지형 원천 인자 `terrain`(지형 칩 값 · 624-4). * 지적도 · 도엽 겹 GeoJSON 경위도 · 도엽 조각 · 그림 겹(급경사 · 음영기복 · 산사태위험) · 보호구역 · 산림입지도 · * 선 겹(단층 · 지질구조선) · 0차곡 · 띠 · 올린 임도망도(올리기 · 지움) · 산사태취약지역. * D03 = `D03_PlanAnalysis_Api` 가 다시 내놓음(부르는 곳 그대로). * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; +import type { TerrainSource } from "./map_terrain_chip"; /** 경위도 GeoJSON 묶음 — B04 `GeoJsonCollection` 과 같은 꼴(공용 폴더는 페이지 파일을 안 부름). */ export type GeoJsonCollection = { @@ -40,8 +42,13 @@ export interface PlanRaster { image: string; /** 급경사만(603) — `counts[v]` = 기울기 % 내림 v(0~250) 칸 수 · 그림 = 회색 칸 값(255 = 빈 칸). */ counts?: number[]; + /** 쓴 지형 원천(624-1 · 급경사 · 음영). */ + source?: string; } +/** 지형 원천 인자(624-1) — 안 넘김 = 서버 기본(D = 수치지형도 · B = 설계 지형). */ +const terrainQuery = (terrain?: TerrainSource): string => (terrain ? `&terrain=${terrain}` : ""); + export const fetchPlanCadastral = (projectId: string): Promise => request(`${base(projectId)}/layers/cadastral`); @@ -69,8 +76,9 @@ export const fetchPlanSheetTile = ( export const fetchPlanRaster = async ( projectId: string, kind: "slope" | "hillshade" | "landslide", + terrain?: TerrainSource, ): Promise => { - const path = `${base(projectId)}/layers/raster?kind=${kind}`; + const path = `${base(projectId)}/layers/raster?kind=${kind}${terrainQuery(terrain)}`; if (kind !== "slope") return request(path); const response = await fetch(`${API_BASE_URL}${path}`, { credentials: "include" }); if (!response.ok) { @@ -123,10 +131,15 @@ export interface PlanZeroOrder { count: number; length_m: number; collection: GeoJsonCollection; + /** 쓴 지형 원천(624-1). */ + source?: string; } -export const fetchPlanZeroOrder = (projectId: string): Promise => - request(`${base(projectId)}/layers/zero-order`); +export const fetchPlanZeroOrder = ( + projectId: string, + terrain?: TerrainSource, +): Promise => + request(`${base(projectId)}/layers/zero-order?${terrainQuery(terrain).slice(1)}`); export const fetchPlanBands = (projectId: string): Promise<{ stream: PlanBand; road: PlanBand }> => request(`${base(projectId)}/layers/bands`); diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts index 83978361e..01e4352ee 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_Api.ts @@ -3,6 +3,7 @@ * D03 분석 화면 서버 요청(PLAN 563-1) — 지형 받기 범위 · 받기 · 진행 · * 사업 전체 지형면 한 벌(`models_plan/` · 격자) · 그은 선 정본(562-2 GET · PUT network · 지점 · 기준점 함께 591-2 · * 지점 · 선 이름 · 노드 기록 · [저장] 기준점 594) · 주소 · 장소 찾기(594-4 공용 `/map/search`) · + * 라이다 지형(624-4 · LAS 올리기 · 지은 지형 · 높이 격자) · * 지도 겹 받기 = 공용 `A00_Common/map/map_layer_api`(624-3 · 다시 내놓음). * ========================================================================== */ @@ -142,8 +143,36 @@ export const startTerrain = ( body: JSON.stringify({ margin_m: marginM, extra, ...aim }), }); -export const fetchPlanProgress = (projectId: string): Promise<{ terrain: PlanJob }> => - request(`${base(projectId)}/progress`); +export const fetchPlanProgress = ( + projectId: string, +): Promise<{ terrain: PlanJob; lidar: PlanJob }> => request(`${base(projectId)}/progress`); + +/** 지은 라이다 지형(624-4) — 없음 = `ready` 거짓 · `model_file` = 높이 격자 모델 행 경로. */ +export interface PlanLidar { + ready: boolean; + epsg?: number; + files?: string[]; + point_count?: number; + model_file?: string; +} + +export const fetchPlanLidar = (projectId: string): Promise => + request(`${base(projectId)}/lidar`); + +/** LAS · LAZ(+ .prj 하나) 올리기 → 뒤에서 지형(진행 = `fetchPlanProgress().lidar`) · 못 받으면 서버 글 그대로 오류. */ +export async function uploadPlanLidar(projectId: string, files: File[]): Promise { + const form = new FormData(); + for (const file of files) form.append("files", file); + const response = await fetch(`${API_BASE_URL}${base(projectId)}/lidar`, { + method: "POST", + credentials: "include", + body: form, + }); + const body = (await response.json().catch(() => ({}))) as PlanJob & { detail?: unknown }; + if (!response.ok) + throw new Error(typeof body.detail === "string" ? body.detail : `HTTP ${response.status}`); + return body; +} export const fetchPlanNetwork = (projectId: string): Promise => request(`${base(projectId)}/network`); @@ -167,10 +196,15 @@ export interface PlaceHit { export const searchPlaces = async (query: string): Promise => (await request<{ items: PlaceHit[] }>(`/map/search?q=${encodeURIComponent(query)}`)).items; -/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */ -export async function fetchPlanSurface(projectId: string): Promise { +/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…`(`file` = 그 경로 행 · 라이다 624-4) · 격자 = 평활본 · 없으면 null. */ +export async function fetchPlanSurface( + projectId: string, + file?: string, +): Promise { const { models } = await listSurfaceModels(projectId); - const model = models.find((entry) => /models_plan\//.test(entry.model_file_path ?? "")); + const model = models.find((entry) => + file ? entry.model_file_path === file : /models_plan\//.test(entry.model_file_path ?? ""), + ); if (!model) return null; const stamp = String(model.generation_params?.file_stamp ?? ""); const url = diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts index ca9057ee1..71aa3a5d9 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LayerLegend.ts @@ -26,6 +26,8 @@ export interface PreparedLines { layer: PreparedLayer; count: number; length_m: number; + /** 0차곡 — 쓴 지형 원천(624-4). */ + source?: string; } export type Prepared = Record & { contour: PreparedLayer; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts index c5767d7a7..4cbf62757 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Layers.ts @@ -31,6 +31,7 @@ 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 type { TerrainSource } from "../A00_Common/map/map_terrain_chip"; import { createMapLegend } from "../A00_Common/map/map_legend"; import { fetchVWorldMeta, type VWorldMeta } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { @@ -107,6 +108,8 @@ export interface PlanLayers { deleteRoad: (bundle: string) => Promise; /** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */ pickRoad: (pick: RoadPick | null) => void; + /** 지형 원천이 바뀜(624-4) — 지형 겹(급경사 · 음영 · 0차곡)만 비우고 켠 것은 새 원천으로 다시 받음. */ + reterrain: () => Promise; /** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */ state: () => { on: Record; @@ -120,6 +123,8 @@ export interface PlanLayers { area: [number, number, number, number] | null; turn: number; tiles: ReturnType; + /** 지형 겹이 받은 원천(응답 `source` · 624-4). */ + sources: Record; }; } @@ -128,8 +133,10 @@ export function createPlanLayers(opts: { map: PlanMap; /** 받는 일을 진행 서클에 셈(끝나면 뺌). */ track: (job: Promise) => Promise; + /** 지형 원천(지형 칩 · 624-4) — 급경사 · 음영 · 0차곡 `terrain=`. */ + terrain: () => TerrainSource; }): PlanLayers { - const { projectId, map, track } = opts; + const { projectId, map, track, terrain } = opts; const data: Partial = {}; const pending = new Map>(); let meta: VWorldMeta | null = null; @@ -457,7 +464,7 @@ export function createPlanLayers(opts: { if (info.missing.length) showToast(`산사태취약지역 — ${info.missing.join(" · ")} 자료 없음`, "info"); } else if (key === "slope") { - const raster = await fetchPlanRaster(projectId, key); + const raster = await fetchPlanRaster(projectId, key, terrain()); const values = slopeValues(await loadImage(raster.image)); const bands = slopeBands; const started = performance.now(); @@ -466,7 +473,11 @@ export function createPlanLayers(opts: { got.slope = { raster, values, bands, ...painted, paintMs }; put("slope", glImage(raster, painted.url)); } else if (key === "hillshade" || key === "landslide") { - const raster = await fetchPlanRaster(projectId, key); + const raster = await fetchPlanRaster( + projectId, + key, + key === "hillshade" ? terrain() : undefined, + ); const image = await loadImage(raster.image); got[key] = { raster, image }; const plain = glImage(raster); @@ -521,11 +532,12 @@ export function createPlanLayers(opts: { collection: lines, ...lines.summary, })) - : await fetchPlanZeroOrder(projectId); + : await fetchPlanZeroOrder(projectId, terrain()); got[key] = { layer: prepare(found.collection), count: found.count, length_m: found.length_m, + source: "source" in found ? found.source : undefined, }; put(key, found.collection); } finally { @@ -608,6 +620,21 @@ export function createPlanLayers(opts: { await Promise.all(LAYERS.map((def) => ensure(def.key))); } + /** 지형 원천 바뀜 — 차례 +1(옛 원천 답 버림 · 받는 중 겹은 다시) · 지형 겹만 비움. */ + async function reterrain(): Promise { + areaTurn += 1; + pending.clear(); + if (data.slope?.url) URL.revokeObjectURL(data.slope.url); + for (const key of ["slope", "hillshade", "zeroOrder"] as const) { + delete data[key]; + gl.setData(key, null); + } + hillshade = null; + sync(); + map.redrawBase(); + await Promise.all(LAYERS.map((def) => ensure(def.key))); + } + return { bar, legend, @@ -616,6 +643,7 @@ export function createPlanLayers(opts: { uploadRoads, deleteRoad, pickRoad, + reterrain, state: () => ({ on: { ...on }, loaded: LAYERS.map((def) => def.key).filter(loadedOf), @@ -634,6 +662,11 @@ export function createPlanLayers(opts: { area, turn: areaTurn, tiles: tiles.state(), + sources: { + slope: data.slope?.raster.source, + hillshade: data.hillshade?.raster.source, + zeroOrder: data.zeroOrder?.source, + }, }), }; } diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts new file mode 100644 index 000000000..0cef6a3a6 --- /dev/null +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_LidarUpload.ts @@ -0,0 +1,176 @@ +/* ============================================================================= + * D03_PlanAnalysis_UI_LidarUpload.ts + * 예정노선 분석 지형(PLAN 624-4 화면 · 요구 530 · 531) — 도구줄 끝 칸: 공용 지형 칩(수치지형도 · 라이다) + + * 라이다 지형이 없으면 「라이다」 대신 [LAS 올리기](LAS · LAZ + .prj → 서버가 뒤에서 지형 · 진행 = 위치 칸 서클 하나 「라이다」) · + * 끝나면 「라이다」를 고를 수 있음(저절로 안 바꿈). + * 고른 원천 = 화면 캐시(세션 `d03-terrain`)만 · 기본 = 수치지형도 · 라이다 지형이 없어지면 수치지형도로. + * 바꾸면 높이 격자(긋기 기울기)를 그 원천으로 받은 뒤 `onChange` — 겹(급경사 · 음영 · 0차곡)은 페이지가 다시 받음. + * 칩 안은 공용 그대로(`map_terrain_chip` · `onChange` · `setHave` 만 씀). + * ========================================================================== */ + +import { createButton } from "@ui/ui_template_button"; +import { pickFiles } from "@ui/ui_template_drop"; +import { showToast } from "@ui/ui_template_elements"; +import { readState, writeState } from "../A00_Common/b_page_state"; +import { createTerrainChip, type TerrainSource } from "../A00_Common/map/map_terrain_chip"; +import { + fetchPlanLidar, + fetchPlanProgress, + fetchPlanSurface, + uploadPlanLidar, + type PlanSurface, +} from "./D03_PlanAnalysis_Api"; +import { errorText, sleep, type WorkCircle } from "./D03_PlanAnalysis_UI_Terrain"; + +const POLL_MS = 1000; + +export interface PlanTerrain { + /** 도구줄 `terrain` 칸 — 칩 + [LAS 올리기]. */ + root: HTMLElement; + /** 고른 원천(겹 받기 `terrain=`). */ + source: () => TerrainSource; + /** 높이 격자 — 수치지형도 = 넘긴 사업 지형면 · 라이다 = 라이다 격자(범위 밖 = 빈칸). */ + surface: (sheet: PlanSurface | null) => PlanSurface | null; + /** 올리기 단추 보임(편집 권한). */ + setEditable: (on: boolean) => void; + /** 들어올 때 — 라이다 지형 있나 · 캐시 원천 되살림 · 도는 올리기 지켜봄(겹 받기 전에). */ + load: () => Promise; + /** 검증 창구. */ + state: () => { source: TerrainSource; ready: boolean; uploading: boolean; heights: boolean }; +} + +export function createPlanTerrain(opts: { + projectId: string; + /** 위치 칸 진행 서클(604-3 · 하나). */ + work: WorkCircle; + /** 원천이 바뀌고 높이 격자를 받은 뒤. */ + onChange: (source: TerrainSource) => void; +}): PlanTerrain { + const { projectId, work } = opts; + let source: TerrainSource = "sheet"; + let ready = false; + let uploading = false; + let editable = false; + let modelFile = ""; + let lidar: PlanSurface | null = null; + + const chip = createTerrainChip({ + page: "d03", + value: source, + have: ["sheet"], + onChange: (next) => void choose(next), + }); + const lidarItem = chip.root.querySelector('[data-value="lidar"]')!; + const upload = createButton({ + label: "LAS 올리기", + variant: "ghost", + size: 32, + iconStart: "upload", + onClick: () => + pickFiles({ accept: ".las,.laz,.prj", multiple: true }, (files) => void send(files)), + }); + upload.title = + "라이다 LAS · LAZ(+ 좌표계 .prj 하나) — 올리면 서버가 지형을 지음 · 끝나면 「라이다」"; + const root = document.createElement("div"); + root.className = "d03-plan__terrain"; + root.append(chip.root, upload); + + function render(): void { + chip.setHave(ready ? ["sheet", "lidar"] : ["sheet"]); + chip.set(source); + lidarItem.style.display = !ready && editable ? "none" : ""; // 올릴 수 있으면 막힌 「라이다」 대신 단추 + upload.hidden = ready || !editable; + upload.disabled = uploading; + } + + async function readLidar(): Promise { + const info = await fetchPlanLidar(projectId); + if (info.model_file !== modelFile) lidar = null; // 다시 올림 = 새 격자 + [ready, modelFile] = [info.ready, info.model_file ?? ""]; + } + + /** 라이다 높이 격자 — 한 번 받아 둠. */ + async function lidarSurface(): Promise { + lidar ??= await fetchPlanSurface(projectId, modelFile); + return lidar; + } + + async function choose(next: TerrainSource): Promise { + if (next === source) return; + try { + if (next === "lidar" && !(await lidarSurface())) + throw new Error("라이다 높이 격자가 없습니다"); + } catch (error) { + showToast(`라이다 지형을 읽지 못했습니다 — ${errorText(error)}`, "error"); + return render(); + } + source = next; + writeState("d03-terrain", source, projectId); + render(); + opts.onChange(source); + } + + async function send(files: File[]): Promise { + try { + await uploadPlanLidar(projectId, files); + } catch (error) { + return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error"); + } + await work.track(follow()); + } + + /** 올린 뒤 지형 짓기를 끝날 때까지 지켜봄 — 진행 = 서클. */ + async function follow(): Promise { + uploading = true; + work.terrain({ ratio: 0, text: "", label: "라이다" }); + render(); + try { + for (;;) { + if (!root.isConnected) return; // 페이지 떠남 + const job = (await fetchPlanProgress(projectId)).lidar; + work.terrain({ ratio: job.percent / 100, text: job.message ?? "", label: "라이다" }); + if (!job.running) { + if (job.error) showToast(`라이다 지형 못 지음 — ${job.error}`, "error"); + break; + } + await sleep(POLL_MS); + } + await readLidar(); + if (ready) showToast("라이다 지형 끝 — 지형 칩에서 「라이다」를 고를 수 있음", "success"); + } catch (error) { + showToast(`라이다 진행을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } finally { + uploading = false; + work.terrain(null); + render(); + } + } + + async function load(): Promise { + try { + await readLidar(); + if (ready && readState("d03-terrain", projectId) === "lidar") { + if (await lidarSurface()) source = "lidar"; + } + if (source === "sheet") writeState("d03-terrain", null, projectId); + } catch (error) { + showToast(`라이다 지형을 읽지 못했습니다 — ${errorText(error)}`, "error"); + } + render(); + const job = await fetchPlanProgress(projectId).catch(() => null); + if (job?.lidar.running) void work.track(follow()); + } + + render(); + return { + root, + source: () => source, + surface: (sheet) => (source === "lidar" ? lidar : sheet), + setEditable(on) { + editable = on; + render(); + }, + load, + state: () => ({ source, ready, uploading, heights: !!lidar }), + }; +} diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts index 9e9fbc041..df044fd04 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Map.ts @@ -20,7 +20,6 @@ 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 { @@ -123,6 +122,8 @@ export interface PlanMapEvents { onRedo: () => void; on3d: () => void; onMeasure: () => void; + /** 도구줄 끝 지형 칸(624-4 · 칩 + LAS 올리기 = `D03_PlanAnalysis_UI_LidarUpload`). */ + terrain: HTMLElement; } export interface PlanTools { @@ -229,7 +230,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { "Ctrl+휠 = 돌리기", ], }, - // 624-2 — 단추 차례 · 보임 = 공용 도구줄 표(페이지 「d03」) · 지형 칩 = 수치지형도(라이다 = 624-4) + // 624-2 — 단추 차례 · 보임 = 공용 도구줄 표(페이지 「d03」) · 지형 칸 = 페이지가 넘김(624-4) groups: mapToolGroups("d03", { undo: undoButton, redo: redoButton, @@ -237,7 +238,7 @@ export function createPlanMap(events: PlanMapEvents): PlanMap { stations: markButton("stations", "측점"), slopes: markButton("slopes", "구간 길이 · 기울기"), measure: measureButton, - terrain: createTerrainChip({ page: "d03", value: "sheet", have: ["sheet"] }).root, + terrain: events.terrain, }), }); const toolbar = tools.root; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts index d93f09ffd..acd639634 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Page.ts @@ -53,6 +53,7 @@ import { openPlan3d } from "./D03_PlanAnalysis_UI_3D"; import { createPlanDraw } from "./D03_PlanAnalysis_UI_Draw"; import { createPlanEdit } from "./D03_PlanAnalysis_UI_Edit"; import { createPlanLayers } from "./D03_PlanAnalysis_UI_Layers"; +import { createPlanTerrain } from "./D03_PlanAnalysis_UI_LidarUpload"; import { createPlanMap, PLAN_STATION_INTERVAL_M, @@ -125,7 +126,10 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { let measuring = false; let lastSaveMs: number | null = null; - const z = (x: number, y: number): number => (surface ? gridHeightAt(surface.grid, x, y) : NaN); + const z = (x: number, y: number): number => { + const heights = terrain.surface(surface); // 지형 칩 원천(624-4) + return heights ? gridHeightAt(heights.grid, x, y) : NaN; + }; const draw = createPlanDraw({ base: () => points, network: () => network, @@ -211,16 +215,22 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { if (!had && viewMid) marks.render(); // 첫 그림 뒤 가운데가 생김 = 지점 칸 [추가] 켬 }, }; + // 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3) + const circle = createProgressCircle({ size: 16, inline: true }); + const work = createWorkCircle(circle); + const terrain = createPlanTerrain({ + projectId, + work, + onChange: () => (void backdrop.reterrain(), refresh()), + }); const map = createPlanMap({ + terrain: terrain.root, onUndo: undo, onRedo: redo, on3d: open3d, onMeasure: () => setMeasure(!measuring), }); - // 받기 진행 = 위치 칸 제목 오른쪽 서클 하나(받는 일 수 · 0 = 숨김 · 지도 조작 안 막음 604-3) - const circle = createProgressCircle({ size: 16, inline: true }); - const work = createWorkCircle(circle); - const backdrop = createPlanLayers({ projectId, map, track: work.track }); + const backdrop = createPlanLayers({ projectId, map, track: work.track, terrain: terrain.source }); map.root.append(backdrop.bar, backdrop.legend, props.panel); const main = document.createElement("div"); main.className = "d03-plan"; @@ -607,6 +617,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { saveMs: () => lastSaveMs, heightAt: z, layers: backdrop.state, + terrain: terrain.state, }; const onKey = (event: KeyboardEvent): void => { if (!root.isConnected) return window.removeEventListener("keydown", onKey); @@ -632,6 +643,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { try { editable = !!(await fetchSessionUser())?.is_forest_service; draw.setEditable(editable); + terrain.setEditable(editable); const view = await fetchPlanNetwork(projectId); if (!view.base.epsg) { note = "기준점이 없습니다 — 주소를 찾아 정하세요."; @@ -646,7 +658,7 @@ export async function renderD03PlanAnalysis(root: HTMLElement): Promise { const drawn = [...points, ...allVertices()]; map.update({ fit: drawn.length && !moved() ? drawn : around(site), epsg }); refresh(); - await loadSurface(); + await Promise.all([loadSurface(), terrain.load()]); // 지형 원천을 겹 받기 전에 refresh(); await backdrop.load(have); const job = (await fetchPlanProgress(projectId)).terrain; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css index 5694f0d85..0ab8d4118 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Style.css @@ -52,6 +52,13 @@ align-items: flex-end; } +/* 624-4 도구줄 끝 지형 칸 — 지형 칩 · LAS 올리기 · 진행 서클 한 줄 */ +.d03-plan__terrain { + display: flex; + align-items: center; + gap: var(--spacing-4); +} + /* 569-2 · 581-1 바탕 겹 단추 — 왼쪽 위 · 지도 반 넘으면 가로 밀림 · 범례 — 왼쪽 아래(켠 겹마다 한 줄) */ .d03-plan__layers { position: absolute; diff --git a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts index acca4ee0b..db6e785f8 100644 --- a/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts +++ b/D03_PlanAnalysis/D03_PlanAnalysis_UI_Terrain.ts @@ -1,7 +1,8 @@ /* ============================================================================= * D03_PlanAnalysis_UI_Terrain.ts * D 예정노선 받기 이음(PLAN 597 · 604 · 요구 463-7 · 475 · 476-2) — 페이지에서 뗀 조각: 기준점 꼴 · 둘레 상자 · - * 받기 지켜보기 · 진행 서클 하나(위치 칸 제목 오른쪽 · 들어간 일 수 = 지형 받기 · 겹 받기 · 처음 열기 · 임도망 올리기). + * 받기 지켜보기 · 진행 서클 하나(위치 칸 제목 오른쪽 · 들어간 일 수 = 지형 받기 · 겹 받기 · 처음 열기 · 임도망 올리기 · + * LAS 올리기 624-4 = 진행률 「라이다」). * 상태는 페이지가 쥐고 여기는 인자로만 받음(서클은 부품 그대로). * ========================================================================== */ @@ -65,21 +66,21 @@ export const farFromSite = (epsg: number, at: Site | null, list: Vertex[]): numb export interface WorkCircle { /** 일 하나를 셈(끝나면 뺌 · 답은 그대로). */ track: (job: Promise) => Promise; - /** 지형 받기 진행(없음 = 끝). */ - terrain: (state: { ratio: number; text: string } | null) => void; + /** 지형 받기 진행(없음 = 끝 · `label` = 서클 글 · 기본 「지형」). */ + terrain: (state: { ratio: number; text: string; label?: string } | null) => void; /** 들어간 일 수(검증 창구). */ count: () => number; } export function createWorkCircle(circle: ReturnType): WorkCircle { let count = 0; - let terrain: { ratio: number; text: string } | null = null; + let terrain: { ratio: number; text: string; label?: string } | null = null; const show = (): void => { circle.root.hidden = count === 0; - if (terrain) circle.set(terrain.ratio, "지형"); + if (terrain) circle.set(terrain.ratio, terrain.label ?? "지형"); else circle.set(null, count > 1 ? `받는 중 ${count}` : "받는 중"); circle.root.title = terrain - ? `지형 받기 ${Math.round(terrain.ratio * 100)}% · ${terrain.text}` + ? `${terrain.label ?? "지형"} 받기 ${Math.round(terrain.ratio * 100)}% · ${terrain.text}` : `겹 · 파일 받는 중 ${count}`; }; show(); diff --git a/resources/tester/test_475_plan_area_refresh.py b/resources/tester/test_475_plan_area_refresh.py index d8a5a9a39..7033eeadb 100644 --- a/resources/tester/test_475_plan_area_refresh.py +++ b/resources/tester/test_475_plan_area_refresh.py @@ -112,7 +112,9 @@ def test_one_circle_in_site_title() -> None: "corner: true" not in page and "circle.root);\n" not in page.split("map.root.append(")[1][:80] ) - assert "createPlanLayers({ projectId, map, track: work.track })" in page + assert ( + "createPlanLayers({ projectId, map, track: work.track, terrain: terrain.source })" in page + ) terrain = _src("D03_PlanAnalysis_UI_Terrain.ts") assert "params.work.track(watching(params))" in terrain # 지형 받기도 같은 셈 layers = _src("D03_PlanAnalysis_UI_Layers.ts") diff --git a/resources/tester/test_624_2_map_parts.py b/resources/tester/test_624_2_map_parts.py index 509dcf635..537de1732 100644 --- a/resources/tester/test_624_2_map_parts.py +++ b/resources/tester/test_624_2_map_parts.py @@ -80,5 +80,7 @@ def test_d03_calls_common_only() -> None: 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 "terrain: events.terrain," in ui_map # 칩 = 예정노선 지형 칸(624-4) + lidar = _read(D03 / "D03_PlanAnalysis_UI_LidarUpload.ts") + assert 'createTerrainChip({\n page: "d03",' in lidar assert not (D03 / "D03_PlanAnalysis_UI_ForestLayers.ts").exists() diff --git a/resources/tester/test_624_4b_plan_lidar_ui.py b/resources/tester/test_624_4b_plan_lidar_ui.py new file mode 100644 index 000000000..ade97ede7 --- /dev/null +++ b/resources/tester/test_624_4b_plan_lidar_ui.py @@ -0,0 +1,72 @@ +"""624-4 화면 — 예정노선 지형 칩 라이다 · LAS 올리기(요구 530 · 531). + +· 칩 = 예정노선 지형 칸(`UI_LidarUpload`)이 만듦 · 공용 칩 안 그대로 · 지도는 넘겨받기만 +· 라이다 없음 = 「라이다」 숨기고 [LAS 올리기](부품 단추 · 파일 고르기) · 진행 = 위치 칸 서클 하나 +· 고른 원천 = 세션 등록부 `d03-terrain`(draft · project) · 겹 급경사 · 음영 · 0차곡 = `terrain=` · 높이 = 원천 격자 +· 원천 바뀜 = 지형 겹 셋만 비우고 다시 + +./venv/Scripts/python.exe -m pytest resources/tester/test_624_4b_plan_lidar_ui.py -q +""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +D03 = ROOT / "D03_PlanAnalysis" + + +def _read(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def test_chip_made_by_plan_terrain() -> None: + ui_map = _read(D03 / "D03_PlanAnalysis_UI_Map.ts") + assert "createTerrainChip" not in ui_map and "terrain: events.terrain," in ui_map + lidar = _read(D03 / "D03_PlanAnalysis_UI_LidarUpload.ts") + assert "onChange: (next) => void choose(next)" in lidar + assert 'chip.setHave(ready ? ["sheet", "lidar"] : ["sheet"])' in lidar + chip = _read(ROOT / "A00_Common" / "map" / "map_terrain_chip.ts") + assert "onChange?: (value: TerrainSource) => void;" in chip # 칩 꼴 그대로 + + +def test_upload_uses_parts_and_one_circle() -> None: + lidar = _read(D03 / "D03_PlanAnalysis_UI_LidarUpload.ts") + assert 'label: "LAS 올리기"' in lidar and "createButton({" in lidar + assert 'pickFiles({ accept: ".las,.laz,.prj", multiple: true }' in lidar + assert 'createElement("button")' not in lidar and 'createElement("input")' not in lidar + assert "createProgressCircle" not in lidar # 서클 = 위치 칸 하나(604-3) + assert 'label: "라이다"' in lidar and "work.track(follow())" in lidar + assert 'lidarItem.style.display = !ready && editable ? "none" : ""' in lidar + api = _read(D03 / "D03_PlanAnalysis_Api.ts") + assert "`${API_BASE_URL}${base(projectId)}/lidar`" in api + assert "Promise<{ terrain: PlanJob; lidar: PlanJob }>" in api + + +def test_source_session_only() -> None: + lidar = _read(D03 / "D03_PlanAnalysis_UI_LidarUpload.ts") + assert 'writeState("d03-terrain", source, projectId)' in lidar + assert "sessionStorage" not in lidar and "localStorage" not in lidar + state = _read(ROOT / "A00_Common" / "b_page_state.ts") + assert '"d03-terrain": { bucket: "draft", scope: "project" },' in state + page = _read(D03 / "D03_PlanAnalysis_UI_Page.ts") + assert "d03-terrain" not in page # 저장 꼴(d03-plan) 그대로 + + +def test_layers_and_heights_follow_source() -> None: + api = _read(ROOT / "A00_Common" / "map" / "map_layer_api.ts") + assert 'terrain ? `&terrain=${terrain}` : ""' in api + assert "/layers/raster?kind=${kind}${terrainQuery(terrain)}" in api + assert "/layers/zero-order?${terrainQuery(terrain).slice(1)}" in api + layers = _read(D03 / "D03_PlanAnalysis_UI_Layers.ts") + assert "fetchPlanRaster(projectId, key, terrain())" in layers + assert 'key === "hillshade" ? terrain() : undefined' in layers # 산사태위험 = 원천 없음 + assert "fetchPlanZeroOrder(projectId, terrain())" in layers + body = layers.split("async function reterrain(): Promise {")[1].split("\n }\n")[0] + assert 'for (const key of ["slope", "hillshade", "zeroOrder"] as const)' in body + assert "areaTurn += 1;" in body and "pending.clear();" in body + page = _read(D03 / "D03_PlanAnalysis_UI_Page.ts") + assert "const heights = terrain.surface(surface);" in page + assert "onChange: () => (void backdrop.reterrain(), refresh())," in page + assert "await Promise.all([loadSurface(), terrain.load()]);" in page + assert len(page.splitlines()) < 700 and len(layers.splitlines()) < 683