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