Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Page.ts
T
2026-07-18 17:48:51 +09:00

274 lines
9.1 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");
} 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]),
});
root.replaceChildren(layout.root);
}