import type { CrossSection, SectionSample } from "./B06_Section_Api_Fetch"; import { CROSS_HEIGHT, CROSS_PAD, validElevation } from "./B06_Section_UI_Section_Common"; import { culvertRequiredHalfWidth } from "./B06_Section_UI_Cross_Culvert_Wire"; import { toeFitHalfWidth } from "./B06_Section_UI_Cross_Fit"; const AREA_OVERLAY_HEADROOM_PX = 58; export interface CrossPlotMetrics { sourceSamples: SectionSample[]; minOffset: number; maxOffset: number; elevationMid: number; displaySpan: number; displayMax: number; pixelsPerMeter: number; heightPx: number; } /** * 강제 높이를 얹기 **전**까지의 값 — 카드 두 벌(행 높이 재기 / 실제 그리기)이 나눠 쓴다. * 예전에는 같은 측점의 기하를 두 번 계산했고, 그 값이 B06 진입에서 `draw` 자기 시간 * 213ms 의 대부분이었다(2026-09-06 CPU 프로파일, 측점 67곳 기준 3.2ms/장). */ export interface CrossPlotBase { sourceSamples: SectionSample[]; minOffset: number; maxOffset: number; elevationMid: number; pixelsPerMeter: number; /** 강제 높이가 없을 때 쓰는 높이(바닥 `CROSS_HEIGHT` 적용 전). */ naturalHeight: number; } export function crossPlotBase( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, ): CrossPlotBase | null { const sourceSamples = section.samples.filter( (sample) => crossHalfWidth === undefined || Math.abs(sample.offset_m ?? 0) <= crossHalfWidth + 1e-6, ); const valid = sourceSamples.filter(validElevation); if (!valid.length) return null; const offsets = sourceSamples.map((sample) => sample.offset_m ?? 0); const minOffset = Math.min(...offsets, -1); const maxOffset = Math.max(...offsets, 1); const elevations = valid.map((sample) => sample.elevation_m); if (designElevation !== undefined && Number.isFinite(designElevation)) elevations.push(designElevation); const rawMin = Math.min(...elevations); const rawMax = Math.max(...elevations); const elevationMid = (rawMin + rawMax) / 2; const padding = rawMax > rawMin ? (rawMax - rawMin) * 0.08 : 0.5; const pixelsPerMeter = (widthPx - CROSS_PAD.left - CROSS_PAD.right) / Math.max(maxOffset - minOffset, 1e-6); const rawSpan = Math.max( (rawMax - rawMin + padding * 2) * Math.max(verticalExaggeration, 0.1), 1e-6, ); const naturalHeight = rawSpan * pixelsPerMeter + CROSS_PAD.top + CROSS_PAD.bottom + AREA_OVERLAY_HEADROOM_PX; return { sourceSamples, minOffset, maxOffset, elevationMid, pixelsPerMeter, naturalHeight }; } /** 재 둔 기하에 행 높이만 얹는다 — 다시 계산하지 않는다. */ export function crossPlotFromBase(base: CrossPlotBase, forcedHeightPx?: number): CrossPlotMetrics { const heightPx = forcedHeightPx ?? Math.max(base.naturalHeight, CROSS_HEIGHT); const displaySpan = Math.max(heightPx - CROSS_PAD.top - CROSS_PAD.bottom, 1e-6) / base.pixelsPerMeter; const displayMax = base.elevationMid + displaySpan / 2 + AREA_OVERLAY_HEADROOM_PX / base.pixelsPerMeter / 2; return { sourceSamples: base.sourceSamples, minOffset: base.minOffset, maxOffset: base.maxOffset, elevationMid: base.elevationMid, displaySpan, displayMax, pixelsPerMeter: base.pixelsPerMeter, heightPx, }; } export function crossPlotMetrics( section: CrossSection, verticalExaggeration: number, widthPx: number, crossHalfWidth?: number, designElevation?: number, forcedHeightPx?: number, ): CrossPlotMetrics | null { const base = crossPlotBase( section, verticalExaggeration, widthPx, crossHalfWidth, designElevation, ); return base ? crossPlotFromBase(base, forcedHeightPx) : null; } /** * 카드가 실제로 그릴 표시 반폭 — 개별값 > 전역값(2026-08-06). * * 절·성토선이 원지반과 만나는 지점(교차점)이 반폭 밖이면 **이 카드만** 자동 줌아웃한다 * (2026-08-22, 판정 기준 개편 2026-08-23: 배수관용 5m 사면 규칙 대신 실제 교차점). * 보유 샘플 밖까지는 넓히지 않는다 — 지반이 없어 빈 화면이 될 뿐이고, 그 경우 * [보기 반폭 적용]이 필요한 폭으로 백엔드 재생성을 건다. */ export function effectiveCardHalfWidth( section: CrossSection, baseHalfWidth: number | undefined, ): number | undefined { const sampledExtent = Math.max( 0, ...section.samples.map((sample) => Math.abs(sample.offset_m ?? 0)), ); const requiredHalfWidth = Math.min( Math.max(culvertRequiredHalfWidth(section) ?? 0, toeFitHalfWidth(section) ?? 0), sampledExtent, ); return baseHalfWidth !== undefined && requiredHalfWidth > 0 ? Math.max(baseHalfWidth, requiredHalfWidth) : baseHalfWidth; }