260710_0
This commit is contained in:
@@ -40,6 +40,47 @@ export interface SurfaceModelSummary {
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SurfaceInputFileSummary {
|
||||
id: number;
|
||||
file_type: string;
|
||||
original_filename: string;
|
||||
raw_file_path: string;
|
||||
file_size_mb: number | null;
|
||||
crs_epsg: number | null;
|
||||
status: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SurfaceInputFileListResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
files: SurfaceInputFileSummary[];
|
||||
}
|
||||
|
||||
export interface SurfacePointCloudSampleResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
point_count: number;
|
||||
sampled_count: number;
|
||||
bounds: Record<string, number>;
|
||||
points: [number, number, number][];
|
||||
}
|
||||
|
||||
export interface SurfaceGroundStatsResponse {
|
||||
status: string;
|
||||
project_id: string;
|
||||
filters: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface SurfaceStatusResponse {
|
||||
project_id: string;
|
||||
status: "pending" | "in_progress" | "completed" | "failed";
|
||||
model_count: number;
|
||||
progress_percent: number;
|
||||
current_stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 지표면 모델 목록 응답 (SurfaceModelListResponse) */
|
||||
export interface SurfaceModelListResponse {
|
||||
status: string;
|
||||
@@ -88,3 +129,36 @@ export async function listSurfaceModels(projectId: string): Promise<SurfaceModel
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function listSurfaceInputFiles(
|
||||
projectId: string,
|
||||
): Promise<SurfaceInputFileListResponse> {
|
||||
return requestJson<SurfaceInputFileListResponse>(`/projects/${projectId}/surface/input-files`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSurfacePointCloud(
|
||||
projectId: string,
|
||||
): Promise<SurfacePointCloudSampleResponse> {
|
||||
return requestJson<SurfacePointCloudSampleResponse>(
|
||||
`/projects/${projectId}/surface/point-cloud`,
|
||||
{
|
||||
method: "GET",
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSurfaceGroundStats(
|
||||
projectId: string,
|
||||
): Promise<SurfaceGroundStatsResponse> {
|
||||
return requestJson<SurfaceGroundStatsResponse>(`/projects/${projectId}/surface/ground-stats`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchSurfaceStatus(projectId: string): Promise<SurfaceStatusResponse> {
|
||||
return requestJson<SurfaceStatusResponse>(`/projects/${projectId}/surface/status`, {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -198,6 +198,38 @@ async def get_input_file(
|
||||
}
|
||||
|
||||
|
||||
async def list_project_point_cloud_inputs(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
"""프로젝트의 LAS/LAZ 원본 입력 파일 목록을 최신순으로 조회한다."""
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT id, file_type, original_filename, raw_file_path, file_size_mb,
|
||||
crs_epsg, status, created_at
|
||||
FROM input_files
|
||||
WHERE project_id = %s AND file_type IN ('las', 'laz')
|
||||
ORDER BY created_at DESC, id DESC
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
rows = await cursor.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(row[0]),
|
||||
"file_type": row[1],
|
||||
"original_filename": row[2],
|
||||
"raw_file_path": row[3],
|
||||
"file_size_mb": float(row[4]) if row[4] is not None else None,
|
||||
"crs_epsg": row[5],
|
||||
"status": row[6],
|
||||
"created_at": row[7].isoformat() if row[7] else None,
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
async def list_surface_models(
|
||||
connection: aiomysql.Connection, project_id: UUID
|
||||
) -> list[dict[str, Any]]:
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""B04 지표면 분석 FastAPI 라우터."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
import aiomysql
|
||||
import numpy as np
|
||||
from fastapi import APIRouter
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
@@ -17,19 +19,39 @@ from B04_wf1_Surface.B04_wf1_Surface_Repository import (
|
||||
create_surface_model,
|
||||
create_terrain_layer,
|
||||
get_input_file,
|
||||
list_project_point_cloud_inputs,
|
||||
list_surface_models,
|
||||
)
|
||||
from B04_wf1_Surface.B04_wf1_Surface_Schema import (
|
||||
SurfaceAnalyzeRequest,
|
||||
SurfaceAnalyzeResponse,
|
||||
SurfaceGroundStatsResponse,
|
||||
SurfaceInputFileListResponse,
|
||||
SurfaceInputFileSummary,
|
||||
SurfaceModelListResponse,
|
||||
SurfaceModelSummary,
|
||||
SurfacePointCloudSampleResponse,
|
||||
)
|
||||
from common_util.common_util_storage import resolve_stored_project_path
|
||||
from config.config_db import get_db_pool
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B04 Surface Analysis"])
|
||||
POINT_CLOUD_SAMPLE_LIMIT = 100_000
|
||||
|
||||
|
||||
async def update_project_status(
|
||||
connection: aiomysql.Connection, project_id: UUID, status: str
|
||||
) -> None:
|
||||
async with connection.cursor() as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
UPDATE projects
|
||||
SET status = %s, updated_at = NOW()
|
||||
WHERE id = %s AND deleted_at IS NULL
|
||||
""",
|
||||
(status, str(project_id)),
|
||||
)
|
||||
|
||||
|
||||
async def save_surface_analysis_to_db(
|
||||
@@ -103,6 +125,8 @@ async def analyze_surface(
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
await update_project_status(connection, project_id, "WF1_ANALYZING")
|
||||
await connection.commit()
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
input_file = await get_input_file(connection, project_id, request.input_file_id)
|
||||
@@ -133,6 +157,7 @@ async def analyze_surface(
|
||||
analysis_result=result,
|
||||
source_filters=source_filters,
|
||||
)
|
||||
await update_project_status(connection, project_id, "WF1_COMPLETE")
|
||||
await connection.commit()
|
||||
except Exception:
|
||||
await connection.rollback()
|
||||
@@ -147,9 +172,15 @@ async def analyze_surface(
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except (OSError, ValueError) as exc:
|
||||
async with pool.acquire() as connection:
|
||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
||||
await connection.commit()
|
||||
return JSONResponse(status_code=400, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 지표면 분석 실패: project_id=%s", project_id)
|
||||
async with pool.acquire() as connection:
|
||||
await update_project_status(connection, project_id, "WF1_FAILED")
|
||||
await connection.commit()
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지표면 분석 처리 중 오류가 발생했습니다."},
|
||||
@@ -175,6 +206,108 @@ async def get_surface_models(project_id: UUID) -> SurfaceModelListResponse | JSO
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/input-files", response_model=SurfaceInputFileListResponse)
|
||||
async def get_surface_input_files(project_id: UUID) -> SurfaceInputFileListResponse | JSONResponse:
|
||||
"""프로젝트의 WF1 분석 대상 LAS/LAZ 입력 파일 목록을 조회한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
files = await list_project_point_cloud_inputs(connection, project_id)
|
||||
return SurfaceInputFileListResponse(
|
||||
project_id=str(project_id),
|
||||
files=[SurfaceInputFileSummary(**item) for item in files],
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("B04 입력 파일 목록 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "입력 파일 목록 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/point-cloud", response_model=SurfacePointCloudSampleResponse)
|
||||
async def get_surface_point_cloud(
|
||||
project_id: UUID,
|
||||
) -> SurfacePointCloudSampleResponse | JSONResponse:
|
||||
"""구조화된 LAS 결과에서 B04 3D 미리보기용 포인트 샘플을 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
structured_path = project_root / "B04_wf1_Surface" / "processed" / "structured.npz"
|
||||
if not structured_path.is_file():
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "구조화된 포인트클라우드가 없습니다."},
|
||||
)
|
||||
|
||||
with np.load(structured_path) as structured:
|
||||
xyz = np.asarray(structured["xyz"], dtype=np.float32)
|
||||
bounds = np.asarray(structured["bounds"], dtype=np.float64)
|
||||
point_count = int(len(xyz))
|
||||
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||
rng = np.random.default_rng(20260710)
|
||||
indexes = rng.choice(point_count, POINT_CLOUD_SAMPLE_LIMIT, replace=False)
|
||||
sample = xyz[indexes]
|
||||
else:
|
||||
sample = xyz
|
||||
|
||||
return SurfacePointCloudSampleResponse(
|
||||
project_id=str(project_id),
|
||||
point_count=point_count,
|
||||
sampled_count=int(len(sample)),
|
||||
bounds={
|
||||
"x_min": float(bounds[0, 0]),
|
||||
"x_max": float(bounds[0, 1]),
|
||||
"y_min": float(bounds[1, 0]),
|
||||
"y_max": float(bounds[1, 1]),
|
||||
"z_min": float(bounds[2, 0]),
|
||||
"z_max": float(bounds[2, 1]),
|
||||
},
|
||||
points=sample.astype(float).tolist(),
|
||||
)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 포인트클라우드 샘플 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "포인트클라우드 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/ground-stats", response_model=SurfaceGroundStatsResponse)
|
||||
async def get_surface_ground_stats(project_id: UUID) -> SurfaceGroundStatsResponse | JSONResponse:
|
||||
"""manifest에서 필터별 지면 포인트 통계를 반환한다."""
|
||||
pool = get_db_pool()
|
||||
try:
|
||||
async with pool.acquire() as connection:
|
||||
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||
project_root = Path(resolve_stored_project_path(stored_path))
|
||||
manifest_path = project_root / "B04_wf1_Surface" / "models" / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return SurfaceGroundStatsResponse(project_id=str(project_id), filters={})
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
filters = {
|
||||
key: {
|
||||
"source_point_count": value.get("source_point_count"),
|
||||
"methods": value.get("methods", {}),
|
||||
}
|
||||
for key, value in manifest.get("source_filters", {}).items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
return SurfaceGroundStatsResponse(project_id=str(project_id), filters=filters)
|
||||
except LookupError as exc:
|
||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||
except Exception:
|
||||
logger.exception("B04 지면 통계 조회 실패: project_id=%s", project_id)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"status": "error", "message": "지면 통계 조회 중 오류가 발생했습니다."},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/surface/status")
|
||||
async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
||||
"""WF1 분석 상태를 조회한다."""
|
||||
@@ -184,20 +317,50 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
||||
async with connection.cursor(aiomysql.DictCursor) as cursor:
|
||||
await cursor.execute(
|
||||
"""
|
||||
SELECT COUNT(*) as model_count
|
||||
FROM surface_models
|
||||
WHERE project_id = %s
|
||||
SELECT p.status as project_status, COUNT(sm.id) as model_count
|
||||
FROM projects p
|
||||
LEFT JOIN surface_models sm ON sm.project_id = p.id
|
||||
WHERE p.id = %s AND p.deleted_at IS NULL
|
||||
GROUP BY p.id, p.status
|
||||
""",
|
||||
(str(project_id),),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if not row:
|
||||
return JSONResponse(
|
||||
status_code=404,
|
||||
content={"status": "error", "message": "프로젝트를 찾을 수 없습니다."},
|
||||
)
|
||||
model_count = int(row["model_count"]) if row else 0
|
||||
|
||||
status = "completed" if model_count > 0 else "in_progress"
|
||||
project_status = str(row.get("project_status") or "NEW")
|
||||
if project_status == "WF1_FAILED":
|
||||
status = "failed"
|
||||
progress_percent = 0
|
||||
current_stage = "failed"
|
||||
message = "WF1 분석에 실패했습니다."
|
||||
elif model_count > 0 or project_status == "WF1_COMPLETE":
|
||||
status = "completed"
|
||||
progress_percent = 100
|
||||
current_stage = "completed"
|
||||
message = "WF1 분석이 완료되었습니다."
|
||||
elif project_status == "WF1_ANALYZING":
|
||||
status = "in_progress"
|
||||
progress_percent = 30
|
||||
current_stage = "surface_analysis"
|
||||
message = "WF1 분석이 진행 중입니다."
|
||||
else:
|
||||
status = "pending"
|
||||
progress_percent = 0
|
||||
current_stage = "pending"
|
||||
message = "WF1 분석 대기 중입니다."
|
||||
return {
|
||||
"project_id": str(project_id),
|
||||
"status": status,
|
||||
"model_count": model_count,
|
||||
"progress_percent": progress_percent,
|
||||
"current_stage": current_stage,
|
||||
"message": message,
|
||||
}
|
||||
except Exception:
|
||||
logger.exception("WF1 분석 상태 조회 실패: project_id=%s", project_id)
|
||||
|
||||
@@ -53,6 +53,46 @@ class SurfaceModelSummary(BaseModel):
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class SurfaceInputFileSummary(BaseModel):
|
||||
"""WF1 분석 대상으로 선택 가능한 LAS/LAZ 입력 파일 요약."""
|
||||
|
||||
id: int
|
||||
file_type: str
|
||||
original_filename: str
|
||||
raw_file_path: str
|
||||
file_size_mb: float | None = None
|
||||
crs_epsg: int | None = None
|
||||
status: str | None = None
|
||||
created_at: str | None = None
|
||||
|
||||
|
||||
class SurfaceInputFileListResponse(BaseModel):
|
||||
"""프로젝트 지표면 분석 입력 파일 목록 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
files: list[SurfaceInputFileSummary]
|
||||
|
||||
|
||||
class SurfacePointCloudSampleResponse(BaseModel):
|
||||
"""B04 3D 미리보기용 포인트클라우드 샘플."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
point_count: int
|
||||
sampled_count: int
|
||||
bounds: dict[str, float]
|
||||
points: list[list[float]]
|
||||
|
||||
|
||||
class SurfaceGroundStatsResponse(BaseModel):
|
||||
"""필터별 지면 포인트 통계 응답."""
|
||||
|
||||
status: str = "success"
|
||||
project_id: str
|
||||
filters: dict[str, dict[str, Any]]
|
||||
|
||||
|
||||
class SurfaceAnalyzeResponse(BaseModel):
|
||||
"""지표면 분석 실행 결과."""
|
||||
|
||||
|
||||
@@ -25,9 +25,17 @@ import {
|
||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||
import {
|
||||
analyzeSurface,
|
||||
fetchSurfaceGroundStats,
|
||||
fetchSurfacePointCloud,
|
||||
fetchSurfaceStatus,
|
||||
listSurfaceInputFiles,
|
||||
listSurfaceModels,
|
||||
type SurfaceInputFileSummary,
|
||||
type SurfaceModelSummary,
|
||||
type SurfaceStatusResponse,
|
||||
} from "./B04_wf1_Surface_Api_Fetch";
|
||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||
import "./B04_wf1_Surface_UI_Style.css";
|
||||
|
||||
/** locale 헬퍼 */
|
||||
@@ -73,7 +81,7 @@ function buildCheckboxGroup(
|
||||
return { root, selected };
|
||||
}
|
||||
|
||||
export function renderB04Surface(root: HTMLElement): void {
|
||||
export function renderB04SurfaceLegacy(root: HTMLElement): void {
|
||||
const shell = createWorkflowShell({
|
||||
title: L("B04_Surface_Title"),
|
||||
steps: workflowSteps(),
|
||||
@@ -166,9 +174,7 @@ export function renderB04Surface(root: HTMLElement): void {
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b04-surface__model-meta";
|
||||
const resolution = document.createElement("span");
|
||||
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${
|
||||
model.resolution_m ?? "-"
|
||||
}`;
|
||||
resolution.textContent = `${L("B04_Surface_Model_Resolution")}: ${model.resolution_m ?? "-"}`;
|
||||
const path = document.createElement("span");
|
||||
path.textContent = `${L("B04_Surface_Model_Path")}: ${model.model_file_path ?? "-"}`;
|
||||
meta.append(resolution, path);
|
||||
@@ -248,3 +254,282 @@ export function renderB04Surface(root: HTMLElement): void {
|
||||
const initialProjectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (initialProjectId) void loadModels(initialProjectId);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function renderB04Surface(root: HTMLElement): void {
|
||||
let selectedInputFile: SurfaceInputFileSummary | null = null;
|
||||
let inputFiles: SurfaceInputFileSummary[] = [];
|
||||
|
||||
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 modelList = document.createElement("div");
|
||||
modelList.className = "b04-surface__models";
|
||||
const statsList = document.createElement("div");
|
||||
statsList.className = "b04-surface__stats";
|
||||
const viewer = createSurfacePointCloudViewer();
|
||||
|
||||
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
||||
"grid_min_z",
|
||||
"csf",
|
||||
"pmf",
|
||||
]);
|
||||
const methodGroup = buildCheckboxGroup(L("B04_Surface_Group_Methods"), MODEL_METHODS, [
|
||||
"dtm",
|
||||
"tin",
|
||||
]);
|
||||
|
||||
const forceLabel = document.createElement("label");
|
||||
forceLabel.className = "b04-surface__check";
|
||||
const forceBox = document.createElement("input");
|
||||
forceBox.type = "checkbox";
|
||||
const forceText = document.createElement("span");
|
||||
forceText.textContent = L("B04_Surface_Field_Force");
|
||||
forceLabel.append(forceBox, forceText);
|
||||
|
||||
const analyzeButton = createButton({
|
||||
label: L("B04_Surface_Btn_Analyze"),
|
||||
variant: "filled",
|
||||
onClick: () => void onB04_Surface_Analyze_Click(),
|
||||
});
|
||||
const refreshButton = createButton({
|
||||
label: L("B04_Surface_Btn_Refresh"),
|
||||
variant: "ghost",
|
||||
onClick: () => void onB04_Surface_Refresh_Click(),
|
||||
});
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "b04-surface__form";
|
||||
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);
|
||||
panel.append(
|
||||
inputGroup,
|
||||
filterGroup.root,
|
||||
methodGroup.root,
|
||||
forceLabel,
|
||||
analyzeButton,
|
||||
refreshButton,
|
||||
);
|
||||
|
||||
const workspace = document.createElement("div");
|
||||
workspace.className = "b04-surface__workspace";
|
||||
const topbar = document.createElement("div");
|
||||
topbar.className = "b04-surface__topbar";
|
||||
const title = document.createElement("h3");
|
||||
title.textContent = L("B04_Surface_PointCloud_Title");
|
||||
topbar.append(title, statusBox);
|
||||
|
||||
const bottom = document.createElement("div");
|
||||
bottom.className = "b04-surface__bottom";
|
||||
const statsSection = document.createElement("section");
|
||||
statsSection.className = "b04-surface__section";
|
||||
const statsTitle = document.createElement("h3");
|
||||
statsTitle.textContent = L("B04_Surface_GroundStats_Title");
|
||||
statsSection.append(statsTitle, statsList);
|
||||
const modelsSection = document.createElement("section");
|
||||
modelsSection.className = "b04-surface__section";
|
||||
const modelsTitle = document.createElement("h3");
|
||||
modelsTitle.textContent = L("B04_Surface_Result_Title");
|
||||
modelsSection.append(modelsTitle, modelList);
|
||||
bottom.append(statsSection, modelsSection);
|
||||
workspace.append(topbar, viewer.root, bottom);
|
||||
|
||||
const layout = createWorkflowLayout({
|
||||
title: L("B04_Surface_Title"),
|
||||
steps: workflowSteps(),
|
||||
activeStep: 1,
|
||||
leftPanel: panel,
|
||||
mainContent: workspace,
|
||||
});
|
||||
|
||||
function getProjectId(): string | null {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
if (!projectId) showToast(L("B04_Surface_Error_Project"), "error");
|
||||
return projectId;
|
||||
}
|
||||
|
||||
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 renderInputFiles(files: readonly SurfaceInputFileSummary[]): void {
|
||||
inputFiles = [...files];
|
||||
inputSelect.replaceChildren();
|
||||
if (inputFiles.length === 0) {
|
||||
selectedInputFile = null;
|
||||
const option = document.createElement("option");
|
||||
option.value = "";
|
||||
option.textContent = L("B04_Surface_InputFiles_Empty");
|
||||
inputSelect.append(option);
|
||||
inputInfo.replaceChildren();
|
||||
analyzeButton.disabled = true;
|
||||
return;
|
||||
}
|
||||
selectedInputFile = inputFiles[0];
|
||||
for (const file of inputFiles) {
|
||||
const option = document.createElement("option");
|
||||
option.value = String(file.id);
|
||||
option.textContent = `${file.original_filename} (#${file.id})`;
|
||||
inputSelect.append(option);
|
||||
}
|
||||
inputSelect.value = String(selectedInputFile.id);
|
||||
analyzeButton.disabled = false;
|
||||
renderInputInfo();
|
||||
}
|
||||
|
||||
function renderInputInfo(): void {
|
||||
inputInfo.replaceChildren();
|
||||
if (!selectedInputFile) return;
|
||||
inputInfo.append(
|
||||
buildInfoLine(L("B04_Surface_Input_FileName"), selectedInputFile.original_filename),
|
||||
buildInfoLine(L("B04_Surface_Input_Crs"), selectedInputFile.crs_epsg),
|
||||
buildInfoLine(L("B04_Surface_Input_Size"), selectedInputFile.file_size_mb?.toFixed(2)),
|
||||
);
|
||||
}
|
||||
|
||||
function renderModels(models: readonly SurfaceModelSummary[]): void {
|
||||
modelList.replaceChildren();
|
||||
if (models.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-surface__empty";
|
||||
empty.textContent = L("B04_Surface_Result_Empty");
|
||||
modelList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const model of models) {
|
||||
const card = document.createElement("article");
|
||||
card.className = "b04-surface__model-card";
|
||||
const head = document.createElement("div");
|
||||
head.className = "b04-surface__model-head";
|
||||
const type = document.createElement("strong");
|
||||
type.textContent = model.model_type;
|
||||
const variant =
|
||||
model.status === "CONFIRMED" || model.status === "COMPLETE" ? "success" : "neutral";
|
||||
head.append(type, createTag(model.status, variant));
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "b04-surface__model-meta";
|
||||
meta.append(
|
||||
buildInfoLine(L("B04_Surface_Model_Resolution"), model.resolution_m),
|
||||
buildInfoLine(L("B04_Surface_Model_Path"), model.model_file_path),
|
||||
);
|
||||
card.append(head, meta);
|
||||
modelList.append(card);
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroundStats(filters: Record<string, Record<string, unknown>>): void {
|
||||
statsList.replaceChildren();
|
||||
const entries = Object.entries(filters);
|
||||
if (entries.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "b04-surface__empty";
|
||||
empty.textContent = L("B04_Surface_GroundStats_Empty");
|
||||
statsList.append(empty);
|
||||
return;
|
||||
}
|
||||
for (const [name, value] of entries) {
|
||||
const item = document.createElement("article");
|
||||
item.className = "b04-surface__stat-card";
|
||||
item.append(
|
||||
buildInfoLine(L("B04_Surface_GroundStats_Filter"), name),
|
||||
buildInfoLine(L("B04_Surface_GroundStats_SourcePoints"), value.source_point_count),
|
||||
);
|
||||
statsList.append(item);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadProjectData(projectId: string): Promise<void> {
|
||||
const [inputs, status, models, stats] = await Promise.all([
|
||||
listSurfaceInputFiles(projectId),
|
||||
fetchSurfaceStatus(projectId),
|
||||
listSurfaceModels(projectId),
|
||||
fetchSurfaceGroundStats(projectId),
|
||||
]);
|
||||
renderInputFiles(inputs.files);
|
||||
renderStatus(status);
|
||||
renderModels(models.models);
|
||||
renderGroundStats(stats.filters);
|
||||
try {
|
||||
viewer.render(await fetchSurfacePointCloud(projectId));
|
||||
} catch {
|
||||
viewer.render(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Analyze_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId || !selectedInputFile) return;
|
||||
if (filterGroup.selected.size === 0 || methodGroup.selected.size === 0) {
|
||||
showToast(L("B04_Surface_Error_Selection"), "error");
|
||||
return;
|
||||
}
|
||||
showLoadingOverlay();
|
||||
try {
|
||||
const response = await analyzeSurface(projectId, {
|
||||
input_file_id: selectedInputFile.id,
|
||||
source_filters: [...filterGroup.selected],
|
||||
methods: [...methodGroup.selected],
|
||||
force: forceBox.checked,
|
||||
});
|
||||
showToast(
|
||||
`${L("B04_Surface_Analyze_Success")} (${response.surface_model_ids.length})`,
|
||||
"success",
|
||||
);
|
||||
await loadProjectData(projectId);
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : L("B04_Surface_Analyze_Failed");
|
||||
showToast(`${L("B04_Surface_Analyze_Failed")} ${detail}`, "error");
|
||||
} finally {
|
||||
hideLoadingOverlay();
|
||||
}
|
||||
}
|
||||
|
||||
async function onB04_Surface_Refresh_Click(): Promise<void> {
|
||||
const projectId = getProjectId();
|
||||
if (!projectId) return;
|
||||
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();
|
||||
});
|
||||
|
||||
root.replaceChildren(layout.root);
|
||||
const projectId = getProjectId();
|
||||
if (projectId) void onB04_Surface_Refresh_Click();
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
padding: var(--spacing-16);
|
||||
}
|
||||
|
||||
.b04-surface__group {
|
||||
@@ -45,7 +46,99 @@
|
||||
accent-color: var(--color-primary);
|
||||
}
|
||||
|
||||
.b04-surface__panel-title {
|
||||
font-size: var(--text-body-sm);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b04-surface__select {
|
||||
width: 100%;
|
||||
min-height: 38px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-inputs);
|
||||
background: var(--color-canvas);
|
||||
color: var(--color-text-body);
|
||||
padding: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b04-surface__input-info,
|
||||
.b04-surface__model-meta,
|
||||
.b04-surface__stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b04-surface__line {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-caption);
|
||||
}
|
||||
|
||||
.b04-surface__line strong {
|
||||
color: var(--color-text-body);
|
||||
text-align: right;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* --- 우측 결과 영역 --- */
|
||||
.b04-surface__workspace {
|
||||
display: grid;
|
||||
grid-template-rows: auto minmax(360px, 1fr) auto;
|
||||
min-height: calc(100vh - var(--wf-header-height));
|
||||
}
|
||||
|
||||
.b04-surface__topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--spacing-16);
|
||||
padding: var(--spacing-16) var(--spacing-24);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b04-surface__topbar h3,
|
||||
.b04-surface__section h3 {
|
||||
font-size: var(--text-body);
|
||||
}
|
||||
|
||||
.b04-surface__status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b04-surface-viewer {
|
||||
min-height: 360px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.b04-surface-viewer__canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.b04-surface__bottom {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
|
||||
gap: var(--spacing-16);
|
||||
padding: var(--spacing-16) var(--spacing-24) var(--spacing-24);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.b04-surface__section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-16);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b04-surface__result {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -86,6 +179,16 @@
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.b04-surface__stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-16);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background-color: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b04-surface__model-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -107,3 +210,9 @@
|
||||
color: var(--color-text-secondary);
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.b04-surface__bottom {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import * as THREE from "three";
|
||||
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||
import { RENDER_OPTIONS } from "@config/config_frontend";
|
||||
import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetch";
|
||||
|
||||
export interface SurfacePointCloudViewer {
|
||||
root: HTMLElement;
|
||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||
dispose: () => void;
|
||||
}
|
||||
|
||||
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b04-surface-viewer";
|
||||
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.className = "b04-surface-viewer__canvas";
|
||||
root.append(canvas);
|
||||
|
||||
const renderer = new THREE.WebGLRenderer({
|
||||
canvas,
|
||||
antialias: RENDER_OPTIONS.antialias,
|
||||
});
|
||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
||||
|
||||
const scene = new THREE.Scene();
|
||||
scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight);
|
||||
const camera = new THREE.PerspectiveCamera(
|
||||
RENDER_OPTIONS.cameraFov,
|
||||
1,
|
||||
RENDER_OPTIONS.cameraNear,
|
||||
RENDER_OPTIONS.cameraFar,
|
||||
);
|
||||
const controls = new OrbitControls(camera, canvas);
|
||||
controls.enableDamping = true;
|
||||
|
||||
const grid = new THREE.GridHelper(
|
||||
200,
|
||||
20,
|
||||
RENDER_OPTIONS.gridCenterColor,
|
||||
RENDER_OPTIONS.gridLineColor,
|
||||
);
|
||||
scene.add(grid);
|
||||
|
||||
let pointsObject: THREE.Points | null = null;
|
||||
let animationFrame = 0;
|
||||
|
||||
function resize(): void {
|
||||
const rect = root.getBoundingClientRect();
|
||||
const width = Math.max(1, Math.floor(rect.width));
|
||||
const height = Math.max(1, Math.floor(rect.height));
|
||||
renderer.setSize(width, height, false);
|
||||
camera.aspect = width / height;
|
||||
camera.updateProjectionMatrix();
|
||||
}
|
||||
|
||||
function animate(): void {
|
||||
resize();
|
||||
controls.update();
|
||||
renderer.render(scene, camera);
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
|
||||
function clearPoints(): void {
|
||||
if (!pointsObject) return;
|
||||
pointsObject.geometry.dispose();
|
||||
const material = pointsObject.material;
|
||||
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||
else material.dispose();
|
||||
scene.remove(pointsObject);
|
||||
pointsObject = null;
|
||||
}
|
||||
|
||||
function render(data: SurfacePointCloudSampleResponse | null): void {
|
||||
clearPoints();
|
||||
if (!data || data.points.length === 0) return;
|
||||
|
||||
const bounds = data.bounds;
|
||||
const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2;
|
||||
const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2;
|
||||
const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2;
|
||||
const positions = new Float32Array(data.points.length * 3);
|
||||
data.points.forEach((point, index) => {
|
||||
positions[index * 3] = point[0] - cx;
|
||||
positions[index * 3 + 1] = point[2] - cz;
|
||||
positions[index * 3 + 2] = point[1] - cy;
|
||||
});
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||
geometry.computeBoundingSphere();
|
||||
const material = new THREE.PointsMaterial({
|
||||
color: RENDER_OPTIONS.pointColor,
|
||||
size: 1.6,
|
||||
sizeAttenuation: false,
|
||||
});
|
||||
pointsObject = new THREE.Points(geometry, material);
|
||||
scene.add(pointsObject);
|
||||
|
||||
const radius = geometry.boundingSphere?.radius ?? 100;
|
||||
camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4);
|
||||
camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000);
|
||||
camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8);
|
||||
camera.updateProjectionMatrix();
|
||||
controls.target.set(0, 0, 0);
|
||||
controls.update();
|
||||
}
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
|
||||
return {
|
||||
root,
|
||||
render,
|
||||
dispose: () => {
|
||||
cancelAnimationFrame(animationFrame);
|
||||
clearPoints();
|
||||
controls.dispose();
|
||||
renderer.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user