/* ============================================================================= * 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; }