확대하면 0.5m·0.2m 눈금까지 내려가 도면에서 읽을 일 없는 값이 표기됐다 (2026-08-23 사용자 지시). 횡단·종단 모두 표고 눈금 간격을 1m로 하한을 둔다 — 라벨은 항상 정수. 편거리(X) 눈금은 종전대로. 자체검증: node tmp/tests/test_cross_y_ticks.mjs (모든 눈금 정수·간격 1m 이상), test_cross_axes_zoom.mjs 재실행, tsc --noEmit 통과. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
393 lines
16 KiB
TypeScript
393 lines
16 KiB
TypeScript
/* =============================================================================
|
|
* B06_Section_UI_Longitudinal.ts
|
|
* 종단면도(지반선·계획선·절성토 음영·측점 마커) SVG 렌더러.
|
|
*
|
|
* 700줄 제한 대응으로 기존 단일 렌더러 파일에서 종단 렌더링만 분리했다. 공통 상수·유틸은
|
|
* `_UI_Section_Common`을 참조한다. 종단은 종/횡 공통 Y스케일(`YScaleOptions`)을 그대로 쓴다.
|
|
* ========================================================================== */
|
|
|
|
import type { DesignProfile, LongitudinalSection } from "./B06_Section_Api_Fetch";
|
|
import {
|
|
emptyView,
|
|
inferStationInterval,
|
|
L,
|
|
LONG_HEIGHT,
|
|
LONG_PAD,
|
|
LONG_WIDTH,
|
|
longitudinalMaxChainage,
|
|
niceTickStep,
|
|
stationLabel,
|
|
svgElement,
|
|
svgText,
|
|
validElevation,
|
|
type YScaleOptions,
|
|
} from "./B06_Section_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);
|
|
}
|
|
|
|
/** 화면 x(px)를 누가거리로 되돌린다. `x()`와 같은 선형 매핑의 역함수다. */
|
|
function inverseOf(x: (chainageM: number) => number, maxChainageM: number): (px: number) => number {
|
|
const origin = x(0);
|
|
const span = x(maxChainageM) - origin;
|
|
return (px: number) => (span > 0 ? ((px - origin) / span) * maxChainageM : 0);
|
|
}
|
|
|
|
/** 구조물 측점선 하나에 끌기 동작을 붙인다. 끌지 않고 뗐으면 아무 일도 하지 않는다(선택은 click). */
|
|
function attachStationDrag(
|
|
marker: SVGElement,
|
|
svg: SVGElement,
|
|
station: { station_id: string; chainage_m: number },
|
|
x: (chainageM: number) => number,
|
|
xInverse: (px: number) => number,
|
|
onDragStation: (stationId: string, toChainageM: number) => void,
|
|
): void {
|
|
marker.classList.add("b06-chart__station--draggable");
|
|
let startX: number | null = null;
|
|
let moved = false;
|
|
marker.addEventListener("pointerdown", (event) => {
|
|
// 이동은 좌클릭 전용 — 우클릭은 메뉴다.
|
|
if (event.button !== 0) return;
|
|
event.preventDefault();
|
|
startX = event.clientX;
|
|
moved = false;
|
|
marker.setPointerCapture(event.pointerId);
|
|
});
|
|
marker.addEventListener("pointermove", (event) => {
|
|
if (startX === null) return;
|
|
if (Math.abs(event.clientX - startX) > STATION_DRAG_SLOP_PX) moved = true;
|
|
if (!moved) return;
|
|
const local = event.clientX - svg.getBoundingClientRect().left;
|
|
marker.setAttribute("transform", `translate(${local - x(station.chainage_m)}, 0)`);
|
|
});
|
|
marker.addEventListener("pointerup", (event) => {
|
|
if (startX === null) return;
|
|
startX = null;
|
|
marker.removeAttribute("transform");
|
|
if (!moved) return;
|
|
const local = event.clientX - svg.getBoundingClientRect().left;
|
|
onDragStation(station.station_id, Number(xInverse(local).toFixed(2)));
|
|
});
|
|
}
|
|
|
|
/** 이만큼(px) 이하로 움직였다 뗐으면 이동이 아니라 고르기로 본다. */
|
|
const STATION_DRAG_SLOP_PX = 3;
|
|
|
|
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,
|
|
/**
|
|
* 구조물(비정규) 측점선을 끌어 옮겼을 때(선택). 넘기면 그 측점선만 잡아 끌 수 있게 된다.
|
|
* B05가 구조물·배관 위치를 그래프에서 바로 조정하는 데 쓴다(2026-08-01 사용자 지시).
|
|
*/
|
|
onDragStation?: (stationId: string, toChainageM: number) => void,
|
|
/**
|
|
* X축·측점 라벨을 그래프 바닥에서 이만큼(px) 더 위로 올린다(기본 0 = 현행).
|
|
* B05는 계획고 편집 오버레이의 ▼ 버튼이 바닥(높이 15px + 여백 2px)에 붙어 있어
|
|
* 라벨과 겹친다 — 버튼은 그대로 두고 라벨 띠만 밀어 올린다(2026-08-04 사용자 지시).
|
|
* LONG_PAD.bottom을 직접 키우면 B06 종단면도 여백까지 변하므로 호출부 옵션으로 뒀다.
|
|
*/
|
|
bottomInsetPx = 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" }));
|
|
|
|
// 유토곡선과 X축을 맞추려면 최댓값 계산이 한 곳이어야 한다(_UI_Section_Common).
|
|
const maxChainage = longitudinalMaxChainage(data);
|
|
// 계획선이 지반선 밖으로 나가도 잘리지 않도록 세로 범위에 함께 반영한다.
|
|
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;
|
|
// X축·라벨을 바닥에서 밀어 올리는 몫까지 합친 실효 bottom 여백 — 아래 모든 y 계산의 기준.
|
|
const padBottom = LONG_PAD.bottom + bottomInsetPx;
|
|
const plotHeight = heightPx - LONG_PAD.top - padBottom;
|
|
const elevationSpan = yScaleOptions
|
|
? plotHeight / yScaleOptions.pixelsPerMeter
|
|
: // 자동 스케일(B05)은 최고·최저 표고가 위아래 축선에 딱 붙지 않게 10%씩 여유를 둔다
|
|
// (2026-08-23 사용자 지시). 공통 Y스케일(B06 yScaleOptions)은 그대로 둔다.
|
|
Math.max(rawMax - rawMin, 1) * 1.2;
|
|
const x = (chainage: number) =>
|
|
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
|
|
const xInverse = inverseOf(x, maxChainage);
|
|
const y = (elevation: number) =>
|
|
LONG_PAD.top + ((elevationMid + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
|
|
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
|
|
|
|
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
|
|
// 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은
|
|
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
|
|
const yAxisTicks: Array<{ y: number; label: string }> = [];
|
|
const rawSpan = elevationSpan / exaggeration;
|
|
const rawTop = elevationMid + rawSpan / 2;
|
|
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
|
|
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
|
|
const tickStep = Math.max(niceTickStep(rawSpan, 10, Math.floor(plotHeight / 16)), 1);
|
|
const tickDecimals = Math.max(0, Math.ceil(-Math.log10(tickStep) - 1e-9));
|
|
for (
|
|
let value = Math.ceil((elevationMid - rawSpan / 2) / tickStep) * tickStep;
|
|
value <= rawTop + 1e-9;
|
|
value += tickStep
|
|
) {
|
|
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
|
|
const label = `${value.toFixed(tickDecimals)}m`;
|
|
yAxisTicks.push({ y: gridY, label });
|
|
svg.append(
|
|
svgElement("line", {
|
|
x1: LONG_PAD.left,
|
|
y1: gridY,
|
|
x2: widthPx - LONG_PAD.right,
|
|
y2: gridY,
|
|
class: "b06-chart__grid",
|
|
}),
|
|
svgText(label, {
|
|
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 - padBottom,
|
|
class: "b06-chart__balance-boundary",
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// 구조물 이름은 **맨 마지막에** 붙인다 — 측점 마커 안에 넣으면 뒤이어 그리는 지반선·
|
|
// 계획선이 그 위를 덮는다(2026-08-02 사용자 지시: 오버레이 순서 최상위).
|
|
const structureLabels: SVGTextElement[] = [];
|
|
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);
|
|
});
|
|
// 구조물 측점선만 끌 수 있다 — 규칙 측점은 격자라 옮길 대상이 아니다.
|
|
if (onDragStation && station.kind === "irregular") {
|
|
attachStationDrag(marker, svg, station, x, xInverse, onDragStation);
|
|
}
|
|
marker.append(
|
|
svgElement("line", {
|
|
x1: stationX,
|
|
y1: LONG_PAD.top,
|
|
x2: stationX,
|
|
y2: heightPx - padBottom,
|
|
class: "b06-chart__station-hit",
|
|
}),
|
|
svgElement("line", {
|
|
x1: stationX,
|
|
y1: LONG_PAD.top,
|
|
x2: stationX,
|
|
y2: heightPx - padBottom,
|
|
class: `b06-chart__station-line b06-chart__station-line--${selected ? "selected" : station.kind}`,
|
|
}),
|
|
// 측점 라벨은 축선과 아래 유토곡선 사이 틈의 **가운데**에 놓는다 — 두 그래프가 측점
|
|
// 표기를 함께 쓰는 것처럼 보여야 한다(2026-08-02 사용자 지시).
|
|
svgText(offsetStationLabel(station.chainage_m, stationInterval, stationNumberOffset), {
|
|
x: stationX,
|
|
y: heightPx - bottomInsetPx - 5,
|
|
"text-anchor": "middle",
|
|
class: "b06-chart__station-label",
|
|
}),
|
|
);
|
|
// 구조물(비정규) 측점: 구조물 이름으로 위치를 식별할 수 있게 한다.
|
|
// 플롯 **하단**(측점 라벨 바로 위)에 둔다 — 상단은 그래프 이름표와 절·성토 표기가
|
|
// 이미 쓰고 있어 서로 겹쳤다(2026-08-02 사용자 지시).
|
|
if (station.structure) {
|
|
structureLabels.push(
|
|
svgText(station.structure, {
|
|
x: stationX,
|
|
y: heightPx - padBottom - 5,
|
|
"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 - padBottom,
|
|
x2: widthPx - LONG_PAD.right,
|
|
y2: heightPx - padBottom,
|
|
class: "b06-chart__axis",
|
|
}),
|
|
svgElement("line", {
|
|
x1: LONG_PAD.left,
|
|
y1: LONG_PAD.top,
|
|
x2: LONG_PAD.left,
|
|
y2: heightPx - padBottom,
|
|
class: "b06-chart__axis",
|
|
}),
|
|
// 그래프 이름표는 플롯 **안쪽 상단에 겹쳐** 놓는다(2026-08-02 사용자 지시) — 위쪽 여백을
|
|
// 이름표 전용으로 비워 두지 않고 그래프 몫으로 돌리기 위해서다.
|
|
svgText(L("B06_Profile_View_LongitudinalXAxis"), {
|
|
x: widthPx / 2,
|
|
y: LONG_PAD.top + 12,
|
|
"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",
|
|
}),
|
|
);
|
|
// 구조물 이름표는 지반선·계획선·축까지 다 그린 **뒤에** 얹는다 — 무엇에도 가리지 않는다.
|
|
if (structureLabels.length) svg.append(...structureLabels);
|
|
wrapper.append(svg);
|
|
return wrapper;
|
|
}
|