feat(D03): 예정노선 지형 칩 라이다 · LAS 올리기 — 칩 바꾸면 급경사 · 음영 · 0차곡 · 높이 다시 받음 (PLAN 624-4 화면 · 요구 530 · 531)

- 새 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 <noreply@anthropic.com>
This commit is contained in:
2026-10-31 21:15:14 +09:00
co-authored by Claude Opus 5.5
parent 216e7c565b
commit 92fdf334b2
13 changed files with 387 additions and 30 deletions
+2
View File
@@ -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).
+17 -4
View File
@@ -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<GeoJsonCollection> =>
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<PlanRaster> => {
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<PlanZeroOrder> =>
request(`${base(projectId)}/layers/zero-order`);
export const fetchPlanZeroOrder = (
projectId: string,
terrain?: TerrainSource,
): Promise<PlanZeroOrder> =>
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`);
+39 -5
View File
@@ -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<PlanLidar> =>
request(`${base(projectId)}/lidar`);
/** LAS · LAZ(+ .prj 하나) 올리기 → 뒤에서 지형(진행 = `fetchPlanProgress().lidar`) · 못 받으면 서버 글 그대로 오류. */
export async function uploadPlanLidar(projectId: string, files: File[]): Promise<PlanJob> {
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<PlanNetworkView> =>
request(`${base(projectId)}/network`);
@@ -167,10 +196,15 @@ export interface PlaceHit {
export const searchPlaces = async (query: string): Promise<PlaceHit[]> =>
(await request<{ items: PlaceHit[] }>(`/map/search?q=${encodeURIComponent(query)}`)).items;
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…` · 격자 = 평활본 · 없으면 null. */
export async function fetchPlanSurface(projectId: string): Promise<PlanSurface | null> {
/** 사업 전체 지형면 — 모델 행 경로 `…/models_plan/…`(`file` = 그 경로 행 · 라이다 624-4) · 격자 = 평활본 · 없으면 null. */
export async function fetchPlanSurface(
projectId: string,
file?: string,
): Promise<PlanSurface | null> {
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 =
@@ -26,6 +26,8 @@ export interface PreparedLines {
layer: PreparedLayer;
count: number;
length_m: number;
/** 0차곡 — 쓴 지형 원천(624-4). */
source?: string;
}
export type Prepared = Record<LineLayerKey, PreparedLines> & {
contour: PreparedLayer;
+37 -4
View File
@@ -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<boolean>;
/** 올린 묶음 · 선 고름(605-2) — 지도 강조(꺼 있던 겹은 켬) · null = 강조 없앰. */
pickRoad: (pick: RoadPick | null) => void;
/** 지형 원천이 바뀜(624-4) — 지형 겹(급경사 · 음영 · 0차곡)만 비우고 켠 것은 새 원천으로 다시 받음. */
reterrain: () => Promise<void>;
/** 검증 창구 — 켬끔 · 받은 겹 · 겹별 수(지물 · 넓이 m²). */
state: () => {
on: Record<PlanLayerKey, boolean>;
@@ -120,6 +123,8 @@ export interface PlanLayers {
area: [number, number, number, number] | null;
turn: number;
tiles: ReturnType<SheetTiles["state"]>;
/** 지형 겹이 받은 원천(응답 `source` · 624-4). */
sources: Record<string, string | undefined>;
};
}
@@ -128,8 +133,10 @@ export function createPlanLayers(opts: {
map: PlanMap;
/** 받는 일을 진행 서클에 셈(끝나면 뺌). */
track: <T>(job: Promise<T>) => Promise<T>;
/** 지형 원천(지형 칩 · 624-4) — 급경사 · 음영 · 0차곡 `terrain=`. */
terrain: () => TerrainSource;
}): PlanLayers {
const { projectId, map, track } = opts;
const { projectId, map, track, terrain } = opts;
const data: Partial<Prepared> = {};
const pending = new Map<DataKey | "satellite", Promise<void>>();
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<void> {
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,
},
}),
};
}
@@ -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<void>;
/** 검증 창구. */
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<HTMLElement>('[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<void> {
const info = await fetchPlanLidar(projectId);
if (info.model_file !== modelFile) lidar = null; // 다시 올림 = 새 격자
[ready, modelFile] = [info.ready, info.model_file ?? ""];
}
/** 라이다 높이 격자 — 한 번 받아 둠. */
async function lidarSurface(): Promise<PlanSurface | null> {
lidar ??= await fetchPlanSurface(projectId, modelFile);
return lidar;
}
async function choose(next: TerrainSource): Promise<void> {
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<void> {
try {
await uploadPlanLidar(projectId, files);
} catch (error) {
return showToast(`LAS 올리지 못함 — ${errorText(error)}`, "error");
}
await work.track(follow());
}
/** 올린 뒤 지형 짓기를 끝날 때까지 지켜봄 — 진행 = 서클. */
async function follow(): Promise<void> {
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<void> {
try {
await readLidar();
if (ready && readState<TerrainSource>("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 }),
};
}
+4 -3
View File
@@ -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;
+18 -6
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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;
@@ -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;
@@ -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: <T>(job: Promise<T>) => Promise<T>;
/** 지형 받기 진행(없음 = 끝). */
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<typeof createProgressCircle>): 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();
@@ -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")
+3 -1
View File
@@ -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()
@@ -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<void> {")[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