/* ============================================================================= * B05_wf2_Route_UI_Profile_Table.ts * 종단면도 하단 도면 테이블 (구배 3행 · 측점값 7행 · 곡선 2행 = 12행). * * 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라 * 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다. * - 구배: 연장 / 고저차 / 기울기 3행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다. * - 곡선: 곡선길이 / 반경 2행. 반경 R만 입력 가능하고 L = R × |대수차| 로 따라온다. * * 값에는 `L=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다. * * 셀은 종단면도 그래프와 **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 맞물린다. * ========================================================================== */ import type { AlignmentCurve, AlignmentSegment, ProfileAlignment, } from "./B05_wf2_Route_UI_Profile_Alignment"; import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common"; import { irregularLabel, irregularStationId, type IrregularStation, } from "./B05_wf2_Route_UI_IrregularStations"; export interface ProfileTableOptions { alignment: ProfileAlignment; stationInterval: number; width: number; /** 테이블 전체 높이(px). 행 수로 나눈 값이 글자 크기 기준이 된다. */ height: number; /** 이웃 측점과 겹치지 않는 셀 폭(px). */ cellWidth: number; /** 행 이름표 열의 폭(px). 그래프의 좌측 여백과 같아야 X축이 맞물린다. */ labelWidth: number; rowCount: number; /** 종단면도와 공유하는 chainage → x(px) 매핑. */ x: (chainageM: number) => number; /** 규칙 격자 밖 비정규 측점(구조물). 선택된 측점만 값 열로 오버레이한다. */ irregularStations?: IrregularStation[]; /** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */ selectedStationId?: string | null; onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void; /** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */ onAdjustStation?: (chainageM: number, deltaM: number) => void; } interface StationRowSpec { label: string; unit: string; cell: (index: number) => string; modifier?: string; } interface SegmentRowSpec { label: string; unit: string; cell: (segment: AlignmentSegment) => string; } const FONT_MIN_PX = 9; const FONT_MAX_PX = 16; /** 행 높이 대비 글자 크기 비율. 위아래 여백을 줄여 글자를 행에 더 꽉 채운다. */ const FONT_PER_ROW_HEIGHT = 0.64; /** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */ const MAX_VALUE_CHARS = 7; /** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */ const CHAR_WIDTH_EM = 0.6; /** 셀 좌우 여백(px). 좁혀서 값이 셀을 더 넉넉히 쓰게 한다. */ const CELL_PADDING_PX = 2; /** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */ export const TABLE_TARGET_FONT_PX = 12; /** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */ export function tableCellWidthFor(fontPx: number): number { return Math.ceil(MAX_VALUE_CHARS * fontPx * CHAR_WIDTH_EM) + CELL_PADDING_PX; } /** * 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다. * * 가로 한계는 "가장 긴 값이 잘리지 않는 크기"로 직접 환산한다. 예전처럼 셀 폭에 * 임의 비율을 곱하면 실제 필요량보다 훨씬 작게 잡혀 늘 하한(9px)에 눌러붙었다. */ function fitFontSize(rowHeight: number, cellWidth: number): number { const byWidth = (cellWidth - CELL_PADDING_PX) / (MAX_VALUE_CHARS * CHAR_WIDTH_EM); const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, byWidth); return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted))); } /** 구간 블록에 이 글자가 실제로 들어가는가 (테두리만 남는 빈 칸 방지). */ function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean { return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2; } function element(tag: string, className: string, text?: string): HTMLElement { const node = document.createElement(tag); node.className = className; if (text !== undefined) node.textContent = text; return node; } /** 절대 배치 셀: 측점 x를 중심으로 좌우 대칭 배치한다. */ function placeCell(row: HTMLElement, centerX: number, node: HTMLElement): void { node.style.left = `${centerX}px`; row.append(node); } /** * 각 측점 셀의 중심 x. 규칙 측점은 측점 수직선 위(`x(chainage)`)에 그대로 두되, * **종점(마지막 측점)**이 앞 측점과 겹치면 오른쪽으로 민다. 종점이라 위치가 수직선에서 * 살짝 벗어나도 사용자가 종점 값임을 인지하는 데 무리가 없다. 캔버스 오른쪽 끝을 넘지 않게 * 가용 여백 안에서만 민다. (곡선/구배 행이 아니라 측점 값 셀·곡선 셀에만 적용한다.) */ function stationCellCenters( stations: ReadonlyArray<{ chainage_m: number }>, x: (chainageM: number) => number, cellWidth: number, width: number, ): number[] { const centers = stations.map((station) => x(station.chainage_m)); const last = centers.length - 1; if (last >= 1) { const cleared = centers[last - 1] + cellWidth; if (centers[last] < cleared) { centers[last] = Math.min(cleared, width - cellWidth / 2 - 2); } } return centers; } /** * 행 하나. 이름표는 sticky로 좌측에 고정되며 **그래프의 좌측 여백과 같은 폭**을 쓴다. * 단위는 이름표를 좁게 유지하려고 툴팁으로 뺐다(넓히면 그래프 시작점을 가린다). */ function createRow(className: string, label: string, unit: string): HTMLElement { const row = element("div", `b05-profile-table__row ${className}`); const caption = element("span", "b05-profile-table__label", label); caption.title = `${label} (${unit})`; row.append(caption); return row; } /** 도면의 구배 블록 3행: 구간 연장(m) / 고저차(m) / 기울기(%). 단위는 행 이름표가 대신한다. */ const SEGMENT_ROWS: SegmentRowSpec[] = [ { label: "구배 L", unit: "m 구간 연장", cell: (segment) => segment.length_m.toFixed(2) }, { label: "구배 H", unit: "m 구간 고저차", cell: (segment) => segment.height_m.toFixed(2) }, { label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; function buildStationRows(alignment: ProfileAlignment, interval: number): StationRowSpec[] { const stations = alignment.stations; return [ { label: "절토고", unit: "m", modifier: "cut", cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""), }, { label: "성토고", unit: "m", modifier: "fill", cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""), }, { label: "계획고", unit: "m", modifier: "plan", cell: (index) => stations[index].plan_elevation_m.toFixed(2), }, { label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) }, { label: "누가거리", unit: "m", cell: (index) => stations[index].chainage_m.toFixed(2) }, { label: "거리", unit: "m 전 측점 대비", cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""), }, { label: "측점", unit: "측점번호+잔여거리", cell: (index) => stationLabel(stations[index].chainage_m, interval), }, ]; } /** * 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다. * * 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고, * 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의 * 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고, * 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다. */ function buildSegmentRows( alignment: ProfileAlignment, x: (chainage: number) => number, fontPx: number, rowHeight: number, ): HTMLElement[] { return SEGMENT_ROWS.map((spec) => { const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit); alignment.segments.forEach((segment) => { const left = x(segment.from_m); const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); // 값은 항상 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰 // 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다). const value = element("span", "b05-profile-table__segment-value", text); if (!segmentTextFits(text, span, fontPx)) { value.classList.add("is-rotated"); const byThickness = Math.max(1, span - 2); const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontPx; value.style.fontSize = `${Math.max(6, Math.min(fontPx, byThickness, byLength))}px`; } node.append(value); node.style.left = `${left}px`; node.style.width = `${span}px`; node.title = `${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선\n` + `연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` + `구배 ${segment.grade_percent.toFixed(2)}%`; if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) { node.classList.add("is-violation"); node.title += `\n⚠ 기준 ${alignment.policy.max_grade_pct.toFixed(1)}% 초과`; } row.append(node); }); return row; }); } function curveTitle(curve: AlignmentCurve): string { return ( `종단곡선 R=${curve.r_m.toFixed(1)}m · L=${curve.l_m.toFixed(2)}m (L = R × |대수차|)\n` + `대수차 A=${curve.delta_pct.toFixed(2)}% K=${curve.k.toFixed(2)} ` + `중앙종거 ${curve.middle_ordinate_m.toFixed(3)}m\n` + `BVC=${curve.bvc_m.toFixed(1)}m (EL ${curve.bvc_elevation_m.toFixed(2)})\n` + `EVC=${curve.evc_m.toFixed(1)}m (EL ${curve.evc_elevation_m.toFixed(2)})` + (curve.omit_reason ? `\n${curve.omit_reason}` : "") ); } /** * 곡선 2행: 반경 R(입력·1차 값) / 곡선길이 L(= R × |대수차| 파생). * * 곡선이 없는 측점에도 **빈 칸을 만든다**. 변화점에만 셀을 두면 세로 구분선이 띄엄띄엄 * 끊겨 위쪽 측점값 행들과 격자가 맞지 않는다. */ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLElement[] { const { alignment, onCurveRadiusChange } = options; const lengthRow = createRow( "b05-profile-table__row--curve is-group-start", "곡선 L", "m 종단곡선 길이 (입력) — L·R은 서로 연동", ); const radiusRow = createRow( "b05-profile-table__row--curve", "곡선 R", "m 종단곡선 반경 (입력) — L·R은 서로 연동", ); const curveByChainage = new Map( alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]), ); alignment.stations.forEach((station, index) => { const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); const curve = curveByChainage.get(station.chainage_m.toFixed(3)); if (curve) { // L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해 // 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다. // L 입력 → R = L × (r_m / l_m) (현재 곡선의 L:R 비율 = 1/|대수차|, 단위 무관). radiusCell.append( curveInput( curve, curve.r_m.toFixed(1), `종단곡선 반경 R (m)\n${curveTitle(curve)}`, (r) => r, ), ); lengthCell.append( curveInput(curve, curve.l_m.toFixed(2), `종단곡선 길이 L (m)\n${curveTitle(curve)}`, (l) => curve.l_m > 1e-9 ? (l * curve.r_m) / curve.l_m : null, ), ); [lengthCell, radiusCell].forEach((cell) => { if (curve.skip_allowed) cell.classList.add("is-optional"); if (curve.omitted) cell.classList.add("is-omitted"); }); } // 선택된 규칙 측점 열의 곡선 셀도 함께 하이라이트한다. if (station.station_id && station.station_id === options.selectedStationId) { lengthCell.classList.add("is-selected"); radiusCell.classList.add("is-selected"); } placeCell(lengthRow, centers[index], lengthCell); placeCell(radiusRow, centers[index], radiusCell); }); function curveInput( curve: AlignmentCurve, value: string, title: string, toRadius: (parsed: number) => number | null, ): HTMLInputElement { const input = document.createElement("input"); input.type = "number"; input.step = "any"; input.min = "0"; // no-spin: 상·하 토글 버튼 제거(항상 사용자가 값 직접 입력). input.className = "b05-profile-table__radius b05-profile-table__no-spin"; input.value = value; input.title = title; input.addEventListener("change", () => { const parsed = Number.parseFloat(input.value); const radius = Number.isFinite(parsed) && parsed > 0 ? toRadius(parsed) : null; onCurveRadiusChange(curve, radius && radius > 0 ? radius : null); }); return input; } return [lengthRow, radiusRow]; } /** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */ function interpolateSample( samples: ProfileAlignment["samples"], chainageM: number, key: "elevation_m" | "ground_elevation_m", ): number | null { if (!samples.length) return null; if (chainageM <= samples[0].chainage_m) return samples[0][key]; const last = samples[samples.length - 1]; if (chainageM >= last.chainage_m) return last[key]; for (let index = 1; index < samples.length; index += 1) { const current = samples[index]; if (current.chainage_m < chainageM) continue; const previous = samples[index - 1]; const span = current.chainage_m - previous.chainage_m; if (span <= 1e-9) return current[key]; const ratio = (chainageM - previous.chainage_m) / span; return previous[key] + (current[key] - previous[key]) * ratio; } return last[key]; } /** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */ type CellValue = { text: string } | { left: string; right: string }; /** * 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고· * 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에 * 있으므로 여기엔 넣지 않고, 세로 점선도 두지 않는다. */ function buildIrregularColumn( station: IrregularStation, alignment: ProfileAlignment, centerX: number, cellWidth: number, interval: number, onAdjustStation?: (chainageM: number, deltaM: number) => void, ): HTMLElement { const plan = interpolateSample(alignment.samples, station.chainage_m, "elevation_m"); const ground = interpolateSample(alignment.samples, station.chainage_m, "ground_elevation_m"); const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null; const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null; // 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고, // 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다. const chainage = station.chainage_m; const inside = alignment.segments.find( (segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6, ); const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01); const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01); const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => { if (inside) return { text: pick(inside).toFixed(digits) }; const left = leftSeg ? pick(leftSeg).toFixed(digits) : ""; const right = rightSeg ? pick(rightSeg).toFixed(digits) : ""; return left && right ? { left, right } : { text: left || right || "" }; }; const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); // 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변). const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [ { value: gradeCell((segment) => segment.length_m, 2), modifier: "" }, // 구배 L { value: gradeCell((segment) => segment.height_m, 2), modifier: "" }, // 구배 H { value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S { value: { text: cut !== null ? cut.toFixed(2) : "" }, modifier: "cut" }, // 절토고 { value: { text: fill !== null ? fill.toFixed(2) : "" }, modifier: "fill" }, // 성토고 { value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고 { value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고 { value: { text: station.chainage_m.toFixed(2) }, modifier: "" }, // 누가거리 { value: { text: "" }, modifier: "" }, // 거리 { value: { text: stationLabel(station.chainage_m, interval) }, modifier: "" }, // 측점 { value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L { value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R ]; const column = element("div", "b05-profile-table__irregular-col"); column.style.left = `${centerX}px`; column.style.width = `${cellWidth}px`; rows.forEach((row) => { const cell = element( "div", `b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`, ); if (row.plan && onAdjustStation && plan !== null) { // 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 규칙 측점과 동일한 station_offset 반영. const input = document.createElement("input"); input.type = "number"; input.step = "any"; input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin"; input.value = plan.toFixed(2); input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`; input.addEventListener("change", () => { const target = Number.parseFloat(input.value); if (Number.isFinite(target)) onAdjustStation(station.chainage_m, target - plan); }); cell.append(input); } else if ("left" in row.value) { // 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다). cell.classList.add("is-split"); cell.append( element("span", "b05-profile-table__irregular-col-half", row.value.left), element("span", "b05-profile-table__irregular-col-half", row.value.right), ); } else { cell.textContent = row.value.text; } column.append(cell); }); column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`; return column; } export function createProfileTable(options: ProfileTableOptions): HTMLElement { const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options; const table = element("div", "b05-profile-table"); const rowHeight = height / Math.max(rowCount, 1); const fontSize = fitFontSize(rowHeight, cellWidth); // 종점이 앞 측점과 겹치면 오른쪽으로 민 셀 중심(측점 값·곡선 셀 공용). 구배 블록은 구간을 // 덮는 넓은 범위라 겹치지 않으므로 x를 그대로 쓴다. const centers = stationCellCenters(alignment.stations, x, cellWidth, width); // 이름표는 "누가거리"(한글 4자)가 들어가야 하므로 값 글자보다 크지 않게 제한한다. const labelFontSize = Math.min(fontSize, Math.floor((labelWidth - 10) / 4)); table.style.width = `${width}px`; table.style.height = `${height}px`; table.style.setProperty("--b05-table-font", `${fontSize}px`); table.style.setProperty("--b05-table-cell-width", `${cellWidth}px`); table.style.setProperty("--b05-table-label-width", `${labelWidth}px`); table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`); table.append(...buildSegmentRows(alignment, x, fontSize, rowHeight)); buildStationRows(alignment, stationInterval).forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`, spec.label, spec.unit, ); // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. alignment.stations.forEach((station, stationIndex) => { const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex)); // 선택된 규칙 측점은 그 열의 값 셀을 하이라이트한다(비정규 측점의 값 열 강조와 통일). if (station.station_id && station.station_id === options.selectedStationId) { cell.classList.add("is-selected"); } placeCell(row, centers[stationIndex], cell); }); table.append(row); }); table.append(...buildCurveRows(options, centers)); // 선택된 비정규 측점만 값 열로 오버레이한다(세로 점선·구조물 태그 없음). const selectedIrregular = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); if (selectedIrregular) { table.append( buildIrregularColumn( selectedIrregular, alignment, x(selectedIrregular.chainage_m), cellWidth, stationInterval, options.onAdjustStation, ), ); } return table; }