From db817b940af500ccf1e224868195649852073cbe Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 19 Jul 2026 11:08:16 +0900 Subject: [PATCH] 260719_4 --- B05_wf2_Route/B05_wf2_Route_UI_Markers.ts | 36 ++++- .../B05_wf2_Route_UI_Profile_Panel.ts | 38 +++++- B05_wf2_Route/B05_wf2_Route_UI_Style.css | 10 +- B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts | 94 ++++++++++++- .../B06_wf3_ProfileCross_UI_Page.ts | 1 + .../B06_wf3_ProfileCross_UI_Section_View.ts | 127 ++++++++++++------ .../B06_wf3_ProfileCross_UI_Style.css | 29 ++-- 7 files changed, 274 insertions(+), 61 deletions(-) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts index e173c9af..e2ca6dd1 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Markers.ts @@ -99,11 +99,20 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou const bounds = getBounds(); if (!bounds) return; allPoints().forEach((point) => { + const pointIndex = + point.type === "bp" || point.type === "ep" + ? 0 + : points[point.type].findIndex((candidate) => candidate.id === point.id); + const interactionData = { + routePointId: point.id, + routePointKind: point.type, + routePointIndex: pointIndex, + }; const material = new THREE.MeshBasicMaterial({ color: COLORS[point.type] }); const marker = new THREE.Mesh(new THREE.SphereGeometry(1.6, 18, 12), material); marker.position.copy(modelToScene(point, bounds)); marker.position.y += 1.6; - marker.userData.routePointId = point.id; + Object.assign(marker.userData, interactionData); if (point.id === selectedId) marker.scale.setScalar(1.35); markerGroup.add(marker); if ((point.type === "ap" || point.type === "fp") && point.radius_m) { @@ -118,6 +127,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou ); zone.position.copy(modelToScene(point, bounds)); zone.position.y += 0.2; + Object.assign(zone.userData, interactionData); markerGroup.add(zone); } }); @@ -155,6 +165,19 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou notify(); } + function movePoint(id: string, model: { x: number; y: number; z: number }): void { + const point = allPoints().find((candidate) => candidate.id === id); + if (!point) return; + const update = (candidate: PlacedRoutePoint) => + candidate.id === id ? { ...candidate, ...model } : candidate; + if (point.type === "bp" || point.type === "ep") { + points = { ...points, [point.type]: update(point) }; + } else { + points = { ...points, [point.type]: points[point.type].map(update) }; + } + notify(); + } + function deleteSelected(): void { const current = selected(); if (!current) return; @@ -264,6 +287,7 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou moveSelected(model: { x: number; y: number; z: number }) { updateSelected(model); }, + movePoint, updateSelected, deleteSelected, selectObject(object: THREE.Object3D | undefined) { @@ -277,6 +301,16 @@ export function createRouteMarkers(scene: THREE.Scene, getBounds: () => ModelBou renderMarkers(); selectionListener?.(selected()); }, + pointIdForObject(object: THREE.Object3D | undefined) { + return typeof object?.userData.routePointId === "string" + ? (object.userData.routePointId as string) + : null; + }, + selectPoint(id: string) { + selectedId = allPoints().some((point) => point.id === id) ? id : null; + renderMarkers(); + selectionListener?.(selected()); + }, renderMarkers, renderRoute, renderStationLines, 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 cfd69641..0cf0fb85 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -2,7 +2,10 @@ import type { LongitudinalSection, SectionDetailResponse, } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_Api_Fetch"; -import { createLongitudinalProfile } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; +import { + createLongitudinalProfile, + longitudinalMinimumWidth, +} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View"; import { createWorkflowPanelHandle } from "@ui/ui_template_overlay"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; @@ -33,9 +36,20 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => let detail: SectionDetailResponse | null = null; let selectedStationId: string | null = null; let stationInterval: number | undefined; + let resizeTimer = 0; + let lastWidth = 0; + let lastHeight = 0; function draw(): void { - if (!detail) return; + if (!detail || body.clientWidth <= 0 || body.clientHeight <= 0) return; + const availableWidth = Math.max(1, body.clientWidth - 30); + const height = body.clientHeight; + const width = Math.max( + availableWidth, + longitudinalMinimumWidth(detail.longitudinal, stationInterval), + ); + lastWidth = body.clientWidth; + lastHeight = height; body.replaceChildren( createLongitudinalProfile( normalizedLongitudinal(detail.longitudinal), @@ -44,14 +58,29 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => undefined, onSelectStation, stationInterval, + width, + height, ), ); } + const resizeObserver = new ResizeObserver(() => { + if ( + body.clientWidth <= 0 || + body.clientHeight <= 0 || + (Math.abs(body.clientWidth - lastWidth) < 1 && Math.abs(body.clientHeight - lastHeight) < 1) + ) + return; + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(draw, 150); + }); + resizeObserver.observe(body); + function setCollapsed(collapsed: boolean): void { root.classList.toggle("is-collapsed", collapsed); panelHandle.setOpen(!collapsed); sessionStorage.setItem(COLLAPSED_KEY, String(collapsed)); + if (!collapsed) requestAnimationFrame(draw); } toggle.addEventListener("click", () => setCollapsed(!root.classList.contains("is-collapsed"))); @@ -63,6 +92,7 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => detail = nextDetail; stationInterval = nextStationInterval; draw(); + requestAnimationFrame(draw); }, setSelectedStation(stationId: string | null) { selectedStationId = stationId; @@ -73,5 +103,9 @@ export function createRouteProfilePanel(onSelectStation: (stationId: string) => selectedStationId = null; body.replaceChildren(empty); }, + dispose() { + window.clearTimeout(resizeTimer); + resizeObserver.disconnect(); + }, }; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 087d2bf1..711d87c3 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -50,8 +50,11 @@ } .b05-route-profile__body { + box-sizing: border-box; height: 100%; - overflow: auto; + overflow-x: auto; + overflow-y: hidden; + padding-inline: 15px; } .b05-route-profile.is-collapsed .b05-route-profile__body { @@ -65,11 +68,8 @@ font-size: var(--text-body-sm); } -.b05-route-profile .b06-section__chart-wrap, -.b05-route-profile .b06-section__chart { - width: 100%; +.b05-route-profile .b06-section__chart-wrap { height: 100%; - min-width: 0; } .b05-route__viewport canvas { diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts index 64809eb7..ee2da3df 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Viewer.ts @@ -80,6 +80,9 @@ export function createRouteViewer(): RouteViewer { let current: { projectId: string; modelId: number; smooth: boolean; interval: number } | null = null; let movingSelected = false; + let dragCandidate: { id: string; pointerId: number; x: number; y: number } | null = null; + let draggingMarker = false; + let lastDragPoint: { x: number; y: number; z: number } | null = null; const markers = createRouteMarkers(scene, () => bounds); function clearContours(): void { @@ -173,7 +176,7 @@ export function createRouteViewer(): RouteViewer { const point = terrainPoint(event); if (point && ["bp", "ep", "cp", "ap", "fp"].includes(kind)) markers.place(kind, point); }); - canvas.addEventListener("pointerdown", (event) => { + function markerHit(event: PointerEvent): THREE.Object3D | undefined { const rect = canvas.getBoundingClientRect(); const pointer = new THREE.Vector2( ((event.clientX - rect.left) / rect.width) * 2 - 1, @@ -181,9 +184,43 @@ export function createRouteViewer(): RouteViewer { ); const raycaster = new THREE.Raycaster(); raycaster.setFromCamera(pointer, camera); - const markerHit = raycaster.intersectObject(markers.group, true)[0]; - if (markerHit) { - markers.selectObject(markerHit.object); + return raycaster.intersectObject(markers.group, true)[0]?.object; + } + + function finishMarkerInteraction(selectCandidate: boolean): void { + if (dragCandidate && (draggingMarker || selectCandidate)) { + markers.selectPoint(dragCandidate.id); + } + if (dragCandidate && canvas.hasPointerCapture(dragCandidate.pointerId)) { + canvas.releasePointerCapture(dragCandidate.pointerId); + } + controls.enabled = true; + dragCandidate = null; + draggingMarker = false; + lastDragPoint = null; + } + + function handlePointerDown(event: PointerEvent): void { + if (event.button !== 0) return; + const hit = markerHit(event); + const pointId = markers.pointIdForObject(hit); + if (pointId) { + dragCandidate = { + id: pointId, + pointerId: event.pointerId, + x: event.clientX, + y: event.clientY, + }; + draggingMarker = false; + lastDragPoint = null; + canvas.setPointerCapture(event.pointerId); + event.preventDefault(); + event.stopPropagation(); + return; + } + if (hit) { + markers.selectObject(hit); + event.stopPropagation(); return; } if (movingSelected) { @@ -193,7 +230,49 @@ export function createRouteViewer(): RouteViewer { } else { markers.selectObject(undefined); } - }); + } + + function handlePointerMove(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if ( + !draggingMarker && + Math.hypot(event.clientX - dragCandidate.x, event.clientY - dragCandidate.y) > 3 + ) { + draggingMarker = true; + controls.enabled = false; + } + if (!draggingMarker) return; + const point = terrainPoint(event); + if (!point) return; + lastDragPoint = point; + markers.movePoint(dragCandidate.id, point); + } + + function handlePointerUp(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + if (draggingMarker) { + const point = terrainPoint(event) ?? lastDragPoint; + if (point) markers.movePoint(dragCandidate.id, point); + } + finishMarkerInteraction(!draggingMarker); + } + + function handlePointerExit(event: PointerEvent): void { + if (!dragCandidate || event.pointerId !== dragCandidate.pointerId) return; + event.preventDefault(); + event.stopPropagation(); + finishMarkerInteraction(false); + } + + canvas.addEventListener("pointerdown", handlePointerDown, true); + canvas.addEventListener("pointermove", handlePointerMove, true); + canvas.addEventListener("pointerup", handlePointerUp, true); + canvas.addEventListener("pointerleave", handlePointerExit, true); + canvas.addEventListener("pointercancel", handlePointerExit, true); let frame = 0; function animate(): void { @@ -256,6 +335,11 @@ export function createRouteViewer(): RouteViewer { dispose() { cancelAnimationFrame(frame); resizeObserver.disconnect(); + canvas.removeEventListener("pointerdown", handlePointerDown, true); + canvas.removeEventListener("pointermove", handlePointerMove, true); + canvas.removeEventListener("pointerup", handlePointerUp, true); + canvas.removeEventListener("pointerleave", handlePointerExit, true); + canvas.removeEventListener("pointercancel", handlePointerExit, true); markers.dispose(); clearContours(); disposeObject(terrain); diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts index 0bd22569..3bab4e00 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Page.ts @@ -231,6 +231,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise { if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]); }, }); + layout.root.classList.add("b06-profile-layout"); root.replaceChildren(layout.root); if (!projectId) { 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 bdbd85c8..870e0186 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View.ts @@ -10,7 +10,7 @@ const SVG_NS = "http://www.w3.org/2000/svg"; const LONG_WIDTH = 1200; const LONG_HEIGHT = 220; const CROSS_WIDTH = 560; -const CROSS_HEIGHT = 260; +const CROSS_HEIGHT = 250; const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; @@ -97,6 +97,19 @@ function stationLabel(chainage: number, interval: number): string { return `${stationNumber}+${remainder.toFixed(1)}`; } +export function longitudinalMinimumWidth( + data: LongitudinalSection, + configuredStationInterval?: number, +): number { + const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations); + const longestLabelLength = Math.max( + 1, + ...data.stations.map((station) => stationLabel(station.chainage_m, stationInterval).length), + ); + const labelWidth = Math.max(48, longestLabelLength * 6 + 16); + return LONG_PAD.left + LONG_PAD.right + Math.max(1, data.stations.length) * labelWidth; +} + export function createLongitudinalProfile( data: LongitudinalSection, selectedStationId: string | null, @@ -104,6 +117,8 @@ export function createLongitudinalProfile( yScaleOptions: YScaleOptions | undefined, onSelectStation: (stationId: string) => void, configuredStationInterval?: number, + widthPx = LONG_WIDTH, + heightPx = LONG_HEIGHT, ): HTMLElement { const samples = data.samples.filter(validElevation); if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal")); @@ -112,13 +127,13 @@ export function createLongitudinalProfile( wrapper.className = "b06-section__chart-wrap"; const svg = svgElement("svg", { class: "b06-section__chart", - viewBox: `0 0 ${LONG_WIDTH} ${LONG_HEIGHT}`, + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": L("B06_Profile_View_Longitudinal"), }); - svg.append( - svgElement("rect", { width: LONG_WIDTH, height: LONG_HEIGHT, class: "b06-chart__bg" }), - ); + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const maxChainage = Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); const elevations = samples.map((sample) => sample.elevation_m); @@ -126,8 +141,8 @@ export function createLongitudinalProfile( const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = LONG_WIDTH - LONG_PAD.left - LONG_PAD.right; - const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; + const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right; + const plotHeight = heightPx - LONG_PAD.top - LONG_PAD.bottom; const elevationSpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max(rawMax - rawMin, 1); @@ -144,7 +159,7 @@ export function createLongitudinalProfile( svgElement("line", { x1: LONG_PAD.left, y1: gridY, - x2: LONG_WIDTH - LONG_PAD.right, + x2: widthPx - LONG_PAD.right, y2: gridY, class: "b06-chart__grid", }), @@ -175,19 +190,19 @@ export function createLongitudinalProfile( x1: stationX, y1: LONG_PAD.top, x2: stationX, - y2: LONG_HEIGHT - LONG_PAD.bottom + 8, + y2: heightPx - LONG_PAD.bottom + 8, class: "b06-chart__station-hit", }), svgElement("line", { x1: stationX, y1: LONG_PAD.top, x2: stationX, - y2: LONG_HEIGHT - LONG_PAD.bottom + 8, + y2: heightPx - LONG_PAD.bottom + 8, class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`, }), svgText(stationLabel(station.chainage_m, stationInterval), { x: stationX, - y: LONG_HEIGHT - 23, + y: heightPx - 23, "text-anchor": "middle", class: "b06-chart__station-label", }), @@ -205,29 +220,29 @@ export function createLongitudinalProfile( svgElement("polyline", { points, class: "b06-chart__profile" }), svgElement("line", { x1: LONG_PAD.left, - y1: LONG_HEIGHT - LONG_PAD.bottom, - x2: LONG_WIDTH - LONG_PAD.right, - y2: LONG_HEIGHT - LONG_PAD.bottom, + y1: heightPx - LONG_PAD.bottom, + x2: widthPx - LONG_PAD.right, + y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: LONG_PAD.left, y1: LONG_PAD.top, x2: LONG_PAD.left, - y2: LONG_HEIGHT - LONG_PAD.bottom, + y2: heightPx - LONG_PAD.bottom, class: "b06-chart__axis", }), svgText(L("B06_Profile_View_LongitudinalXAxis"), { - x: LONG_WIDTH / 2, - y: LONG_HEIGHT - 4, + x: widthPx / 2, + y: heightPx - 4, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 15, - y: LONG_HEIGHT / 2, + y: heightPx / 2, "text-anchor": "middle", - transform: `rotate(-90 15 ${LONG_HEIGHT / 2})`, + transform: `rotate(-90 15 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); @@ -243,6 +258,8 @@ export function createCrossSectionCard( onSelect: (stationId: string) => void, stationInterval: number, crossHalfWidth?: number, + widthPx = CROSS_WIDTH, + heightPx = CROSS_HEIGHT, ): HTMLElement { const card = document.createElement("article"); card.id = `cross-${section.station_id}`; @@ -287,8 +304,8 @@ export function createCrossSectionCard( const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const exaggeration = Math.max(verticalExaggeration, 0.1); - const plotWidth = CROSS_WIDTH - CROSS_PAD.left - CROSS_PAD.right; - const plotHeight = CROSS_HEIGHT - CROSS_PAD.top - CROSS_PAD.bottom; + const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right; + const plotHeight = heightPx - CROSS_PAD.top - CROSS_PAD.bottom; const displaySpan = yScaleOptions ? plotHeight / yScaleOptions.pixelsPerMeter : Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1); @@ -301,13 +318,13 @@ export function createCrossSectionCard( ((displayMax - elevation) / Math.max(displayMax - displayMin, 1)) * plotHeight; const svg = svgElement("svg", { class: "b06-section__chart", - viewBox: `0 0 ${CROSS_WIDTH} ${CROSS_HEIGHT}`, + width: widthPx, + height: heightPx, + viewBox: `0 0 ${widthPx} ${heightPx}`, role: "img", "aria-label": `${section.label} ${L("B06_Profile_View_Cross")}`, }); - svg.append( - svgElement("rect", { width: CROSS_WIDTH, height: CROSS_HEIGHT, class: "b06-chart__bg" }), - ); + svg.append(svgElement("rect", { width: widthPx, height: heightPx, class: "b06-chart__bg" })); const xTicks = Array.from( { length: 7 }, @@ -319,12 +336,12 @@ export function createCrossSectionCard( x1: x(tick), y1: CROSS_PAD.top, x2: x(tick), - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__grid", }), svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), { x: x(tick), - y: CROSS_HEIGHT - CROSS_PAD.bottom + 16, + y: heightPx - CROSS_PAD.bottom + 16, "text-anchor": "middle", class: "b06-chart__tick", }), @@ -338,7 +355,7 @@ export function createCrossSectionCard( svgElement("line", { x1: CROSS_PAD.left, y1: y(displayTick), - x2: CROSS_WIDTH - CROSS_PAD.right, + x2: widthPx - CROSS_PAD.right, y2: y(displayTick), class: "b06-chart__grid", }), @@ -375,20 +392,20 @@ export function createCrossSectionCard( const centerX = x(0); const centerY = centerSample ? y(elevationMid + (centerSample.elevation_m - elevationMid) * exaggeration) - : CROSS_HEIGHT / 2; + : heightPx / 2; svg.append( svgElement("line", { x1: CROSS_PAD.left, - y1: CROSS_HEIGHT - CROSS_PAD.bottom, - x2: CROSS_WIDTH - CROSS_PAD.right, - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y1: heightPx - CROSS_PAD.bottom, + x2: widthPx - CROSS_PAD.right, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { x1: CROSS_PAD.left, y1: CROSS_PAD.top, x2: CROSS_PAD.left, - y2: CROSS_HEIGHT - CROSS_PAD.bottom, + y2: heightPx - CROSS_PAD.bottom, class: "b06-chart__axis", }), svgElement("line", { @@ -406,16 +423,16 @@ export function createCrossSectionCard( class: "b06-chart__center-marker", }), svgText(L("B06_Profile_View_CrossXAxis"), { - x: CROSS_WIDTH / 2, - y: CROSS_HEIGHT - 8, + x: widthPx / 2, + y: heightPx - 8, "text-anchor": "middle", class: "b06-chart__axis-label", }), svgText(L("B06_Profile_View_ElevationAxis"), { x: 13, - y: CROSS_HEIGHT / 2, + y: heightPx / 2, "text-anchor": "middle", - transform: `rotate(-90 13 ${CROSS_HEIGHT / 2})`, + transform: `rotate(-90 13 ${heightPx / 2})`, class: "b06-chart__axis-label", }), ); @@ -441,6 +458,7 @@ export interface SectionViewController { stationInterval?: number, ) => void; clear: () => void; + dispose: () => void; } export function createSectionView(): SectionViewController { @@ -451,10 +469,20 @@ export function createSectionView(): SectionViewController { let currentExaggeration = 1; let currentCrossHalfWidth: number | undefined; let currentStationInterval: number | undefined; + let renderWidth = 0; + let resizeTimer = 0; + + const contentWidth = (): number => { + const style = getComputedStyle(root); + return Math.max( + 0, + root.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight), + ); + }; const draw = (): void => { + if (!currentDetail || renderWidth <= 0) return; root.replaceChildren(); - if (!currentDetail) return; const detail = currentDetail; const yScale = calculateYScale(detail); const stationInterval = @@ -479,6 +507,8 @@ export function createSectionView(): SectionViewController { yScale, (stationId) => selectStation(stationId, true), stationInterval, + Math.max(renderWidth, longitudinalMinimumWidth(detail.longitudinal, stationInterval)), + LONG_HEIGHT, ), ); @@ -491,6 +521,8 @@ export function createSectionView(): SectionViewController { crossHeading.append(crossTitle, crossCount); const grid = document.createElement("div"); grid.className = "b06-section__grid"; + const columnCount = renderWidth >= 976 ? 2 : 1; + const cardWidth = (renderWidth - (columnCount - 1) * 16) / columnCount; if (detail.cross_sections.length) { detail.cross_sections.forEach((section) => grid.append( @@ -502,6 +534,8 @@ export function createSectionView(): SectionViewController { (stationId) => selectStation(stationId, false), stationInterval, currentCrossHalfWidth, + cardWidth, + CROSS_HEIGHT, ), ), ); @@ -511,6 +545,17 @@ export function createSectionView(): SectionViewController { root.append(longitudinalPanel, crossHeading, grid); }; + const resizeObserver = new ResizeObserver(() => { + const nextWidth = contentWidth(); + if (nextWidth <= 0 || Math.abs(nextWidth - renderWidth) < 1) return; + window.clearTimeout(resizeTimer); + resizeTimer = window.setTimeout(() => { + renderWidth = nextWidth; + draw(); + }, 150); + }); + resizeObserver.observe(root); + return { root, render(detail, verticalExaggeration, crossHalfWidth, stationInterval) { @@ -521,12 +566,18 @@ export function createSectionView(): SectionViewController { currentStationInterval = stationInterval !== undefined && stationInterval > 0 ? stationInterval : undefined; selectedStationId ??= detail.longitudinal.stations[0]?.station_id ?? null; + renderWidth = contentWidth(); draw(); + if (renderWidth <= 0) requestAnimationFrame(() => resizeObserver.observe(root)); }, clear() { currentDetail = null; selectedStationId = null; root.replaceChildren(); }, + dispose() { + window.clearTimeout(resizeTimer); + resizeObserver.disconnect(); + }, }; } diff --git a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css index cd92daa1..f11f013c 100644 --- a/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css +++ b/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css @@ -8,6 +8,23 @@ * ========================================================================== */ /* --- 좌측 입력 폼 --- */ +.b06-profile-layout { + height: calc(100vh - var(--spacing-64)); + height: calc(100dvh - var(--spacing-64)); + min-height: 0; + overflow: hidden; +} + +.b06-profile-layout .ui-workflow-layout__body, +.b06-profile-layout .ui-workflow-layout__main { + height: 100%; + min-height: 0; +} + +.b06-profile-layout .ui-workflow-layout__main { + overflow: auto; +} + .b06-profile__form { display: flex; flex-direction: column; @@ -162,9 +179,7 @@ .b06-section__chart { display: block; - width: 100%; - min-width: 520px; - height: auto; + max-width: none; } .b06-section__heading { @@ -178,7 +193,7 @@ .b06-section__grid { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(auto-fill, minmax(min(480px, 100%), 1fr)); gap: var(--spacing-16); } @@ -308,9 +323,3 @@ fill: var(--color-danger); font-weight: var(--font-weight-bold); } - -@media (max-width: 900px) { - .b06-section__grid { - grid-template-columns: 1fr; - } -}