Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Page.ts
T
2026-07-19 10:45:22 +09:00

352 lines
12 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,
updateContourInterval,
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 { createRouteProfilePanel } from "./B05_wf2_Route_UI_Profile_Panel";
import { createRouteViewer } from "./B05_wf2_Route_UI_Viewer";
import {
fetchSectionContext,
fetchSectionDetail,
type SectionDetailResponse,
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch";
import "./B05_wf2_Route_UI_Style.css";
type GradeClass = RoutePanelValues["gradeClass"];
type RoadWidths = Record<GradeClass, number>;
const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 };
async function fetchRoadWidths(projectId: string): Promise<RoadWidths> {
const response = await fetch(`/api/projects/${projectId}/sections/road-widths`);
if (!response.ok) return DEFAULT_ROAD_WIDTHS;
const payload = (await response.json()) as { forest_road_min_width_m?: Partial<RoadWidths> };
return { ...DEFAULT_ROAD_WIDTHS, ...payload.forest_road_min_width_m };
}
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();
const profilePanel = createRouteProfilePanel((stationId) =>
viewer.markers.selectStation(stationId),
);
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
let roadWidths = DEFAULT_ROAD_WIDTHS;
let currentSectionDetail: SectionDetailResponse | 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,
onStationLinesVisible: viewer.setStationLinesVisible,
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();
if (currentSectionDetail) renderStationLines(currentSectionDetail);
}
viewer.markers.onChange(markStale);
viewer.markers.onSelectionChange(panel.setSelected);
viewer.markers.onStationSelectionChange(profilePanel.setSelectedStation);
viewer.root.append(panel.viewControls);
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,
stationInterval: next.route_params?.station_interval_m ?? undefined,
crossSampleInterval: next.route_params?.cross_sample_interval_m ?? undefined,
longSampleInterval: next.route_params?.long_sample_interval_m ?? undefined,
});
viewer.markers.setPoints(restorePoints(next));
}
function renderStationLines(detail: SectionDetailResponse): void {
viewer.renderStationLines(
detail.longitudinal.stations,
roadWidths[panel.values().gradeClass] / 2,
);
}
function renderSections(detail: SectionDetailResponse): void {
currentSectionDetail = detail;
profilePanel.render(detail, panel.values().stationInterval ?? undefined);
renderStationLines(detail);
}
async function restoreSections(routeId: number): Promise<void> {
try {
renderSections(await fetchSectionDetail(activeProjectId, routeId));
} catch {
currentSectionDetail = null;
profilePanel.clear();
viewer.renderStationLines([], 0);
}
}
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,
(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);
await updateContourInterval(activeProjectId, 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 {
const solved = 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,
station_interval_m: values.stationInterval,
cross_half_width_m: null,
cross_sample_interval_m: values.crossSampleInterval,
long_sample_interval_m: values.longSampleInterval,
});
renderLatest(await fetchLatestRoute(activeProjectId));
await restoreSections(solved.route_id);
if (solved.cross_section_count === null) {
showToast("경로는 저장되었지만 종횡단 생성에 실패했습니다.", "error");
} else {
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, sectionContext, configuredRoadWidths] =
await Promise.all([
fetchWorkflowState(activeProjectId),
listSurfaceModels(activeProjectId),
fetchLatestRoute(activeProjectId),
fetchSectionContext(activeProjectId),
fetchRoadWidths(activeProjectId),
]);
roadWidths = configuredRoadWidths;
confirmedSurface = models.models.find((model) => model.status === "CONFIRMED") ?? null;
if (!confirmedSurface) {
showToast("확정된 지표면 모델이 없습니다.", "error");
} else {
const cloud = await fetchSurfacePointCloud(
activeProjectId,
latestResponse.surface_params.source_filter,
);
panel.restore({
stationInterval: sectionContext.defaults.station_interval_m,
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
});
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);
if (latestResponse.route) await restoreSections(latestResponse.route.id);
}
latest = latestResponse;
restoring = false;
const mainContent = document.createElement("div");
mainContent.className = "b05-route__main";
mainContent.append(viewer.root, profilePanel.root);
const layout = createWorkflowLayout({
title: "노선 설계",
steps: workflowSteps(),
activeStep: 2,
leftPanel: panel.root,
mainContent,
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);
}