260722_1_B06_리팩토링
This commit is contained in:
@@ -5,7 +5,7 @@ import type {
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_View";
|
||||
} from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal";
|
||||
import { createWorkflowPanelHandle } from "@ui/ui_template_overlay";
|
||||
import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css";
|
||||
|
||||
|
||||
@@ -0,0 +1,317 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Cross_View.ts
|
||||
* 개별 횡단면 카드 SVG 렌더러와 X:Y 1:1 스케일 지표 계산.
|
||||
*
|
||||
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 횡단 카드 렌더링만 분리했다. 공통 상수·유틸은
|
||||
* `_UI_Section_Common`, 설계 지정 컨트롤/설계선 오버레이는 `_UI_Cross_Design`을 참조한다.
|
||||
* 횡단은 종/횡 공통 Y스케일을 쓰지 않고 카드마다 자체 1:1 ppm으로 스케일한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, SectionSample } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
buildDesignControls,
|
||||
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
|
||||
import {
|
||||
CROSS_HEIGHT,
|
||||
CROSS_PAD,
|
||||
CROSS_WIDTH,
|
||||
type DesignChangeHandler,
|
||||
emptyView,
|
||||
L,
|
||||
stationLabel,
|
||||
svgElement,
|
||||
svgText,
|
||||
validElevation,
|
||||
} from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
interface CrossPlotMetrics {
|
||||
sourceSamples: SectionSample[];
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
elevationMid: number;
|
||||
displaySpan: number;
|
||||
displayMax: number;
|
||||
pixelsPerMeter: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다.
|
||||
* X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서
|
||||
* 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소
|
||||
* `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일).
|
||||
* 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고,
|
||||
* 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다.
|
||||
*/
|
||||
function crossPlotMetrics(
|
||||
section: CrossSection,
|
||||
verticalExaggeration: number,
|
||||
widthPx: number,
|
||||
crossHalfWidth?: number,
|
||||
designElevation?: number,
|
||||
forcedHeightPx?: number,
|
||||
): CrossPlotMetrics | null {
|
||||
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) return null;
|
||||
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
|
||||
const minOffset = Math.min(...offsets, -1);
|
||||
const maxOffset = Math.max(...offsets, 1);
|
||||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||||
const elevations = valid.map((sample) => sample.elevation_m);
|
||||
if (hasDesign) elevations.push(designElevation as number);
|
||||
const rawMin = Math.min(...elevations);
|
||||
const rawMax = Math.max(...elevations);
|
||||
const elevationMid = (rawMin + rawMax) / 2;
|
||||
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right;
|
||||
const xSpan = Math.max(maxOffset - minOffset, 1e-6);
|
||||
const pixelsPerMeter = plotWidth / xSpan;
|
||||
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6);
|
||||
const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom;
|
||||
// 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용.
|
||||
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
|
||||
const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6);
|
||||
// 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백.
|
||||
const displaySpan = plotHeight / pixelsPerMeter;
|
||||
const displayMax = elevationMid + displaySpan / 2;
|
||||
return {
|
||||
sourceSamples,
|
||||
minOffset,
|
||||
maxOffset,
|
||||
elevationMid,
|
||||
displaySpan,
|
||||
displayMax,
|
||||
pixelsPerMeter,
|
||||
heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */
|
||||
export function crossCardNaturalHeight(
|
||||
section: CrossSection,
|
||||
verticalExaggeration: number,
|
||||
widthPx: number,
|
||||
crossHalfWidth?: number,
|
||||
designElevation?: number,
|
||||
): number {
|
||||
return (
|
||||
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
|
||||
?.heightPx ?? CROSS_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
export function createCrossSectionCard(
|
||||
section: CrossSection,
|
||||
selected: boolean,
|
||||
verticalExaggeration: number,
|
||||
onSelect: (stationId: string) => void,
|
||||
stationInterval: number,
|
||||
crossHalfWidth?: number,
|
||||
widthPx = CROSS_WIDTH,
|
||||
forcedHeightPx?: number,
|
||||
designElevation?: number,
|
||||
onDesignChange?: DesignChangeHandler,
|
||||
): HTMLElement {
|
||||
const card = document.createElement("article");
|
||||
card.id = `cross-${section.station_id}`;
|
||||
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
||||
card.tabIndex = 0;
|
||||
card.addEventListener("click", () => onSelect(section.station_id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
||||
});
|
||||
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("div");
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = stationLabel(section.chainage_m, stationInterval);
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
const kind = document.createElement("span");
|
||||
kind.textContent =
|
||||
section.kind === "ep"
|
||||
? L("B06_Profile_View_Kind_EP")
|
||||
: section.kind === "bp"
|
||||
? L("B06_Profile_View_Kind_BP")
|
||||
: L("B06_Profile_View_Kind_Station");
|
||||
header.append(title, kind);
|
||||
card.append(header);
|
||||
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange));
|
||||
|
||||
const metrics = crossPlotMetrics(
|
||||
section,
|
||||
verticalExaggeration,
|
||||
widthPx,
|
||||
crossHalfWidth,
|
||||
designElevation,
|
||||
forcedHeightPx,
|
||||
);
|
||||
if (!metrics) {
|
||||
card.append(emptyView(L("B06_Profile_View_NoCross")));
|
||||
} else {
|
||||
const {
|
||||
sourceSamples,
|
||||
minOffset,
|
||||
maxOffset,
|
||||
elevationMid,
|
||||
displaySpan,
|
||||
displayMax,
|
||||
pixelsPerMeter,
|
||||
heightPx,
|
||||
} = metrics;
|
||||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||||
const valid = sourceSamples.filter(validElevation);
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * pixelsPerMeter;
|
||||
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
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: widthPx, height: heightPx, class: "b06-chart__bg" }));
|
||||
|
||||
const xTicks = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
|
||||
);
|
||||
for (const tick of xTicks) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(tick),
|
||||
y1: CROSS_PAD.top,
|
||||
x2: x(tick),
|
||||
y2: heightPx - CROSS_PAD.bottom,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
|
||||
x: x(tick),
|
||||
y: heightPx - CROSS_PAD.bottom + 16,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const rawDisplaySpan = displaySpan / exaggeration;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
|
||||
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
y1: y(displayTick),
|
||||
x2: widthPx - CROSS_PAD.right,
|
||||
y2: y(displayTick),
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(tick.toFixed(1), {
|
||||
x: CROSS_PAD.left - 7,
|
||||
y: y(displayTick) + 3,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of sourceSamples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
|
||||
if (section.design) {
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
|
||||
}
|
||||
|
||||
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
|
||||
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
|
||||
return sample;
|
||||
return nearest;
|
||||
}, null);
|
||||
const centerX = x(0);
|
||||
// 십자선은 계획 노선(계획고) 위치를 가리킨다. 계획선이 없는 구 데이터는 지반고로 폴백한다.
|
||||
const centerElevation = hasDesign
|
||||
? (designElevation as number)
|
||||
: (centerSample?.elevation_m ?? null);
|
||||
const centerY =
|
||||
centerElevation !== null
|
||||
? y(elevationMid + (centerElevation - elevationMid) * exaggeration)
|
||||
: heightPx / 2;
|
||||
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
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: heightPx - CROSS_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX,
|
||||
y1: centerY - 18,
|
||||
x2: centerX,
|
||||
y2: centerY + 18,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX - 18,
|
||||
y1: centerY,
|
||||
x2: centerX + 18,
|
||||
y2: centerY,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
svgText(L("B06_Profile_View_CrossXAxis"), {
|
||||
x: widthPx / 2,
|
||||
y: heightPx - 8,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 13,
|
||||
y: heightPx / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 13 ${heightPx / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
card.append(svg);
|
||||
}
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
const center = document.createElement("span");
|
||||
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
|
||||
const azimuth = document.createElement("span");
|
||||
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
|
||||
footer.append(center, azimuth);
|
||||
card.append(footer);
|
||||
return card;
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Longitudinal.ts
|
||||
* 종단면도(지반선·계획선·절성토 음영·측점 마커) SVG 렌더러.
|
||||
*
|
||||
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 종단 렌더링만 분리했다. 공통 상수·유틸은
|
||||
* `_UI_Section_Common`을 참조한다. 종단은 종/횡 공통 Y스케일(`YScaleOptions`)을 그대로 쓴다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { DesignProfile, LongitudinalSection } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import {
|
||||
emptyView,
|
||||
inferStationInterval,
|
||||
L,
|
||||
LONG_HEIGHT,
|
||||
LONG_PAD,
|
||||
LONG_WIDTH,
|
||||
stationLabel,
|
||||
svgElement,
|
||||
svgText,
|
||||
validElevation,
|
||||
type YScaleOptions,
|
||||
} from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다.
|
||||
* 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다.
|
||||
*/
|
||||
function appendCutFillBands(
|
||||
svg: SVGSVGElement,
|
||||
profile: DesignProfile,
|
||||
x: (chainage: number) => number,
|
||||
planY: (index: number) => number,
|
||||
groundY: (index: number) => number,
|
||||
): void {
|
||||
const samples = profile.samples;
|
||||
let start = 0;
|
||||
const flush = (end: number): void => {
|
||||
if (end - start < 1) return;
|
||||
const sign = samples[start].difference_m;
|
||||
if (Math.abs(sign) < 1e-9) return;
|
||||
const top: string[] = [];
|
||||
const bottom: string[] = [];
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
top.push(`${x(samples[index].chainage_m)},${planY(index)}`);
|
||||
bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...top, ...bottom].join(" "),
|
||||
class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1].difference_m;
|
||||
const current = samples[index].difference_m;
|
||||
if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) {
|
||||
flush(index);
|
||||
start = index;
|
||||
}
|
||||
}
|
||||
flush(samples.length - 1);
|
||||
}
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelectStation: (stationId: string) => void,
|
||||
configuredStationInterval?: number,
|
||||
widthPx = LONG_WIDTH,
|
||||
heightPx = LONG_HEIGHT,
|
||||
minimumWidthPx = widthPx,
|
||||
designProfiles: DesignProfile[] = [],
|
||||
): HTMLElement {
|
||||
const samples = data.samples.filter(validElevation);
|
||||
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "b06-section__chart-wrap";
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
role: "img",
|
||||
"aria-label": L("B06_Profile_View_Longitudinal"),
|
||||
});
|
||||
svg.style.width = "100%";
|
||||
svg.style.minWidth = `${minimumWidthPx}px`;
|
||||
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)
|
||||
.concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m)));
|
||||
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
|
||||
const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations);
|
||||
const elevationMid = (rawMin + rawMax) / 2;
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
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);
|
||||
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;
|
||||
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
|
||||
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: gridY,
|
||||
x2: widthPx - LONG_PAD.right,
|
||||
y2: gridY,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(`${rawValue.toFixed(1)}m`, {
|
||||
x: LONG_PAD.left - 9,
|
||||
y: gridY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
|
||||
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
appendCutFillBands(
|
||||
svg,
|
||||
profile,
|
||||
x,
|
||||
(index) => toY(profile.samples[index].elevation_m),
|
||||
(index) => toY(profile.samples[index].ground_elevation_m),
|
||||
);
|
||||
if (profile.balance_segments.length > 1) {
|
||||
for (const segment of profile.balance_segments.slice(1)) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(segment.start_chainage_m),
|
||||
y1: LONG_PAD.top,
|
||||
x2: x(segment.start_chainage_m),
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
class: "b06-chart__balance-boundary",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const station of data.stations) {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"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: heightPx - LONG_PAD.bottom + 8,
|
||||
class: "b06-chart__station-hit",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
x2: stationX,
|
||||
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: heightPx - 23,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
);
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
const points = samples
|
||||
.map((sample) => {
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
|
||||
})
|
||||
.join(" ");
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
svg.append(
|
||||
svgElement("polyline", {
|
||||
points: profile.samples
|
||||
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
|
||||
.join(" "),
|
||||
class: "b06-chart__design-profile",
|
||||
}),
|
||||
);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polyline", { points, class: "b06-chart__profile" }),
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
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: heightPx - LONG_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
|
||||
x: widthPx / 2,
|
||||
y: heightPx - 4,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 15,
|
||||
y: heightPx / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 15 ${heightPx / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
wrapper.append(svg);
|
||||
return wrapper;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Section_Common.ts
|
||||
* 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티.
|
||||
*
|
||||
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러
|
||||
* (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가
|
||||
* 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다.
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design";
|
||||
import type {
|
||||
DesignProfile,
|
||||
SectionDetailResponse,
|
||||
SectionSample,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
|
||||
export type { CrossDesignChange };
|
||||
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
|
||||
|
||||
export const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
export const LONG_WIDTH = 1200;
|
||||
export const LONG_HEIGHT = 220;
|
||||
export const CROSS_WIDTH = 560;
|
||||
export const CROSS_HEIGHT = 250;
|
||||
export const CROSS_GRID_MIN_WIDTH = 480;
|
||||
export const CROSS_GRID_GAP = 16;
|
||||
export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
|
||||
export const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 };
|
||||
|
||||
export interface YScaleOptions {
|
||||
pixelsPerMeter: number;
|
||||
globalMinElevation: number;
|
||||
globalMaxElevation: number;
|
||||
}
|
||||
|
||||
export function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
export function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attributes: Record<string, string | number> = {},
|
||||
): SVGElementTagNameMap[K] {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
export function svgText(
|
||||
value: string,
|
||||
attributes: Record<string, string | number>,
|
||||
): SVGTextElement {
|
||||
const text = svgElement("text", attributes);
|
||||
text.textContent = value;
|
||||
return text;
|
||||
}
|
||||
|
||||
export function validElevation(
|
||||
sample: SectionSample,
|
||||
): sample is SectionSample & { elevation_m: number } {
|
||||
return (
|
||||
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
export function designElevationAt(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
chainageM: number,
|
||||
): number | undefined {
|
||||
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
||||
Number.isFinite(sample.elevation_m),
|
||||
);
|
||||
if (!samples?.length) return undefined;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
if (chainageM > current.chainage_m) continue;
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
return last.elevation_m;
|
||||
}
|
||||
|
||||
export function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined {
|
||||
const elevations = [
|
||||
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
||||
...detail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
|
||||
profile.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
if (!elevations.length) return undefined;
|
||||
const globalMinElevation = Math.min(...elevations);
|
||||
const globalMaxElevation = Math.max(...elevations);
|
||||
const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
|
||||
return {
|
||||
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
||||
globalMinElevation,
|
||||
globalMaxElevation,
|
||||
};
|
||||
}
|
||||
|
||||
export function emptyView(message: string): HTMLElement {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b06-section__empty";
|
||||
empty.textContent = message;
|
||||
return empty;
|
||||
}
|
||||
|
||||
export 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
|
||||
);
|
||||
}
|
||||
|
||||
export 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)}`;
|
||||
}
|
||||
@@ -1,671 +1,40 @@
|
||||
import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale";
|
||||
import type {
|
||||
CrossSection,
|
||||
DesignProfile,
|
||||
LongitudinalSection,
|
||||
SectionDetailResponse,
|
||||
SectionSample,
|
||||
} from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
/* =============================================================================
|
||||
* B06_wf3_ProfileCross_UI_Section_View.ts
|
||||
* 종·횡단 도면 뷰 컨트롤러: 종단 렌더러·횡단 카드 렌더러를 조립하고, 측점 선택/스크롤,
|
||||
* 반응형 폭 측정(ResizeObserver), 같은 행 높이 통일, 단건 카드 갱신을 관장한다.
|
||||
*
|
||||
* 700줄 제한 대응으로 렌더러 본체는 분리했다:
|
||||
* - 공통 상수·유틸: `_UI_Section_Common`
|
||||
* - 종단 렌더러: `_UI_Longitudinal`
|
||||
* - 횡단 카드 렌더러: `_UI_Cross_View`
|
||||
* 외부(Page)에는 `createSectionView`와 `CrossDesignChange` 타입만 노출한다.
|
||||
* ========================================================================== */
|
||||
|
||||
import type { CrossSection, SectionDetailResponse } from "./B06_wf3_ProfileCross_Api_Fetch";
|
||||
import {
|
||||
appendCrossDesignOverlay,
|
||||
buildDesignControls,
|
||||
createCrossSectionCard,
|
||||
crossCardNaturalHeight,
|
||||
} from "./B06_wf3_ProfileCross_UI_Cross_View";
|
||||
import {
|
||||
createLongitudinalProfile,
|
||||
longitudinalMinimumWidth,
|
||||
} from "./B06_wf3_ProfileCross_UI_Longitudinal";
|
||||
import {
|
||||
calculateYScale,
|
||||
CROSS_GRID_GAP,
|
||||
CROSS_GRID_MIN_WIDTH,
|
||||
CROSS_WIDTH,
|
||||
type CrossDesignChange,
|
||||
} from "./B06_wf3_ProfileCross_UI_Cross_Design";
|
||||
type DesignChangeHandler,
|
||||
designElevationAt,
|
||||
emptyView,
|
||||
inferStationInterval,
|
||||
L,
|
||||
LONG_HEIGHT,
|
||||
type YScaleOptions,
|
||||
} from "./B06_wf3_ProfileCross_UI_Section_Common";
|
||||
|
||||
export type { CrossDesignChange };
|
||||
export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void;
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
const LONG_WIDTH = 1200;
|
||||
const LONG_HEIGHT = 220;
|
||||
const CROSS_WIDTH = 560;
|
||||
const CROSS_HEIGHT = 250;
|
||||
const CROSS_GRID_MIN_WIDTH = 480;
|
||||
const CROSS_GRID_GAP = 16;
|
||||
const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 };
|
||||
const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 };
|
||||
|
||||
interface YScaleOptions {
|
||||
pixelsPerMeter: number;
|
||||
globalMinElevation: number;
|
||||
globalMaxElevation: number;
|
||||
}
|
||||
|
||||
function L(key: keyof typeof ui_locales): string {
|
||||
return ui_locales[key][currentLanguageIndex];
|
||||
}
|
||||
|
||||
function svgElement<K extends keyof SVGElementTagNameMap>(
|
||||
tag: K,
|
||||
attributes: Record<string, string | number> = {},
|
||||
): SVGElementTagNameMap[K] {
|
||||
const element = document.createElementNS(SVG_NS, tag);
|
||||
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
|
||||
return element;
|
||||
}
|
||||
|
||||
function svgText(value: string, attributes: Record<string, string | number>): SVGTextElement {
|
||||
const text = svgElement("text", attributes);
|
||||
text.textContent = value;
|
||||
return text;
|
||||
}
|
||||
|
||||
function validElevation(sample: SectionSample): sample is SectionSample & { elevation_m: number } {
|
||||
return (
|
||||
sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다.
|
||||
* 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다.
|
||||
*/
|
||||
function designElevationAt(
|
||||
designProfiles: DesignProfile[] | undefined,
|
||||
chainageM: number,
|
||||
): number | undefined {
|
||||
const samples = designProfiles?.[0]?.samples?.filter((sample) =>
|
||||
Number.isFinite(sample.elevation_m),
|
||||
);
|
||||
if (!samples?.length) return undefined;
|
||||
if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m;
|
||||
const last = samples[samples.length - 1];
|
||||
if (chainageM >= last.chainage_m) return last.elevation_m;
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1];
|
||||
const current = samples[index];
|
||||
if (chainageM > current.chainage_m) continue;
|
||||
const span = current.chainage_m - previous.chainage_m;
|
||||
if (span <= 0) return current.elevation_m;
|
||||
const ratio = (chainageM - previous.chainage_m) / span;
|
||||
return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio;
|
||||
}
|
||||
return last.elevation_m;
|
||||
}
|
||||
|
||||
function calculateYScale(detail: SectionDetailResponse): YScaleOptions | undefined {
|
||||
const elevations = [
|
||||
...detail.longitudinal.samples.map((sample) => sample.elevation_m),
|
||||
...detail.cross_sections.flatMap((section) =>
|
||||
section.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
...(detail.longitudinal.design_profiles ?? []).flatMap((profile) =>
|
||||
profile.samples.map((sample) => sample.elevation_m),
|
||||
),
|
||||
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
||||
if (!elevations.length) return undefined;
|
||||
const globalMinElevation = Math.min(...elevations);
|
||||
const globalMaxElevation = Math.max(...elevations);
|
||||
const plotHeight = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom;
|
||||
return {
|
||||
pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1),
|
||||
globalMinElevation,
|
||||
globalMaxElevation,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyView(message: string): HTMLElement {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "b06-section__empty";
|
||||
empty.textContent = message;
|
||||
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 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 계획선과 지반선 사이를 절토(계획고 < 지반고)·성토 구간으로 나눠 음영을 그린다.
|
||||
* 부호가 바뀌는 지점에서 끊어 절토와 성토가 섞이지 않게 한다.
|
||||
*/
|
||||
function appendCutFillBands(
|
||||
svg: SVGSVGElement,
|
||||
profile: DesignProfile,
|
||||
x: (chainage: number) => number,
|
||||
planY: (index: number) => number,
|
||||
groundY: (index: number) => number,
|
||||
): void {
|
||||
const samples = profile.samples;
|
||||
let start = 0;
|
||||
const flush = (end: number): void => {
|
||||
if (end - start < 1) return;
|
||||
const sign = samples[start].difference_m;
|
||||
if (Math.abs(sign) < 1e-9) return;
|
||||
const top: string[] = [];
|
||||
const bottom: string[] = [];
|
||||
for (let index = start; index <= end; index += 1) {
|
||||
top.push(`${x(samples[index].chainage_m)},${planY(index)}`);
|
||||
bottom.unshift(`${x(samples[index].chainage_m)},${groundY(index)}`);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polygon", {
|
||||
points: [...top, ...bottom].join(" "),
|
||||
class: `b06-chart__band b06-chart__band--${sign < 0 ? "cut" : "fill"}`,
|
||||
}),
|
||||
);
|
||||
};
|
||||
for (let index = 1; index < samples.length; index += 1) {
|
||||
const previous = samples[index - 1].difference_m;
|
||||
const current = samples[index].difference_m;
|
||||
if (previous === 0 || current === 0 || Math.sign(previous) !== Math.sign(current)) {
|
||||
flush(index);
|
||||
start = index;
|
||||
}
|
||||
}
|
||||
flush(samples.length - 1);
|
||||
}
|
||||
|
||||
export function createLongitudinalProfile(
|
||||
data: LongitudinalSection,
|
||||
selectedStationId: string | null,
|
||||
verticalExaggeration: number,
|
||||
yScaleOptions: YScaleOptions | undefined,
|
||||
onSelectStation: (stationId: string) => void,
|
||||
configuredStationInterval?: number,
|
||||
widthPx = LONG_WIDTH,
|
||||
heightPx = LONG_HEIGHT,
|
||||
minimumWidthPx = widthPx,
|
||||
designProfiles: DesignProfile[] = [],
|
||||
): HTMLElement {
|
||||
const samples = data.samples.filter(validElevation);
|
||||
if (samples.length < 2) return emptyView(L("B06_Profile_View_NoLongitudinal"));
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "b06-section__chart-wrap";
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
width: widthPx,
|
||||
height: heightPx,
|
||||
viewBox: `0 0 ${widthPx} ${heightPx}`,
|
||||
role: "img",
|
||||
"aria-label": L("B06_Profile_View_Longitudinal"),
|
||||
});
|
||||
svg.style.width = "100%";
|
||||
svg.style.minWidth = `${minimumWidthPx}px`;
|
||||
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)
|
||||
.concat(designProfiles.flatMap((profile) => profile.samples.map((s) => s.elevation_m)));
|
||||
const rawMin = yScaleOptions?.globalMinElevation ?? Math.min(...elevations);
|
||||
const rawMax = yScaleOptions?.globalMaxElevation ?? Math.max(...elevations);
|
||||
const elevationMid = (rawMin + rawMax) / 2;
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
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);
|
||||
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;
|
||||
const displayed = elevationMid + elevationSpan / 2 - ratio * elevationSpan;
|
||||
const rawValue = elevationMid + (displayed - elevationMid) / exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
y1: gridY,
|
||||
x2: widthPx - LONG_PAD.right,
|
||||
y2: gridY,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(`${rawValue.toFixed(1)}m`, {
|
||||
x: LONG_PAD.left - 9,
|
||||
y: gridY + 4,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
|
||||
const toY = (elevation: number) => y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
appendCutFillBands(
|
||||
svg,
|
||||
profile,
|
||||
x,
|
||||
(index) => toY(profile.samples[index].elevation_m),
|
||||
(index) => toY(profile.samples[index].ground_elevation_m),
|
||||
);
|
||||
if (profile.balance_segments.length > 1) {
|
||||
for (const segment of profile.balance_segments.slice(1)) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(segment.start_chainage_m),
|
||||
y1: LONG_PAD.top,
|
||||
x2: x(segment.start_chainage_m),
|
||||
y2: heightPx - LONG_PAD.bottom,
|
||||
class: "b06-chart__balance-boundary",
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const station of data.stations) {
|
||||
const stationX = x(station.chainage_m);
|
||||
const selected = station.station_id === selectedStationId;
|
||||
const marker = svgElement("g", {
|
||||
class: `b06-chart__station${selected ? " b06-chart__station--selected" : ""}`,
|
||||
tabindex: "0",
|
||||
role: "button",
|
||||
"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: heightPx - LONG_PAD.bottom + 8,
|
||||
class: "b06-chart__station-hit",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: stationX,
|
||||
y1: LONG_PAD.top,
|
||||
x2: stationX,
|
||||
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: heightPx - 23,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__station-label",
|
||||
}),
|
||||
);
|
||||
svg.append(marker);
|
||||
}
|
||||
|
||||
const points = samples
|
||||
.map((sample) => {
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
|
||||
})
|
||||
.join(" ");
|
||||
for (const profile of designProfiles) {
|
||||
if (profile.samples.length < 2) continue;
|
||||
svg.append(
|
||||
svgElement("polyline", {
|
||||
points: profile.samples
|
||||
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
|
||||
.join(" "),
|
||||
class: "b06-chart__design-profile",
|
||||
}),
|
||||
);
|
||||
}
|
||||
svg.append(
|
||||
svgElement("polyline", { points, class: "b06-chart__profile" }),
|
||||
svgElement("line", {
|
||||
x1: LONG_PAD.left,
|
||||
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: heightPx - LONG_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
|
||||
x: widthPx / 2,
|
||||
y: heightPx - 4,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 15,
|
||||
y: heightPx / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 15 ${heightPx / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
wrapper.append(svg);
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
interface CrossPlotMetrics {
|
||||
sourceSamples: SectionSample[];
|
||||
minOffset: number;
|
||||
maxOffset: number;
|
||||
elevationMid: number;
|
||||
displaySpan: number;
|
||||
displayMax: number;
|
||||
pixelsPerMeter: number;
|
||||
heightPx: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 횡단 카드의 X:Y 1:1 스케일 지표를 계산한다.
|
||||
* X pixels-per-meter는 반폭(offset 범위)이 카드 폭을 채우도록 고정하고, 같은 ppm을 Y에도 써서
|
||||
* 형상 왜곡을 없앤다(과장=1 → 정확히 1:1). 카드 높이는 데이터가 요구하는 자연 높이에 최소
|
||||
* `CROSS_HEIGHT`(250px) 바닥을 적용하며, `forcedHeightPx`가 오면 그 높이로 강제한다(같은 행 높이 통일).
|
||||
* 어느 경우든 displaySpan을 실제 plotHeight에 맞춰 되계산하므로 ppm(=1:1)은 그대로 보존되고,
|
||||
* 남는 세로 공간은 elevationMid를 중심으로 대칭 여백이 된다.
|
||||
*/
|
||||
function crossPlotMetrics(
|
||||
section: CrossSection,
|
||||
verticalExaggeration: number,
|
||||
widthPx: number,
|
||||
crossHalfWidth?: number,
|
||||
designElevation?: number,
|
||||
forcedHeightPx?: number,
|
||||
): CrossPlotMetrics | null {
|
||||
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) return null;
|
||||
const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0);
|
||||
const minOffset = Math.min(...offsets, -1);
|
||||
const maxOffset = Math.max(...offsets, 1);
|
||||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||||
const elevations = valid.map((sample) => sample.elevation_m);
|
||||
if (hasDesign) elevations.push(designElevation as number);
|
||||
const rawMin = Math.min(...elevations);
|
||||
const rawMax = Math.max(...elevations);
|
||||
const elevationMid = (rawMin + rawMax) / 2;
|
||||
const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5;
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
const plotWidth = widthPx - CROSS_PAD.left - CROSS_PAD.right;
|
||||
const xSpan = Math.max(maxOffset - minOffset, 1e-6);
|
||||
const pixelsPerMeter = plotWidth / xSpan;
|
||||
const rawSpan = Math.max((rawMax - rawMin + padding * 2) * exaggeration, 1e-6);
|
||||
const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom;
|
||||
// 강제 높이가 있으면 그것을, 없으면 자연 높이에 250px 바닥 적용.
|
||||
const heightPx = forcedHeightPx ?? Math.max(naturalHeight, CROSS_HEIGHT);
|
||||
const plotHeight = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6);
|
||||
// 실제 plotHeight에 맞춰 표시 표고폭을 되계산 → ppm(1:1) 보존, 여유분은 상하 대칭 여백.
|
||||
const displaySpan = plotHeight / pixelsPerMeter;
|
||||
const displayMax = elevationMid + displaySpan / 2;
|
||||
return {
|
||||
sourceSamples,
|
||||
minOffset,
|
||||
maxOffset,
|
||||
elevationMid,
|
||||
displaySpan,
|
||||
displayMax,
|
||||
pixelsPerMeter,
|
||||
heightPx,
|
||||
};
|
||||
}
|
||||
|
||||
/** 같은 행 높이 통일을 위해 카드를 만들지 않고 자연(바닥 적용) 높이만 계산한다. */
|
||||
export function crossCardNaturalHeight(
|
||||
section: CrossSection,
|
||||
verticalExaggeration: number,
|
||||
widthPx: number,
|
||||
crossHalfWidth?: number,
|
||||
designElevation?: number,
|
||||
): number {
|
||||
return (
|
||||
crossPlotMetrics(section, verticalExaggeration, widthPx, crossHalfWidth, designElevation)
|
||||
?.heightPx ?? CROSS_HEIGHT
|
||||
);
|
||||
}
|
||||
|
||||
export function createCrossSectionCard(
|
||||
section: CrossSection,
|
||||
selected: boolean,
|
||||
verticalExaggeration: number,
|
||||
onSelect: (stationId: string) => void,
|
||||
stationInterval: number,
|
||||
crossHalfWidth?: number,
|
||||
widthPx = CROSS_WIDTH,
|
||||
forcedHeightPx?: number,
|
||||
designElevation?: number,
|
||||
onDesignChange?: DesignChangeHandler,
|
||||
): HTMLElement {
|
||||
const card = document.createElement("article");
|
||||
card.id = `cross-${section.station_id}`;
|
||||
card.className = `b06-cross-card${selected ? " b06-cross-card--selected" : ""}`;
|
||||
card.tabIndex = 0;
|
||||
card.addEventListener("click", () => onSelect(section.station_id));
|
||||
card.addEventListener("keydown", (event) => {
|
||||
if (event.key === "Enter" || event.key === " ") onSelect(section.station_id);
|
||||
});
|
||||
|
||||
const header = document.createElement("header");
|
||||
const title = document.createElement("div");
|
||||
const label = document.createElement("strong");
|
||||
label.textContent = stationLabel(section.chainage_m, stationInterval);
|
||||
const chainage = document.createElement("span");
|
||||
chainage.textContent = `${section.chainage_m.toFixed(1)}m`;
|
||||
title.append(label, chainage);
|
||||
const kind = document.createElement("span");
|
||||
kind.textContent =
|
||||
section.kind === "ep"
|
||||
? L("B06_Profile_View_Kind_EP")
|
||||
: section.kind === "bp"
|
||||
? L("B06_Profile_View_Kind_BP")
|
||||
: L("B06_Profile_View_Kind_Station");
|
||||
header.append(title, kind);
|
||||
card.append(header);
|
||||
if (onDesignChange) card.append(buildDesignControls(section, onDesignChange));
|
||||
|
||||
const metrics = crossPlotMetrics(
|
||||
section,
|
||||
verticalExaggeration,
|
||||
widthPx,
|
||||
crossHalfWidth,
|
||||
designElevation,
|
||||
forcedHeightPx,
|
||||
);
|
||||
if (!metrics) {
|
||||
card.append(emptyView(L("B06_Profile_View_NoCross")));
|
||||
} else {
|
||||
const {
|
||||
sourceSamples,
|
||||
minOffset,
|
||||
maxOffset,
|
||||
elevationMid,
|
||||
displaySpan,
|
||||
displayMax,
|
||||
pixelsPerMeter,
|
||||
heightPx,
|
||||
} = metrics;
|
||||
const hasDesign = designElevation !== undefined && Number.isFinite(designElevation);
|
||||
const valid = sourceSamples.filter(validElevation);
|
||||
const exaggeration = Math.max(verticalExaggeration, 0.1);
|
||||
const x = (offset: number) => CROSS_PAD.left + (offset - minOffset) * pixelsPerMeter;
|
||||
const y = (elevation: number) => CROSS_PAD.top + (displayMax - elevation) * pixelsPerMeter;
|
||||
const svg = svgElement("svg", {
|
||||
class: "b06-section__chart",
|
||||
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: widthPx, height: heightPx, class: "b06-chart__bg" }));
|
||||
|
||||
const xTicks = Array.from(
|
||||
{ length: 7 },
|
||||
(_, index) => minOffset + ((maxOffset - minOffset) * index) / 6,
|
||||
);
|
||||
for (const tick of xTicks) {
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: x(tick),
|
||||
y1: CROSS_PAD.top,
|
||||
x2: x(tick),
|
||||
y2: heightPx - CROSS_PAD.bottom,
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(Math.abs(tick) < 1e-6 ? "0" : tick.toFixed(0), {
|
||||
x: x(tick),
|
||||
y: heightPx - CROSS_PAD.bottom + 16,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
const rawDisplaySpan = displaySpan / exaggeration;
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
const tick = elevationMid - rawDisplaySpan / 2 + (rawDisplaySpan * index) / 4;
|
||||
const displayTick = elevationMid + (tick - elevationMid) * exaggeration;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
y1: y(displayTick),
|
||||
x2: widthPx - CROSS_PAD.right,
|
||||
y2: y(displayTick),
|
||||
class: "b06-chart__grid",
|
||||
}),
|
||||
svgText(tick.toFixed(1), {
|
||||
x: CROSS_PAD.left - 7,
|
||||
y: y(displayTick) + 3,
|
||||
"text-anchor": "end",
|
||||
class: "b06-chart__tick",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
let current: string[] = [];
|
||||
for (const sample of sourceSamples) {
|
||||
if (!validElevation(sample)) {
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
current = [];
|
||||
continue;
|
||||
}
|
||||
const elevated = elevationMid + (sample.elevation_m - elevationMid) * exaggeration;
|
||||
current.push(`${x(sample.offset_m ?? 0)},${y(elevated)}`);
|
||||
}
|
||||
if (current.length > 1) segments.push(current.join(" "));
|
||||
segments.forEach((points) =>
|
||||
svg.append(svgElement("polyline", { points, class: "b06-chart__cross-profile" })),
|
||||
);
|
||||
|
||||
if (section.design) {
|
||||
const toDisplayY = (elevation: number): number =>
|
||||
y(elevationMid + (elevation - elevationMid) * exaggeration);
|
||||
appendCrossDesignOverlay(svg, section.design, x, toDisplayY);
|
||||
}
|
||||
|
||||
const centerSample = valid.reduce<(typeof valid)[number] | null>((nearest, sample) => {
|
||||
if (!nearest || Math.abs(sample.offset_m ?? 0) < Math.abs(nearest.offset_m ?? 0))
|
||||
return sample;
|
||||
return nearest;
|
||||
}, null);
|
||||
const centerX = x(0);
|
||||
// 십자선은 계획 노선(계획고) 위치를 가리킨다. 계획선이 없는 구 데이터는 지반고로 폴백한다.
|
||||
const centerElevation = hasDesign
|
||||
? (designElevation as number)
|
||||
: (centerSample?.elevation_m ?? null);
|
||||
const centerY =
|
||||
centerElevation !== null
|
||||
? y(elevationMid + (centerElevation - elevationMid) * exaggeration)
|
||||
: heightPx / 2;
|
||||
const centerMarkerClass = `b06-chart__center-marker${hasDesign ? " b06-chart__center-marker--design" : ""}`;
|
||||
svg.append(
|
||||
svgElement("line", {
|
||||
x1: CROSS_PAD.left,
|
||||
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: heightPx - CROSS_PAD.bottom,
|
||||
class: "b06-chart__axis",
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX,
|
||||
y1: centerY - 18,
|
||||
x2: centerX,
|
||||
y2: centerY + 18,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
svgElement("line", {
|
||||
x1: centerX - 18,
|
||||
y1: centerY,
|
||||
x2: centerX + 18,
|
||||
y2: centerY,
|
||||
class: centerMarkerClass,
|
||||
}),
|
||||
svgText(L("B06_Profile_View_CrossXAxis"), {
|
||||
x: widthPx / 2,
|
||||
y: heightPx - 8,
|
||||
"text-anchor": "middle",
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
svgText(L("B06_Profile_View_ElevationAxis"), {
|
||||
x: 13,
|
||||
y: heightPx / 2,
|
||||
"text-anchor": "middle",
|
||||
transform: `rotate(-90 13 ${heightPx / 2})`,
|
||||
class: "b06-chart__axis-label",
|
||||
}),
|
||||
);
|
||||
card.append(svg);
|
||||
}
|
||||
|
||||
const footer = document.createElement("footer");
|
||||
const center = document.createElement("span");
|
||||
center.textContent = `${L("B06_Profile_View_CenterElevation")} ${section.center_z?.toFixed(2) ?? "-"}m`;
|
||||
const azimuth = document.createElement("span");
|
||||
azimuth.textContent = `${L("B06_Profile_View_Azimuth")} ${section.azimuth_deg?.toFixed(1) ?? "-"}°`;
|
||||
footer.append(center, azimuth);
|
||||
card.append(footer);
|
||||
return card;
|
||||
}
|
||||
export type { CrossDesignChange, DesignChangeHandler };
|
||||
|
||||
export interface SectionViewController {
|
||||
root: HTMLElement;
|
||||
|
||||
Reference in New Issue
Block a user