276 lines
9.2 KiB
TypeScript
276 lines
9.2 KiB
TypeScript
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
|
import { hideLoadingOverlay, showLoadingOverlay, showToast } from "@ui/ui_template_elements";
|
|
import { createWorkflowLayout } from "@ui/ui_template_workflow_layout";
|
|
import { workflowSteps } from "../A00_Common/b_page_scaffold";
|
|
import {
|
|
fetchWorkflowState,
|
|
goToWorkflowStage,
|
|
WORKFLOW_STEP_ROUTES,
|
|
} from "../A00_Common/b_workflow_nav";
|
|
import {
|
|
fetchSurfacePointCloud,
|
|
listSurfaceModels,
|
|
type SurfaceModelSummary,
|
|
} from "../B04_wf1_Surface/B04_wf1_Surface_Api_Fetch";
|
|
import {
|
|
confirmRoute,
|
|
fetchLatestRoute,
|
|
solveRoute,
|
|
type CirclePoint,
|
|
type RouteLatestResponse,
|
|
type RoutePoint,
|
|
} from "./B05_wf2_Route_Api_Fetch";
|
|
import {
|
|
type ModelBounds,
|
|
type PlacedRoutePoint,
|
|
type RouteDesignPoints,
|
|
type RoutePointKind,
|
|
} from "./B05_wf2_Route_UI_Markers";
|
|
import { createRoutePanel, type RoutePanelValues } from "./B05_wf2_Route_UI_Panel";
|
|
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
|
|
import "./B05_wf2_Route_UI_Style.css";
|
|
|
|
function toBounds(bounds: {
|
|
x_min: number;
|
|
x_max: number;
|
|
y_min: number;
|
|
y_max: number;
|
|
z_min: number;
|
|
z_max: number;
|
|
}): ModelBounds {
|
|
return {
|
|
x: [bounds.x_min, bounds.x_max],
|
|
y: [bounds.y_min, bounds.y_max],
|
|
z: [bounds.z_min, bounds.z_max],
|
|
};
|
|
}
|
|
|
|
function placed(
|
|
type: RoutePointKind,
|
|
point: RoutePoint | CirclePoint,
|
|
index = 0,
|
|
): PlacedRoutePoint {
|
|
return {
|
|
id: `${type}-restored-${index}`,
|
|
type,
|
|
x: point.x,
|
|
y: point.y,
|
|
z: point.z ?? 0,
|
|
...(type === "ap" || type === "fp" ? { radius_m: (point as CirclePoint).radius_m ?? 25 } : {}),
|
|
};
|
|
}
|
|
|
|
function restorePoints(latest: RouteLatestResponse): RouteDesignPoints {
|
|
const points = latest.route_params?.points;
|
|
return {
|
|
bp: points?.bp ? placed("bp", points.bp) : null,
|
|
ep: points?.ep ? placed("ep", points.ep) : null,
|
|
cp: (points?.cp ?? []).map((point, index) => placed("cp", point, index)),
|
|
ap: (points?.ap ?? []).map((point, index) => placed("ap", point, index)),
|
|
fp: (points?.fp ?? []).map((point, index) => placed("fp", point, index)),
|
|
};
|
|
}
|
|
|
|
function routePoint(point: PlacedRoutePoint): RoutePoint {
|
|
return { x: point.x, y: point.y, z: point.z };
|
|
}
|
|
|
|
function circlePoint(point: PlacedRoutePoint): CirclePoint {
|
|
return { ...routePoint(point), radius_m: point.radius_m ?? 25 };
|
|
}
|
|
|
|
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
|
if (!projectId) {
|
|
showToast("프로젝트를 먼저 선택하세요.", "error");
|
|
return;
|
|
}
|
|
const activeProjectId: string = projectId;
|
|
|
|
const viewer = createRouteViewer();
|
|
let confirmedSurface: SurfaceModelSummary | null = null;
|
|
let latest: RouteLatestResponse | null = null;
|
|
let routeReady = false;
|
|
let stale = false;
|
|
let restoring = true;
|
|
|
|
const panel = createRoutePanel({
|
|
onSolve: () => void solve(),
|
|
onConfirm: () => void confirm(),
|
|
onContourApply: (interval) => void applyContours(interval),
|
|
onSurfaceVisible: viewer.setSurfaceVisible,
|
|
onContoursVisible: viewer.setContoursVisible,
|
|
onAxesVisible: viewer.setAxesVisible,
|
|
onView: viewer.setView,
|
|
onResetView: () => viewer.setView("top"),
|
|
onMovePoint: viewer.beginMoveSelected,
|
|
onDeletePoint: viewer.markers.deleteSelected,
|
|
onRadiusChange: (radius) => viewer.markers.updateSelected({ radius_m: radius }),
|
|
onInputChange: markStale,
|
|
});
|
|
|
|
function updateConfirmGate(): void {
|
|
panel.setCanConfirm(routeReady && !stale);
|
|
}
|
|
|
|
function markStale(): void {
|
|
if (restoring || !routeReady) return;
|
|
stale = true;
|
|
panel.setStale(true);
|
|
updateConfirmGate();
|
|
}
|
|
|
|
viewer.markers.onChange(markStale);
|
|
viewer.markers.onSelectionChange(panel.setSelected);
|
|
|
|
function restorePanel(next: RouteLatestResponse): void {
|
|
const options = next.route_params?.options ?? {};
|
|
panel.restore({
|
|
contourInterval: next.surface_params.contour_interval_m,
|
|
algorithm: next.route_params?.algorithm as RoutePanelValues["algorithm"] | undefined,
|
|
gradeClass: options.grade_class as RoutePanelValues["gradeClass"] | undefined,
|
|
paved: options.paved as boolean | undefined,
|
|
minCurveRadius: options.min_curve_radius_m as number | undefined,
|
|
maxUphillGrade: options.max_uphill_grade as number | undefined,
|
|
maxDownhillGrade: options.max_downhill_grade as number | undefined,
|
|
minUphillGrade: options.min_uphill_grade as number | undefined,
|
|
minDownhillGrade: options.min_downhill_grade as number | undefined,
|
|
allowAvoidPassThrough: options.allow_avoid_pass_through as boolean | undefined,
|
|
});
|
|
viewer.markers.setPoints(restorePoints(next));
|
|
}
|
|
|
|
function renderLatest(next: RouteLatestResponse): void {
|
|
latest = next;
|
|
routeReady = Boolean(next.route && next.route_points.length > 1);
|
|
stale = false;
|
|
panel.setStale(false);
|
|
if (next.route) {
|
|
const stored = next.route.algorithm_params ?? {};
|
|
const metrics = (stored.metrics as Record<string, unknown> | undefined) ?? {
|
|
length_m: next.route.total_length_m,
|
|
min_slope: next.route.min_slope,
|
|
max_slope: next.route.max_slope,
|
|
mean_slope: next.route.mean_slope,
|
|
cost_score: next.route.cost_score,
|
|
};
|
|
panel.renderMetrics(metrics);
|
|
viewer.markers.renderRoute(
|
|
next.route_points,
|
|
panel.values().gradeClass,
|
|
(stored.curve_warning_segments as Array<{
|
|
polyline_start_index: number;
|
|
polyline_end_index: number;
|
|
}>) ?? [],
|
|
);
|
|
}
|
|
updateConfirmGate();
|
|
}
|
|
|
|
async function applyContours(interval: number): Promise<void> {
|
|
showLoadingOverlay();
|
|
try {
|
|
await viewer.reloadContours(interval);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "등고선 조회에 실패했습니다.", "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
async function solve(): Promise<void> {
|
|
if (!confirmedSurface || !latest) return;
|
|
const points = viewer.markers.getPoints();
|
|
if (!points.bp || !points.ep) {
|
|
showToast("BP와 EP를 지형에 배치하세요.", "error");
|
|
return;
|
|
}
|
|
const values = panel.values();
|
|
showLoadingOverlay();
|
|
try {
|
|
await solveRoute(activeProjectId, {
|
|
filter_key: latest.surface_params.source_filter,
|
|
method: latest.surface_params.method,
|
|
smooth: latest.surface_params.smooth,
|
|
surface_model_id: confirmedSurface.id,
|
|
algorithm: values.algorithm,
|
|
bp: routePoint(points.bp),
|
|
ep: routePoint(points.ep),
|
|
cp: points.cp.map(routePoint),
|
|
ap: points.ap.map(circlePoint),
|
|
fp: points.fp.map(circlePoint),
|
|
grade_class: values.gradeClass,
|
|
paved: values.paved,
|
|
min_curve_radius_m: values.minCurveRadius,
|
|
max_uphill_grade: values.maxUphillGrade,
|
|
max_downhill_grade: values.maxDownhillGrade,
|
|
min_uphill_grade: values.minUphillGrade,
|
|
min_downhill_grade: values.minDownhillGrade,
|
|
allow_avoid_pass_through: values.allowAvoidPassThrough,
|
|
});
|
|
renderLatest(await fetchLatestRoute(activeProjectId));
|
|
showToast("최적 경로 계산이 완료되었습니다.", "success");
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "경로 계산에 실패했습니다.", "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
async function confirm(): Promise<void> {
|
|
if (!routeReady || stale) return;
|
|
showLoadingOverlay();
|
|
try {
|
|
await confirmRoute(activeProjectId);
|
|
renderLatest(await fetchLatestRoute(activeProjectId));
|
|
showToast("경로를 확정했습니다.", "success");
|
|
goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[3]);
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : "경로 확정에 실패했습니다.", "error");
|
|
} finally {
|
|
hideLoadingOverlay();
|
|
}
|
|
}
|
|
|
|
const [workflowState, models, latestResponse] = await Promise.all([
|
|
fetchWorkflowState(activeProjectId),
|
|
listSurfaceModels(activeProjectId),
|
|
fetchLatestRoute(activeProjectId),
|
|
]);
|
|
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
|
|
if (!confirmedSurface) {
|
|
showToast("확정된 지표면 모델이 없습니다.", "error");
|
|
} else {
|
|
const cloud = await fetchSurfacePointCloud(
|
|
activeProjectId,
|
|
latestResponse.surface_params.source_filter,
|
|
);
|
|
restorePanel(latestResponse);
|
|
await viewer.loadSurface(
|
|
activeProjectId,
|
|
confirmedSurface.id,
|
|
latestResponse.surface_params.method,
|
|
latestResponse.surface_params.smooth,
|
|
latestResponse.surface_params.contour_interval_m,
|
|
toBounds(cloud.bounds),
|
|
);
|
|
renderLatest(latestResponse);
|
|
}
|
|
latest = latestResponse;
|
|
restoring = false;
|
|
|
|
const layout = createWorkflowLayout({
|
|
title: "노선 설계",
|
|
steps: workflowSteps(),
|
|
activeStep: 2,
|
|
leftPanel: panel.root,
|
|
mainContent: viewer.root,
|
|
stages: workflowState.stages,
|
|
currentStage: workflowState.current_stage,
|
|
routes: WORKFLOW_STEP_ROUTES,
|
|
onStepClick: (stepIndex) => goToWorkflowStage(activeProjectId, WORKFLOW_STEP_ROUTES[stepIndex]),
|
|
});
|
|
layout.root.classList.add("b05-route-layout");
|
|
root.replaceChildren(layout.root);
|
|
}
|