/* ============================================================================= * B05_Profile_UI_Profile_Layout.ts * 종단면도의 X축 배치 — 측점 칸 폭·캔버스 폭·chainage ↔ 화면 x 매핑. * * 패널 본체(B05_Profile_UI_Profile_Panel)가 700줄 한계에 닿아 분리했다. * 그래프·테이블·구조물 레인이 **같은 매핑**을 써야 X축이 맞물리므로 그 계산을 * 한 곳에 모아 둔다. 전부 순수 함수라 패널 상태와 무관하다. * ========================================================================== */ import { LONG_PAD } from "../B06_Section/B06_Section_UI_Section_Common"; import { normalizedLongitudinal } from "./B05_Profile_UI_Profile_Data"; import { TABLE_TARGET_FONT_PX, tableCellWidthFor } from "./B05_Profile_UI_Profile_Table"; import type { LongitudinalSection, SectionStation } from "../B06_Section/B06_Section_Api_Fetch"; import { irregularLabel, irregularStationId, type IrregularStation, } from "./B05_Profile_UI_IrregularStations"; /** 테이블 6행(구배 S · 계획고 · 지반고 · 측점 · 곡선 L · 곡선 R). 행 높이와 셀 폭에서 * 글자 크기를 정하는 데 쓴다. 2026-09-06 사용자 지시로 12행에서 줄였다. */ export const TABLE_ROW_COUNT = 6; /** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ export const CELL_GAP_PX = 2; /** * 측점 한 칸의 **기준 폭(px)** — 목표 글자 크기(12px)로 7자리 값(`3000.00`)이 잘리지 않는 크기. * 실제 기본 간격은 여기에 배수(`PROFILE_SPACING_MULTIPLIER`)를 곱한 값을 쓴다. */ export const STATION_SPACING_PX = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; /** * **측점 간격 기본 배수**. 기준 폭의 1.5배를 한 측점 칸의 기본 간격으로 삼는다. * * 이 배수로 펼친 폭이 **최소 폭**이다 — 브라우저가 이보다 넓으면 폭맞춤으로 늘리고, 좁으면 * 이 간격을 유지한 채 스크롤로 훑는다. 넉넉한 기본값은 다음 세션의 **비정규 측점**(`+18` 등)이 * 규칙 칸 안에서 chainage 비례로 자리 잡을 여유도 함께 확보한다. */ export const PROFILE_SPACING_MULTIPLIER = 1.5; /** 유효 표고 샘플 기준의 노선 최대 chainage(m). 그래프·테이블이 같은 값을 써야 X축이 맞물린다. */ export function maxChainageOf(data: LongitudinalSection): number { const samples = normalizedLongitudinal(data).samples.filter( (sample) => sample.valid !== false && Number.isFinite(sample.elevation_m ?? NaN), ); return Math.max(data.length_m, samples[samples.length - 1]?.chainage_m ?? 1, 1); } /** * 비정규 측점을 그래프용 `SectionStation`으로 만든다. 그래프 렌더러는 chainage·라벨·kind만 * 쓰므로 월드 좌표는 0으로 둔다(3D 마커용 좌표는 Page가 따로 보간). 범위 밖은 제외. */ export function irregularGraphStations( list: IrregularStation[], maxChainage: number, ): SectionStation[] { return list .filter((entry) => entry.chainage_m >= 0 && entry.chainage_m <= maxChainage + 1e-6) .map((entry) => ({ station_id: irregularStationId(entry.id), chainage_m: entry.chainage_m, label: irregularLabel(entry), kind: "irregular" as const, center_z: null, azimuth_deg: null, center_x: 0, center_y: 0, frame: { left_xy: [0, 0] as [number, number] }, })); } /** 종단면도 렌더러와 **같은** chainage → x(px) 매핑을 만든다 (테이블·버튼 정렬 기준). */ export function chainageMapper( data: LongitudinalSection, width: number, originOffset: number, ): (chainage: number) => number { const maxChainage = maxChainageOf(data); const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset; return (chainage: number) => LONG_PAD.left + originOffset + (chainage / maxChainage) * plotWidth; } /** `chainageMapper`의 역변환 — 구조물 라인을 끌 때 화면 x를 누가거리로 되돌린다. */ export function chainageInverter( data: LongitudinalSection, width: number, originOffset: number, ): (px: number) => number { const maxChainage = maxChainageOf(data); const plotWidth = width - LONG_PAD.left - LONG_PAD.right - 2 * originOffset; return (px: number) => plotWidth > 0 ? ((px - LONG_PAD.left - originOffset) / plotWidth) * maxChainage : 0; } export interface ProfileLayout { /** 캔버스 폭(px). 화면이 넓으면 폭맞춤으로, 좁으면 최소 폭으로. */ width: number; /** 0측점·종점을 축 프레임 안으로 반 칸씩 들여쓰는 여백(px) — 그래프·테이블 공통. */ originOffset: number; /** 이웃 측점과 겹치지 않는 테이블 셀 폭(px). 실제 측점 간격에 맞춰 함께 늘어난다. */ cellWidth: number; } /** * 측점 간격 기본값(기준 폭 × 1.5)으로 노선을 펼치되, 화면이 더 넓으면 폭맞춤으로 늘린다. * * 매핑은 `x(c) = LONG_PAD.left + halfCell + c·pxPerMeter`이고, 좌우로 반 칸(halfCell)씩 띄워 * 0측점 셀이 이름표 열 밖으로, 종점 셀이 오른쪽 끝 밖으로 나오게 한다. 좌우 여백을 합치면 * 한 칸(측점간격)이므로 `width = pads + (maxChainage + interval)·pxPerMeter`가 되고, 이를 뒤집어 * pxPerMeter를 구하면 halfCell·셀 폭이 실제 간격과 항상 맞물린다. */ export function computeProfileLayout( data: LongitudinalSection, stationIntervalM: number, availableWidth: number, /** 가로 줌 배수(1 = 현행). 측점 간격 기본값과 폭맞춤 폭에 함께 곱해 넷(그래프·테이블· * 편집 버튼층·구조물 레인)이 같은 비율로 늘어나게 한다(2026-09-04 사용자 지시). */ zoomX = 1, ): ProfileLayout { const maxChainage = maxChainageOf(data); const interval = Math.max(stationIntervalM, 1e-6); const framePad = LONG_PAD.left + LONG_PAD.right; const zoom = Math.max(zoomX, 1e-6); const minSpacing = STATION_SPACING_PX * PROFILE_SPACING_MULTIPLIER * zoom; // 기본 배수로 펼친 최소 폭 (좌우 반 칸 = 한 칸 여백 포함). const minWidth = framePad + ((maxChainage + interval) / interval) * minSpacing; const width = Math.max(minWidth, availableWidth * zoom); const pxPerMeter = (width - framePad) / (maxChainage + interval); const spacing = interval * pxPerMeter; return { width, originOffset: spacing / 2, cellWidth: Math.max(1, spacing - CELL_GAP_PX) }; }