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