/* ============================================================================= * B06_Section_UI_Section_Common.ts * 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티. * * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러 * (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가 * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. * ========================================================================== */ import type { CrossDesignChange } from "./B06_Section_UI_Cross_Design"; import type { DesignProfile, LongitudinalSection, SectionDetailResponse, SectionSample, } from "./B06_Section_Api_Fetch"; export type { CrossDesignChange }; export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; // SVG 생성·측점 표기 유틸은 B05 계획 유토곡선과 공유하려고 `@util/common_util_svg`로 옮겼다. // 여기서 재수출해 기존 B06 임포트 경로를 그대로 둔다(정의는 저쪽 한 곳뿐). export { SVG_NS, L, svgElement, svgText, inferStationInterval, stationLabel, } from "@util/common_util_svg"; export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; // 한 행 맞춤 기준 최소 카드 폭 — 조금 더 넓은 화면 필요(480→560, 약 +17%). export const CROSS_GRID_MIN_WIDTH = 560; export const CROSS_GRID_GAP = 16; // bottom은 측점 라벨 한 줄 몫이다. X축 제목을 그래프 상단으로 올린 뒤 남던 여백을 걷어내, // 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다(2026-08-02 사용자 지시). // left/right는 유토곡선과 X축을 맞추는 값이라 함부로 바꾸면 두 그래프 측점선이 어긋난다. // top은 그래프 이름표가 겹쳐 놓이는 자리다. 이름표를 플롯 안 오버레이로 돌린 뒤 남던 위쪽 // 여백을 걷어 그래프 몫으로 돌렸다(2026-08-02 사용자 지시). // bottom은 측점 라벨 한 줄 몫이다. X축 제목을 이름표로 합치며 남던 여백을 걷어내, // 종단면도 아래 라벨이 그 밑 유토곡선과의 틈 **가운데**에 오도록 좁혔다. // left/right는 유토곡선과 X축을 맞추는 값이라 함부로 바꾸면 두 그래프 측점선이 어긋난다. // left 62→78: B05 좌측 패널 접기 버튼이 Y축 라벨·행제목을 가려 여유를 더 줬다 // (2026-08-05 사용자 보고). sticky Y축 마스크·테이블 행제목 폭이 모두 이 값에서 // 파생되므로 함께 넓어져 가로 스크롤 시 값 누출이 없다. export const LONG_PAD = { left: 78, right: 24, top: 12, bottom: 26 }; export const CROSS_PAD = { left: 58, right: 20, top: 10, bottom: 52 }; export interface YScaleOptions { pixelsPerMeter: number; globalMinElevation: number; globalMaxElevation: number; } export function validElevation( sample: SectionSample, ): sample is SectionSample & { elevation_m: number } { return ( sample.valid !== false && sample.elevation_m !== null && Number.isFinite(sample.elevation_m) ); } /** * 측점 chainage 위치의 계획고를 계획선 샘플에서 선형보간한다. * 범위를 벗어나면 양 끝값으로 클램프하며, 계획선이 없으면 undefined를 반환해 지반고 폴백을 유도한다. */ export function designElevationAt( designProfiles: DesignProfile[] | undefined, chainageM: number, ): number | undefined { const samples = designProfiles?.[0]?.samples?.filter((sample) => Number.isFinite(sample.elevation_m), ); if (!samples?.length) return undefined; if (chainageM <= samples[0].chainage_m) return samples[0].elevation_m; const last = samples[samples.length - 1]; if (chainageM >= last.chainage_m) return last.elevation_m; for (let index = 1; index < samples.length; index += 1) { const previous = samples[index - 1]; const current = samples[index]; if (chainageM > current.chainage_m) continue; const span = current.chainage_m - previous.chainage_m; if (span <= 0) return current.elevation_m; const ratio = (chainageM - previous.chainage_m) / span; return previous.elevation_m + (current.elevation_m - previous.elevation_m) * ratio; } return last.elevation_m; } /** * 종단 X축이 덮는 누가거리의 최댓값. * * 종단면도와 유토곡선이 **같은 X 매핑**을 써야 측점 위치가 어긋나지 않으므로, * 두 렌더러가 이 함수 하나만 보게 한다(각자 계산하면 조용히 틀어진다). */ export function longitudinalMaxChainage(data: LongitudinalSection): number { const samples = data.samples.filter(validElevation); return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); } /** * 종·횡단 공통 Y스케일. `longHeightPx`는 종단면도의 실제 렌더 높이로, 패널 리사이즈로 * 종단도가 줄면 그 높이를 넘겨야 표고 범위가 잘리지 않는다(기본은 고정 높이). */ export function calculateYScale( detail: SectionDetailResponse, longHeightPx: number = LONG_HEIGHT, ): YScaleOptions | undefined { const elevations = [ ...detail.longitudinal.samples.map((sample) => sample.elevation_m), ...detail.cross_sections.flatMap((section) => section.samples.map((sample) => sample.elevation_m), ), ...(detail.longitudinal.design_profiles ?? []).flatMap((profile) => profile.samples.map((sample) => sample.elevation_m), ), ].filter((value): value is number => typeof value === "number" && Number.isFinite(value)); if (!elevations.length) return undefined; const globalMinElevation = Math.min(...elevations); const globalMaxElevation = Math.max(...elevations); const plotHeight = Math.max(longHeightPx - LONG_PAD.top - LONG_PAD.bottom, 1); return { pixelsPerMeter: plotHeight / Math.max(globalMaxElevation - globalMinElevation, 1), globalMinElevation, globalMaxElevation, }; } export function emptyView(message: string): HTMLElement { const empty = document.createElement("div"); empty.className = "b06-section__empty"; empty.textContent = message; return empty; }