/* ============================================================================= * B06_wf3_ProfileCross_UI_Section_Common.ts * 종·횡단 SVG 렌더러가 공유하는 상수·타입·유틸리티. * * 700줄 제한 대응으로 기존 단일 렌더러 파일에서 공통 부분만 분리했다. 종단 렌더러 * (`_UI_Longitudinal`), 횡단 카드 렌더러(`_UI_Cross_View`), 뷰 컨트롤러(`_UI_Section_View`)가 * 이 모듈을 참조한다. 색상 하드코딩 금지 원칙은 그대로이며 여기서는 지오메트리 계산만 다룬다. * ========================================================================== */ import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import type { CrossDesignChange } from "./B06_wf3_ProfileCross_UI_Cross_Design"; import type { DesignProfile, SectionDetailResponse, SectionSample, } from "./B06_wf3_ProfileCross_Api_Fetch"; export type { CrossDesignChange }; export type DesignChangeHandler = (chainageM: number, change: CrossDesignChange) => void; export const SVG_NS = "http://www.w3.org/2000/svg"; export const LONG_WIDTH = 1200; export const LONG_HEIGHT = 220; export const CROSS_WIDTH = 560; export const CROSS_HEIGHT = 250; export const CROSS_GRID_MIN_WIDTH = 480; export const CROSS_GRID_GAP = 16; export const LONG_PAD = { left: 62, right: 24, top: 30, bottom: 52 }; export const CROSS_PAD = { left: 58, right: 20, top: 20, bottom: 52 }; export interface YScaleOptions { pixelsPerMeter: number; globalMinElevation: number; globalMaxElevation: number; } export function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } export function svgElement( tag: K, attributes: Record = {}, ): SVGElementTagNameMap[K] { const element = document.createElementNS(SVG_NS, tag); Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value))); return element; } export function svgText( value: string, attributes: Record, ): SVGTextElement { const text = svgElement("text", attributes); text.textContent = value; return text; } 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; } export function calculateYScale(detail: SectionDetailResponse): 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 = LONG_HEIGHT - LONG_PAD.top - LONG_PAD.bottom; 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; } export function inferStationInterval(stations: Array<{ chainage_m: number }>): number { const counts = new Map(); for (let index = 1; index < stations.length; index += 1) { const difference = stations[index].chainage_m - stations[index - 1].chainage_m; if (difference <= 0) continue; const rounded = Math.round(difference * 10) / 10; counts.set(rounded, (counts.get(rounded) ?? 0) + 1); } return ( [...counts.entries()].sort( ([intervalA, countA], [intervalB, countB]) => countB - countA || intervalB - intervalA, )[0]?.[0] ?? 1 ); } export function stationLabel(chainage: number, interval: number): string { const safeInterval = interval > 0 ? interval : 1; let stationNumber = Math.floor((chainage + 1e-6) / safeInterval); let remainder = chainage - stationNumber * safeInterval; if (Math.abs(remainder) < 0.05) remainder = 0; if (remainder >= safeInterval - 0.05) { stationNumber += 1; remainder = 0; } return `${stationNumber}+${remainder.toFixed(1)}`; }