Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
T
2026-07-24 17:52:01 +09:00

495 lines
22 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";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
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;
/** 규칙 격자 밖 비정규 측점(구조물). 선택된 측점만 값 열로 오버레이한다. */
irregularStations?: IrregularStation[];
/** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
selectedStationId?: string | null;
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
/** 임의 chainage의 계획고를 delta만큼 조정(비정규 측점 값 열 직접 입력용). */
onAdjustStation?: (chainageM: number, deltaM: number) => 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.64;
/** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */
const MAX_VALUE_CHARS = 7;
/** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */
const CHAR_WIDTH_EM = 0.6;
/** 셀 좌우 여백(px). 좁혀서 값이 셀을 더 넉넉히 쓰게 한다. */
const CELL_PADDING_PX = 2;
/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */
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);
}
/**
* 각 측점 셀의 중심 x. 규칙 측점은 측점 수직선 위(`x(chainage)`)에 그대로 두되,
* **종점(마지막 측점)**이 앞 측점과 겹치면 오른쪽으로 민다. 종점이라 위치가 수직선에서
* 살짝 벗어나도 사용자가 종점 값임을 인지하는 데 무리가 없다. 캔버스 오른쪽 끝을 넘지 않게
* 가용 여백 안에서만 민다. (곡선/구배 행이 아니라 측점 값 셀·곡선 셀에만 적용한다.)
*/
function stationCellCenters(
stations: ReadonlyArray<{ chainage_m: number }>,
x: (chainageM: number) => number,
cellWidth: number,
width: number,
): number[] {
const centers = stations.map((station) => x(station.chainage_m));
const last = centers.length - 1;
if (last >= 1) {
const cleared = centers[last - 1] + cellWidth;
if (centers[last] < cleared) {
centers[last] = Math.min(cleared, width - cellWidth / 2 - 2);
}
}
return centers;
}
/**
* 행 하나. 이름표는 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,
rowHeight: 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", "");
// 값은 항상 표기한다. 가로로 안 들어가면 90도로 세워 블록의 폭(span)·행 높이에 맞춰
// 글자를 줄여 넣는다(작아도 무시 — 값이 아예 안 보이는 것보단 낫다).
const value = element("span", "b05-profile-table__segment-value", text);
if (!segmentTextFits(text, span, fontPx)) {
value.classList.add("is-rotated");
const byThickness = Math.max(1, span - 2);
const byLength = text.length ? (rowHeight - 2) / (text.length * CHAR_WIDTH_EM) : fontPx;
value.style.fontSize = `${Math.max(6, Math.min(fontPx, byThickness, byLength))}px`;
}
node.append(value);
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, centers: number[]): HTMLElement[] {
const { alignment, onCurveRadiusChange } = options;
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
"곡선 L",
"m 종단곡선 길이 (입력) — L·R은 서로 연동",
);
const radiusRow = createRow(
"b05-profile-table__row--curve",
"곡선 R",
"m 종단곡선 반경 (입력) — L·R은 서로 연동",
);
const curveByChainage = new Map(
alignment.curves.map((curve) => [curve.chainage_m.toFixed(3), curve]),
);
alignment.stations.forEach((station, index) => {
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) {
// L·R은 L = R × |대수차| 로 연동된다. 둘 다 입력 가능하고, 어느 쪽을 고쳐도 R로 환산해
// 같은 파이프라인(onCurveRadiusChange)을 태운다. 재계산 후 마지막에 입력한 값이 반영된다.
// L 입력 → R = L × (r_m / l_m) (현재 곡선의 L:R 비율 = 1/|대수차|, 단위 무관).
radiusCell.append(
curveInput(
curve,
curve.r_m.toFixed(1),
`종단곡선 반경 R (m)\n${curveTitle(curve)}`,
(r) => r,
),
);
lengthCell.append(
curveInput(curve, curve.l_m.toFixed(2), `종단곡선 길이 L (m)\n${curveTitle(curve)}`, (l) =>
curve.l_m > 1e-9 ? (l * curve.r_m) / curve.l_m : null,
),
);
[lengthCell, radiusCell].forEach((cell) => {
if (curve.skip_allowed) cell.classList.add("is-optional");
if (curve.omitted) cell.classList.add("is-omitted");
});
}
// 선택된 규칙 측점 열의 곡선 셀도 함께 하이라이트한다.
if (station.station_id && station.station_id === options.selectedStationId) {
lengthCell.classList.add("is-selected");
radiusCell.classList.add("is-selected");
}
placeCell(lengthRow, centers[index], lengthCell);
placeCell(radiusRow, centers[index], radiusCell);
});
function curveInput(
curve: AlignmentCurve,
value: string,
title: string,
toRadius: (parsed: number) => number | null,
): HTMLInputElement {
const input = document.createElement("input");
input.type = "number";
input.step = "any";
input.min = "0";
// no-spin: 상·하 토글 버튼 제거(항상 사용자가 값 직접 입력).
input.className = "b05-profile-table__radius b05-profile-table__no-spin";
input.value = value;
input.title = title;
input.addEventListener("change", () => {
const parsed = Number.parseFloat(input.value);
const radius = Number.isFinite(parsed) && parsed > 0 ? toRadius(parsed) : null;
onCurveRadiusChange(curve, radius && radius > 0 ? radius : null);
});
return input;
}
return [lengthRow, radiusRow];
}
/** 정렬된 계획선 샘플에서 chainage 위치 값을 선형보간한다(범위 밖은 양 끝 클램프). */
function interpolateSample(
samples: ProfileAlignment["samples"],
chainageM: number,
key: "elevation_m" | "ground_elevation_m",
): number | null {
if (!samples.length) return null;
if (chainageM <= samples[0].chainage_m) return samples[0][key];
const last = samples[samples.length - 1];
if (chainageM >= last.chainage_m) return last[key];
for (let index = 1; index < samples.length; index += 1) {
const current = samples[index];
if (current.chainage_m < chainageM) continue;
const previous = samples[index - 1];
const span = current.chainage_m - previous.chainage_m;
if (span <= 1e-9) return current[key];
const ratio = (chainageM - previous.chainage_m) / span;
return previous[key] + (current[key] - previous[key]) * ratio;
}
return last[key];
}
/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */
type CellValue = { text: string } | { left: string; right: string };
/**
* 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고·
* 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에
* 있으므로 여기엔 넣지 않고, 세로 점선도 두지 않는다.
*/
function buildIrregularColumn(
station: IrregularStation,
alignment: ProfileAlignment,
centerX: number,
cellWidth: number,
interval: number,
onAdjustStation?: (chainageM: number, deltaM: number) => void,
): HTMLElement {
const plan = interpolateSample(alignment.samples, station.chainage_m, "elevation_m");
const ground = interpolateSample(alignment.samples, station.chainage_m, "ground_elevation_m");
const cut = plan !== null && ground !== null && ground - plan > 0.005 ? ground - plan : null;
const fill = plan !== null && ground !== null && plan - ground > 0.005 ? plan - ground : null;
// 구배(L/H/S): 이 측점이 구간 안에 있으면 그 구간 값을 그대로(중복이어도) 표기하고,
// 변화점이라 좌우 구간이 갈리면 세로 구분선으로 셀을 반 나눠 좌/우 값을 각각 넣는다.
const chainage = station.chainage_m;
const inside = alignment.segments.find(
(segment) => segment.from_m + 1e-6 < chainage && chainage < segment.to_m - 1e-6,
);
const leftSeg = alignment.segments.find((segment) => Math.abs(segment.to_m - chainage) < 0.01);
const rightSeg = alignment.segments.find((segment) => Math.abs(segment.from_m - chainage) < 0.01);
const gradeCell = (pick: (segment: AlignmentSegment) => number, digits: number): CellValue => {
if (inside) return { text: pick(inside).toFixed(digits) };
const left = leftSeg ? pick(leftSeg).toFixed(digits) : "";
const right = rightSeg ? pick(rightSeg).toFixed(digits) : "";
return left && right ? { left, right } : { text: left || right || "" };
};
const curve = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01);
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
// `plan: true`인 계획고 행만 직접 입력 셀로 만든다(값 열은 오버레이라 실제 12행 격자는 불변).
const rows: Array<{ value: CellValue; modifier: string; plan?: boolean }> = [
{ value: gradeCell((segment) => segment.length_m, 2), modifier: "" }, // 구배 L
{ value: gradeCell((segment) => segment.height_m, 2), modifier: "" }, // 구배 H
{ value: gradeCell((segment) => segment.grade_percent, 2), modifier: "grade" }, // 구배 S
{ value: { text: cut !== null ? cut.toFixed(2) : "" }, modifier: "cut" }, // 절토고
{ value: { text: fill !== null ? fill.toFixed(2) : "" }, modifier: "fill" }, // 성토고
{ value: { text: plan !== null ? plan.toFixed(2) : "" }, modifier: "plan", plan: true }, // 계획고
{ value: { text: ground !== null ? ground.toFixed(2) : "" }, modifier: "" }, // 지반고
{ value: { text: station.chainage_m.toFixed(2) }, modifier: "" }, // 누가거리
{ value: { text: "" }, modifier: "" }, // 거리
{ value: { text: stationLabel(station.chainage_m, interval) }, modifier: "" }, // 측점
{ value: { text: curve ? curve.l_m.toFixed(2) : "" }, modifier: "" }, // 곡선 L
{ value: { text: curve ? curve.r_m.toFixed(1) : "" }, modifier: "" }, // 곡선 R
];
const column = element("div", "b05-profile-table__irregular-col");
column.style.left = `${centerX}px`;
column.style.width = `${cellWidth}px`;
rows.forEach((row) => {
const cell = element(
"div",
`b05-profile-table__irregular-col-cell${row.modifier ? ` is-${row.modifier}` : ""}`,
);
if (row.plan && onAdjustStation && plan !== null) {
// 계획고 직접 입력 → (입력값 − 현재 계획고) delta로 규칙 측점과 동일한 station_offset 반영.
const input = document.createElement("input");
input.type = "number";
input.step = "any";
input.className = "b05-profile-table__irregular-input b05-profile-table__no-spin";
input.value = plan.toFixed(2);
input.title = `계획고 직접 입력 (현재 ${plan.toFixed(2)}m)`;
input.addEventListener("change", () => {
const target = Number.parseFloat(input.value);
if (Number.isFinite(target)) onAdjustStation(station.chainage_m, target - plan);
});
cell.append(input);
} else if ("left" in row.value) {
// 좌우 값을 세로 구분선으로 반씩 나눠 넣는다(각 반쪽에서 폰트를 줄여 맞춘다).
cell.classList.add("is-split");
cell.append(
element("span", "b05-profile-table__irregular-col-half", row.value.left),
element("span", "b05-profile-table__irregular-col-half", row.value.right),
);
} else {
cell.textContent = row.value.text;
}
column.append(cell);
});
column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`;
return column;
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
const table = element("div", "b05-profile-table");
const rowHeight = height / Math.max(rowCount, 1);
const fontSize = fitFontSize(rowHeight, cellWidth);
// 종점이 앞 측점과 겹치면 오른쪽으로 민 셀 중심(측점 값·곡선 셀 공용). 구배 블록은 구간을
// 덮는 넓은 범위라 겹치지 않으므로 x를 그대로 쓴다.
const centers = stationCellCenters(alignment.stations, x, cellWidth, width);
// 이름표는 "누가거리"(한글 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, rowHeight));
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));
// 선택된 규칙 측점은 그 열의 값 셀을 하이라이트한다(비정규 측점의 값 열 강조와 통일).
if (station.station_id && station.station_id === options.selectedStationId) {
cell.classList.add("is-selected");
}
placeCell(row, centers[stationIndex], cell);
});
table.append(row);
});
table.append(...buildCurveRows(options, centers));
// 선택된 비정규 측점만 값 열로 오버레이한다(세로 점선·구조물 태그 없음).
const selectedIrregular = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
if (selectedIrregular) {
table.append(
buildIrregularColumn(
selectedIrregular,
alignment,
x(selectedIrregular.chainage_m),
cellWidth,
stationInterval,
options.onAdjustStation,
),
);
}
return table;
}