260710_1
This commit is contained in:
@@ -126,6 +126,7 @@ function buildHeader(): HTMLElement {
|
|||||||
|
|
||||||
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
|
/** 서버 세션 상태에 따라 로그인/로그아웃 버튼을 채운다. */
|
||||||
async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
||||||
|
try {
|
||||||
const loggedIn = await isAuthenticated();
|
const loggedIn = await isAuthenticated();
|
||||||
slot.innerHTML = "";
|
slot.innerHTML = "";
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
@@ -157,6 +158,22 @@ async function fillAuthActions(slot: HTMLElement): Promise<void> {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("[app_shell] fillAuthActions error:", error);
|
||||||
|
slot.innerHTML = "";
|
||||||
|
slot.append(
|
||||||
|
createButton({
|
||||||
|
label: L("Nav_Login"),
|
||||||
|
variant: "ghost",
|
||||||
|
onClick: () => navigateTo(ROUTES.A06_LOGIN),
|
||||||
|
}),
|
||||||
|
createButton({
|
||||||
|
label: L("Nav_Register"),
|
||||||
|
variant: "filled",
|
||||||
|
onClick: () => navigateTo(ROUTES.A07_REGISTER),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildFooter(): HTMLElement {
|
function buildFooter(): HTMLElement {
|
||||||
|
|||||||
@@ -93,10 +93,12 @@ export function renderPendingWorkflow(root: HTMLElement, opts: PendingWorkflowOp
|
|||||||
root.append(shell.root);
|
root.append(shell.root);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 워크플로우 스텝 라벨 6종 (locale 결과) — B04~B09 공용.
|
/** 워크플로우 스텝 라벨 7종 (locale 결과) — B03~B09 공용.
|
||||||
* 기존 WF_Step_* 키를 재사용 (중복 등록 방지, frontend.md §3). */
|
* 파일 입력(B03)부터 견적/문서(B09)까지 대시보드 워크플로우와 동일한 순서.
|
||||||
|
* 기존 WF_Step_* / B03_File_Title 키를 재사용 (중복 등록 방지, frontend.md §3). */
|
||||||
export function workflowSteps(): string[] {
|
export function workflowSteps(): string[] {
|
||||||
return [
|
return [
|
||||||
|
L("B03_File_Title"),
|
||||||
L("WF_Step_Surface"),
|
L("WF_Step_Surface"),
|
||||||
L("WF_Step_Route"),
|
L("WF_Step_Route"),
|
||||||
L("WF_Step_ProfileCross"),
|
L("WF_Step_ProfileCross"),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||||
import { isBlank } from "@util/common_util_validate";
|
import { isBlank } from "@util/common_util_validate";
|
||||||
import { navigateTo } from "../A00_Common/router";
|
import { navigateTo } from "../A00_Common/router";
|
||||||
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
@@ -382,12 +383,13 @@ function workflow(project: ProjectItem): HTMLElement {
|
|||||||
box.className = "b01-dashboard__workflow";
|
box.className = "b01-dashboard__workflow";
|
||||||
|
|
||||||
const stages = project.workflow_state?.stages;
|
const stages = project.workflow_state?.stages;
|
||||||
|
const stepLabels = workflowSteps();
|
||||||
|
|
||||||
routes.forEach((route, index) => {
|
routes.forEach((route, index) => {
|
||||||
const button = document.createElement("button");
|
const button = document.createElement("button");
|
||||||
button.type = "button";
|
button.type = "button";
|
||||||
button.className = "b01-dashboard__step";
|
button.className = "b01-dashboard__step";
|
||||||
button.textContent = `B${String(index + 3).padStart(2, "0")}`;
|
button.textContent = stepLabels[index] ?? `B${String(index + 3).padStart(2, "0")}`;
|
||||||
|
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
if (stages && stages.length > 0) {
|
if (stages && stages.length > 0) {
|
||||||
|
|||||||
@@ -83,17 +83,19 @@
|
|||||||
|
|
||||||
.b01-dashboard__workflow {
|
.b01-dashboard__workflow {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: var(--spacing-4);
|
gap: var(--spacing-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b01-dashboard__step {
|
.b01-dashboard__step {
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
height: 28px;
|
||||||
|
padding: 0 var(--spacing-12);
|
||||||
border: 1px solid var(--color-border);
|
border: 1px solid var(--color-border);
|
||||||
border-radius: var(--radius-icons);
|
border-radius: var(--radius-pills);
|
||||||
background: var(--color-surface);
|
background: var(--color-surface);
|
||||||
color: var(--color-text-muted);
|
color: var(--color-text-muted);
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
|
white-space: nowrap;
|
||||||
cursor: default;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ export interface SurfacePointCloudSampleResponse {
|
|||||||
sampled_count: number;
|
sampled_count: number;
|
||||||
bounds: Record<string, number>;
|
bounds: Record<string, number>;
|
||||||
points: [number, number, number][];
|
points: [number, number, number][];
|
||||||
|
rgb?: [number, number, number][];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SurfaceGroundStatsResponse {
|
export interface SurfaceGroundStatsResponse {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ from uuid import UUID
|
|||||||
import aiomysql
|
import aiomysql
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from fastapi import APIRouter
|
from fastapi import APIRouter
|
||||||
from fastapi.responses import JSONResponse
|
from fastapi.responses import FileResponse, JSONResponse
|
||||||
|
|
||||||
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
|
||||||
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
from B04_wf1_Surface.B04_wf1_Surface_Engine import run_surface_analysis
|
||||||
@@ -304,6 +304,15 @@ async def get_surface_point_cloud(
|
|||||||
else:
|
else:
|
||||||
sample = xyz
|
sample = xyz
|
||||||
|
|
||||||
|
rgb_sample = None
|
||||||
|
if "rgb" in structured:
|
||||||
|
rgb_arr = np.asarray(structured["rgb"])
|
||||||
|
if rgb_arr.ndim > 0 and len(rgb_arr) == point_count:
|
||||||
|
if point_count > POINT_CLOUD_SAMPLE_LIMIT:
|
||||||
|
rgb_sample = rgb_arr[indexes]
|
||||||
|
else:
|
||||||
|
rgb_sample = rgb_arr
|
||||||
|
|
||||||
return SurfacePointCloudSampleResponse(
|
return SurfacePointCloudSampleResponse(
|
||||||
project_id=str(project_id),
|
project_id=str(project_id),
|
||||||
point_count=point_count,
|
point_count=point_count,
|
||||||
@@ -317,6 +326,7 @@ async def get_surface_point_cloud(
|
|||||||
"z_max": float(bounds[2, 1]),
|
"z_max": float(bounds[2, 1]),
|
||||||
},
|
},
|
||||||
points=sample.astype(float).tolist(),
|
points=sample.astype(float).tolist(),
|
||||||
|
rgb=rgb_sample.astype(int).tolist() if rgb_sample is not None else None,
|
||||||
)
|
)
|
||||||
except LookupError as exc:
|
except LookupError as exc:
|
||||||
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
return JSONResponse(status_code=404, content={"status": "error", "message": str(exc)})
|
||||||
@@ -469,3 +479,161 @@ async def get_wf1_analysis_status(project_id: UUID) -> dict:
|
|||||||
status_code=500,
|
status_code=500,
|
||||||
content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."},
|
content={"status": "error", "message": "분석 상태 조회 중 오류가 발생했습니다."},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/surface/models/{model_id}/preview", response_model=None)
|
||||||
|
async def get_surface_model_preview(
|
||||||
|
project_id: UUID,
|
||||||
|
model_id: int,
|
||||||
|
smooth: bool = False,
|
||||||
|
) -> FileResponse | JSONResponse:
|
||||||
|
"""지표면 모델의 3D 프리뷰 파일(GLB/PLY)을 반환한다."""
|
||||||
|
pool = get_db_pool()
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as connection:
|
||||||
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT model_type, model_file_path
|
||||||
|
FROM surface_models
|
||||||
|
WHERE id = %s AND project_id = %s
|
||||||
|
""",
|
||||||
|
(model_id, str(project_id)),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||||
|
)
|
||||||
|
model_type, model_file_path = row[0], row[1]
|
||||||
|
|
||||||
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
|
if not model_file_path:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = project_root / model_file_path
|
||||||
|
models_dir = model_path.parent
|
||||||
|
stem = model_path.stem
|
||||||
|
|
||||||
|
ext = "ply" if model_type == "meshfree" else "glb"
|
||||||
|
if smooth and model_type in ("dtm", "tin"):
|
||||||
|
preview_filename = f"{stem}_smooth_preview.glb"
|
||||||
|
else:
|
||||||
|
preview_filename = f"{stem}_preview.{ext}"
|
||||||
|
|
||||||
|
preview_path = models_dir / preview_filename
|
||||||
|
if not preview_path.is_file():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={
|
||||||
|
"status": "error",
|
||||||
|
"message": "프리뷰 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
media_type = "application/octet-stream"
|
||||||
|
if ext == "glb":
|
||||||
|
media_type = "model/gltf-binary"
|
||||||
|
elif ext == "ply":
|
||||||
|
media_type = "application/ply"
|
||||||
|
|
||||||
|
return FileResponse(preview_path, media_type=media_type, filename=preview_filename)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"지표면 모델 프리뷰 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "프리뷰 파일 조회 중 오류가 발생했습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{project_id}/surface/models/{model_id}/contour", response_model=None)
|
||||||
|
async def get_surface_model_contour(
|
||||||
|
project_id: UUID,
|
||||||
|
model_id: int,
|
||||||
|
interval: float = 5.0,
|
||||||
|
smooth: bool = False,
|
||||||
|
) -> FileResponse | JSONResponse:
|
||||||
|
"""지표면 모델의 등고선 JSON 파일을 반환한다."""
|
||||||
|
pool = get_db_pool()
|
||||||
|
try:
|
||||||
|
async with pool.acquire() as connection:
|
||||||
|
stored_path = await get_project_storage_relative_path(connection, project_id)
|
||||||
|
async with connection.cursor() as cursor:
|
||||||
|
await cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT model_type, model_file_path
|
||||||
|
FROM surface_models
|
||||||
|
WHERE id = %s AND project_id = %s
|
||||||
|
""",
|
||||||
|
(model_id, str(project_id)),
|
||||||
|
)
|
||||||
|
row = await cursor.fetchone()
|
||||||
|
if not row:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "해당 모델을 찾을 수 없습니다."},
|
||||||
|
)
|
||||||
|
model_type, model_file_path = row[0], row[1]
|
||||||
|
|
||||||
|
project_root = Path(resolve_stored_project_path(stored_path))
|
||||||
|
if not model_file_path:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={"status": "error", "message": "모델 파일 경로가 없습니다."},
|
||||||
|
)
|
||||||
|
|
||||||
|
model_path = project_root / model_file_path
|
||||||
|
models_dir = model_path.parent
|
||||||
|
stem = model_path.stem
|
||||||
|
|
||||||
|
parts = stem.split("_")
|
||||||
|
if len(parts) >= 2:
|
||||||
|
method = parts[0]
|
||||||
|
filter_key = "_".join(parts[1:])
|
||||||
|
else:
|
||||||
|
method = model_type
|
||||||
|
filter_key = "csf"
|
||||||
|
|
||||||
|
if smooth and method in ("dtm", "tin"):
|
||||||
|
contour_filename = f"contour_{filter_key}_{method}_smooth_{interval}m.json"
|
||||||
|
else:
|
||||||
|
contour_filename = f"contour_{filter_key}_{method}_{interval}m.json"
|
||||||
|
|
||||||
|
contour_path = models_dir / contour_filename
|
||||||
|
|
||||||
|
if not contour_path.is_file():
|
||||||
|
fallback_files = list(models_dir.glob(f"contour_{filter_key}_{method}*.json"))
|
||||||
|
if smooth and method in ("dtm", "tin"):
|
||||||
|
fallback_files = list(
|
||||||
|
models_dir.glob(f"contour_{filter_key}_{method}_smooth_*.json")
|
||||||
|
)
|
||||||
|
if fallback_files:
|
||||||
|
contour_path = fallback_files[0]
|
||||||
|
|
||||||
|
if not contour_path.is_file():
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=404,
|
||||||
|
content={
|
||||||
|
"status": "error",
|
||||||
|
"message": "등고선 파일이 생성되지 않았거나 존재하지 않습니다.",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
return FileResponse(contour_path, media_type="application/json", filename=contour_filename)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"지표면 모델 등고선 조회 실패: project_id=%s, model_id=%s", project_id, model_id
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"status": "error", "message": "등고선 파일 조회 중 오류가 발생했습니다."},
|
||||||
|
)
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ class SurfacePointCloudSampleResponse(BaseModel):
|
|||||||
sampled_count: int
|
sampled_count: int
|
||||||
bounds: dict[str, float]
|
bounds: dict[str, float]
|
||||||
points: list[list[float]]
|
points: list[list[float]]
|
||||||
|
rgb: list[list[int]] | None = None
|
||||||
|
|
||||||
|
|
||||||
class SurfaceGroundStatsResponse(BaseModel):
|
class SurfaceGroundStatsResponse(BaseModel):
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
} from "./B04_wf1_Surface_Api_Fetch";
|
} from "./B04_wf1_Surface_Api_Fetch";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
||||||
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
import { createSurfacePointCloudViewer } from "./B04_wf1_Surface_UI_Viewer";
|
||||||
|
import { createSurfaceTerrainViewer } from "./B04_wf1_Surface_UI_TerrainViewer";
|
||||||
import "./B04_wf1_Surface_UI_Style.css";
|
import "./B04_wf1_Surface_UI_Style.css";
|
||||||
|
|
||||||
/** locale 헬퍼 */
|
/** locale 헬퍼 */
|
||||||
@@ -105,6 +106,7 @@ export function renderB04Surface(root: HTMLElement): void {
|
|||||||
const statsList = document.createElement("div");
|
const statsList = document.createElement("div");
|
||||||
statsList.className = "b04-surface__stats";
|
statsList.className = "b04-surface__stats";
|
||||||
const viewer = createSurfacePointCloudViewer();
|
const viewer = createSurfacePointCloudViewer();
|
||||||
|
const terrainViewer = createSurfaceTerrainViewer();
|
||||||
|
|
||||||
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
const filterGroup = buildCheckboxGroup(L("B04_Surface_Group_Filters"), SOURCE_FILTERS, [
|
||||||
"grid_min_z",
|
"grid_min_z",
|
||||||
@@ -148,6 +150,8 @@ export function renderB04Surface(root: HTMLElement): void {
|
|||||||
filterGroup.root,
|
filterGroup.root,
|
||||||
methodGroup.root,
|
methodGroup.root,
|
||||||
forceLabel,
|
forceLabel,
|
||||||
|
viewer.controlsGroup,
|
||||||
|
viewer.optionsGroup,
|
||||||
analyzeButton,
|
analyzeButton,
|
||||||
refreshButton,
|
refreshButton,
|
||||||
);
|
);
|
||||||
@@ -156,24 +160,35 @@ export function renderB04Surface(root: HTMLElement): void {
|
|||||||
workspace.className = "b04-surface__workspace";
|
workspace.className = "b04-surface__workspace";
|
||||||
const topbar = document.createElement("div");
|
const topbar = document.createElement("div");
|
||||||
topbar.className = "b04-surface__topbar";
|
topbar.className = "b04-surface__topbar";
|
||||||
|
|
||||||
|
const titleWrap = document.createElement("div");
|
||||||
|
titleWrap.style.display = "flex";
|
||||||
|
titleWrap.style.alignItems = "baseline";
|
||||||
|
titleWrap.style.gap = "var(--spacing-12)";
|
||||||
|
|
||||||
const title = document.createElement("h3");
|
const title = document.createElement("h3");
|
||||||
title.textContent = L("B04_Surface_PointCloud_Title");
|
title.textContent = L("B04_Surface_PointCloud_Title");
|
||||||
topbar.append(title, statusBox);
|
titleWrap.append(title, viewer.statusSpan);
|
||||||
|
|
||||||
|
topbar.append(titleWrap, statusBox);
|
||||||
|
|
||||||
const bottom = document.createElement("div");
|
const bottom = document.createElement("div");
|
||||||
bottom.className = "b04-surface__bottom";
|
bottom.className = "b04-surface__bottom";
|
||||||
|
|
||||||
const statsSection = document.createElement("section");
|
const statsSection = document.createElement("section");
|
||||||
statsSection.className = "b04-surface__section";
|
statsSection.className = "b04-surface__section";
|
||||||
const statsTitle = document.createElement("h3");
|
const statsTitle = document.createElement("h3");
|
||||||
statsTitle.textContent = L("B04_Surface_GroundStats_Title");
|
statsTitle.textContent = L("B04_Surface_GroundStats_Title");
|
||||||
statsSection.append(statsTitle, statsList);
|
statsSection.append(statsTitle, statsList);
|
||||||
|
|
||||||
const modelsSection = document.createElement("section");
|
const modelsSection = document.createElement("section");
|
||||||
modelsSection.className = "b04-surface__section";
|
modelsSection.className = "b04-surface__section";
|
||||||
const modelsTitle = document.createElement("h3");
|
const modelsTitle = document.createElement("h3");
|
||||||
modelsTitle.textContent = L("B04_Surface_Result_Title");
|
modelsTitle.textContent = L("B04_Surface_Result_Title");
|
||||||
modelsSection.append(modelsTitle, modelList);
|
modelsSection.append(modelsTitle, modelList);
|
||||||
|
|
||||||
bottom.append(statsSection, modelsSection);
|
bottom.append(statsSection, modelsSection);
|
||||||
workspace.append(topbar, viewer.root, bottom);
|
workspace.append(topbar, viewer.root, terrainViewer.root, bottom);
|
||||||
|
|
||||||
const layout = createWorkflowLayout({
|
const layout = createWorkflowLayout({
|
||||||
title: L("B04_Surface_Title"),
|
title: L("B04_Surface_Title"),
|
||||||
@@ -240,6 +255,10 @@ export function renderB04Surface(root: HTMLElement): void {
|
|||||||
|
|
||||||
function renderModels(models: readonly SurfaceModelSummary[]): void {
|
function renderModels(models: readonly SurfaceModelSummary[]): void {
|
||||||
modelList.replaceChildren();
|
modelList.replaceChildren();
|
||||||
|
const projectId = getProjectId();
|
||||||
|
if (projectId) {
|
||||||
|
terrainViewer.render(projectId, models);
|
||||||
|
}
|
||||||
if (models.length === 0) {
|
if (models.length === 0) {
|
||||||
const empty = document.createElement("p");
|
const empty = document.createElement("p");
|
||||||
empty.className = "b04-surface__empty";
|
empty.className = "b04-surface__empty";
|
||||||
|
|||||||
@@ -116,12 +116,15 @@
|
|||||||
.b04-surface-viewer {
|
.b04-surface-viewer {
|
||||||
min-height: 360px;
|
min-height: 360px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
padding: 0 var(--spacing-24) var(--spacing-24);
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.b04-surface-viewer__canvas {
|
.b04-surface-viewer__canvas {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
border-radius: var(--radius-cards);
|
||||||
}
|
}
|
||||||
|
|
||||||
.b04-surface__bottom {
|
.b04-surface__bottom {
|
||||||
@@ -216,3 +219,113 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.point-viewer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-cards);
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--color-surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--spacing-12);
|
||||||
|
padding: var(--spacing-12) var(--spacing-16);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-toolbar--stacked {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-toolbar div {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.point-toolbar span {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls button {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 var(--spacing-12);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: var(--radius-buttons);
|
||||||
|
background: var(--color-canvas);
|
||||||
|
color: var(--color-text);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-controls button:hover {
|
||||||
|
border-color: var(--color-accent);
|
||||||
|
background: var(--color-mist-violet);
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-4);
|
||||||
|
min-height: 32px;
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-options {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-16);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
padding: var(--spacing-12) var(--spacing-16);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-options label {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--spacing-8);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-option-val {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
min-width: 38px;
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--color-text);
|
||||||
|
font-weight: var(--font-weight-semibold);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtle-notice {
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
background: var(--color-surface-raised);
|
||||||
|
border-bottom: 1px solid var(--color-border);
|
||||||
|
padding: var(--spacing-8) var(--spacing-16);
|
||||||
|
font-size: var(--text-caption);
|
||||||
|
}
|
||||||
|
|
||||||
|
.three-viewer {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 560px;
|
||||||
|
background: var(--color-canvas);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,664 @@
|
|||||||
|
import * as THREE from "three";
|
||||||
|
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
|
||||||
|
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
||||||
|
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";
|
||||||
|
import type { SurfaceModelSummary } from "./B04_wf1_Surface_Api_Fetch";
|
||||||
|
import { API_BASE_URL } from "@config/config_frontend";
|
||||||
|
|
||||||
|
export interface SurfaceTerrainViewer {
|
||||||
|
root: HTMLElement;
|
||||||
|
render: (projectId: string, models: readonly SurfaceModelSummary[]) => void;
|
||||||
|
dispose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSurfaceTerrainViewer(): SurfaceTerrainViewer {
|
||||||
|
const root = document.createElement("div");
|
||||||
|
root.className = "terrain-model-group panel panel--no-bottom";
|
||||||
|
root.style.marginTop = "var(--spacing-24)";
|
||||||
|
|
||||||
|
// Header Title
|
||||||
|
const header = document.createElement("div");
|
||||||
|
header.className = "panel-title";
|
||||||
|
header.style.display = "flex";
|
||||||
|
header.style.justifyContent = "space-between";
|
||||||
|
header.style.alignItems = "center";
|
||||||
|
|
||||||
|
const titleSpan = document.createElement("span");
|
||||||
|
titleSpan.innerHTML = "🏕️ 지면 필터별 5가지 지표면 모델 비교";
|
||||||
|
|
||||||
|
const statusSpan = document.createElement("span");
|
||||||
|
statusSpan.className = "terrain-status";
|
||||||
|
statusSpan.style.fontSize = "var(--text-caption)";
|
||||||
|
statusSpan.style.color = "var(--color-text-secondary)";
|
||||||
|
statusSpan.textContent = "모델 선택 대기 중...";
|
||||||
|
|
||||||
|
header.append(titleSpan, statusSpan);
|
||||||
|
root.append(header);
|
||||||
|
|
||||||
|
// Filters and Methods selector bars
|
||||||
|
const selectorBar = document.createElement("div");
|
||||||
|
selectorBar.className = "filter-selector-bar terrain-selector-bar";
|
||||||
|
selectorBar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
||||||
|
selectorBar.style.background = "var(--color-bg-light)";
|
||||||
|
selectorBar.style.borderBottom = "1px solid var(--color-border)";
|
||||||
|
selectorBar.style.display = "flex";
|
||||||
|
selectorBar.style.flexDirection = "column";
|
||||||
|
selectorBar.style.gap = "var(--spacing-12)";
|
||||||
|
|
||||||
|
// 1. Source Filter row
|
||||||
|
const filterRow = document.createElement("div");
|
||||||
|
filterRow.style.display = "flex";
|
||||||
|
filterRow.style.alignItems = "center";
|
||||||
|
filterRow.style.gap = "var(--spacing-12)";
|
||||||
|
|
||||||
|
const filterLabel = document.createElement("span");
|
||||||
|
filterLabel.className = "filter-bar-label";
|
||||||
|
filterLabel.textContent = "서피스 기준 데이터:";
|
||||||
|
filterLabel.style.fontWeight = "bold";
|
||||||
|
filterLabel.style.minWidth = "120px";
|
||||||
|
|
||||||
|
const filterSegmented = document.createElement("div");
|
||||||
|
filterSegmented.className = "segmented";
|
||||||
|
|
||||||
|
const filters = [
|
||||||
|
{ key: "csf", label: "CSF" },
|
||||||
|
{ key: "pmf", label: "PMF" },
|
||||||
|
{ key: "grid_min_z", label: "Grid Min-Z" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let activeFilter = "csf";
|
||||||
|
const filterButtons = filters.map((f) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = f.label;
|
||||||
|
btn.className = f.key === activeFilter ? "active" : "";
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
filterButtons.forEach((b) => b.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
activeFilter = f.key;
|
||||||
|
updateSelectedModel();
|
||||||
|
});
|
||||||
|
filterSegmented.append(btn);
|
||||||
|
return btn;
|
||||||
|
});
|
||||||
|
filterRow.append(filterLabel, filterSegmented);
|
||||||
|
|
||||||
|
// 2. Method row
|
||||||
|
const methodRow = document.createElement("div");
|
||||||
|
methodRow.style.display = "flex";
|
||||||
|
methodRow.style.alignItems = "center";
|
||||||
|
methodRow.style.gap = "var(--spacing-12)";
|
||||||
|
|
||||||
|
const methodLabel = document.createElement("span");
|
||||||
|
methodLabel.className = "filter-bar-label";
|
||||||
|
methodLabel.textContent = "지표면 표현 방식:";
|
||||||
|
methodLabel.style.fontWeight = "bold";
|
||||||
|
methodLabel.style.minWidth = "120px";
|
||||||
|
|
||||||
|
const methodSegmented = document.createElement("div");
|
||||||
|
methodSegmented.className = "segmented";
|
||||||
|
|
||||||
|
const methods = [
|
||||||
|
{ key: "tin", label: "TIN" },
|
||||||
|
{ key: "dtm", label: "DTM (Grid)" },
|
||||||
|
{ key: "nurbs", label: "NURBS" },
|
||||||
|
{ key: "implicit", label: "Implicit" },
|
||||||
|
{ key: "meshfree", label: "Meshfree" },
|
||||||
|
];
|
||||||
|
|
||||||
|
let activeMethod = "dtm";
|
||||||
|
const methodButtons = methods.map((m) => {
|
||||||
|
const btn = document.createElement("button");
|
||||||
|
btn.type = "button";
|
||||||
|
btn.textContent = m.label;
|
||||||
|
btn.className = m.key === activeMethod ? "active" : "";
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
methodButtons.forEach((b) => b.classList.remove("active"));
|
||||||
|
btn.classList.add("active");
|
||||||
|
activeMethod = m.key;
|
||||||
|
updateSelectedModel();
|
||||||
|
});
|
||||||
|
methodSegmented.append(btn);
|
||||||
|
return btn;
|
||||||
|
});
|
||||||
|
methodRow.append(methodLabel, methodSegmented);
|
||||||
|
selectorBar.append(filterRow, methodRow);
|
||||||
|
root.append(selectorBar);
|
||||||
|
|
||||||
|
// Viewer Controls toolbar & checkbox options
|
||||||
|
const toolbar = document.createElement("div");
|
||||||
|
toolbar.className = "point-toolbar";
|
||||||
|
toolbar.style.display = "flex";
|
||||||
|
toolbar.style.justifyContent = "space-between";
|
||||||
|
toolbar.style.alignItems = "center";
|
||||||
|
toolbar.style.padding = "var(--spacing-12) var(--spacing-16)";
|
||||||
|
toolbar.style.background = "var(--color-bg-card)";
|
||||||
|
toolbar.style.borderBottom = "1px solid var(--color-border)";
|
||||||
|
|
||||||
|
const leftControls = document.createElement("div");
|
||||||
|
leftControls.className = "viewer-controls";
|
||||||
|
|
||||||
|
const btnIso = document.createElement("button");
|
||||||
|
btnIso.type = "button";
|
||||||
|
btnIso.innerHTML = "🔍 사시도";
|
||||||
|
const btnTop = document.createElement("button");
|
||||||
|
btnTop.type = "button";
|
||||||
|
btnTop.textContent = "상단";
|
||||||
|
const btnFront = document.createElement("button");
|
||||||
|
btnFront.type = "button";
|
||||||
|
btnFront.textContent = "정면";
|
||||||
|
const btnSide = document.createElement("button");
|
||||||
|
btnSide.type = "button";
|
||||||
|
btnSide.textContent = "측면";
|
||||||
|
const btnReset = document.createElement("button");
|
||||||
|
btnReset.type = "button";
|
||||||
|
btnReset.innerHTML = "🔄 리셋";
|
||||||
|
|
||||||
|
const toggleAxesLabel = document.createElement("label");
|
||||||
|
toggleAxesLabel.className = "toggle-label";
|
||||||
|
const axesCheck = document.createElement("input");
|
||||||
|
axesCheck.type = "checkbox";
|
||||||
|
axesCheck.checked = true;
|
||||||
|
toggleAxesLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||||
|
|
||||||
|
leftControls.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleAxesLabel);
|
||||||
|
|
||||||
|
const rightControls = document.createElement("div");
|
||||||
|
rightControls.style.display = "flex";
|
||||||
|
rightControls.style.alignItems = "center";
|
||||||
|
rightControls.style.gap = "var(--spacing-16)";
|
||||||
|
|
||||||
|
// Surface Toggle
|
||||||
|
const surfLabel = document.createElement("label");
|
||||||
|
surfLabel.className = "toggle-label";
|
||||||
|
const surfCheck = document.createElement("input");
|
||||||
|
surfCheck.type = "checkbox";
|
||||||
|
surfCheck.checked = true;
|
||||||
|
surfLabel.append(surfCheck, document.createTextNode(" 서피스"));
|
||||||
|
|
||||||
|
// Smooth Toggle (for tin/dtm)
|
||||||
|
const smoothLabel = document.createElement("label");
|
||||||
|
smoothLabel.className = "toggle-label";
|
||||||
|
const smoothCheck = document.createElement("input");
|
||||||
|
smoothCheck.type = "checkbox";
|
||||||
|
smoothCheck.checked = true;
|
||||||
|
smoothLabel.append(smoothCheck, document.createTextNode(" 스무딩"));
|
||||||
|
|
||||||
|
// Contour Toggle
|
||||||
|
const contourLabel = document.createElement("label");
|
||||||
|
contourLabel.className = "toggle-label";
|
||||||
|
const contourCheck = document.createElement("input");
|
||||||
|
contourCheck.type = "checkbox";
|
||||||
|
contourCheck.checked = true;
|
||||||
|
contourLabel.append(contourCheck, document.createTextNode(" 등고선"));
|
||||||
|
|
||||||
|
// Contour Interval input form
|
||||||
|
const intervalForm = document.createElement("form");
|
||||||
|
intervalForm.style.display = "flex";
|
||||||
|
intervalForm.style.alignItems = "center";
|
||||||
|
intervalForm.style.gap = "var(--spacing-4)";
|
||||||
|
|
||||||
|
const intervalInput = document.createElement("input");
|
||||||
|
intervalInput.type = "number";
|
||||||
|
intervalInput.value = "5.0";
|
||||||
|
intervalInput.step = "0.5";
|
||||||
|
intervalInput.min = "0.5";
|
||||||
|
intervalInput.style.width = "60px";
|
||||||
|
intervalInput.style.padding = "var(--spacing-4) var(--spacing-8)";
|
||||||
|
intervalInput.style.border = "1px solid var(--color-border)";
|
||||||
|
intervalInput.style.borderRadius = "var(--radius-normal)";
|
||||||
|
|
||||||
|
const intervalSubmit = document.createElement("button");
|
||||||
|
intervalSubmit.type = "submit";
|
||||||
|
intervalSubmit.textContent = "적용";
|
||||||
|
intervalSubmit.style.padding = "var(--spacing-4) var(--spacing-12)";
|
||||||
|
|
||||||
|
intervalForm.append(
|
||||||
|
document.createTextNode("간격 "),
|
||||||
|
intervalInput,
|
||||||
|
document.createTextNode("m "),
|
||||||
|
intervalSubmit,
|
||||||
|
);
|
||||||
|
|
||||||
|
rightControls.append(surfLabel, smoothLabel, contourLabel, intervalForm);
|
||||||
|
toolbar.append(leftControls, rightControls);
|
||||||
|
root.append(toolbar);
|
||||||
|
|
||||||
|
// 3D View container
|
||||||
|
const viewerArea = document.createElement("div");
|
||||||
|
viewerArea.className = "three-viewer";
|
||||||
|
viewerArea.style.height = "520px";
|
||||||
|
viewerArea.style.position = "relative";
|
||||||
|
viewerArea.style.borderRadius = "0 0 var(--radius-cards) var(--radius-cards)";
|
||||||
|
viewerArea.style.overflow = "hidden";
|
||||||
|
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.className = "b04-surface-viewer__canvas";
|
||||||
|
viewerArea.append(canvas);
|
||||||
|
root.append(viewerArea);
|
||||||
|
|
||||||
|
// Scale bar overlay
|
||||||
|
const scaleBar = document.createElement("div");
|
||||||
|
scaleBar.style.position = "absolute";
|
||||||
|
scaleBar.style.bottom = "16px";
|
||||||
|
scaleBar.style.left = "16px";
|
||||||
|
scaleBar.style.background = "rgba(255, 255, 255, 0.9)";
|
||||||
|
scaleBar.style.border = "1.5px solid #1e293b";
|
||||||
|
scaleBar.style.borderTop = "none";
|
||||||
|
scaleBar.style.height = "8px";
|
||||||
|
scaleBar.style.width = "100px";
|
||||||
|
scaleBar.style.zIndex = "10";
|
||||||
|
scaleBar.style.display = "none";
|
||||||
|
scaleBar.style.flexDirection = "column";
|
||||||
|
scaleBar.style.alignItems = "center";
|
||||||
|
scaleBar.style.justifyContent = "flex-end";
|
||||||
|
|
||||||
|
const scaleLabel = document.createElement("span");
|
||||||
|
scaleLabel.style.fontSize = "10px";
|
||||||
|
scaleLabel.style.fontWeight = "bold";
|
||||||
|
scaleLabel.style.color = "#1e293b";
|
||||||
|
scaleLabel.style.position = "absolute";
|
||||||
|
scaleLabel.style.bottom = "10px";
|
||||||
|
scaleLabel.style.whiteSpace = "nowrap";
|
||||||
|
scaleBar.append(scaleLabel);
|
||||||
|
viewerArea.append(scaleBar);
|
||||||
|
|
||||||
|
// Three.js context variables
|
||||||
|
let currentProjectId = "";
|
||||||
|
let currentModelsList: readonly SurfaceModelSummary[] = [];
|
||||||
|
|
||||||
|
const scene = new THREE.Scene();
|
||||||
|
scene.background = new THREE.Color(0xf5f7f9);
|
||||||
|
|
||||||
|
const camera = new THREE.PerspectiveCamera(50, 1, 0.01, 100000);
|
||||||
|
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
|
||||||
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
||||||
|
|
||||||
|
const controls = new OrbitControls(camera, renderer.domElement);
|
||||||
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
|
||||||
|
scene.add(new THREE.HemisphereLight(0xffffff, 0x445566, 1.8));
|
||||||
|
const directional = new THREE.DirectionalLight(0xffffff, 1.5);
|
||||||
|
directional.position.set(100, 180, 120);
|
||||||
|
scene.add(directional);
|
||||||
|
|
||||||
|
const axes = new THREE.AxesHelper(25);
|
||||||
|
axes.visible = axesCheck.checked;
|
||||||
|
scene.add(axes);
|
||||||
|
|
||||||
|
const contourGroup = new THREE.Group();
|
||||||
|
contourGroup.visible = contourCheck.checked;
|
||||||
|
scene.add(contourGroup);
|
||||||
|
|
||||||
|
let terrainMesh: THREE.Object3D | null = null;
|
||||||
|
const labelElements: HTMLDivElement[] = [];
|
||||||
|
|
||||||
|
function disposeObject(obj: THREE.Object3D) {
|
||||||
|
obj.traverse((child) => {
|
||||||
|
const renderable = child as THREE.Mesh | THREE.Points | THREE.LineSegments;
|
||||||
|
renderable.geometry?.dispose();
|
||||||
|
const material = renderable.material;
|
||||||
|
if (Array.isArray(material)) material.forEach((item) => item.dispose());
|
||||||
|
else material?.dispose();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMesh() {
|
||||||
|
if (terrainMesh) {
|
||||||
|
scene.remove(terrainMesh);
|
||||||
|
disposeObject(terrainMesh);
|
||||||
|
terrainMesh = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearContours() {
|
||||||
|
while (contourGroup.children.length > 0) {
|
||||||
|
const child = contourGroup.children[0];
|
||||||
|
contourGroup.remove(child);
|
||||||
|
if (child instanceof THREE.LineSegments) {
|
||||||
|
child.geometry.dispose();
|
||||||
|
(child.material as THREE.Material).dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labelElements.forEach((el) => el.remove());
|
||||||
|
labelElements.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFitParams = (object: THREE.Object3D) => {
|
||||||
|
const box = new THREE.Box3().setFromObject(object);
|
||||||
|
const center = box.getCenter(new THREE.Vector3());
|
||||||
|
const size = box.getSize(new THREE.Vector3());
|
||||||
|
const span = Math.max(size.x, size.y, size.z, 1);
|
||||||
|
return { center, span };
|
||||||
|
};
|
||||||
|
|
||||||
|
const fitCamera = (object: THREE.Object3D) => {
|
||||||
|
const { center, span } = getFitParams(object);
|
||||||
|
controls.target.copy(center);
|
||||||
|
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
||||||
|
camera.near = Math.max(span / 10000, 0.01);
|
||||||
|
camera.far = span * 100;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.update();
|
||||||
|
};
|
||||||
|
|
||||||
|
function setCameraView(view: "iso" | "top" | "front" | "side") {
|
||||||
|
if (!terrainMesh) return;
|
||||||
|
const { center, span } = getFitParams(terrainMesh);
|
||||||
|
controls.target.copy(center);
|
||||||
|
if (view === "iso") {
|
||||||
|
camera.position.set(center.x + span * 0.8, center.y + span * 0.65, center.z + span * 0.9);
|
||||||
|
} else if (view === "top") {
|
||||||
|
camera.position.set(center.x, center.y + span * 1.2, center.z + 0.001);
|
||||||
|
} else if (view === "front") {
|
||||||
|
camera.position.set(center.x, center.y, center.z + span * 1.2);
|
||||||
|
} else if (view === "side") {
|
||||||
|
camera.position.set(center.x + span * 1.2, center.y, center.z);
|
||||||
|
}
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load mesh and contours
|
||||||
|
async function updateSelectedModel() {
|
||||||
|
if (!currentProjectId || currentModelsList.length === 0) return;
|
||||||
|
|
||||||
|
clearMesh();
|
||||||
|
clearContours();
|
||||||
|
scaleBar.style.display = "none";
|
||||||
|
statusSpan.textContent = "모델 조회 중...";
|
||||||
|
|
||||||
|
// 1. Find matching model in list
|
||||||
|
// model_type is TIN / DTM / NURBS / Implicit / Meshfree (we match activeMethod)
|
||||||
|
// model_file_path contains the activeFilter (e.g. csf, pmf, grid_min_z)
|
||||||
|
const match = currentModelsList.find((m) => {
|
||||||
|
const typeMatches = m.model_type.toLowerCase() === activeMethod.toLowerCase();
|
||||||
|
const pathContainsFilter =
|
||||||
|
m.model_file_path &&
|
||||||
|
m.model_file_path.toLowerCase().includes(activeFilter.toLowerCase());
|
||||||
|
return typeMatches && pathContainsFilter;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!match) {
|
||||||
|
statusSpan.textContent = "일치하는 완성된 모델을 찾을 수 없습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const modelId = match.id;
|
||||||
|
const isSmooth = (activeMethod === "tin" || activeMethod === "dtm") && smoothCheck.checked;
|
||||||
|
|
||||||
|
statusSpan.textContent = "3D 메쉬 파일 다운로드 중...";
|
||||||
|
const previewUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/preview?smooth=${isSmooth}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (activeMethod === "meshfree") {
|
||||||
|
new PLYLoader().load(
|
||||||
|
previewUrl,
|
||||||
|
(geometry) => {
|
||||||
|
geometry.computeBoundingSphere();
|
||||||
|
const material = new THREE.PointsMaterial({
|
||||||
|
size: 0.35,
|
||||||
|
vertexColors: geometry.hasAttribute("color"),
|
||||||
|
sizeAttenuation: true,
|
||||||
|
});
|
||||||
|
const points = new THREE.Points(geometry, material);
|
||||||
|
points.visible = surfCheck.checked;
|
||||||
|
terrainMesh = points;
|
||||||
|
scene.add(points);
|
||||||
|
fitCamera(points);
|
||||||
|
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||||
|
loadContourLines(modelId, isSmooth);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
statusSpan.textContent = "3D 파일 로드에 실패했습니다.";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
new GLTFLoader().load(
|
||||||
|
previewUrl,
|
||||||
|
(gltf) => {
|
||||||
|
gltf.scene.traverse((child) => {
|
||||||
|
if (child instanceof THREE.Mesh) {
|
||||||
|
child.material.side = THREE.DoubleSide;
|
||||||
|
child.material.vertexColors = child.geometry.hasAttribute("color");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
gltf.scene.visible = surfCheck.checked;
|
||||||
|
terrainMesh = gltf.scene;
|
||||||
|
scene.add(gltf.scene);
|
||||||
|
fitCamera(gltf.scene);
|
||||||
|
statusSpan.textContent = `${activeFilter.toUpperCase()} · ${activeMethod.toUpperCase()} 표시 중`;
|
||||||
|
loadContourLines(modelId, isSmooth);
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
() => {
|
||||||
|
statusSpan.textContent = "3D 메쉬 파일이 없거나 로드할 수 없습니다.";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
statusSpan.textContent = "에러 발생";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadContourLines(modelId: number, isSmooth: boolean) {
|
||||||
|
const interval = parseFloat(intervalInput.value) || 5.0;
|
||||||
|
const contourUrl = `${API_BASE_URL}/projects/${currentProjectId}/surface/models/${modelId}/contour?interval=${interval}&smooth=${isSmooth}`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(contourUrl);
|
||||||
|
if (!res.ok) throw new Error("등고선 조회 실패");
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
clearContours();
|
||||||
|
|
||||||
|
const bounds = data.bounds;
|
||||||
|
if (!bounds) return;
|
||||||
|
|
||||||
|
const cx = (bounds.x[0] + bounds.x[1]) / 2;
|
||||||
|
const cy = (bounds.y[0] + bounds.y[1]) / 2;
|
||||||
|
const cz = (bounds.z[0] + bounds.z[1]) / 2;
|
||||||
|
|
||||||
|
const transform = (coords: [number, number, number][]) => {
|
||||||
|
return coords.map(([x_model, y_model, z_model]) => {
|
||||||
|
const x_scene = x_model - cx;
|
||||||
|
const y_scene = z_model - cz;
|
||||||
|
const z_scene = -(y_model - cy);
|
||||||
|
return new THREE.Vector3(x_scene, y_scene, z_scene);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
data.contours.forEach((c: any) => {
|
||||||
|
const points = transform(c.coordinates);
|
||||||
|
if (points.length < 2) return;
|
||||||
|
|
||||||
|
const linePoints: THREE.Vector3[] = [];
|
||||||
|
for (let i = 0; i < points.length - 1; i++) {
|
||||||
|
linePoints.push(points[i], points[i + 1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const geometry = new THREE.BufferGeometry().setFromPoints(linePoints);
|
||||||
|
const isMajor = c.level % (interval * 5) === 0;
|
||||||
|
const material = new THREE.LineBasicMaterial({
|
||||||
|
color: isMajor ? 0xd97706 : 0xf59e0b,
|
||||||
|
linewidth: isMajor ? 2 : 1,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.8,
|
||||||
|
});
|
||||||
|
|
||||||
|
const segments = new THREE.LineSegments(geometry, material);
|
||||||
|
contourGroup.add(segments);
|
||||||
|
|
||||||
|
if (isMajor && points.length > 4) {
|
||||||
|
const labelPos = points[Math.floor(points.length / 2)];
|
||||||
|
const labelDiv = document.createElement("div");
|
||||||
|
labelDiv.className = "contour-label";
|
||||||
|
labelDiv.innerText = `${Math.round(c.level)}m`;
|
||||||
|
labelDiv.style.position = "absolute";
|
||||||
|
labelDiv.style.background = "rgba(255, 255, 255, 0.85)";
|
||||||
|
labelDiv.style.border = "1px solid #d97706";
|
||||||
|
labelDiv.style.color = "#b45309";
|
||||||
|
labelDiv.style.padding = "1px 4px";
|
||||||
|
labelDiv.style.borderRadius = "3px";
|
||||||
|
labelDiv.style.fontSize = "9px";
|
||||||
|
labelDiv.style.fontWeight = "bold";
|
||||||
|
labelDiv.style.pointerEvents = "none";
|
||||||
|
labelDiv.style.zIndex = "5";
|
||||||
|
labelDiv.style.transform = "translate(-50%, -50%)";
|
||||||
|
|
||||||
|
(labelDiv as any).__updateLabelPos = () => {
|
||||||
|
if (!contourCheck.checked) {
|
||||||
|
labelDiv.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proj = labelPos.clone().project(camera);
|
||||||
|
const x = (proj.x * 0.5 + 0.5) * viewerArea.clientWidth;
|
||||||
|
const y = (-(proj.y * 0.5) + 0.5) * viewerArea.clientHeight;
|
||||||
|
|
||||||
|
if (proj.z > 1) {
|
||||||
|
labelDiv.style.display = "none";
|
||||||
|
} else {
|
||||||
|
labelDiv.style.display = "block";
|
||||||
|
labelDiv.style.left = `${x}px`;
|
||||||
|
labelDiv.style.top = `${y}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
viewerArea.appendChild(labelDiv);
|
||||||
|
labelElements.push(labelDiv);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
// Contour loading failed or silent fail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animation render loop
|
||||||
|
let animationFrameId = 0;
|
||||||
|
function animate() {
|
||||||
|
if (!root.isConnected) {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
clearMesh();
|
||||||
|
clearContours();
|
||||||
|
controls.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
controls.update();
|
||||||
|
|
||||||
|
// Render scale bar dynamically
|
||||||
|
if (terrainMesh && terrainMesh.visible) {
|
||||||
|
scaleBar.style.display = "flex";
|
||||||
|
const dist = camera.position.distanceTo(controls.target);
|
||||||
|
const metersPerPixel =
|
||||||
|
(2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / viewerArea.clientWidth;
|
||||||
|
const roughMeters = 100 * metersPerPixel;
|
||||||
|
const prettyMeters =
|
||||||
|
roughMeters < 5
|
||||||
|
? 2
|
||||||
|
: roughMeters < 15
|
||||||
|
? 10
|
||||||
|
: roughMeters < 35
|
||||||
|
? 20
|
||||||
|
: roughMeters < 75
|
||||||
|
? 50
|
||||||
|
: roughMeters < 150
|
||||||
|
? 100
|
||||||
|
: roughMeters < 350
|
||||||
|
? 200
|
||||||
|
: roughMeters < 750
|
||||||
|
? 500
|
||||||
|
: roughMeters < 1500
|
||||||
|
? 1000
|
||||||
|
: roughMeters < 3500
|
||||||
|
? 2000
|
||||||
|
: roughMeters < 7500
|
||||||
|
? 5000
|
||||||
|
: 10000;
|
||||||
|
scaleBar.style.width = `${prettyMeters / metersPerPixel}px`;
|
||||||
|
scaleLabel.textContent =
|
||||||
|
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||||
|
} else {
|
||||||
|
scaleBar.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update labels position
|
||||||
|
labelElements.forEach((label) => {
|
||||||
|
if (typeof (label as any).__updateLabelPos === "function") {
|
||||||
|
(label as any).__updateLabelPos();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
renderer.render(scene, camera);
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
btnIso.addEventListener("click", () => setCameraView("iso"));
|
||||||
|
btnTop.addEventListener("click", () => setCameraView("top"));
|
||||||
|
btnFront.addEventListener("click", () => setCameraView("front"));
|
||||||
|
btnSide.addEventListener("click", () => setCameraView("side"));
|
||||||
|
btnReset.addEventListener("click", () => {
|
||||||
|
if (terrainMesh) fitCamera(terrainMesh);
|
||||||
|
});
|
||||||
|
|
||||||
|
axesCheck.addEventListener("change", () => {
|
||||||
|
axes.visible = axesCheck.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
surfCheck.addEventListener("change", () => {
|
||||||
|
if (terrainMesh) {
|
||||||
|
terrainMesh.visible = surfCheck.checked;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
smoothCheck.addEventListener("change", () => {
|
||||||
|
updateSelectedModel();
|
||||||
|
});
|
||||||
|
|
||||||
|
contourCheck.addEventListener("change", () => {
|
||||||
|
contourGroup.visible = contourCheck.checked;
|
||||||
|
labelElements.forEach((el) => {
|
||||||
|
el.style.display = contourCheck.checked ? "block" : "none";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
intervalForm.addEventListener("submit", (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
updateSelectedModel();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
const resizeObserver = new ResizeObserver((entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
const width = entry.contentRect.width || 800;
|
||||||
|
const height = entry.contentRect.height || 520;
|
||||||
|
camera.aspect = width / height;
|
||||||
|
camera.updateProjectionMatrix();
|
||||||
|
renderer.setSize(width, height);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
resizeObserver.observe(viewerArea);
|
||||||
|
|
||||||
|
animationFrameId = requestAnimationFrame(animate);
|
||||||
|
|
||||||
|
return {
|
||||||
|
root,
|
||||||
|
render(projectId, models) {
|
||||||
|
currentProjectId = projectId;
|
||||||
|
currentModelsList = models;
|
||||||
|
updateSelectedModel();
|
||||||
|
},
|
||||||
|
dispose() {
|
||||||
|
cancelAnimationFrame(animationFrameId);
|
||||||
|
resizeObserver.disconnect();
|
||||||
|
clearMesh();
|
||||||
|
clearContours();
|
||||||
|
controls.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -5,18 +5,132 @@ import type { SurfacePointCloudSampleResponse } from "./B04_wf1_Surface_Api_Fetc
|
|||||||
|
|
||||||
export interface SurfacePointCloudViewer {
|
export interface SurfacePointCloudViewer {
|
||||||
root: HTMLElement;
|
root: HTMLElement;
|
||||||
|
controlsGroup: HTMLElement;
|
||||||
|
optionsGroup: HTMLElement;
|
||||||
|
statusSpan: HTMLElement;
|
||||||
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
render: (data: SurfacePointCloudSampleResponse | null) => void;
|
||||||
dispose: () => void;
|
dispose: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
||||||
const root = document.createElement("div");
|
const root = document.createElement("div");
|
||||||
root.className = "b04-surface-viewer";
|
root.className = "point-viewer";
|
||||||
|
|
||||||
|
const statusSpan = document.createElement("span");
|
||||||
|
statusSpan.className = "b04-surface__status-info";
|
||||||
|
statusSpan.style.color = "var(--color-text-secondary)";
|
||||||
|
statusSpan.style.fontSize = "var(--text-caption)";
|
||||||
|
statusSpan.textContent = "포인트 데이터 로딩 중...";
|
||||||
|
|
||||||
|
const controlsDiv = document.createElement("div");
|
||||||
|
controlsDiv.className = "viewer-controls";
|
||||||
|
|
||||||
|
const btnIso = document.createElement("button");
|
||||||
|
btnIso.type = "button";
|
||||||
|
btnIso.innerHTML = "🔍 사시도";
|
||||||
|
|
||||||
|
const btnTop = document.createElement("button");
|
||||||
|
btnTop.type = "button";
|
||||||
|
btnTop.textContent = "상단";
|
||||||
|
|
||||||
|
const btnFront = document.createElement("button");
|
||||||
|
btnFront.type = "button";
|
||||||
|
btnFront.textContent = "정면";
|
||||||
|
|
||||||
|
const btnSide = document.createElement("button");
|
||||||
|
btnSide.type = "button";
|
||||||
|
btnSide.textContent = "측면";
|
||||||
|
|
||||||
|
const btnReset = document.createElement("button");
|
||||||
|
btnReset.type = "button";
|
||||||
|
btnReset.innerHTML = "🔄 리셋";
|
||||||
|
|
||||||
|
const toggleLabel = document.createElement("label");
|
||||||
|
toggleLabel.className = "toggle-label";
|
||||||
|
const axesCheck = document.createElement("input");
|
||||||
|
axesCheck.type = "checkbox";
|
||||||
|
axesCheck.checked = true;
|
||||||
|
toggleLabel.append(axesCheck, document.createTextNode(" 축"));
|
||||||
|
|
||||||
|
controlsDiv.append(btnIso, btnTop, btnFront, btnSide, btnReset, toggleLabel);
|
||||||
|
|
||||||
|
const controlsGroup = document.createElement("section");
|
||||||
|
controlsGroup.className = "b04-surface__group";
|
||||||
|
const controlsTitle = document.createElement("h3");
|
||||||
|
controlsTitle.className = "b04-surface__panel-title";
|
||||||
|
controlsTitle.textContent = "뷰어 시점 제어";
|
||||||
|
controlsGroup.append(controlsTitle, controlsDiv);
|
||||||
|
|
||||||
|
// 2. Options Sliders
|
||||||
|
const optionsDiv = document.createElement("div");
|
||||||
|
optionsDiv.className = "viewer-options";
|
||||||
|
|
||||||
|
const sizeLabel = document.createElement("label");
|
||||||
|
sizeLabel.textContent = "점 크기 ";
|
||||||
|
const sizeInput = document.createElement("input");
|
||||||
|
sizeInput.type = "range";
|
||||||
|
sizeInput.min = "0.1";
|
||||||
|
sizeInput.max = "2.5";
|
||||||
|
sizeInput.step = "0.01";
|
||||||
|
sizeInput.value = "0.42";
|
||||||
|
sizeLabel.append(sizeInput);
|
||||||
|
|
||||||
|
const densityLabel = document.createElement("label");
|
||||||
|
densityLabel.innerHTML = '밀도 <span class="viewer-option-val">100%</span>';
|
||||||
|
const densityInput = document.createElement("input");
|
||||||
|
densityInput.type = "range";
|
||||||
|
densityInput.min = "1";
|
||||||
|
densityInput.max = "10";
|
||||||
|
densityInput.step = "1";
|
||||||
|
densityInput.value = "10";
|
||||||
|
densityLabel.append(densityInput);
|
||||||
|
|
||||||
|
optionsDiv.append(sizeLabel, densityLabel);
|
||||||
|
|
||||||
|
const optionsGroup = document.createElement("section");
|
||||||
|
optionsGroup.className = "b04-surface__group";
|
||||||
|
const optionsTitle = document.createElement("h3");
|
||||||
|
optionsTitle.className = "b04-surface__panel-title";
|
||||||
|
optionsTitle.textContent = "표시 옵션";
|
||||||
|
optionsGroup.append(optionsTitle, optionsDiv);
|
||||||
|
|
||||||
|
// 4. Viewer Area & Canvas
|
||||||
|
const viewerArea = document.createElement("div");
|
||||||
|
viewerArea.className = "three-viewer";
|
||||||
|
viewerArea.style.height = "520px";
|
||||||
|
|
||||||
const canvas = document.createElement("canvas");
|
const canvas = document.createElement("canvas");
|
||||||
canvas.className = "b04-surface-viewer__canvas";
|
canvas.className = "b04-surface-viewer__canvas";
|
||||||
root.append(canvas);
|
viewerArea.append(canvas);
|
||||||
|
root.append(viewerArea);
|
||||||
|
|
||||||
|
// 5. Scale Bar Overlay
|
||||||
|
const scaleBar = document.createElement("div");
|
||||||
|
scaleBar.style.position = "absolute";
|
||||||
|
scaleBar.style.bottom = "16px";
|
||||||
|
scaleBar.style.left = "16px";
|
||||||
|
scaleBar.style.background = "rgba(255, 255, 255, 0.9)";
|
||||||
|
scaleBar.style.border = "1.5px solid #1e293b";
|
||||||
|
scaleBar.style.borderTop = "none";
|
||||||
|
scaleBar.style.height = "8px";
|
||||||
|
scaleBar.style.zIndex = "10";
|
||||||
|
scaleBar.style.display = "none";
|
||||||
|
scaleBar.style.flexDirection = "column";
|
||||||
|
scaleBar.style.alignItems = "center";
|
||||||
|
scaleBar.style.justifyContent = "flex-end";
|
||||||
|
scaleBar.style.pointerEvents = "none";
|
||||||
|
|
||||||
|
const scaleText = document.createElement("span");
|
||||||
|
scaleText.style.fontSize = "10px";
|
||||||
|
scaleText.style.fontWeight = "bold";
|
||||||
|
scaleText.style.color = "#1e293b";
|
||||||
|
scaleText.style.position = "absolute";
|
||||||
|
scaleText.style.bottom = "10px";
|
||||||
|
scaleText.style.whiteSpace = "nowrap";
|
||||||
|
scaleBar.append(scaleText);
|
||||||
|
viewerArea.append(scaleBar);
|
||||||
|
|
||||||
|
// 6. ThreeJS Setup
|
||||||
const renderer = new THREE.WebGLRenderer({
|
const renderer = new THREE.WebGLRenderer({
|
||||||
canvas,
|
canvas,
|
||||||
antialias: RENDER_OPTIONS.antialias,
|
antialias: RENDER_OPTIONS.antialias,
|
||||||
@@ -24,29 +138,24 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
|||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, RENDER_OPTIONS.maxPixelRatio));
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
const scene = new THREE.Scene();
|
||||||
scene.background = new THREE.Color(RENDER_OPTIONS.canvasClearColorLight);
|
scene.background = new THREE.Color(0xf5f7f9);
|
||||||
const camera = new THREE.PerspectiveCamera(
|
|
||||||
RENDER_OPTIONS.cameraFov,
|
const camera = new THREE.PerspectiveCamera(55, 1, 0.1, 22000);
|
||||||
1,
|
|
||||||
RENDER_OPTIONS.cameraNear,
|
|
||||||
RENDER_OPTIONS.cameraFar,
|
|
||||||
);
|
|
||||||
const controls = new OrbitControls(camera, canvas);
|
const controls = new OrbitControls(camera, canvas);
|
||||||
controls.enableDamping = true;
|
controls.enableDamping = true;
|
||||||
|
controls.dampingFactor = 0.08;
|
||||||
|
controls.screenSpacePanning = true;
|
||||||
|
|
||||||
const grid = new THREE.GridHelper(
|
const axes = new THREE.AxesHelper(45);
|
||||||
200,
|
axes.visible = axesCheck.checked;
|
||||||
20,
|
scene.add(axes);
|
||||||
RENDER_OPTIONS.gridCenterColor,
|
|
||||||
RENDER_OPTIONS.gridLineColor,
|
|
||||||
);
|
|
||||||
scene.add(grid);
|
|
||||||
|
|
||||||
let pointsObject: THREE.Points | null = null;
|
let pointsObject: THREE.Points | null = null;
|
||||||
let animationFrame = 0;
|
let animationFrame = 0;
|
||||||
|
let currentData: SurfacePointCloudSampleResponse | null = null;
|
||||||
|
|
||||||
function resize(): void {
|
function resize(): void {
|
||||||
const rect = root.getBoundingClientRect();
|
const rect = viewerArea.getBoundingClientRect();
|
||||||
const width = Math.max(1, Math.floor(rect.width));
|
const width = Math.max(1, Math.floor(rect.width));
|
||||||
const height = Math.max(1, Math.floor(rect.height));
|
const height = Math.max(1, Math.floor(rect.height));
|
||||||
renderer.setSize(width, height, false);
|
renderer.setSize(width, height, false);
|
||||||
@@ -54,10 +163,85 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
|||||||
camera.updateProjectionMatrix();
|
camera.updateProjectionMatrix();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setCameraView(view: "iso" | "top" | "front" | "side"): void {
|
||||||
|
const positions: Record<string, [number, number, number]> = {
|
||||||
|
iso: [120, 95, 135],
|
||||||
|
top: [0, 190, 0.001],
|
||||||
|
front: [0, 60, 240],
|
||||||
|
side: [240, 60, 0],
|
||||||
|
};
|
||||||
|
camera.position.set(...positions[view]);
|
||||||
|
camera.lookAt(0, 0, 0);
|
||||||
|
controls.target.set(0, 0, 0);
|
||||||
|
controls.update();
|
||||||
|
}
|
||||||
|
|
||||||
function animate(): void {
|
function animate(): void {
|
||||||
|
if (!root.isConnected) {
|
||||||
|
cancelAnimationFrame(animationFrame);
|
||||||
|
clearPoints();
|
||||||
|
controls.dispose();
|
||||||
|
renderer.dispose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
resize();
|
resize();
|
||||||
controls.update();
|
controls.update();
|
||||||
renderer.render(scene, camera);
|
renderer.render(scene, camera);
|
||||||
|
|
||||||
|
// Update scale bar overlay
|
||||||
|
if (currentData) {
|
||||||
|
const bounds = currentData.bounds;
|
||||||
|
const xMin = bounds.x_min ?? 0;
|
||||||
|
const xMax = bounds.x_max ?? 0;
|
||||||
|
const yMin = bounds.y_min ?? 0;
|
||||||
|
const yMax = bounds.y_max ?? 0;
|
||||||
|
const zMin = bounds.z_min ?? 0;
|
||||||
|
const zMax = bounds.z_max ?? 0;
|
||||||
|
const xSpan = xMax - xMin;
|
||||||
|
const ySpan = yMax - yMin;
|
||||||
|
const zSpan = zMax - zMin;
|
||||||
|
const maxSpan = Math.max(xSpan, ySpan, zSpan);
|
||||||
|
const internalScale = 180 / maxSpan;
|
||||||
|
|
||||||
|
const dist = camera.position.distanceTo(controls.target);
|
||||||
|
const rect = viewerArea.getBoundingClientRect();
|
||||||
|
const clientWidth = rect.width || 1200;
|
||||||
|
const sceneUnitsPerPixel = (2 * Math.tan((camera.fov * Math.PI) / 360) * dist) / clientWidth;
|
||||||
|
const metersPerPixel = sceneUnitsPerPixel / internalScale;
|
||||||
|
|
||||||
|
const roughMeters = 100 * metersPerPixel;
|
||||||
|
const prettyMeters =
|
||||||
|
roughMeters < 5
|
||||||
|
? 2
|
||||||
|
: roughMeters < 15
|
||||||
|
? 10
|
||||||
|
: roughMeters < 35
|
||||||
|
? 20
|
||||||
|
: roughMeters < 75
|
||||||
|
? 50
|
||||||
|
: roughMeters < 150
|
||||||
|
? 100
|
||||||
|
: roughMeters < 350
|
||||||
|
? 200
|
||||||
|
: roughMeters < 750
|
||||||
|
? 500
|
||||||
|
: roughMeters < 1500
|
||||||
|
? 1000
|
||||||
|
: roughMeters < 3500
|
||||||
|
? 2000
|
||||||
|
: roughMeters < 7500
|
||||||
|
? 5000
|
||||||
|
: 10000;
|
||||||
|
|
||||||
|
const pixels = (prettyMeters * internalScale) / sceneUnitsPerPixel;
|
||||||
|
scaleBar.style.display = "flex";
|
||||||
|
scaleBar.style.width = `${pixels}px`;
|
||||||
|
scaleText.textContent =
|
||||||
|
prettyMeters >= 1000 ? `${(prettyMeters / 1000).toFixed(0)} km` : `${prettyMeters} m`;
|
||||||
|
} else {
|
||||||
|
scaleBar.style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(animate);
|
animationFrame = requestAnimationFrame(animate);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,45 +255,138 @@ export function createSurfacePointCloudViewer(): SurfacePointCloudViewer {
|
|||||||
pointsObject = null;
|
pointsObject = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function render(data: SurfacePointCloudSampleResponse | null): void {
|
function renderPointCloudInternal(data: SurfacePointCloudSampleResponse): void {
|
||||||
clearPoints();
|
clearPoints();
|
||||||
if (!data || data.points.length === 0) return;
|
if (data.points.length === 0) return;
|
||||||
|
|
||||||
const bounds = data.bounds;
|
const bounds = data.bounds;
|
||||||
const cx = ((bounds.x_min ?? 0) + (bounds.x_max ?? 0)) / 2;
|
const xMin = bounds.x_min ?? 0;
|
||||||
const cy = ((bounds.y_min ?? 0) + (bounds.y_max ?? 0)) / 2;
|
const xMax = bounds.x_max ?? 0;
|
||||||
const cz = ((bounds.z_min ?? 0) + (bounds.z_max ?? 0)) / 2;
|
const yMin = bounds.y_min ?? 0;
|
||||||
const positions = new Float32Array(data.points.length * 3);
|
const yMax = bounds.y_max ?? 0;
|
||||||
data.points.forEach((point, index) => {
|
const zMin = bounds.z_min ?? 0;
|
||||||
positions[index * 3] = point[0] - cx;
|
const zMax = bounds.z_max ?? 0;
|
||||||
positions[index * 3 + 1] = point[2] - cz;
|
|
||||||
positions[index * 3 + 2] = point[1] - cy;
|
const xMid = (xMin + xMax) / 2;
|
||||||
|
const yMid = (yMin + yMax) / 2;
|
||||||
|
const zMid = (zMin + zMax) / 2;
|
||||||
|
|
||||||
|
const xSpan = Math.max(xMax - xMin, 1e-9);
|
||||||
|
const ySpan = Math.max(yMax - yMin, 1e-9);
|
||||||
|
const zSpan = Math.max(zMax - zMin, 1e-9);
|
||||||
|
const scale = 180 / Math.max(xSpan, ySpan, zSpan);
|
||||||
|
|
||||||
|
const hasRgb = data.rgb && data.rgb.length === data.points.length;
|
||||||
|
const densityVal = parseInt(densityInput.value, 10);
|
||||||
|
const step = Math.max(1, Math.ceil(10 / densityVal));
|
||||||
|
const renderPoints: typeof data.points = [];
|
||||||
|
const renderRgb: [number, number, number][] = [];
|
||||||
|
for (let i = 0; i < data.points.length; i += step) {
|
||||||
|
renderPoints.push(data.points[i]);
|
||||||
|
if (hasRgb && data.rgb) {
|
||||||
|
renderRgb.push(data.rgb[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const positions = new Float32Array(renderPoints.length * 3);
|
||||||
|
const colors = new Float32Array(renderPoints.length * 3);
|
||||||
|
|
||||||
|
renderPoints.forEach((point, index) => {
|
||||||
|
const x = point[0];
|
||||||
|
const y = point[1];
|
||||||
|
const z = point[2];
|
||||||
|
|
||||||
|
positions[index * 3] = (x - xMid) * scale;
|
||||||
|
positions[index * 3 + 1] = (z - zMid) * scale;
|
||||||
|
positions[index * 3 + 2] = -(y - yMid) * scale;
|
||||||
|
|
||||||
|
let r = 0;
|
||||||
|
let g = 0;
|
||||||
|
let b = 0;
|
||||||
|
if (hasRgb && renderRgb[index]) {
|
||||||
|
const rgbPoint = renderRgb[index];
|
||||||
|
const maxVal = Math.max(rgbPoint[0], rgbPoint[1], rgbPoint[2]);
|
||||||
|
const divisor = maxVal > 255 ? 65535 : 255;
|
||||||
|
r = rgbPoint[0] / divisor;
|
||||||
|
g = rgbPoint[1] / divisor;
|
||||||
|
b = rgbPoint[2] / divisor;
|
||||||
|
} else {
|
||||||
|
const t = Math.max(0, Math.min(1, (z - zMin) / zSpan));
|
||||||
|
r = (36 + t * 190) / 255;
|
||||||
|
g = (86 + Math.sin(t * Math.PI) * 95) / 255;
|
||||||
|
b = (128 - t * 80) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors[index * 3] = r;
|
||||||
|
colors[index * 3 + 1] = g;
|
||||||
|
colors[index * 3 + 2] = b;
|
||||||
});
|
});
|
||||||
|
|
||||||
const geometry = new THREE.BufferGeometry();
|
const geometry = new THREE.BufferGeometry();
|
||||||
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
geometry.setAttribute("position", new THREE.BufferAttribute(positions, 3));
|
||||||
|
geometry.setAttribute("color", new THREE.BufferAttribute(colors, 3));
|
||||||
geometry.computeBoundingSphere();
|
geometry.computeBoundingSphere();
|
||||||
|
|
||||||
|
const sizeVal = parseFloat(sizeInput.value);
|
||||||
const material = new THREE.PointsMaterial({
|
const material = new THREE.PointsMaterial({
|
||||||
color: RENDER_OPTIONS.pointColor,
|
size: sizeVal,
|
||||||
size: 1.6,
|
vertexColors: true,
|
||||||
sizeAttenuation: false,
|
sizeAttenuation: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
pointsObject = new THREE.Points(geometry, material);
|
pointsObject = new THREE.Points(geometry, material);
|
||||||
scene.add(pointsObject);
|
scene.add(pointsObject);
|
||||||
|
|
||||||
const radius = geometry.boundingSphere?.radius ?? 100;
|
// Update status text
|
||||||
camera.position.set(radius * 0.9, radius * 0.7, radius * 1.4);
|
const nearestMin10 = Math.round(zMin / 10) * 10;
|
||||||
camera.near = Math.max(RENDER_OPTIONS.cameraNear, radius / 10000);
|
const nearestMax10 = Math.round(zMax / 10) * 10;
|
||||||
camera.far = Math.max(RENDER_OPTIONS.cameraFar, radius * 8);
|
statusSpan.textContent = ` ${renderPoints.length.toLocaleString()}개 점 표시 중 [높이범위: ~${nearestMin10}m ... ${nearestMax10}m~]`;
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
controls.target.set(0, 0, 0);
|
|
||||||
controls.update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function render(data: SurfacePointCloudSampleResponse | null): void {
|
||||||
|
currentData = data;
|
||||||
|
clearPoints();
|
||||||
|
if (!data) {
|
||||||
|
statusSpan.textContent = "데이터가 존재하지 않습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
renderPointCloudInternal(data);
|
||||||
|
setCameraView("top");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Event Listeners
|
||||||
|
btnIso.addEventListener("click", () => setCameraView("iso"));
|
||||||
|
btnTop.addEventListener("click", () => setCameraView("top"));
|
||||||
|
btnFront.addEventListener("click", () => setCameraView("front"));
|
||||||
|
btnSide.addEventListener("click", () => setCameraView("side"));
|
||||||
|
btnReset.addEventListener("click", () => setCameraView("iso"));
|
||||||
|
|
||||||
|
axesCheck.addEventListener("change", () => {
|
||||||
|
axes.visible = axesCheck.checked;
|
||||||
|
});
|
||||||
|
|
||||||
|
sizeInput.addEventListener("input", () => {
|
||||||
|
if (pointsObject) {
|
||||||
|
(pointsObject.material as THREE.PointsMaterial).size = parseFloat(sizeInput.value);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
densityInput.addEventListener("input", () => {
|
||||||
|
const val = parseInt(densityInput.value, 10);
|
||||||
|
const valSpan = densityLabel.querySelector(".viewer-option-val");
|
||||||
|
if (valSpan) valSpan.textContent = `${val * 10}%`;
|
||||||
|
if (currentData) {
|
||||||
|
renderPointCloudInternal(currentData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
animationFrame = requestAnimationFrame(animate);
|
animationFrame = requestAnimationFrame(animate);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
root,
|
root,
|
||||||
|
controlsGroup,
|
||||||
|
optionsGroup,
|
||||||
|
statusSpan,
|
||||||
render,
|
render,
|
||||||
dispose: () => {
|
dispose: () => {
|
||||||
cancelAnimationFrame(animationFrame);
|
cancelAnimationFrame(animationFrame);
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
@@ -22,8 +22,9 @@ import {
|
|||||||
showToast,
|
showToast,
|
||||||
type InputFieldHandle,
|
type InputFieldHandle,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
|
import { navigateTo } from "../A00_Common/router";
|
||||||
import {
|
import {
|
||||||
confirmRoute,
|
confirmRoute,
|
||||||
solveRoute,
|
solveRoute,
|
||||||
@@ -318,12 +319,44 @@ export function renderB05Route(root: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderEmptyResult();
|
renderEmptyResult();
|
||||||
|
|
||||||
|
const workflowRoutes = [
|
||||||
|
ROUTES.B03_FILE_INPUT,
|
||||||
|
ROUTES.B04_WF1_SURFACE,
|
||||||
|
ROUTES.B05_WF2_ROUTE,
|
||||||
|
ROUTES.B06_WF3_PROFILE_CROSS,
|
||||||
|
ROUTES.B07_WF4_DESIGN_DETAIL,
|
||||||
|
ROUTES.B08_WF5_QUANTITY,
|
||||||
|
ROUTES.B09_WF6_ESTIMATION,
|
||||||
|
];
|
||||||
|
|
||||||
|
let workflowStages: WorkflowStage[] = [];
|
||||||
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||||
|
if (projectId) {
|
||||||
|
fetch(`/api/projects/${projectId}/workflow-state`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
workflowStages = data.stages || [];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* silently fail */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const layout = createWorkflowLayout({
|
const layout = createWorkflowLayout({
|
||||||
title: L("B05_Route_Title"),
|
title: L("B05_Route_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 2,
|
activeStep: 2,
|
||||||
leftPanel: leftForm,
|
leftPanel: leftForm,
|
||||||
mainContent: resultCard,
|
mainContent: resultCard,
|
||||||
|
stages: workflowStages,
|
||||||
|
routes: workflowRoutes,
|
||||||
|
onStepClick: (_stepIndex, route) => {
|
||||||
|
if (projectId) {
|
||||||
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||||
|
navigateTo(route as RoutePath);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
root.replaceChildren(layout.root);
|
root.replaceChildren(layout.root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
* 텍스트는 ui_template_locale에 선(先) 등록 후 참조 (frontend.md §3).
|
||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
import { CURRENT_PROJECT_ID_KEY, ROUTES, type RoutePath } from "@config/config_frontend";
|
||||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||||
import {
|
import {
|
||||||
createButton,
|
createButton,
|
||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
showLoadingOverlay,
|
showLoadingOverlay,
|
||||||
showToast,
|
showToast,
|
||||||
} from "@ui/ui_template_elements";
|
} from "@ui/ui_template_elements";
|
||||||
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
import { createWorkflowLayout, type WorkflowStage } from "@ui/ui_template_workflow_layout";
|
||||||
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
||||||
|
import { navigateTo } from "../A00_Common/router";
|
||||||
import {
|
import {
|
||||||
confirmSections,
|
confirmSections,
|
||||||
generateSections,
|
generateSections,
|
||||||
@@ -240,12 +241,44 @@ export function renderB06ProfileCross(root: HTMLElement): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
renderEmptyResult();
|
renderEmptyResult();
|
||||||
|
|
||||||
|
const workflowRoutes = [
|
||||||
|
ROUTES.B03_FILE_INPUT,
|
||||||
|
ROUTES.B04_WF1_SURFACE,
|
||||||
|
ROUTES.B05_WF2_ROUTE,
|
||||||
|
ROUTES.B06_WF3_PROFILE_CROSS,
|
||||||
|
ROUTES.B07_WF4_DESIGN_DETAIL,
|
||||||
|
ROUTES.B08_WF5_QUANTITY,
|
||||||
|
ROUTES.B09_WF6_ESTIMATION,
|
||||||
|
];
|
||||||
|
|
||||||
|
let workflowStages: WorkflowStage[] = [];
|
||||||
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||||
|
if (projectId) {
|
||||||
|
fetch(`/api/projects/${projectId}/workflow-state`)
|
||||||
|
.then((res) => res.json())
|
||||||
|
.then((data) => {
|
||||||
|
workflowStages = data.stages || [];
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* silently fail */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const layout = createWorkflowLayout({
|
const layout = createWorkflowLayout({
|
||||||
title: L("B06_Profile_Title"),
|
title: L("B06_Profile_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 3,
|
activeStep: 3,
|
||||||
leftPanel: leftForm,
|
leftPanel: leftForm,
|
||||||
mainContent: resultCard,
|
mainContent: resultCard,
|
||||||
|
stages: workflowStages,
|
||||||
|
routes: workflowRoutes,
|
||||||
|
onStepClick: (_stepIndex, route) => {
|
||||||
|
if (projectId) {
|
||||||
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||||
|
navigateTo(route as RoutePath);
|
||||||
|
}
|
||||||
|
},
|
||||||
});
|
});
|
||||||
root.replaceChildren(layout.root);
|
root.replaceChildren(layout.root);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export function renderB07DesignDetail(root: HTMLElement): void {
|
|||||||
renderPendingWorkflow(root, {
|
renderPendingWorkflow(root, {
|
||||||
title: L("B07_Design_Title"),
|
title: L("B07_Design_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 3,
|
activeStep: 4,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export function renderB08Quantity(root: HTMLElement): void {
|
|||||||
renderPendingWorkflow(root, {
|
renderPendingWorkflow(root, {
|
||||||
title: L("B08_Quantity_Title"),
|
title: L("B08_Quantity_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 4,
|
activeStep: 5,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,6 @@ export function renderB09Estimation(root: HTMLElement): void {
|
|||||||
renderPendingWorkflow(root, {
|
renderPendingWorkflow(root, {
|
||||||
title: L("B09_Estimation_Title"),
|
title: L("B09_Estimation_Title"),
|
||||||
steps: workflowSteps(),
|
steps: workflowSteps(),
|
||||||
activeStep: 5,
|
activeStep: 6,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-21
@@ -1,23 +1,9 @@
|
|||||||
import asyncio
|
import re
|
||||||
import sys
|
|
||||||
import aiomysql
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
load_dotenv(".env")
|
|
||||||
|
|
||||||
from config.config_db import init_db_pool, close_db_pool
|
path = "D:/02_Software_Prog/임도설계 및 견적자동화 프로그램 개발/0_old/main.py"
|
||||||
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
|
code = f.read()
|
||||||
|
|
||||||
async def main():
|
print("--- Endpoints ---")
|
||||||
await init_db_pool()
|
for m in re.finditer(r'@app\.(get|post)\("([^"]+)"\)', code):
|
||||||
from config.config_db import get_db_pool
|
print(m.group(0))
|
||||||
from B01_Dashboard.B01_Dashboard_Repository import get_system_resources
|
|
||||||
res = await get_system_resources(30)
|
|
||||||
print("Current:", res["current"])
|
|
||||||
print("History count:", len(res["history"]))
|
|
||||||
for h in res["history"][:5]:
|
|
||||||
print(h)
|
|
||||||
except Exception as e:
|
|
||||||
print("Error:", e)
|
|
||||||
await close_db_pool()
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
asyncio.run(main())
|
|
||||||
|
|||||||
@@ -100,6 +100,20 @@
|
|||||||
background: var(--color-paper);
|
background: var(--color-paper);
|
||||||
color: var(--color-text-secondary);
|
color: var(--color-text-secondary);
|
||||||
font-size: var(--text-caption);
|
font-size: var(--text-caption);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
cursor: default;
|
||||||
|
transition:
|
||||||
|
background-color var(--transition-base),
|
||||||
|
color var(--transition-base),
|
||||||
|
cursor var(--transition-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.is-enabled {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.is-enabled:hover {
|
||||||
|
background: var(--color-paper-hover, var(--color-paper));
|
||||||
}
|
}
|
||||||
|
|
||||||
.ui-workflow-layout__step.is-active {
|
.ui-workflow-layout__step.is-active {
|
||||||
@@ -107,6 +121,28 @@
|
|||||||
color: var(--color-accent);
|
color: var(--color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.state-complete {
|
||||||
|
border-color: var(--color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.state-in_progress {
|
||||||
|
border-color: var(--color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.state-stale {
|
||||||
|
border-color: var(--color-muted);
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ui-workflow-layout__step.state-failed {
|
||||||
|
border-color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|
||||||
.ui-workflow-layout__body {
|
.ui-workflow-layout__body {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -1,11 +1,20 @@
|
|||||||
import "./ui_template_workflow_layout.css";
|
import "./ui_template_workflow_layout.css";
|
||||||
|
|
||||||
|
export interface WorkflowStage {
|
||||||
|
stage_no: number;
|
||||||
|
stage_key: string;
|
||||||
|
state: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface WorkflowLayoutOptions {
|
export interface WorkflowLayoutOptions {
|
||||||
title: string;
|
title: string;
|
||||||
steps: string[];
|
steps: string[];
|
||||||
activeStep: number;
|
activeStep: number;
|
||||||
leftPanel: HTMLElement;
|
leftPanel: HTMLElement;
|
||||||
mainContent: HTMLElement;
|
mainContent: HTMLElement;
|
||||||
|
stages?: WorkflowStage[];
|
||||||
|
routes?: string[];
|
||||||
|
onStepClick?: (stepIndex: number, route: string) => void;
|
||||||
onMenuToggle?: (isOpen: boolean) => void;
|
onMenuToggle?: (isOpen: boolean) => void;
|
||||||
onHeaderToggle?: (isVisible: boolean) => void;
|
onHeaderToggle?: (isVisible: boolean) => void;
|
||||||
}
|
}
|
||||||
@@ -16,15 +25,62 @@ export interface WorkflowLayoutHandle {
|
|||||||
setHeaderVisible: (isVisible: boolean) => void;
|
setHeaderVisible: (isVisible: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createStepBar(steps: readonly string[], activeStep: number): HTMLElement {
|
function createStepBar(
|
||||||
|
steps: readonly string[],
|
||||||
|
activeStep: number,
|
||||||
|
options?: {
|
||||||
|
stages?: WorkflowStage[];
|
||||||
|
routes?: string[];
|
||||||
|
onStepClick?: (stepIndex: number, route: string) => void;
|
||||||
|
},
|
||||||
|
): HTMLElement {
|
||||||
const bar = document.createElement("div");
|
const bar = document.createElement("div");
|
||||||
bar.className = "ui-workflow-layout__steps";
|
bar.className = "ui-workflow-layout__steps";
|
||||||
steps.forEach((step, index) => {
|
steps.forEach((step, index) => {
|
||||||
const item = document.createElement("span");
|
const button = document.createElement("button");
|
||||||
item.className = "ui-workflow-layout__step";
|
button.type = "button";
|
||||||
if (index === activeStep) item.classList.add("is-active");
|
button.className = "ui-workflow-layout__step";
|
||||||
item.textContent = step;
|
if (index === activeStep) button.classList.add("is-active");
|
||||||
bar.append(item);
|
button.textContent = step;
|
||||||
|
|
||||||
|
// DB state 기반 활성화 로직
|
||||||
|
let enabled = false;
|
||||||
|
if (options?.stages && options.stages.length > 0) {
|
||||||
|
enabled = index === 0 || options.stages[index - 1]?.state === "COMPLETE";
|
||||||
|
} else {
|
||||||
|
enabled = index <= activeStep;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.disabled = !enabled;
|
||||||
|
if (enabled) {
|
||||||
|
button.classList.add("is-enabled");
|
||||||
|
}
|
||||||
|
|
||||||
|
// state 기반 스타일링
|
||||||
|
if (options?.stages && options.stages[index]) {
|
||||||
|
const state = options.stages[index].state;
|
||||||
|
button.classList.add(`state-${state.toLowerCase()}`);
|
||||||
|
if (state === "STALE") {
|
||||||
|
button.title = "Stale (하위 단계 변경으로 무효화됨)";
|
||||||
|
} else if (state === "FAILED") {
|
||||||
|
button.title = "Failed (실패)";
|
||||||
|
} else if (state === "COMPLETE") {
|
||||||
|
button.title = "Complete (완료)";
|
||||||
|
} else if (state === "IN_PROGRESS") {
|
||||||
|
button.title = "In Progress (진행 중)";
|
||||||
|
} else {
|
||||||
|
button.title = "Not Started (미실행)";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 클릭 이벤트
|
||||||
|
if (enabled && options?.routes && options?.routes[index]) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
options.onStepClick?.(index, options.routes![index]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
bar.append(button);
|
||||||
});
|
});
|
||||||
return bar;
|
return bar;
|
||||||
}
|
}
|
||||||
@@ -47,7 +103,14 @@ export function createWorkflowLayout(options: WorkflowLayoutOptions): WorkflowLa
|
|||||||
const title = document.createElement("h2");
|
const title = document.createElement("h2");
|
||||||
title.className = "ui-workflow-layout__title";
|
title.className = "ui-workflow-layout__title";
|
||||||
title.textContent = options.title;
|
title.textContent = options.title;
|
||||||
titleWrap.append(title, createStepBar(options.steps, options.activeStep));
|
titleWrap.append(
|
||||||
|
title,
|
||||||
|
createStepBar(options.steps, options.activeStep, {
|
||||||
|
stages: options.stages,
|
||||||
|
routes: options.routes,
|
||||||
|
onStepClick: options.onStepClick,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
const headerHideButton = document.createElement("button");
|
const headerHideButton = document.createElement("button");
|
||||||
headerHideButton.type = "button";
|
headerHideButton.type = "button";
|
||||||
|
|||||||
Reference in New Issue
Block a user