/* ============================================================================= * B05_Profile_UI_Profile_Table.ts * 종단면도 하단 도면 테이블 (구배 1행 · 측점값 3행 · 곡선 2행 = 6행). * * 실무 종단면도 좌측 하단 표를 옮긴 구성이되, **화면에서는 판단에 쓰는 행만** 둔다 * (2026-09-06 사용자 지시 — 표가 그래프 자리를 너무 먹었다). 뺀 6행은 구배 L·H, * 절토고, 성토고, 누가거리, 거리이며 **B07 도면 종단도는 12행 그대로**다(별도 코드). * - 구배: 기울기(S) 1행. 블록은 곡선 구간을 뺀 실제 직선부만 덮는다. * - 곡선: 곡선길이 / 반경 2행. 반경 R만 입력 가능하고 L = R × |대수차| 로 따라온다. * * 값에는 `L=` 같은 접두를 붙이지 않는다 — 행 이름표가 이미 항목과 단위를 말해준다. * * 셀은 종단면도 그래프와 **같은 X 매핑**으로 절대 배치되므로 측점 수직선과 맞물린다. * ========================================================================== */ import type { AlignmentCurve, AlignmentSegment, ProfileAlignment, } from "./B05_Profile_UI_Profile_Alignment"; import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; import { irregularStationId, isPipeStation, type IrregularStation, } from "./B05_Profile_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; /** 이어 공사 시작 기준 — 측점번호·누가거리 표시를 이만큼 더한다(내부 chainage는 0기준 유지). */ stationDisplay?: { station: number; cumulative: number }; 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; /** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ const CURVE_ROTATED_WIDTH_PX = 16; /** * 같은 측점으로 볼 누가거리 차이(m). * * 구조물 목록의 누가거리(`85.59`)와 계획선 측점의 누가거리(`85.595`)는 소수 셋째 자리에서 * 어긋난다 — 정본을 만든 경로가 달라 최대 0.05m 벌어진다(`_Profile_Edit` 의 같은 상수 참조). * 문자열 키로 맞추면 구조물 열을 못 알아봐 값이 그대로 펼쳐졌다(2026-09-04 실측). */ const SAME_STATION_TOLERANCE_M = 0.1; /** * 대수차가 이 값보다 작으면 **직선**으로 본다(%). * * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. */ const STRAIGHT_DELTA_PCT = 1e-4; /** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ function isStraightCurve(curve: AlignmentCurve): boolean { return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; } /** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */ function isNearChainage(chainageM: number, list: ReadonlyArray): boolean { return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M); } /** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */ 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; } /** 화면 구배 블록 1행: 기울기(%). 연장·고저차 두 행은 2026-09-06 사용자 지시로 뺐다 — * 두 값은 블록 툴팁에 그대로 남아 있어 필요할 때 짚어 볼 수 있다. */ const SEGMENT_ROWS: SegmentRowSpec[] = [ { label: "구배 S", unit: "% 구간 기울기", cell: (segment) => segment.grade_percent.toFixed(2) }, ]; /** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */ function displayStationLabel(chainageM: number, interval: number, stationOffset: number): string { const base = stationLabel(chainageM, interval); // "X+YY.Y" if (!stationOffset) return base; const [number, remainder] = base.split("+"); return `${Number(number) + stationOffset}+${remainder}`; } function buildStationRows( alignment: ProfileAlignment, interval: number, display: { station: number; cumulative: number }, ): StationRowSpec[] { const stations = alignment.stations; // 절토고·성토고·누가거리·거리 네 행은 2026-09-06 사용자 지시로 뺐다 — 절·성토는 // 그래프 음영과 유토곡선이, 누가거리는 측점 라벨이 이미 말해 준다. return [ { 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: "측점번호+잔여거리", cell: (index) => displayStationLabel(stations[index].chainage_m, interval, display.station), }, ]; } /** * 구배 행. 각 블록은 **변화점에서 변화점까지**를 덮는다. * * 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고, * 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의 * 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고, * 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다. */ function buildSegmentRows( alignment: ProfileAlignment, x: (chainage: number) => number, fontPx: number, rowHeight: number, /** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 목록(m). */ structureChainages: ReadonlyArray, /** 현재 선택된 측점 chainage — 구조물 파생 블록은 선택됐을 때만 값을 보인다. */ selectedChainageM: number | null, ): HTMLElement[] { // 구조물 배치로 갈라진 구간인지 — 양 끝 중 하나라도 구조물 변화점이면 파생 블록이다. // 자리 비교는 **허용오차**로 한다(2026-09-04) — 소수 3자리 문자열로 맞추면 같은 // 자리인데도 끝자리가 갈려(85.590 ↔ 85.594) 구조물 구간으로 안 잡혔다. const isStructureSegment = (segment: AlignmentSegment): boolean => structureChainages.some( (at) => Math.abs(segment.from_m - at) < 0.01 || Math.abs(segment.to_m - at) < 0.01, ); const touchesSelected = (segment: AlignmentSegment): boolean => selectedChainageM !== null && (Math.abs(segment.from_m - selectedChainageM) < 0.01 || Math.abs(segment.to_m - selectedChainageM) < 0.01); 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", ""); // 구배 행은 **늘 보인다** — 구조물로 갈라진 좁은 구간도 값을 적는다 // (2026-09-04 사용자 확정: 값 열을 접는 규칙에서 구배 행은 제외). // 옛 규칙(2026-08-03: 구조물 구간은 고를 때만 값 표시)은 여기서 걷어냈고, // 구조물 구간 표시(색·하이라이트)와 툴팁은 그대로 둔다. const structural = isStructureSegment(segment); if (structural) node.classList.add("is-structure"); if (structural && touchesSelected(segment)) node.classList.add("is-highlight"); // 값은 표기한다. 가로로 안 들어가면 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[], /** 구조물 측점 누가거리 — 이 열의 곡선 입력은 기본 표기하지 않는다. */ structureChainageList: ReadonlyArray, /** 배관 구조물 누가거리 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ pipeChainageList: ReadonlyArray, /** 기본 글자 크기(px)와 행 높이(px) — 회전 셀의 글자 축소 산식에 쓴다. */ fontPx: number, rowHeightPx: 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]), ); // 1차: 곡선 값이 있는 열을 모은다. 이웃 곡선 셀과 가로로 겹치면(배관 측점이 규칙 측점 // 옆에 붙는 경우) **자리를 옮기지 않고** 그 쌍을 세로(회전) 표기로 바꾼다 — 옆으로 // 비키면 다른 열의 값으로 오해된다(2026-08-04 사용자 지시). 값은 측점 수직선 위에 // 그대로 서고, 글자 크기는 행 높이에 맞춰 줄인다(구배 행 is-rotated와 같은 방식). const curveEntries = alignment.stations.map((station, index) => { const key = station.chainage_m.toFixed(3); const isPipe = isNearChainage(station.chainage_m, pipeChainageList); // 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지). // 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다. const found = isNearChainage(station.chainage_m, structureChainageList) && !isPipe ? undefined : curveByChainage.get(key); // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. const curve = found && !isStraightCurve(found) ? found : undefined; return { center: centers[index], curve, rotated: false }; }); const withCurve = curveEntries.filter((entry) => entry.curve); for (let pair = 1; pair < withCurve.length; pair += 1) { if (withCurve[pair].center - withCurve[pair - 1].center < options.cellWidth) { withCurve[pair - 1].rotated = true; withCurve[pair].rotated = true; } } curveEntries.forEach(({ center, curve, rotated }) => { const lengthCell = element("span", "b05-profile-table__curve"); const radiusCell = element("span", "b05-profile-table__curve"); if (curve) { // L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해 // 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다. // L 입력 → R = L × (r_m / l_m) (현재 곡선의 L:R 비율 = 1/|대수차|, 단위 무관). const radiusInput = curveInput( curve, curve.r_m.toFixed(1), `종단곡선 반경 R (m)\n${curveTitle(curve)}`, (r) => r, ); const lengthInput = 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), ); radiusCell.append(radiusInput); lengthCell.append(lengthInput); if (rotated) { [lengthCell, radiusCell].forEach((cell) => { cell.classList.add("is-rotated"); cell.style.width = `${CURVE_ROTATED_WIDTH_PX}px`; }); [lengthInput, radiusInput].forEach((input) => { const byLength = input.value.length ? (rowHeightPx - 6) / (input.value.length * CHAR_WIDTH_EM) : fontPx; input.style.fontSize = `${Math.max(7, Math.min(fontPx, CURVE_ROTATED_WIDTH_PX - 4, byLength))}px`; }); } [lengthCell, radiusCell].forEach((cell) => { if (curve.skip_allowed) cell.classList.add("is-optional"); if (curve.omitted) cell.classList.add("is-omitted"); }); } placeCell(lengthRow, center, lengthCell); placeCell(radiusRow, center, 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행 순서 그대로 값(계획고·지반고·누가거리· * 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로 * 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된 * 중심을, 비정규 측점은 `x(chainage)`를 넘긴다. */ function buildSelectedColumn( chainage: number, alignment: ProfileAlignment, centerX: number, cellWidth: number, interval: number, display: { station: number; cumulative: number }, onAdjustStation?: (chainageM: number, deltaM: number) => void, isIrregular = false, ): HTMLElement { const plan = interpolateSample(alignment.samples, chainage, "elevation_m"); const ground = interpolateSample(alignment.samples, chainage, "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 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 curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; // 테이블 행 순서(구배 1 · 측점값 3 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백. // `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 6행 격자는 불변). // 절토·성토고는 행이 빠졌어도 값 자체는 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [ { value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S { value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고 { value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고 { value: { text: displayStationLabel(chainage, interval, display.station) }, 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${isIrregular ? " is-floating" : ""}`, ); 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(chainage, 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); }); // 행에서 뺀 값(절토고·성토고·누가거리)은 툴팁에 남긴다 — 고른 측점에서 흔히 찾는 값이다. const cutFillText = cut !== null ? ` · 절토 ${cut.toFixed(2)}m` : fill !== null ? ` · 성토 ${fill.toFixed(2)}m` : ""; column.title = `${displayStationLabel(chainage, interval, display.station)} · 누가거리 ${( chainage + display.cumulative ).toFixed(2)}m${cutFillText}`; return column; } export function createProfileTable(options: ProfileTableOptions): HTMLElement { const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options; const display = options.stationDisplay ?? { station: 0, cumulative: 0 }; 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`); // 구조물(비정규) 측점 승격 변화점 = 사용자 변화점 중 규칙 측점과 겹치지 않는 것. const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3))); const structureChainages = alignment.pvi .filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3))) .map((node) => node.chainage_m); // 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다. // 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의 // chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다. const structureChainageList = (options.irregularStations ?? []).map((entry) => entry.chainage_m); // 병합된 구조물 측점도 **구조물 구간 경계**다 — 확정으로 종단 정본에 들어간 뒤에는 // 위 `alignment.pvi` 걸러내기에서 빠져, 고른 측점의 값 열이 강조되지 않았다 // (2026-09-04 사용자 보고: 테이블만 함께 안 켜짐). structureChainages.push(...structureChainageList); // 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시). const pipeChainageList = (options.irregularStations ?? []) .filter((entry) => isPipeStation(entry)) .map((entry) => entry.chainage_m); const selectedIrregularEntry = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); const selectedStationRow = options.selectedStationId ? alignment.stations.find((row) => row.station_id === options.selectedStationId) : undefined; const selectedChainageM = selectedIrregularEntry?.chainage_m ?? selectedStationRow?.chainage_m ?? null; table.append( ...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM), ); buildStationRows(alignment, stationInterval, display).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 structural = isNearChainage(station.chainage_m, structureChainageList); const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, structural ? "" : spec.cell(stationIndex), ); placeCell(row, centers[stationIndex], cell); }); table.append(row); }); table.append( ...buildCurveRows( options, centers, structureChainageList, pipeChainageList, fontSize, rowHeight, ), ); // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다. // 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다. const selectedIrregular = selectedIrregularEntry; const selectedRegularIndex = selectedStationRow ? alignment.stations.indexOf(selectedStationRow) : -1; if (selectedIrregular) { // 표는 그대로 두고 선택된 값만 위에 얹는다 — 이웃 셀을 숨기면 고를 때마다 표가 비어 // 보인다(2026-08-02 사용자 지시). table.append( buildSelectedColumn( selectedIrregular.chainage_m, alignment, x(selectedIrregular.chainage_m), cellWidth, stationInterval, display, options.onAdjustStation, true, ), ); } else if (selectedRegularIndex >= 0) { table.append( buildSelectedColumn( alignment.stations[selectedRegularIndex].chainage_m, alignment, centers[selectedRegularIndex], cellWidth, stationInterval, display, options.onAdjustStation, false, ), ); } return table; }