260719_4
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -231,6 +231,7 @@ export async function renderB06ProfileCross(root: HTMLElement): Promise<void> {
|
||||
if (projectId) goToWorkflowStage(projectId, WORKFLOW_STEP_ROUTES[stepIndex]);
|
||||
},
|
||||
});
|
||||
layout.root.classList.add("b06-profile-layout");
|
||||
root.replaceChildren(layout.root);
|
||||
|
||||
if (!projectId) {
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user