Files
Aislo/B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Longitudinal.ts
T
2026-07-25 10:01:55 +09:00

299 lines
11 KiB
TypeScript

/* =============================================================================
* 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";
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
function offsetStationLabel(chainageM: number, interval: number, stationOffset: number): string {
const base = stationLabel(chainageM, interval);
if (!stationOffset) return base;
const [number, remainder] = base.split("+");
return `${Number(number) + stationOffset}+${remainder}`;
}
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[] = [],
/**
* 데이터(측점선·프로파일)를 그래프 축 프레임 안쪽으로 더 들여쓰는 좌우 여백(px).
* B05 도면 테이블과 정렬할 때 0측점을 이름표 열 바깥으로 밀어내는 데 쓴다(기본 0).
*/
originOffsetPx = 0,
/**
* Y축(표고 눈금) 정보를 넘겨받는 콜백(선택). B05가 가로 스크롤에도 고정되는 sticky Y축
* 오버레이를 그릴 때 SVG와 **같은 Y-스케일**을 공유하려고 쓴다. B06은 넘기지 않는다.
*/
onYAxis?: (axis: { padLeft: number; ticks: Array<{ y: number; label: string }> }) => void,
/** 이어 공사 시작 측점 오프셋. 측점 라벨의 측점번호에 이만큼 더한다(B05 표시용, 기본 0). */
stationNumberOffset = 0,
): 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);
// 데이터 영역은 축 프레임(LONG_PAD)보다 originOffsetPx만큼 더 좁게 잡아,
// 0측점·종점이 좌우 끝에 딱 붙지 않고 반 칸씩 안으로 들어오게 한다.
const plotWidth = widthPx - LONG_PAD.left - LONG_PAD.right - 2 * originOffsetPx;
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 + originOffsetPx + (chainage / maxChainage) * plotWidth;
const y = (elevation: number) =>
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
const yAxisTicks: Array<{ y: number; label: string }> = [];
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;
yAxisTicks.push({ y: gridY, label: `${rawValue.toFixed(1)}m` });
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",
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
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(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
x: stationX,
y: heightPx - 23,
"text-anchor": "middle",
class: "b06-chart__station-label",
}),
);
// 구조물(비정규) 측점: 구조물 이름을 측점선 상단에 표기해 위치를 식별할 수 있게 한다.
if (station.structure) {
marker.append(
svgText(station.structure, {
x: stationX,
y: LONG_PAD.top + 12,
"text-anchor": "middle",
class: "b06-chart__structure-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;
}