/* ============================================================================= * B05_Profile_UI_Profile_Table_Selected.ts * 종단면도 하단 표 — 고른 측점의 **값 열 오버레이**(`buildSelectedColumn`)와 표 본체가 같이 쓰는 조각 * (`element` · `displayStationLabel` · `isStraightCurve`). 표 본체는 `B05_Profile_UI_Profile_Table`. * ========================================================================== */ import type { AlignmentCurve, AlignmentSegment, ProfileAlignment, } from "./logic/B05_Profile_UI_Profile_Alignment"; import { stationLabel } from "../B06_Section/B06_Section_UI_Section_Common"; /** * 대수차가 이 값보다 작으면 **직선**으로 본다(%). * * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. */ const STRAIGHT_DELTA_PCT = 1e-4; /** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ export function isStraightCurve(curve: AlignmentCurve): boolean { return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; } export 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; } /** 이어 공사 시작 기준을 반영한 측점 표기 — 측점번호에 시작 측점을 더한다(잔여거리는 그대로). */ export 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}`; } /** 정렬된 계획선 샘플에서 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)`를 넘긴다. */ export 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, mc = 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) + (mc ? " (MC)" : ""), }, modifier: mc ? "mc" : "", }, // 측점 { 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; }