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:
@@ -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).
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user