283 lines
12 KiB
TypeScript
283 lines
12 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;
|
||
/** 행 이름표 열의 폭(px). 그래프의 좌측 여백과 같아야 X축이 맞물린다. */
|
||
labelWidth: number;
|
||
rowCount: number;
|
||
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
|
||
x: (chainageM: number) => number;
|
||
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
|
||
}
|
||
|
||
interface StationRowSpec {
|
||
label: string;
|
||
unit: string;
|
||
cell: (index: number) => string;
|
||
modifier?: string;
|
||
}
|
||
|
||
interface SegmentRowSpec {
|
||
label: string;
|
||
unit: string;
|
||
cell: (segment: AlignmentSegment) => string;
|
||
}
|
||
|
||
const FONT_MIN_PX = 9;
|
||
const FONT_MAX_PX = 16;
|
||
/** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */
|
||
const FONT_PER_ROW_HEIGHT = 0.52;
|
||
/** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */
|
||
const MAX_VALUE_CHARS = 7;
|
||
/** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */
|
||
const CHAR_WIDTH_EM = 0.6;
|
||
const CELL_PADDING_PX = 4;
|
||
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
|
||
export const TABLE_TARGET_FONT_PX = 12;
|
||
|
||
/** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */
|
||
export function tableCellWidthFor(fontPx: number): number {
|
||
return Math.ceil(MAX_VALUE_CHARS * fontPx * CHAR_WIDTH_EM) + CELL_PADDING_PX;
|
||
}
|
||
|
||
/**
|
||
* 행 높이와 셀 폭 중 빡빡한 쪽에 글자 크기를 맞춘다.
|
||
*
|
||
* 가로 한계는 "가장 긴 값이 잘리지 않는 크기"로 직접 환산한다. 예전처럼 셀 폭에
|
||
* 임의 비율을 곱하면 실제 필요량보다 훨씬 작게 잡혀 늘 하한(9px)에 눌러붙었다.
|
||
*/
|
||
function fitFontSize(rowHeight: number, cellWidth: number): number {
|
||
const byWidth = (cellWidth - CELL_PADDING_PX) / (MAX_VALUE_CHARS * CHAR_WIDTH_EM);
|
||
const fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, byWidth);
|
||
return Math.round(Math.max(FONT_MIN_PX, Math.min(FONT_MAX_PX, fitted)));
|
||
}
|
||
|
||
/** 구간 블록에 이 글자가 실제로 들어가는가 (테두리만 남는 빈 칸 방지). */
|
||
function segmentTextFits(text: string, widthPx: number, fontPx: number): boolean {
|
||
return widthPx >= text.length * fontPx * CHAR_WIDTH_EM + CELL_PADDING_PX * 2;
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* 행 하나. 이름표는 sticky로 좌측에 고정되며 **그래프의 좌측 여백과 같은 폭**을 쓴다.
|
||
* 단위는 이름표를 좁게 유지하려고 툴팁으로 뺐다(넓히면 그래프 시작점을 가린다).
|
||
*/
|
||
function createRow(className: string, label: string, unit: string): HTMLElement {
|
||
const row = element("div", `b05-profile-table__row ${className}`);
|
||
const caption = element("span", "b05-profile-table__label", label);
|
||
caption.title = `${label} (${unit})`;
|
||
row.append(caption);
|
||
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: "절토고",
|
||
unit: "m",
|
||
modifier: "cut",
|
||
cell: (index) => (stations[index].cut_m > 0.005 ? stations[index].cut_m.toFixed(2) : ""),
|
||
},
|
||
{
|
||
label: "성토고",
|
||
unit: "m",
|
||
modifier: "fill",
|
||
cell: (index) => (stations[index].fill_m > 0.005 ? stations[index].fill_m.toFixed(2) : ""),
|
||
},
|
||
{
|
||
label: "계획고",
|
||
unit: "m",
|
||
modifier: "plan",
|
||
cell: (index) => stations[index].plan_elevation_m.toFixed(2),
|
||
},
|
||
{ label: "지반고", unit: "m", cell: (index) => stations[index].ground_elevation_m.toFixed(2) },
|
||
{ label: "누가거리", unit: "m", cell: (index) => stations[index].chainage_m.toFixed(2) },
|
||
{
|
||
label: "거리",
|
||
unit: "m 전 측점 대비",
|
||
cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""),
|
||
},
|
||
{
|
||
label: "측점",
|
||
unit: "측점번호+잔여거리",
|
||
cell: (index) => stationLabel(stations[index].chainage_m, interval),
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다.
|
||
*
|
||
* 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고,
|
||
* 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의
|
||
* 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고,
|
||
* 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다.
|
||
*/
|
||
function buildSegmentRows(
|
||
alignment: ProfileAlignment,
|
||
x: (chainage: number) => number,
|
||
fontPx: number,
|
||
): HTMLElement[] {
|
||
return SEGMENT_ROWS.map((spec) => {
|
||
const row = createRow("b05-profile-table__row--grade", spec.label, spec.unit);
|
||
alignment.segments.forEach((segment) => {
|
||
const left = x(segment.from_m);
|
||
const span = x(segment.to_m) - left;
|
||
const text = spec.cell(segment);
|
||
const node = element("span", "b05-profile-table__segment", "");
|
||
// 구간이 짧아 글자가 안 들어가면 숫자만 비운다. 블록을 지우면 그 자리가 다시
|
||
// 틈으로 남아 빈 셀처럼 보이므로, 칸은 유지하고 값은 툴팁으로 읽게 한다.
|
||
if (segmentTextFits(text, span, fontPx)) node.textContent = text;
|
||
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 종단곡선 길이 (R × |대수차|)",
|
||
);
|
||
const radiusRow = createRow("b05-profile-table__row--curve", "곡선 R", "m 종단곡선 반경 (입력)");
|
||
const curveByChainage = new Map(
|
||
alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]),
|
||
);
|
||
|
||
alignment.stations.forEach((station) => {
|
||
const lengthCell = element("span", "b05-profile-table__curve");
|
||
const radiusCell = element("span", "b05-profile-table__curve");
|
||
const curve = curveByChainage.get(station.chainage_m.toFixed(3));
|
||
if (curve) {
|
||
lengthCell.textContent = curve.l_m.toFixed(2);
|
||
lengthCell.title = curveTitle(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(station.chainage_m), lengthCell);
|
||
placeCell(radiusRow, x(station.chainage_m), radiusCell);
|
||
});
|
||
return [lengthRow, radiusRow];
|
||
}
|
||
|
||
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
|
||
const table = element("div", "b05-profile-table");
|
||
const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth);
|
||
// 이름표는 "누가거리"(한글 4자)가 들어가야 하므로 값 글자보다 크지 않게 제한한다.
|
||
const labelFontSize = Math.min(fontSize, Math.floor((labelWidth - 10) / 4));
|
||
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`);
|
||
table.style.setProperty("--b05-table-label-width", `${labelWidth}px`);
|
||
table.style.setProperty("--b05-table-label-font", `${Math.max(8, labelFontSize)}px`);
|
||
|
||
table.append(...buildSegmentRows(alignment, x, fontSize));
|
||
buildStationRows(alignment, stationInterval).forEach((spec, index) => {
|
||
const row = createRow(
|
||
`${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,
|
||
spec.label,
|
||
spec.unit,
|
||
);
|
||
// 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다.
|
||
alignment.stations.forEach((station, stationIndex) => {
|
||
const cell = element("span", "b05-profile-table__cell", spec.cell(stationIndex));
|
||
placeCell(row, x(station.chainage_m), cell);
|
||
});
|
||
table.append(row);
|
||
});
|
||
table.append(...buildCurveRows(options));
|
||
return table;
|
||
}
|