사용자 지적(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>
513 lines
23 KiB
TypeScript
513 lines
23 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,
|
||
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;
|
||
}
|