227 lines
9.8 KiB
TypeScript
227 lines
9.8 KiB
TypeScript
/* =============================================================================
|
||
* 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;
|
||
}
|