Files
Aislo/B06_Section/B06_Section_UI_Longitudinal.ts
T
eomsangdonandClaude Opus 5 685d44ac39 perf(B05,B06): 유토곡선·B06 페이지도 세로 창을 실시간으로 따라오게
사용자 지적(2026-09-04) — 「유토곡선도 반영해야지. B06 페이지도 있잖아. 같은 로직일 텐데
왜 반영이 안 된 거야」. 그리는 쪽(종단 렌더러)은 공용이라 이미 바뀌어 있었고, **스크롤을
받아 창을 옮기는 쪽이 화면마다 따로**라 B05 만 연결돼 있던 것.

- 세로 창 모듈을 `common_util_chart_ywindow.ts` 로 옮겨 B05·B06 이 함께 씀.
  좌측 고정 축은 표식(`b05-profile__yaxis--ywindow`)이 붙은 것만 옮김 — B06 은 한 컨테이너에
  종단·유토곡선 축이 함께 살아 남의 축까지 옮기면 안 됨.
- 세로 과장(B06 조절값)을 각인·반영 — 예전에는 과장이 1일 때만 대상이었음.
- 유토곡선은 변환이 아니라 **곡선만 다시 그림**(스크롤마다). 곡선 위 말풍선·EP 표·측점
  점처럼 세로로 늘어나면 안 되는 것이 많아, 그것만 따로 옮기는 값이 다시 그리는 값보다 큼
  (유토 SVG 234개 = 종단의 1/3).
- B06 상단 패널을 통째로 다시 그리던 스크롤 처리를 걷어내고, 종단은 변환·유토곡선은
  제자리 교체로 바꿈. 700줄 제한에 맞춰 종단 조립을 `_View_Chart.ts`(116줄)로,
  유토곡선 조립기를 `_View_MassHaul.ts` 로 분리(727 → 718줄).

자체검증(공용 브라우저 임시 탭):
① B05 유토곡선 눈금이 스크롤을 따라 4.3㎥ → −1,348.8㎥ 로 이동(예전에는 안 따라옴)
② B06 종단 눈금 830/840/850m → 824/826/828m, 유토곡선 68.2㎥ → −3,375.1㎥ 로 동시 이동
③ B06 최대 프레임 **35.9 → 23.5ms**, 33ms 초과 1 → **0건**(중앙값 16.7ms 유지)
④ B05 최대 프레임 27.4ms, 33ms 초과 0건 ⑤ 전체 시험 381 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:13:03 +09:00

