Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
T
2026-07-23 19:31:52 +09:00

227 lines
9.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* 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";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
stationInterval: number;
width: number;
/** 테이블 전체 높이(px). 행 수로 나눈 값이 글자 크기 기준이 된다. */
height: number;
/** 이웃 측점과 겹치지 않는 셀 폭(px). */
cellWidth: number;
rowCount: number;
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
x: (chainageM: number) => number;
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
}
interface StationRowSpec {
label: string;
cell: (index: number) => string;
modifier?: string;
}
interface SegmentRowSpec {
label: string;
unit: string;
cell: (segment: AlignmentSegment) => string;
}
/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */
const MIN_SEGMENT_WIDTH = 32;
const FONT_MIN_PX = 9;
const FONT_MAX_PX = 16;
/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */
const FONT_PER_ROW_HEIGHT = 0.52;
/** 셀 폭 대비 글자 크기 비율 — "409.96"(6자) + `-` 여유가 잘리지 않는 값. */
const FONT_PER_CELL_WIDTH = 0.145;
/**
* 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다.
* 패널을 키우면 행이 두꺼워지며 글자도 같이 커지고, 측점이 촘촘해 셀이 좁아지면
* 가로가 먼저 한계에 걸려 글자가 작아진다.
*/
function fitFontSize(rowHeight: number, cellWidth: number): number {
const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, cellWidth * FONT_PER_CELL_WIDTH);
return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted)));
}
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);
}
function createRow(className: string, label: string): HTMLElement {
const row = element("div", `b05-profile-table__row ${className}`);
row.append(element("span", "b05-profile-table__label", label));
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: "절토고",
modifier: "cut",
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
},
{
label: "성토고",
modifier: "fill",
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
},
{
label: "계획고",
modifier: "plan",
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
},
{ label: "지반고", cell: (index) => stations[index].ground_elevation_m.toFixed(2) },
{ label: "누가거리", cell: (index) => stations[index].chainage_m.toFixed(2) },
{ label: "거리", cell: (index) => (index ? stations[index].distance_m.toFixed(2) : "") },
{ label: "측점", cell: (index) => stationLabel(stations[index].chainage_m, interval) },
];
}
/**
* 구배 3행. 각 블록은 **곡선 구간(BVC~EVC)을 뺀 실제 직선 부분**만 덮는다.
* 변화점 자리는 곡선이 차지하므로 그 구간에 구배를 적어 두면 존재하지 않는 직선의
* 값을 읽는 셈이 된다. 값(L/H/S) 자체는 도면 관례대로 변화점 사이 기준이다.
*/
function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
): HTMLElement[] {
const curveByPvi = new Map(
alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]),
);
return SEGMENT_ROWS.map((spec) => {
const row = createRow("b05-profile-table__row--grade", `${spec.label} (${spec.unit})`);
alignment.segments.forEach((segment) => {
const startCurve = curveByPvi.get(segment.index);
const endCurve = curveByPvi.get(segment.index + 1);
const left = x(startCurve ? startCurve.evc_m : segment.from_m);
const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left;
if (span < MIN_SEGMENT_WIDTH) return;
const node = element("span", "b05-profile-table__segment", spec.cell(segment));
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): HTMLElement[] {
const { alignment, x, onCurveRadiusChange } = options;
const lengthRow = createRow("b05-profile-table__row--curve is-group-start", "곡선 L (m)");
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R (m)");
alignment.curves.forEach((curve) => {
const lengthCell = element("span", "b05-profile-table__curve", curve.l_m.toFixed(2));
lengthCell.title = curveTitle(curve);
const radiusCell = element("span", "b05-profile-table__curve");
const input = document.createElement("input");
input.type = "number";
input.step = "1";
input.min = "1";
input.className = "b05-profile-table__radius";
input.value = curve.r_m.toFixed(1);
input.title = `종단곡선 반경 R (m) — 이 값이 기준이고 곡선길이 L이 따라 계산됩니다.\n${curveTitle(curve)}`;
input.addEventListener("change", () => {
const parsed = Number.parseFloat(input.value);
onCurveRadiusChange(curve, Number.isFinite(parsed) && parsed > 0 ? parsed : null);
});
radiusCell.append(input);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
});
placeCell(lengthRow, x(curve.chainage_m), lengthCell);
placeCell(radiusRow, x(curve.chainage_m), radiusCell);
});
return [lengthRow, radiusRow];
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth);
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`);
// 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다.
table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`);
table.append(...buildSegmentRows(alignment, x));
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
const row = createRow(
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
spec.label,
);
alignment.stations.forEach((station, stationIndex) => {
const value = spec.cell(stationIndex);
if (!value) return;
placeCell(row, x(station.chainage_m), element("span", "b05-profile-table__cell", value));
});
table.append(row);
});
table.append(...buildCurveRows(options));
return table;
}