B05_06_횡단 개선
This commit is contained in:
@@ -26,6 +26,7 @@ export interface ModelBounds {
|
||||
}
|
||||
|
||||
export interface SectionStationMarker {
|
||||
station_id: string;
|
||||
center_x: number;
|
||||
center_y: number;
|
||||
center_z: number | null;
|
||||
@@ -70,14 +71,18 @@ export function sceneToModel(point: THREE.Vector3, bounds: ModelBounds) {
|
||||
}
|
||||
|
||||
export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBounds | null) {
|
||||
const interactionGroup = new THREE.Group();
|
||||
const markerGroup = new THREE.Group();
|
||||
const routeGroup = new THREE.Group();
|
||||
const stationGroup = new THREE.Group();
|
||||
scene.add(markerGroup, routeGroup, stationGroup);
|
||||
interactionGroup.add(markerGroup, stationGroup);
|
||||
scene.add(interactionGroup, routeGroup);
|
||||
let points = emptyPoints();
|
||||
let selectedId: string | null = null;
|
||||
let changeListener: ((points: RouteDesignPoints) => void) | undefined;
|
||||
let selectionListener: ((point: PlacedRoutePoint | null) => void) | undefined;
|
||||
let stationSelectionListener: ((stationId: string | null) => void) | undefined;
|
||||
let selectedStationId: string | null = null;
|
||||
|
||||
function allPoints(): PlacedRoutePoint[] {
|
||||
return [points.bp, points.ep, ...points.cp, ...points.ap, ...points.fp].filter(
|
||||
@@ -166,7 +171,6 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
|
||||
function renderRoute(
|
||||
polyline: Array<{ x: number; y: number; z?: number }>,
|
||||
gradeClass: string,
|
||||
warnings: Array<{ polyline_start_index: number; polyline_end_index: number }> = [],
|
||||
): void {
|
||||
disposeGroup(routeGroup);
|
||||
@@ -204,36 +208,17 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
routeGroup.add(marker);
|
||||
}
|
||||
});
|
||||
const width = gradeClass === "trunk" ? 4 : gradeClass === "branch" ? 3 : 2.5;
|
||||
const perpendiculars: THREE.Vector3[] = [];
|
||||
for (let index = 0; index < linePoints.length; index += 10) {
|
||||
const previous = linePoints[Math.max(0, index - 1)];
|
||||
const next = linePoints[Math.min(linePoints.length - 1, index + 1)];
|
||||
const direction = next.clone().sub(previous).normalize();
|
||||
const perpendicular = new THREE.Vector3(-direction.z, 0, direction.x);
|
||||
perpendiculars.push(
|
||||
linePoints[index].clone().addScaledVector(perpendicular, width / 2),
|
||||
linePoints[index].clone().addScaledVector(perpendicular, -width / 2),
|
||||
);
|
||||
}
|
||||
routeGroup.add(
|
||||
new THREE.LineSegments(
|
||||
new THREE.BufferGeometry().setFromPoints(perpendiculars),
|
||||
new THREE.LineBasicMaterial({ color: 0xfacc15 }),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function renderStationLines(stations: SectionStationMarker[], halfWidth: number): void {
|
||||
disposeGroup(stationGroup);
|
||||
const bounds = getBounds();
|
||||
if (!bounds || halfWidth <= 0) return;
|
||||
const points: THREE.Vector3[] = [];
|
||||
stations.forEach((station) => {
|
||||
if (station.center_z === null) return;
|
||||
const [leftX, leftY] = station.frame.left_xy;
|
||||
const center = { x: station.center_x, y: station.center_y, z: station.center_z + 0.45 };
|
||||
points.push(
|
||||
const points = [
|
||||
modelToScene(
|
||||
{ x: center.x + leftX * halfWidth, y: center.y + leftY * halfWidth, z: center.z },
|
||||
bounds,
|
||||
@@ -242,18 +227,32 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
{ x: center.x - leftX * halfWidth, y: center.y - leftY * halfWidth, z: center.z },
|
||||
bounds,
|
||||
),
|
||||
);
|
||||
});
|
||||
stationGroup.add(
|
||||
new THREE.LineSegments(
|
||||
];
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const line = new THREE.Line(
|
||||
new THREE.BufferGeometry().setFromPoints(points),
|
||||
new THREE.LineBasicMaterial({ color: 0xa855f7 }),
|
||||
),
|
||||
);
|
||||
new THREE.LineBasicMaterial({ color: selected ? 0xef4444 : 0xfacc15 }),
|
||||
);
|
||||
line.userData.stationId = station.station_id;
|
||||
if (selected) line.material.linewidth = 2;
|
||||
stationGroup.add(line);
|
||||
});
|
||||
}
|
||||
|
||||
function selectStation(stationId: string | null): void {
|
||||
selectedStationId = stationId;
|
||||
stationGroup.children.forEach((object) => {
|
||||
if (!(object instanceof THREE.Line)) return;
|
||||
const selected = object.userData.stationId === selectedStationId;
|
||||
const material = object.material as THREE.LineBasicMaterial;
|
||||
material.color.set(selected ? 0xef4444 : 0xfacc15);
|
||||
material.linewidth = selected ? 3 : 1;
|
||||
});
|
||||
stationSelectionListener?.(selectedStationId);
|
||||
}
|
||||
|
||||
return {
|
||||
group: markerGroup,
|
||||
group: interactionGroup,
|
||||
getPoints: () => points,
|
||||
getSelected: selected,
|
||||
setPoints(next: RouteDesignPoints) {
|
||||
@@ -268,6 +267,11 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
updateSelected,
|
||||
deleteSelected,
|
||||
selectObject(object: THREE.Object3D | undefined) {
|
||||
if (typeof object?.userData.stationId === "string") {
|
||||
selectStation(object.userData.stationId);
|
||||
selectionListener?.(null);
|
||||
return;
|
||||
}
|
||||
selectedId =
|
||||
typeof object?.userData.routePointId === "string" ? object.userData.routePointId : null;
|
||||
renderMarkers();
|
||||
@@ -276,6 +280,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
renderMarkers,
|
||||
renderRoute,
|
||||
renderStationLines,
|
||||
selectStation,
|
||||
setStationLinesVisible(visible: boolean) {
|
||||
stationGroup.visible = visible;
|
||||
},
|
||||
@@ -285,11 +290,13 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou
|
||||
onSelectionChange(listener: (point: PlacedRoutePoint | null) => void) {
|
||||
selectionListener = listener;
|
||||
},
|
||||
onStationSelectionChange(listener: (stationId: string | null) => void) {
|
||||
stationSelectionListener = listener;
|
||||
},
|
||||
dispose() {
|
||||
disposeGroup(markerGroup);
|
||||
disposeGroup(interactionGroup);
|
||||
disposeGroup(routeGroup);
|
||||
disposeGroup(stationGroup);
|
||||
scene.remove(markerGroup, routeGroup, stationGroup);
|
||||
scene.remove(interactionGroup, routeGroup);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ import {
|
||||
} 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;
|
||||
@@ -94,10 +106,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
const activeProjectId: string = projectId;
|
||||
|
||||
const viewer = createRouteViewer();
|
||||
const profilePanel = createRouteProfilePanel();
|
||||
const profilePanel = createRouteProfilePanel((stationId) =>
|
||||
viewer.markers.selectStation(stationId),
|
||||
);
|
||||
let confirmedSurface: SurfaceModelSummary | null = null;
|
||||
let latest: RouteLatestResponse | null = null;
|
||||
let defaultCrossHalfWidth = 0;
|
||||
let roadWidths = DEFAULT_ROAD_WIDTHS;
|
||||
let currentSectionDetail: SectionDetailResponse | null = null;
|
||||
let routeReady = false;
|
||||
let stale = false;
|
||||
let restoring = true;
|
||||
@@ -127,10 +142,13 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
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 ?? {};
|
||||
@@ -146,25 +164,30 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
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,
|
||||
crossHalfWidth: next.route_params?.cross_half_width_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 renderSections(detail: SectionDetailResponse): void {
|
||||
profilePanel.render(detail);
|
||||
function renderStationLines(detail: SectionDetailResponse): void {
|
||||
viewer.renderStationLines(
|
||||
detail.longitudinal.stations,
|
||||
panel.values().crossHalfWidth ?? defaultCrossHalfWidth,
|
||||
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);
|
||||
}
|
||||
@@ -187,7 +210,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
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;
|
||||
@@ -238,7 +260,7 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
min_downhill_grade: values.minDownhillGrade,
|
||||
allow_avoid_pass_through: values.allowAvoidPassThrough,
|
||||
station_interval_m: values.stationInterval,
|
||||
cross_half_width_m: values.crossHalfWidth,
|
||||
cross_half_width_m: null,
|
||||
cross_sample_interval_m: values.crossSampleInterval,
|
||||
long_sample_interval_m: values.longSampleInterval,
|
||||
});
|
||||
@@ -271,14 +293,16 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
const [workflowState, models, latestResponse, sectionContext] = await Promise.all([
|
||||
fetchWorkflowState(activeProjectId),
|
||||
listSurfaceModels(activeProjectId),
|
||||
fetchLatestRoute(activeProjectId),
|
||||
fetchSectionContext(activeProjectId),
|
||||
]);
|
||||
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;
|
||||
defaultCrossHalfWidth = sectionContext.defaults.cross_half_width_m;
|
||||
if (!confirmedSurface) {
|
||||
showToast("확정된 지표면 모델이 없습니다.", "error");
|
||||
} else {
|
||||
@@ -288,7 +312,6 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||
);
|
||||
panel.restore({
|
||||
stationInterval: sectionContext.defaults.station_interval_m,
|
||||
crossHalfWidth: sectionContext.defaults.cross_half_width_m,
|
||||
crossSampleInterval: sectionContext.defaults.cross_sample_interval_m,
|
||||
longSampleInterval: sectionContext.defaults.long_sample_interval_m,
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ export interface RoutePanelValues {
|
||||
minDownhillGrade: number | null;
|
||||
allowAvoidPassThrough: boolean;
|
||||
stationInterval: number | null;
|
||||
crossHalfWidth: number | null;
|
||||
crossSampleInterval: number | null;
|
||||
longSampleInterval: number | null;
|
||||
}
|
||||
@@ -83,6 +82,22 @@ function checkbox(label: string, checked: boolean): WrappedInput {
|
||||
return Object.assign(input, { wrapper });
|
||||
}
|
||||
|
||||
function toggleButton(
|
||||
label: string,
|
||||
checked: boolean,
|
||||
onChange: (checked: boolean) => void,
|
||||
): HTMLButtonElement {
|
||||
const element = button(label, () => {
|
||||
const active = !element.classList.contains("is-active");
|
||||
element.classList.toggle("is-active", active);
|
||||
element.setAttribute("aria-pressed", String(active));
|
||||
onChange(active);
|
||||
});
|
||||
element.classList.toggle("is-active", checked);
|
||||
element.setAttribute("aria-pressed", String(checked));
|
||||
return element;
|
||||
}
|
||||
|
||||
function parseOptional(input: HTMLInputElement): number | null {
|
||||
if (!input.value.trim()) return null;
|
||||
const value = Number(input.value);
|
||||
@@ -93,41 +108,41 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
const root = document.createElement("div");
|
||||
root.className = "b05-route__panel";
|
||||
|
||||
const view = section("뷰 컨트롤");
|
||||
const viewControls = document.createElement("div");
|
||||
viewControls.className = "b05-route__view-controls";
|
||||
const viewButtons = document.createElement("div");
|
||||
viewButtons.className = "b05-route__button-grid";
|
||||
viewButtons.className = "b05-route__view-group";
|
||||
(["iso", "top", "front", "side"] as const).forEach((preset) =>
|
||||
viewButtons.append(button(preset.toUpperCase(), () => callbacks.onView(preset))),
|
||||
);
|
||||
const surfaceVisible = checkbox("지표면", true);
|
||||
const contoursVisible = checkbox("등고선", true);
|
||||
const axesVisible = checkbox("축 표시", false);
|
||||
const stationLinesVisible = checkbox(L("B05_Route_Field_StationLines"), true);
|
||||
surfaceVisible.addEventListener("change", () =>
|
||||
callbacks.onSurfaceVisible(surfaceVisible.checked),
|
||||
const visibilityButtons = document.createElement("div");
|
||||
visibilityButtons.className = "b05-route__view-group";
|
||||
visibilityButtons.append(
|
||||
toggleButton("지표면", true, callbacks.onSurfaceVisible),
|
||||
toggleButton("등고선", true, callbacks.onContoursVisible),
|
||||
toggleButton("축 표시", false, callbacks.onAxesVisible),
|
||||
toggleButton(L("B05_Route_Field_StationLines"), true, callbacks.onStationLinesVisible),
|
||||
);
|
||||
contoursVisible.addEventListener("change", () =>
|
||||
callbacks.onContoursVisible(contoursVisible.checked),
|
||||
);
|
||||
axesVisible.addEventListener("change", () => callbacks.onAxesVisible(axesVisible.checked));
|
||||
stationLinesVisible.addEventListener("change", () =>
|
||||
callbacks.onStationLinesVisible(stationLinesVisible.checked),
|
||||
);
|
||||
view.body.append(
|
||||
const separator1 = document.createElement("span");
|
||||
separator1.className = "b05-route__view-separator";
|
||||
const separator2 = separator1.cloneNode() as HTMLSpanElement;
|
||||
viewControls.append(
|
||||
viewButtons,
|
||||
surfaceVisible.wrapper,
|
||||
contoursVisible.wrapper,
|
||||
axesVisible.wrapper,
|
||||
stationLinesVisible.wrapper,
|
||||
separator1,
|
||||
visibilityButtons,
|
||||
separator2,
|
||||
button("뷰 초기화", callbacks.onResetView),
|
||||
);
|
||||
|
||||
const contour = section("등고선 간격");
|
||||
const contourInterval = numberField("간격 (m)", "1");
|
||||
contour.body.append(
|
||||
const contourRow = document.createElement("div");
|
||||
contourRow.className = "b05-route__contour-row";
|
||||
contourRow.append(
|
||||
contourInterval.wrapper,
|
||||
button("등고선 재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
|
||||
button("재적용", () => callbacks.onContourApply(Number(contourInterval.value) || 1)),
|
||||
);
|
||||
contour.body.append(contourRow);
|
||||
|
||||
const palette = section("포인트 팔레트");
|
||||
const paletteGrid = document.createElement("div");
|
||||
@@ -207,12 +222,10 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
|
||||
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
|
||||
const stationInterval = numberField(L("B05_Route_Field_StationInterval"));
|
||||
const crossHalfWidth = numberField(L("B05_Route_Field_CrossHalfWidth"));
|
||||
const crossSampleInterval = numberField(L("B05_Route_Field_CrossSample"));
|
||||
const longSampleInterval = numberField(L("B05_Route_Field_LongSample"));
|
||||
sectionOptions.body.append(
|
||||
stationInterval.wrapper,
|
||||
crossHalfWidth.wrapper,
|
||||
crossSampleInterval.wrapper,
|
||||
longSampleInterval.wrapper,
|
||||
);
|
||||
@@ -245,13 +258,11 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minUphillGrade,
|
||||
minDownhillGrade,
|
||||
stationInterval,
|
||||
crossHalfWidth,
|
||||
crossSampleInterval,
|
||||
longSampleInterval,
|
||||
];
|
||||
inputElements.forEach((input) => input.addEventListener("change", callbacks.onInputChange));
|
||||
root.append(
|
||||
view.root,
|
||||
contour.root,
|
||||
palette.root,
|
||||
selected.root,
|
||||
@@ -263,6 +274,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
|
||||
return {
|
||||
root,
|
||||
viewControls,
|
||||
values(): RoutePanelValues {
|
||||
return {
|
||||
contourInterval: Number(contourInterval.value) || 1,
|
||||
@@ -276,7 +288,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
minDownhillGrade: parseOptional(minDownhillGrade),
|
||||
allowAvoidPassThrough: avoidPass.checked,
|
||||
stationInterval: parseOptional(stationInterval),
|
||||
crossHalfWidth: parseOptional(crossHalfWidth),
|
||||
crossSampleInterval: parseOptional(crossSampleInterval),
|
||||
longSampleInterval: parseOptional(longSampleInterval),
|
||||
};
|
||||
@@ -293,7 +304,6 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
|
||||
if (values.minDownhillGrade != null) minDownhillGrade.value = String(values.minDownhillGrade);
|
||||
if (values.allowAvoidPassThrough != null) avoidPass.checked = values.allowAvoidPassThrough;
|
||||
if (values.stationInterval != null) stationInterval.value = String(values.stationInterval);
|
||||
if (values.crossHalfWidth != null) crossHalfWidth.value = String(values.crossHalfWidth);
|
||||
if (values.crossSampleInterval != null)
|
||||
crossSampleInterval.value = String(values.crossSampleInterval);
|
||||
if (values.longSampleInterval != null)
|
||||
|
||||
@@ -17,12 +17,10 @@ function normalizedLongitudinal(data: LongitudinalSection): LongitudinalSection
|
||||
};
|
||||
}
|
||||
|
||||
export function createRouteProfilePanel() {
|
||||
export function createRouteProfilePanel(onSelectStation: (stationId: string) => void) {
|
||||
const root = document.createElement("section");
|
||||
root.className = "b05-route-profile";
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = "종단면도";
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "b05-route-profile__toggle";
|
||||
@@ -32,12 +30,30 @@ export function createRouteProfilePanel() {
|
||||
empty.className = "b05-route-profile__empty";
|
||||
empty.textContent = "최적 경로를 계산하면 종단면도가 표시됩니다.";
|
||||
body.append(empty);
|
||||
header.append(title, toggle);
|
||||
header.append(toggle);
|
||||
root.append(header, body);
|
||||
let detail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
function draw(): void {
|
||||
if (!detail) return;
|
||||
body.replaceChildren(
|
||||
createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
selectedStationId,
|
||||
1,
|
||||
undefined,
|
||||
onSelectStation,
|
||||
stationInterval,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function setCollapsed(collapsed: boolean): void {
|
||||
root.classList.toggle("is-collapsed", collapsed);
|
||||
toggle.textContent = collapsed ? "펼치기" : "접기";
|
||||
toggle.textContent = collapsed ? "⌃" : "⌄";
|
||||
toggle.setAttribute("aria-label", collapsed ? "종단면도 펼치기" : "종단면도 접기");
|
||||
toggle.setAttribute("aria-expanded", String(!collapsed));
|
||||
sessionStorage.setItem(COLLAPSED_KEY, String(collapsed));
|
||||
}
|
||||
@@ -47,18 +63,18 @@ export function createRouteProfilePanel() {
|
||||
|
||||
return {
|
||||
root,
|
||||
render(detail: SectionDetailResponse) {
|
||||
body.replaceChildren(
|
||||
createLongitudinalProfile(
|
||||
normalizedLongitudinal(detail.longitudinal),
|
||||
null,
|
||||
1,
|
||||
undefined,
|
||||
() => undefined,
|
||||
),
|
||||
);
|
||||
render(nextDetail: SectionDetailResponse, nextStationInterval?: number) {
|
||||
detail = nextDetail;
|
||||
stationInterval = nextStationInterval;
|
||||
draw();
|
||||
},
|
||||
setSelectedStation(stationId: string | null) {
|
||||
selectedStationId = stationId;
|
||||
draw();
|
||||
},
|
||||
clear() {
|
||||
detail = null;
|
||||
selectedStationId = null;
|
||||
body.replaceChildren(empty);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
.b05-route-profile {
|
||||
flex: 0 0 250px;
|
||||
min-height: 0;
|
||||
min-height: 250px;
|
||||
overflow: hidden;
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface-raised);
|
||||
@@ -44,20 +44,23 @@
|
||||
}
|
||||
|
||||
.b05-route-profile.is-collapsed {
|
||||
flex-basis: 42px;
|
||||
flex-basis: 24px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.b05-route-profile > header {
|
||||
display: flex;
|
||||
height: 42px;
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 var(--spacing-16);
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.b05-route-profile__toggle {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--color-primary);
|
||||
@@ -65,7 +68,7 @@
|
||||
}
|
||||
|
||||
.b05-route-profile__body {
|
||||
height: calc(100% - 42px);
|
||||
height: calc(100% - 24px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
@@ -80,8 +83,11 @@
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.b05-route-profile .b06-section__chart-wrap,
|
||||
.b05-route-profile .b06-section__chart {
|
||||
max-height: 205px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.b05-route__viewport canvas {
|
||||
@@ -103,6 +109,42 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.b05-route__view-controls {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
top: 58px;
|
||||
left: var(--spacing-16);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-route__view-group {
|
||||
display: flex;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.b05-route__view-controls .b05-route__button {
|
||||
height: 34px;
|
||||
padding: 0 var(--spacing-8);
|
||||
border-color: color-mix(in srgb, var(--color-border) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--color-surface-raised) 72%, transparent);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.b05-route__view-controls .b05-route__button:hover,
|
||||
.b05-route__view-controls .b05-route__button.is-active {
|
||||
border-color: var(--color-primary);
|
||||
background: color-mix(in srgb, var(--color-primary) 78%, transparent);
|
||||
color: var(--color-text-on-primary);
|
||||
}
|
||||
|
||||
.b05-route__view-separator {
|
||||
width: 1px;
|
||||
height: 24px;
|
||||
background: color-mix(in srgb, var(--color-border) 75%, transparent);
|
||||
}
|
||||
|
||||
.b05-route__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -139,6 +181,20 @@
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-route__contour-row {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
gap: var(--spacing-8);
|
||||
}
|
||||
|
||||
.b05-route__contour-row .b05-route__field {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.b05-route__contour-row .b05-route__button {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.b05-route__field,
|
||||
.b05-route__check,
|
||||
.b05-route__metrics {
|
||||
|
||||
@@ -28,7 +28,7 @@ from common_util.common_util_storage import resolve_stored_project_path
|
||||
from common_util.common_util_surface_confirmation import get_surface_confirmation_params
|
||||
from common_util.common_util_workflow_state import complete_stage
|
||||
from config.config_db import get_db_pool
|
||||
from config.config_system import SECTION_VERTICAL_EXAGGERATION
|
||||
from config.config_system import FOREST_ROAD_MIN_WIDTH_M, SECTION_VERTICAL_EXAGGERATION
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"])
|
||||
@@ -67,6 +67,12 @@ async def get_section_context(project_id: UUID) -> SectionContextResponse | JSON
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/road-widths")
|
||||
async def get_forest_road_min_widths(project_id: UUID) -> dict[str, dict[str, float]]:
|
||||
"""B05 측점 가로선에 적용할 임도 등급별 법정 최소너비를 반환한다."""
|
||||
return {"forest_road_min_width_m": FOREST_ROAD_MIN_WIDTH_M}
|
||||
|
||||
|
||||
@router.get("/{project_id}/sections/{route_id}", response_model=SectionSummaryResponse)
|
||||
async def get_sections(project_id: UUID, route_id: int) -> SectionSummaryResponse | JSONResponse:
|
||||
"""경로의 종단면 요약을 조회한다."""
|
||||
|
||||
@@ -69,6 +69,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||
let currentRouteId: number | null = null;
|
||||
let sectionDetail: SectionDetailResponse | null = null;
|
||||
let stationInterval: number | undefined;
|
||||
|
||||
const routeGroup = buildGroup(L("B06_Profile_Group_Route"));
|
||||
const routeIdInfo = buildInfoLine(L("B06_Profile_Field_RouteId"));
|
||||
@@ -96,7 +97,13 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
});
|
||||
verticalExaggerationField.input.min = "0.1";
|
||||
verticalExaggerationField.input.step = "0.1";
|
||||
displayGroup.append(verticalExaggerationField.root);
|
||||
const crossHalfWidthField = createInputField({
|
||||
label: L("B05_Route_Field_CrossHalfWidth"),
|
||||
type: "number",
|
||||
});
|
||||
crossHalfWidthField.input.min = "0.1";
|
||||
crossHalfWidthField.input.step = "0.1";
|
||||
displayGroup.append(crossHalfWidthField.root, verticalExaggerationField.root);
|
||||
|
||||
const confirmButton = createButton({
|
||||
label: L("B06_Profile_Btn_Confirm"),
|
||||
@@ -137,9 +144,18 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
return Number.isFinite(parsed) && parsed >= 0.1 ? parsed : 1;
|
||||
}
|
||||
|
||||
verticalExaggerationField.input.addEventListener("input", () => {
|
||||
if (sectionDetail) sectionView.render(sectionDetail, verticalExaggeration());
|
||||
});
|
||||
function crossHalfWidth(): number | undefined {
|
||||
const parsed = Number(crossHalfWidthField.input.value);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function renderSectionDetail(): void {
|
||||
if (sectionDetail)
|
||||
sectionView.render(sectionDetail, verticalExaggeration(), crossHalfWidth(), stationInterval);
|
||||
}
|
||||
|
||||
verticalExaggerationField.input.addEventListener("input", renderSectionDetail);
|
||||
crossHalfWidthField.input.addEventListener("input", renderSectionDetail);
|
||||
|
||||
async function confirmCurrentSections(): Promise<void> {
|
||||
if (!projectId || currentRouteId === null) return;
|
||||
@@ -199,6 +215,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
: L("B06_Profile_Smooth_Off");
|
||||
crsInfo.value.textContent = context.crs_epsg === null ? "-" : `EPSG:${context.crs_epsg}`;
|
||||
verticalExaggerationField.input.value = String(context.defaults.vertical_exaggeration);
|
||||
crossHalfWidthField.input.value = String(context.defaults.cross_half_width_m);
|
||||
stationInterval = context.defaults.station_interval_m;
|
||||
|
||||
if (context.route_id === null) {
|
||||
renderMessage(L("B06_Profile_Calculate_In_B05"));
|
||||
@@ -214,7 +232,14 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
}
|
||||
renderSummary(existing);
|
||||
sectionDetail = await fetchSectionDetail(projectId, context.route_id);
|
||||
sectionView.render(sectionDetail, verticalExaggeration());
|
||||
const storedHalfWidth = Math.max(
|
||||
0,
|
||||
...sectionDetail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)),
|
||||
),
|
||||
);
|
||||
if (storedHalfWidth > 0) crossHalfWidthField.input.value = storedHalfWidth.toFixed(1);
|
||||
renderSectionDetail();
|
||||
confirmButton.disabled = false;
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? ` ${error.message}` : "";
|
||||
|
||||
@@ -70,12 +70,40 @@ function emptyView(message: string): HTMLElement {
|
||||
return empty;
|
||||
}
|
||||
|
||||
function inferStationInterval(stations: Array<{ chainage_m: number }>): number {
|
||||
const counts = new Map<number, number>();
|
||||
for (let index = 1; index < stations.length; index += 1) {
|
||||
const difference = stations[index].chainage_m - stations[index - 1].chainage_m;
|
||||
if (difference <= 0) continue;
|
||||
const rounded = Math.round(difference * 10) / 10;
|
||||
counts.set(rounded, (counts.get(rounded) ?? 0) + 1);
|
||||
}
|
||||
return (
|
||||
[...counts.entries()].sort(
|
||||
([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA,
|
||||
)[0]?.[0] ?? 1
|
||||
);
|
||||
}
|
||||
|
||||
function stationLabel(chainage: number, interval: number): string {
|
||||
const safeInterval = interval > 0 ? interval : 1;
|
||||
let stationNumber = Math.floor((chainage + 1e-6) / safeInterval);
|
||||
let remainder = chainage - stationNumber * safeInterval;
|
||||
if (Math.abs(remainder) < 0.05) remainder = 0;
|
||||
if (remainder >= safeInterval - 0.05) {
|
||||
stationNumber += 1;
|
||||
remainder = 0;
|
||||
}
|
||||
return `${stationNumber}+${remainder.toFixed(1)}`;
|
||||
}
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelectStation: (stationId: string) => void,
|
||||
configuredStationInterval?: number,
|
||||
): HTMLElement {
|
||||
const samples = data.samples.filter(validElevation);
|
||||
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
|
||||
@@ -106,6 +134,7 @@ export function createLongitudinalProfile(
|
||||
const x = (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth;
|
||||
const y = (elevation: number) =>
|
||||
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
|
||||
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
|
||||
|
||||
for (const ratio of [0, 0.25, 0.5, 0.75, 1]) {
|
||||
const gridY = LONG_PAD.top + ratio * plotHeight;
|
||||
@@ -135,13 +164,20 @@ export function createLongitudinalProfile(
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"aria-label": `${station.label} ${station.chainage_m.toFixed(1)}m`,
|
||||
"aria-label": `${stationLabel(station.chainage_m, stationInterval)} ${station.chainage_m.toFixed(1)}m`,
|
||||
});
|
||||
marker.addEventListener("click", () => onSelectStation(station.station_id));
|
||||
marker.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelectStation(station.station_id);
|
||||
});
|
||||
marker.append(
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
x2: stationX,
|
||||
y2: LONG_HEIGHT - LONG_PAD.bottom + 8,
|
||||
class: "b06-chart__station-hit",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
@@ -149,7 +185,7 @@ export function createLongitudinalProfile(
|
||||
y2: LONG_HEIGHT - LONG_PAD.bottom + 8,
|
||||
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
||||
}),
|
||||
svgText(station.label, {
|
||||
svgText(stationLabel(station.chainage_m, stationInterval), {
|
||||
x: stationX,
|
||||
y: LONG_HEIGHT - 23,
|
||||
"text-anchor": "middle",
|
||||
@@ -205,6 +241,8 @@ export function createCrossSectionCard(
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelect: (stationId: string) => void,
|
||||
stationInterval: number,
|
||||
crossHalfWidth?: number,
|
||||
): HTMLElement {
|
||||
const card = document.createElement("article");
|
||||
card.id = `cross-${section.station_id}`;
|
||||
@@ -218,7 +256,7 @@ export function createCrossSectionCard(
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("div");
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = section.label;
|
||||
label.textContent = stationLabel(section.chainage_m, stationInterval);
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
@@ -232,11 +270,15 @@ export function createCrossSectionCard(
|
||||
header.append(title, kind);
|
||||
card.append(header);
|
||||
|
||||
const valid = section.samples.filter(validElevation);
|
||||
const sourceSamples = section.samples.filter(
|
||||
(sample) =>
|
||||
crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6,
|
||||
);
|
||||
const valid = sourceSamples.filter(validElevation);
|
||||
if (!valid.length) {
|
||||
card.append(emptyView(L("B06_Profile_View_NoCross")));
|
||||
} else {
|
||||
const offsets = section.samples.map((sample) => sample.offset_m ?? 0);
|
||||
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
|
||||
const minOffset = Math.min(...offsets, -1);
|
||||
const maxOffset = Math.max(...offsets, 1);
|
||||
const elevations = valid.map((sample) => sample.elevation_m);
|
||||
@@ -311,7 +353,7 @@ export function createCrossSectionCard(
|
||||
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of section.samples) {
|
||||
for (const sample of sourceSamples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
@@ -392,7 +434,12 @@ export function createCrossSectionCard(
|
||||
|
||||
export interface SectionViewController {
|
||||
root: HTMLElement;
|
||||
render: (detail: SectionDetailResponse, verticalExaggeration: number) => void;
|
||||
render: (
|
||||
detail: SectionDetailResponse,
|
||||
verticalExaggeration: number,
|
||||
crossHalfWidth?: number,
|
||||
stationInterval?: number,
|
||||
) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
@@ -402,12 +449,16 @@ export function createSectionView(): SectionViewController {
|
||||
let currentDetail: SectionDetailResponse | null = null;
|
||||
let selectedStationId: string | null = null;
|
||||
let currentExaggeration = 1;
|
||||
let currentCrossHalfWidth: number | undefined;
|
||||
let currentStationInterval: number | undefined;
|
||||
|
||||
const draw = (): void => {
|
||||
root.replaceChildren();
|
||||
if (!currentDetail) return;
|
||||
const detail = currentDetail;
|
||||
const yScale = calculateYScale(detail);
|
||||
const stationInterval =
|
||||
currentStationInterval ?? inferStationInterval(detail.longitudinal.stations);
|
||||
const selectStation = (stationId: string, scroll: boolean): void => {
|
||||
selectedStationId = stationId;
|
||||
draw();
|
||||
@@ -420,20 +471,14 @@ export function createSectionView(): SectionViewController {
|
||||
|
||||
const longitudinalPanel = document.createElement("section");
|
||||
longitudinalPanel.className = "b06-section__panel";
|
||||
const longitudinalHeader = document.createElement("header");
|
||||
const longitudinalTitle = document.createElement("h3");
|
||||
longitudinalTitle.textContent = L("B06_Profile_View_Longitudinal");
|
||||
const stationCount = document.createElement("span");
|
||||
stationCount.textContent = `${L("B06_Profile_View_StationCount")} ${detail.longitudinal.stations.length}`;
|
||||
longitudinalHeader.append(longitudinalTitle, stationCount);
|
||||
longitudinalPanel.append(
|
||||
longitudinalHeader,
|
||||
createLongitudinalProfile(
|
||||
detail.longitudinal,
|
||||
selectedStationId,
|
||||
currentExaggeration,
|
||||
yScale,
|
||||
(stationId) => selectStation(stationId, true),
|
||||
stationInterval,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -455,6 +500,8 @@ export function createSectionView(): SectionViewController {
|
||||
currentExaggeration,
|
||||
yScale,
|
||||
(stationId) => selectStation(stationId, false),
|
||||
stationInterval,
|
||||
currentCrossHalfWidth,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -466,9 +513,13 @@ export function createSectionView(): SectionViewController {
|
||||
|
||||
return {
|
||||
root,
|
||||
render(detail, verticalExaggeration) {
|
||||
render(detail, verticalExaggeration, crossHalfWidth, stationInterval) {
|
||||
currentDetail = detail;
|
||||
currentExaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
currentCrossHalfWidth =
|
||||
crossHalfWidth !== undefined && crossHalfWidth > 0 ? crossHalfWidth : undefined;
|
||||
currentStationInterval =
|
||||
stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined;
|
||||
selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null;
|
||||
draw();
|
||||
},
|
||||
|
||||
@@ -122,6 +122,12 @@
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.b06-section__panel {
|
||||
position: sticky;
|
||||
z-index: 2;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.b06-section__panel > header,
|
||||
.b06-cross-card > header,
|
||||
.b06-cross-card > footer {
|
||||
@@ -271,6 +277,12 @@
|
||||
stroke-width: 1.2;
|
||||
}
|
||||
|
||||
.b06-chart__station-hit {
|
||||
stroke: transparent;
|
||||
stroke-width: 14;
|
||||
pointer-events: stroke;
|
||||
}
|
||||
|
||||
.b06-chart__station-line--bp,
|
||||
.b06-chart__station-line--regular {
|
||||
stroke: var(--color-warning);
|
||||
|
||||
@@ -241,6 +241,7 @@ SECTION_CROSS_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_CROSS_SAMPLE_INTERVAL
|
||||
SECTION_LONG_SAMPLE_INTERVAL_M = float(os.getenv("SECTION_LONG_SAMPLE_INTERVAL_M", "1.0"))
|
||||
SECTION_VERTICAL_EXAGGERATION = float(os.getenv("SECTION_VERTICAL_EXAGGERATION", "1.0"))
|
||||
SECTION_INCLUDE_ENDPOINT = os.getenv("SECTION_INCLUDE_ENDPOINT", "True").lower() == "true"
|
||||
FOREST_ROAD_MIN_WIDTH_M = {"trunk": 3.0, "branch": 3.0, "work": 2.5}
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user