Files
Aislo/B04_wf1_Surface/B04_wf1_Surface_UI_Page.ts
T
eomsangdon c970812cf3 feat(3D): 휠 방향 반전·회전점 표시 + 3D/등고선 브라우저 보관함
- 휠 위 = 축소로 반전, 커서 지점을 축으로 한 dolly를 공용 유틸에서 직접 처리
- 회전 중심을 작은 구로 표시(돌리는 동안만, 화면상 크기 일정, 항상 위에 그림)
- 등고선 렌더 비용 감소: 폴리라인을 주곡선·보조곡선 2덩어리로 병합(드로우콜 424 → 2),
  라벨은 카메라가 움직였을 때만 재배치
- common_util_http_cache: 파일 mtime+크기 ETag, If-None-Match 일치 시 304
  (preview·contour 적용, 파일이 바뀌면 자동 무효화)
- A00_Common/b_asset_cache: IndexedDB 보관함(키 = projectId|url, 값 = 바이트+ETag).
  보관본 즉시 사용 후 백그라운드 재검증, 3D는 보관 바이트를 직접 파싱
- 프로젝트 전환 시 타 프로젝트 보관분 삭제, 대시보드→B그룹 이동 시 확정 모델의
  3D 프리뷰·등고선(1.0m) 미리 받기(포인트클라우드 제외)
2026-08-01 09:58:44 +09:00

408 lines
14 KiB
TypeScript

import { CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend";
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
import {
createButton,
createTag,
hideLoadingOverlay,
showLoadingOverlay,
showToast,
} from "@ui/ui_template_elements";
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
import { fetchDashboardMe } from "../B01_Dashboard/B01_Dashboard_Api_Fetch";
import { purgeOtherProjects } from "../A00_Common/b_asset_cache";
import { workflowSteps } from "../A00_Common/b_page_scaffold";
import {
fetchWorkflowState,
goToWorkflowStage,
WORKFLOW_STEP_ROUTES,
type WorkflowState,
} from "../A00_Common/b_workflow_nav";
import {
confirmSurfaceModel,
fetchSurfacePointCloud,
fetchSurfaceStatus,
listSurfaceInputFiles,
listSurfaceModels,
type SurfaceInputFileSummary,
type SurfaceModelSummary,
type SurfacePointCloudSampleResponse,
type SurfaceStatusResponse,
} from "./B04_wf1_Surface_Api_Fetch";
import { createSurfaceMapViewer } from "./B04_wf1_Surface_UI_MapViewer";
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
import "./B04_wf1_Surface_UI_Style.css";
const SOURCE_FILTERS = ["grid_min_z", "csf", "pmf"] as const;
const MODEL_METHODS = ["tin", "dtm", "nurbs", "implicit", "meshfree"] as const;
const DEFAULT_FILTER = "csf";
const DEFAULT_METHOD = "dtm";
const ROUTE_STAGE = ROUTES.B05_WF2_ROUTE;
function L(key: keyof typeof ui_locales): string {
return ui_locales[key][currentLanguageIndex];
}
function buildInfoLine(label: string, value: unknown): HTMLElement {
const row = document.createElement("div");
row.className = "b04-surface__line";
const key = document.createElement("span");
key.textContent = label;
const val = document.createElement("strong");
val.textContent = value == null || value === "" ? "-" : String(value);
row.append(key, val);
return row;
}
function buildSelectGroup(
title: string,
values: readonly string[],
defaultValue: string,
): { root: HTMLElement; select: HTMLSelectElement } {
const root = document.createElement("section");
root.className = "b04-surface__group";
const heading = document.createElement("h3");
heading.className = "b04-surface__panel-title";
heading.textContent = title;
const select = document.createElement("select");
select.className = "b04-surface__select";
values.forEach((value) => {
const option = document.createElement("option");
option.value = value;
option.textContent = value.replaceAll("_", " ").toUpperCase();
select.append(option);
});
select.value = defaultValue;
root.append(heading, select);
return { root, select };
}
function getModelFilter(model: SurfaceModelSummary): string {
const configured = model.generation_params?.source_filter;
if (typeof configured === "string") return configured.toLowerCase();
const path = model.model_file_path?.toLowerCase() ?? "";
return SOURCE_FILTERS.find((filter) => path.includes(filter)) ?? "";
}
export async function renderB04Surface(root: HTMLElement): Promise<void> {
const guardedProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
// 새로고침으로 바로 들어온 경우에도 다른 프로젝트 자료는 보관함에서 지운다.
if (guardedProjectId) void purgeOtherProjects(guardedProjectId);
if (guardedProjectId) {
const user = await fetchDashboardMe();
if (user.role !== "SYSTEM_ADMIN") {
const workflowState = await fetchWorkflowState(guardedProjectId).catch(() => undefined);
const surfaceStage = workflowState?.stages.find((stage) => stage.stage_no === 1);
goToWorkflowStage(
guardedProjectId,
surfaceStage?.state === "COMPLETE" ? ROUTES.B05_WF2_ROUTE : ROUTES.B03_FILE_INPUT,
);
return;
}
}
let selectedInputFile: SurfaceInputFileSummary | null = null;
let inputFiles: SurfaceInputFileSummary[] = [];
let models: SurfaceModelSummary[] = [];
let pointCloud: SurfacePointCloudSampleResponse | null = null;
const inputSelect = document.createElement("select");
inputSelect.className = "b04-surface__select";
const inputInfo = document.createElement("div");
inputInfo.className = "b04-surface__input-info";
const statusBox = document.createElement("div");
statusBox.className = "b04-surface__status";
const filterGroup = buildSelectGroup("지면 필터 선택", SOURCE_FILTERS, DEFAULT_FILTER);
const methodGroup = buildSelectGroup("지표면 표현 선택", MODEL_METHODS, DEFAULT_METHOD);
const viewer = createSurfacePointCloudViewer();
const terrainViewer = createSurfaceTerrainViewer();
const mapViewer = createSurfaceMapViewer();
let syncingCamera = false;
viewer.onCameraChange((state) => {
if (syncingCamera) return;
syncingCamera = true;
terrainViewer.applyCameraState(state);
syncingCamera = false;
});
terrainViewer.onCameraChange((state) => {
if (syncingCamera) return;
syncingCamera = true;
viewer.applyCameraState(state);
syncingCamera = false;
});
terrainViewer.onAxesVisibilityChange((visible) => {
viewer.setAxesVisible(visible);
});
viewer.setAxesVisible(false);
const confirmButton = createButton({
label: "모델 확정",
variant: "filled",
onClick: () => void onB04_Surface_Confirm_Click(),
});
const resetButton = createButton({
label: "초기화",
variant: "ghost",
onClick: () => void onB04_Surface_Reset_Click(),
});
const inputGroup = document.createElement("section");
inputGroup.className = "b04-surface__group";
const inputTitle = document.createElement("h3");
inputTitle.className = "b04-surface__panel-title";
inputTitle.textContent = L("B04_Surface_InputFiles");
inputGroup.append(inputTitle, inputSelect, inputInfo);
const panel = document.createElement("div");
panel.className = "b04-surface__form";
panel.append(
inputGroup,
filterGroup.root,
methodGroup.root,
viewer.optionsGroup,
terrainViewer.optionsGroup,
viewer.controlsGroup,
confirmButton,
resetButton,
);
const viewers = document.createElement("div");
viewers.className = "b04-surface__viewers";
viewers.append(viewer.root, terrainViewer.root);
const workspace = document.createElement("div");
workspace.className = "b04-surface__workspace";
workspace.append(statusBox, viewers, mapViewer.root);
let workflowState: WorkflowState | undefined;
const layoutProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (layoutProjectId) {
try {
workflowState = await fetchWorkflowState(layoutProjectId);
} catch {
/* 조회 실패 시 전체 이동 허용 */
}
}
const layout = createWorkflowLayout({
title: L("B04_Surface_Title"),
steps: workflowSteps(),
activeStep: 1,
leftPanel: panel,
mainContent: workspace,
stages: workflowState?.stages,
currentStage: workflowState?.current_stage,
routes: WORKFLOW_STEP_ROUTES,
onStepClick: (stepIndex) => {
if (layoutProjectId) goToWorkflowStage(layoutProjectId, WORKFLOW_STEP_ROUTES[stepIndex]);
},
});
function getProjectId(): string | null {
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
if (!projectId) showToast(L("B04_Surface_Error_Project"), "error");
return projectId;
}
function enableRouteStep(projectId: string): void {
const route = ROUTE_STAGE;
const routeIndex = WORKFLOW_STEP_ROUTES.indexOf(route);
const routeButton = layout.root.querySelectorAll<HTMLButtonElement>(
".ui-workflow-layout__step",
)[routeIndex];
if (!routeButton) return;
routeButton.disabled = false;
routeButton.classList.add("is-enabled", "state-in_progress");
routeButton.classList.remove("state-not_started", "state-stale");
if (routeButton.dataset.b04RouteEnabled === "true") return;
routeButton.dataset.b04RouteEnabled = "true";
routeButton.addEventListener("click", () => {
goToWorkflowStage(projectId, route);
});
}
function renderStatus(status: SurfaceStatusResponse | null): void {
statusBox.replaceChildren();
if (!status) {
statusBox.append(createTag(L("B04_Surface_Status_Unknown"), "neutral"));
return;
}
const variant =
status.status === "completed" ? "success" : status.status === "failed" ? "danger" : "warning";
statusBox.append(
createTag(`${status.progress_percent}%`, variant),
document.createTextNode(status.message),
);
}
function renderInputInfo(): void {
inputInfo.replaceChildren();
if (!selectedInputFile) return;
const bounds = pointCloud?.bounds;
const heightRange = bounds
? `${bounds.z_min.toFixed(2)} m ~ ${bounds.z_max.toFixed(2)} m`
: null;
inputInfo.append(
buildInfoLine(
"좌표계",
selectedInputFile.crs_epsg ? `EPSG:${selectedInputFile.crs_epsg}` : null,
),
buildInfoLine(
"크기",
selectedInputFile.file_size_mb == null
? null
: `${selectedInputFile.file_size_mb.toFixed(2)} MB`,
),
buildInfoLine("포인트 수", pointCloud?.point_count.toLocaleString()),
buildInfoLine("표시 포인트 수", pointCloud?.sampled_count.toLocaleString()),
buildInfoLine("높이 범위", heightRange),
);
}
function renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
inputFiles = [...files];
inputSelect.replaceChildren();
selectedInputFile = inputFiles[0] ?? null;
if (!selectedInputFile) {
const option = document.createElement("option");
option.value = "";
option.textContent = L("B04_Surface_InputFiles_Empty");
inputSelect.append(option);
confirmButton.disabled = true;
renderInputInfo();
return;
}
inputFiles.forEach((file) => {
const option = document.createElement("option");
option.value = String(file.id);
option.textContent = `입력 LAS #${file.id}`;
inputSelect.append(option);
});
inputSelect.value = String(selectedInputFile.id);
renderInputInfo();
}
function findSelectedModel(): SurfaceModelSummary | undefined {
return models.find(
(model) =>
model.model_type.toLowerCase() === methodGroup.select.value &&
getModelFilter(model) === filterGroup.select.value,
);
}
function updateSelectedModel(): void {
const projectId = getProjectId();
if (!projectId) return;
terrainViewer.setSelection(filterGroup.select.value, methodGroup.select.value);
terrainViewer.render(projectId, models);
confirmButton.disabled = !findSelectedModel();
}
async function updatePointCloudForFilter(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
showLoadingOverlay();
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
renderInputInfo();
} catch (error) {
pointCloud = null;
viewer.render(null);
const detail = error instanceof Error ? error.message : "지면 포인트 조회에 실패했습니다.";
showToast(detail, "error");
} finally {
hideLoadingOverlay();
}
}
async function loadProjectData(projectId: string): Promise<void> {
const [inputs, status, modelResponse] = await Promise.all([
listSurfaceInputFiles(projectId),
fetchSurfaceStatus(projectId),
listSurfaceModels(projectId),
]);
models = modelResponse.models;
renderInputFiles(inputs.files);
renderStatus(status);
viewer.setLoading("포인트 데이터 로딩 중…");
try {
pointCloud = await fetchSurfacePointCloud(projectId, filterGroup.select.value);
terrainViewer.setReferenceBounds(pointCloud.bounds);
viewer.render(pointCloud);
mapViewer.render(projectId, pointCloud.bounds);
} catch {
pointCloud = null;
viewer.render(null);
mapViewer.render(projectId);
}
renderInputInfo();
updateSelectedModel();
}
async function onB04_Surface_Confirm_Click(): Promise<void> {
const projectId = getProjectId();
const model = findSelectedModel();
if (!projectId || !model) {
showToast(L("B04_Surface_Error_Selection"), "error");
return;
}
showLoadingOverlay();
try {
await confirmSurfaceModel(projectId, model.id, {
smooth: terrainViewer.isSmoothingEnabled(),
contour_interval_m: terrainViewer.getContourInterval(),
});
showToast(
L("B04_Surface_Confirm_Success")
.replace("{filter}", filterGroup.select.value)
.replace("{method}", methodGroup.select.value)
.replace("{smoothing}", terrainViewer.isSmoothingEnabled() ? "ON" : "OFF"),
"success",
);
await loadProjectData(projectId);
enableRouteStep(projectId);
goToWorkflowStage(projectId, ROUTE_STAGE);
} catch (error) {
const detail = error instanceof Error ? error.message : L("B04_Surface_Confirm_Failed");
showToast(`${L("B04_Surface_Confirm_Failed")} ${detail}`, "error");
} finally {
hideLoadingOverlay();
}
}
async function onB04_Surface_Reset_Click(): Promise<void> {
const projectId = getProjectId();
if (!projectId) return;
filterGroup.select.value = DEFAULT_FILTER;
methodGroup.select.value = DEFAULT_METHOD;
viewer.resetOptions();
terrainViewer.resetOptions();
showLoadingOverlay();
try {
await loadProjectData(projectId);
} catch {
showToast(L("B04_Surface_Load_Failed"), "error");
} finally {
hideLoadingOverlay();
}
}
inputSelect.addEventListener("change", () => {
selectedInputFile = inputFiles.find((file) => String(file.id) === inputSelect.value) ?? null;
renderInputInfo();
});
filterGroup.select.addEventListener("change", () => {
updateSelectedModel();
void updatePointCloudForFilter();
});
methodGroup.select.addEventListener("change", updateSelectedModel);
root.replaceChildren(layout.root);
const projectId = getProjectId();
if (projectId) void onB04_Surface_Reset_Click();
}