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

196 lines
7.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행).
*
* 실무 종단면도 좌측 하단 표를 그대로 옮긴 구성이다. 구배와 곡선은 단일값 행이 아니라
* 도면에서도 여러 줄로 찍히므로 각각 물리적인 행으로 분리했다.
* - 구배: `L=` / `H=` / `S=` 3행 (S행에 변화점 중앙종거를 함께 얹는다)
* - 곡선: `L=` / `R=` 2행 (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;
/** 종단면도와 공유하는 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;
cell: (segment: AlignmentSegment) => string;
}
const CELL_WIDTH = 56;
/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */
const MIN_SEGMENT_WIDTH = 32;
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 - CELL_WIDTH / 2}px`;
node.style.width = `${CELL_WIDTH}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행: 구간 연장 / 고저차 / 기울기. */
const SEGMENT_ROWS: SegmentRowSpec[] = [
{ label: "구배 L", cell: (segment) => `L=${segment.length_m.toFixed(2)}m` },
{ label: "구배 H", cell: (segment) => `H=${segment.height_m.toFixed(2)}m` },
{ label: "구배 S", cell: (segment) => `S=${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행. 각 행은 변화점 사이 구간 전체를 덮는 블록으로 그린다.
* S행에는 변화점 중앙종거를 함께 얹는다 — 도면 구배 행의 소수값(0.56, -0.75 …)이 이 값이다(|A|·L/8).
*/
function buildSegmentRows(
alignment: ProfileAlignment,
x: (chainage: number) => number,
): HTMLElement[] {
return SEGMENT_ROWS.map((spec, rowIndex) => {
const row = createRow("b05-profile-table__row--grade", spec.label);
alignment.segments.forEach((segment) => {
const left = x(segment.from_m);
const span = Math.abs(x(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`;
if (Math.abs(segment.grade_percent) > alignment.policy.max_grade_pct + 1e-6) {
node.classList.add("is-violation");
node.title = `종단기울기 ${segment.grade_percent.toFixed(2)}%가 기준 ${alignment.policy.max_grade_pct.toFixed(1)}%를 초과합니다.`;
}
row.append(node);
});
// 중앙종거는 기울기 변화의 결과이므로 마지막 S행에만 표기한다.
if (rowIndex === SEGMENT_ROWS.length - 1) {
alignment.curves.forEach((curve) => {
const node = element(
"span",
"b05-profile-table__ordinate",
(curve.delta_pct >= 0 ? "" : "-") + curve.middle_ordinate_m.toFixed(2),
);
node.title = `중앙종거 (대수차 ${curve.delta_pct.toFixed(2)}% × L ${curve.l_m.toFixed(1)}m / 8)`;
placeCell(row, x(curve.chainage_m), node);
});
}
return row;
});
}
function curveTitle(curve: AlignmentCurve): string {
return (
`K=${curve.k.toFixed(2)} A=${curve.delta_pct.toFixed(2)}%\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행: 곡선길이 L(파생) / 반경 R(입력). */
function buildCurveRows(options: ProfileTableOptions): HTMLElement[] {
const { alignment, x, onCurveRadiusChange } = options;
const lengthRow = createRow("b05-profile-table__row--curve", "곡선 L");
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R");
alignment.curves.forEach((curve) => {
const lengthCell = element("span", "b05-profile-table__curve", `L=${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 = R × |A| 로 역산합니다.\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, x } = options;
const table = element("div", "b05-profile-table");
table.style.width = `${width}px`;
table.append(...buildSegmentRows(alignment, x));
buildStationRows(alignment, stationInterval).forEach((spec) => {
const row = createRow(spec.modifier ? `is-${spec.modifier}` : "", spec.label);
alignment.stations.forEach((station, index) => {
const value = spec.cell(index);
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;
}