299 lines
11 KiB
TypeScript
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;
|
|
}
|