513 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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,
structureDisplayName,
svgElement,
svgText,
validElevation,
type YScaleOptions,
} from "./B06_Section_UI_Section_Common";
/** 측점 라벨의 측점번호에 시작 측점 오프셋을 더한다(잔여거리는 그대로). */
/**
* 세로 창 변환 규약 — 그리는 쪽(이 파일)과 갱신하는 쪽(B05 `_UI_Profile_YWindow`)이 함께 쓴다.
*
* 세로 위치를 좌표에 박아 두면 창이 바뀔 때마다 그래프를 통째로 다시 만들어야 한다
* (실측 34ms · 60Hz 두 프레임). 세로에 딸린 도형만 한 겹으로 묶어 두면 **변환 한 줄**로
* 창을 옮길 수 있다(2026-09-04 사용자 확정).
*/
/** 자동 세로 창의 여유 — 최고·최저가 축선에 붙지 않게 위아래로 10%씩(2026-08-23 사용자). */
export const Y_WINDOW_SPAN_PAD = 1.2;
/** 세로 창 변환을 받는 겹 — 세로에 딸린 **도형만** 들어간다(글자는 늘어나므로 제외). */
export const Y_WINDOW_CLASS = "b06-chart__ywindow";
/** 눈금선 겹·눈금 글자 겹 — 창이 크게 바뀌면 이 둘만 다시 만든다(그래프는 그대로). */
export const Y_GRID_CLASS = "b06-chart__ygrid";
export const Y_TICK_CLASS = "b06-chart__yticks";
/** 글자가 그릴 때의 y를 남기는 속성 — 변환 대신 이 값으로 자리만 옮긴다. */
export const Y_WINDOW_BAKED_ATTR = "data-yw-y";
/** 바깥에서 새 창의 변환을 계산하는 데 필요한 기준값(SVG 속성 이름). */
export const Y_WINDOW_ATTRS = {
/** "1"이면 자동 세로 창(B05) — 고정 배율(B06)은 갱신 대상이 아니다. */
auto: "data-yw-auto",
/** 그릴 때 쓴 창 중심 표고(m). */
center: "data-yw-center",
/** 표고 1m당 화면 px. */
pxPerM: "data-yw-pxm",
/** 창 중심 표고가 놓인 y(px). */
zero: "data-yw-y0",
/** 플롯 상단 y(px)와 높이(px) — 창 밖으로 나간 글자를 감추는 데 쓴다. */
top: "data-yw-top",
height: "data-yw-height",
/** 세로 과장(B06 조절값). 새 창의 px 환산에 그대로 곱한다. */
exaggeration: "data-yw-exag",
} as const;
/**
* 세로 눈금값 목록 — 창(중심·폭)에 대해 1·2·5 계열의 딱 떨어지는 표고를 고른다.
* 라벨 글자(최대 13px)가 겹치지 않게 눈금 간격은 16px 이상, 표고 눈금은 1m 아래로
* 내려가지 않는다(2026-08-23 사용자 지시).
*
* 그릴 때(이 파일)와 창이 바뀌어 눈금만 다시 만들 때(B05 `_UI_Profile_YWindow`)가
* **같은 눈금**을 써야 해서 여기로 뺐다(2026-09-04).
*/
export function yWindowTickValues(
center: number,
span: number,
plotHeight: number,
): Array<{ value: number; label: string }> {
const step = Math.max(niceTickStep(span, 10, Math.floor(plotHeight / 16)), 1);
const decimals = Math.max(0, Math.ceil(-Math.log10(step) - 1e-9));
const ticks: Array<{ value: number; label: string }> = [];
const topValue = center + span / 2;
for (let value = Math.ceil((center - span / 2) / step) * step; value <= topValue + 1e-9;) {
ticks.push({ value, label: `${value.toFixed(decimals)}m` });
value += step;
}
return ticks;
}
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: SVGElement,
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,
/**
* 세로 표시창의 중심을 위·아래로 옮기는 몫 — **창 높이 대비 비율**(0 = 현행 가운데,
* +0.1 = 창 높이의 10%만큼 위쪽을 본다). B05 종단도의 Y 레인지 조작구가 쓴다.
* 표고(m)가 아니라 비율이라 호출부가 노선 표고 범위를 몰라도 된다(2026-09-04).
*/
elevationOffsetRatio = 0,
/**
* **보이는 구간의 표고 범위**(자동 세로 맞춤, 2026-09-04 사용자 확정). 넘기면 전 구간
* 최저~최고 대신 이 범위로 Y 창을 잡는다 — 가로로 확대했을 때 그 구간의 고저차가
* 화면 높이를 채운다. 공통 Y 스케일(`yScaleOptions`)이 있으면 그쪽이 우선이다.
*/
elevationRange?: { min: number; max: number },
): 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 ?? elevationRange?.min ?? Math.min(...elevations);
const rawMax =
yScaleOptions?.globalMaxElevation ?? elevationRange?.max ?? 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) * Y_WINDOW_SPAN_PAD;
// 화면에 담기는 표고 폭 = 전범위 ÷ 배율. 창 중심은 그 폭의 비율만큼 위·아래로 옮긴다.
const viewCenter = elevationMid + elevationOffsetRatio * (elevationSpan / exaggeration);
const x = (chainage: number) =>
LONG_PAD.left + originOffsetPx + (chainage / maxChainage) * plotWidth;
const xInverse = inverseOf(x, maxChainage);
const y = (elevation: number) =>
LONG_PAD.top + ((viewCenter + elevationSpan / 2 - elevation) / elevationSpan) * plotHeight;
// 세로 창 갱신에 필요한 기준값을 SVG에 남긴다 — 그리는 함수의 인자를 늘리지 않으려는 것.
// 매핑은 1차식이다: y = zero (표고 center) × pxPerM.
svg.setAttribute(Y_WINDOW_ATTRS.center, String(viewCenter));
svg.setAttribute(Y_WINDOW_ATTRS.pxPerM, String((exaggeration * plotHeight) / elevationSpan));
svg.setAttribute(Y_WINDOW_ATTRS.zero, String(LONG_PAD.top + plotHeight / 2));
svg.setAttribute(Y_WINDOW_ATTRS.top, String(LONG_PAD.top));
svg.setAttribute(Y_WINDOW_ATTRS.height, String(plotHeight));
svg.setAttribute(Y_WINDOW_ATTRS.exaggeration, String(exaggeration));
// 고정 배율(B06 공통 Y스케일)에 묶인 그래프만 창을 옮기지 않는다 — 세로 과장은 새 창의
// px 환산에 그대로 곱하면 되므로 대상이다(B06 종단, 2026-09-04).
if (!yScaleOptions) svg.setAttribute(Y_WINDOW_ATTRS.auto, "1");
const stationInterval = configuredStationInterval ?? inferStationInterval(data.stations);
// Y축 눈금: 화면에 담긴 표고 범위를 10칸 안팎으로 나누되, 눈금값이 1·2·5 계열의
// 딱 떨어지는 수(범위가 아주 좁을 때만 소수)로 오게 한다 — 0.25 비율 고정 눈금은
// 표고가 소수로 나와 어느 지점인지 못 읽었다(2026-08-23 사용자 보고).
// 표시 표고창 밖으로 나간 것은 그리지 않는다 — 눈금선·음영·프로파일선이 함께 쓴다.
// (예전에는 이 자리가 아래에 있었다. 눈금선도 세로 변환을 타므로 여기로 올렸다.)
const clipId = `b06-long-clip-${Math.random().toString(36).slice(2, 9)}`;
const clipPath = svgElement("clipPath", { id: clipId });
clipPath.append(
svgElement("rect", {
x: LONG_PAD.left,
y: LONG_PAD.top,
width: Math.max(0, widthPx - LONG_PAD.left - LONG_PAD.right),
height: Math.max(0, plotHeight),
}),
);
const defs = svgElement("defs", {});
defs.append(clipPath);
// 세로 창이 바뀌면 좌표를 다시 만들지 않고 **이 겹의 변환만** 갈아 끼운다(2026-09-04
// 사용자 확정 — 「실시간처럼 되려면 표현을 바꿔야 한다」). 자를 영역은 변환 **밖**이라
// 창이 움직여도 플롯 테두리는 제자리다. 글자는 늘어나므로 이 겹에 넣지 않는다.
const gridLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
const gridWindow = svgElement("g", { class: `${Y_WINDOW_CLASS} ${Y_GRID_CLASS}` });
gridLayer.append(gridWindow);
const tickLayer = svgElement("g", { class: Y_TICK_CLASS });
svg.append(defs, gridLayer, tickLayer);
const yAxisTicks: Array<{ y: number; label: string }> = [];
const rawSpan = elevationSpan / exaggeration;
const rawTop = viewCenter + rawSpan / 2;
// 라벨 글자(최대 13px, sticky 축)가 겹치지 않게 눈금 간격은 16px 이상 띄운다.
// 표고 눈금은 1m 아래로 내려가지 않는다(2026-08-23 사용자 지시).
for (const { value, label } of yWindowTickValues(viewCenter, rawSpan, plotHeight)) {
const gridY = LONG_PAD.top + ((rawTop - value) / rawSpan) * plotHeight;
yAxisTicks.push({ y: gridY, label });
gridWindow.append(
svgElement("line", {
x1: LONG_PAD.left,
y1: gridY,
x2: widthPx - LONG_PAD.right,
y2: gridY,
class: "b06-chart__grid",
}),
);
tickLayer.append(
svgText(label, {
x: LONG_PAD.left - 9,
y: gridY + 4,
"text-anchor": "end",
class: "b06-chart__tick",
// 세로 창 변환이 글자를 늘리지 않고 **자리만** 옮기도록 그릴 때의 y를 남긴다.
// 남기는 값은 **눈금선의 y** — 글자를 내리는 4px 은 변환 뒤에 더해야 배율이
// 커져도 선과 글자 사이가 벌어지지 않는다(2026-09-04 실측 6.4px 어긋남).
[Y_WINDOW_BAKED_ATTR]: String(gridY),
}),
);
}
// sticky Y축 오버레이가 SVG와 동일한 눈금을 쓰도록 전달(B05 전용, B06은 콜백 없음).
onYAxis?.({ padLeft: LONG_PAD.left, ticks: yAxisTicks });
const bandLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
// 음영은 세로에 딸린 것 — 변환 겹 안. 균형 구역 경계선은 플롯 높이 전체라 밖에 둔다.
const bandWindow = svgElement("g", { class: Y_WINDOW_CLASS });
bandLayer.append(bandWindow);
svg.append(bandLayer);
// 절·성토 음영과 균형 구역 경계는 측점선·프로파일선보다 아래에 깔린다.
const toY = (elevation: number) => y(viewCenter + (elevation - viewCenter) * exaggeration);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
appendCutFillBands(
bandWindow,
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)) {
bandLayer.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" : ""}`,
// B05 [직선화]·[쉬프트] 도구가 클릭한 측점의 누가거리를 여기서 읽는다(2026-09-02).
"data-chainage": station.chainage_m.toFixed(3),
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(
// 배수관은 관종·관경 대신 표시 이름 하나로 적는다(2026-09-03 — 횡단 카드와 같은 규칙).
svgText(structureDisplayName(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 = viewCenter + (sample.elevation_m - viewCenter) * exaggeration;
return `${x(sample.chainage_m ?? 0)},${y(elevated)}`;
})
.join(" ");
const lineLayer = svgElement("g", { "clip-path": `url(#${clipId})` });
const lineWindow = svgElement("g", { class: Y_WINDOW_CLASS });
lineLayer.append(lineWindow);
for (const profile of designProfiles) {
if (profile.samples.length < 2) continue;
lineWindow.append(
svgElement("polyline", {
points: profile.samples
.map((sample) => `${x(sample.chainage_m)},${toY(sample.elevation_m)}`)
.join(" "),
class: "b06-chart__design-profile",
}),
);
}
lineWindow.append(svgElement("polyline", { points, class: "b06-chart__profile" }));
svg.append(
lineLayer,
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;
}