diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index 85198f34..e173c9af 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -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); }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 509c2d4a..45174f42 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -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; + +const DEFAULT_ROAD_WIDTHS: RoadWidths = { trunk: 3, branch: 3, work: 2.5 }; + +async function fetchRoadWidths(projectId: string): Promise { + 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 }; + 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 { 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 { 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 { 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 { 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 { 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 { 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 { } } - 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 { ); 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, }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index d88435e5..0f4cd800 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -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) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index 5985bd4f..24c6b1f7 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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); }, }; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 7e60ffa1..a0eddf55 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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 { diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py index 4732ec62..cee36fae 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_Router.py @@ -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: """경로의 종단면 요약을 조회한다.""" diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 0ac748fe..df6029e1 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -69,6 +69,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { 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 { }); 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 { 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 { if (!projectId || currentRouteId === null) return; @@ -199,6 +215,8 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { : 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 { } 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}` : ""; diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts index d34a520e..bdbd85c8 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -70,12 +70,40 @@ function emptyView(message: string): HTMLElement { return empty; } +function inferStationInterval(stations: Array<{ chainage_m: number }>): number { + const counts = new Map(); + 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(); }, diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index 2deee323..9955a859 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -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); diff --git a/config/config_system.py b/config/config_system.py index d8cd3f55..9dae5b5d 100644 --- a/config/config_system.py +++ b/config/config_system.py @@ -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} # ─────────────────────────────────────────────────────────────────────────