B05_06_횡단 개선

This commit is contained in:
2026-07-19 09:17:48 +09:00
parent fe007bd2e4
commit 971a8865fc
10 changed files with 328 additions and 121 deletions
+40 -33
View File
@@ -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);
},
};
}
+39 -16
View File
@@ -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,
});
+39 -29
View File
@@ -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)
+31 -15
View File
@@ -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);
},
};
+63 -7
View File
@@ -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);
+1
View File
@@ -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}
# ─────────────────────────────────────────────────────────────────────────