- 27: 종단 X라벨↔버튼 간격 70%로(bottomInset 19→15) - 28: 사이드 패널 접힘 시 본문 24px 거터 — 손잡이가 Y축 라벨을 덮지 않음(B04~B07 공용) - 29: B06 종단·유토곡선 가로 스크롤 고정 Y축 — buildStickyYAxis 재사용, yaxis CSS 정의처를 MassHaul css로 이동(B05·B06 공용). 앵커를 첫 자식으로 넣고 유토축 inner를 종단 높이만큼 내려 자기 구간만 덮음. 스크롤 600px 블리드 테스트 통과 - 30: 배관 측점 곡선 L·R 항상 표기(R 필수), 이웃 곡선 셀 근접 시 배관 셀만 우측 비킴, 선택 오버레이는 한 열 원위치 유지 Playwright: sticky 앵커 2개(x 고정), 거터 24px, gap 7px, 곡선 셀 8, 오류 0. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
626 lines
29 KiB
TypeScript
626 lines
29 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";
|
||
import {
|
||
irregularStationId,
|
||
isPipeStation,
|
||
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;
|
||
/** 이어 공사 시작 기준 — 측점번호·누가거리 표시를 이만큼 더한다(내부 chainage는 0기준 유지). */
|
||
stationDisplay?: { station: number; cumulative: number };
|
||
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 displayStationLabel(chainageM: number, interval: number, stationOffset: number): string {
|
||
const base = stationLabel(chainageM, interval); // "X+YY.Y"
|
||
if (!stationOffset) return base;
|
||
const [number, remainder] = base.split("+");
|
||
return `${Number(number) + stationOffset}+${remainder}`;
|
||
}
|
||
|
||
function buildStationRows(
|
||
alignment: ProfileAlignment,
|
||
interval: number,
|
||
display: { station: number; cumulative: 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 + display.cumulative).toFixed(2),
|
||
},
|
||
{
|
||
label: "거리",
|
||
unit: "m 전 측점 대비",
|
||
cell: (index) => (index ? stations[index].distance_m.toFixed(2) : ""),
|
||
},
|
||
{
|
||
label: "측점",
|
||
unit: "측점번호+잔여거리",
|
||
cell: (index) => displayStationLabel(stations[index].chainage_m, interval, display.station),
|
||
},
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 구배 3행. 각 블록은 **변화점에서 변화점까지**를 덮는다.
|
||
*
|
||
* 구분선을 곡선의 접선점(BVC/EVC)에 두면 곡선 길이만큼 블록 사이에 틈이 생기고,
|
||
* 양옆 블록의 테두리가 그 틈을 감싸 "빈 셀"처럼 보인다. 변화점은 곧 **생성된 R의
|
||
* 중심**이자 측점 수직선이므로 그 자리를 구분선으로 삼으면 블록이 빈틈없이 이어지고,
|
||
* 표기 값(L/H/S)이 변화점 사이 기준이라는 점과도 일치한다.
|
||
*/
|
||
function buildSegmentRows(
|
||
alignment: ProfileAlignment,
|
||
x: (chainage: number) => number,
|
||
fontPx: number,
|
||
rowHeight: number,
|
||
/** 구조물(비정규) 측점 승격으로 생긴 변화점 chainage 집합(소수 3자리 키). */
|
||
structureChainages: ReadonlySet<string>,
|
||
/** 현재 선택된 측점 chainage — 구조물 파생 블록은 선택됐을 때만 값을 보인다. */
|
||
selectedChainageM: number | null,
|
||
): HTMLElement[] {
|
||
// 구조물 배치로 갈라진 구간인지 — 양 끝 중 하나라도 구조물 변화점이면 파생 블록이다.
|
||
const isStructureSegment = (segment: AlignmentSegment): boolean =>
|
||
structureChainages.has(segment.from_m.toFixed(3)) ||
|
||
structureChainages.has(segment.to_m.toFixed(3));
|
||
const touchesSelected = (segment: AlignmentSegment): boolean =>
|
||
selectedChainageM !== null &&
|
||
(Math.abs(segment.from_m - selectedChainageM) < 0.01 ||
|
||
Math.abs(segment.to_m - selectedChainageM) < 0.01);
|
||
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", "");
|
||
// 구조물 배치로 갈라진 좁은 구간 값은 처음부터 보이지 않는다 — 블록·경계선·툴팁만 남기고,
|
||
// 그 측점을 **선택했을 때** 하이라이트와 함께 값을 보인다(2026-08-03 사용자 지시).
|
||
const structural = isStructureSegment(segment);
|
||
const highlighted = structural && touchesSelected(segment);
|
||
if (structural) node.classList.add("is-structure");
|
||
if (highlighted) node.classList.add("is-highlight");
|
||
if (structural && !highlighted) {
|
||
node.style.left = `${left}px`;
|
||
node.style.width = `${span}px`;
|
||
node.title =
|
||
`${segment.from_m.toFixed(1)} ~ ${segment.to_m.toFixed(1)}m 직선 (구조물 구간)
|
||
` +
|
||
`연장 ${segment.length_m.toFixed(2)}m · 고저차 ${segment.height_m.toFixed(2)}m · ` +
|
||
`구배 ${segment.grade_percent.toFixed(2)}%`;
|
||
row.append(node);
|
||
return;
|
||
}
|
||
// 값은 표기한다. 가로로 안 들어가면 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[],
|
||
/** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */
|
||
structureStationKeys: ReadonlySet<string>,
|
||
/** 배관 구조물 chainage 키 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */
|
||
pipeStationKeys: ReadonlySet<string>,
|
||
): 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]),
|
||
);
|
||
|
||
// 곡선 값이 든 셀들의 오른쪽 끝 — 배관 곡선 셀이 이웃 곡선 구간과 근접해 겹치면
|
||
// 배관 쪽만 오른쪽으로 비켜 세운다(선택 하이라이트 오버레이는 원위치·한 열 그대로).
|
||
let lastContentRight = Number.NEGATIVE_INFINITY;
|
||
alignment.stations.forEach((station, index) => {
|
||
const lengthCell = element("span", "b05-profile-table__curve");
|
||
const radiusCell = element("span", "b05-profile-table__curve");
|
||
const key = station.chainage_m.toFixed(3);
|
||
const isPipe = pipeStationKeys.has(key);
|
||
// 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지).
|
||
// 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다.
|
||
const curve = structureStationKeys.has(key) && !isPipe ? undefined : curveByChainage.get(key);
|
||
let center = centers[index];
|
||
if (curve) {
|
||
const half = options.cellWidth / 2;
|
||
if (isPipe && center - half < lastContentRight + 2) {
|
||
center = lastContentRight + 2 + half;
|
||
}
|
||
lastContentRight = center + half;
|
||
}
|
||
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");
|
||
});
|
||
}
|
||
placeCell(lengthRow, center, lengthCell);
|
||
placeCell(radiusRow, center, 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행 순서 그대로 값(계획고·지반고·누가거리·
|
||
* 측점·구배·곡선 등)을 쌓아 테이블 위에 겹친다. 규칙 측점을 눌러도 비정규와 **같은 오버레이 방식**으로
|
||
* 눈에 잘 들어오게 한다. `centerX`는 호출부가 정한다 — 종점처럼 셀이 우측 이동된 규칙 측점은 그 이동된
|
||
* 중심을, 비정규 측점은 `x(chainage)`를 넘긴다.
|
||
*/
|
||
function buildSelectedColumn(
|
||
chainage: number,
|
||
alignment: ProfileAlignment,
|
||
centerX: number,
|
||
cellWidth: number,
|
||
interval: number,
|
||
display: { station: number; cumulative: number },
|
||
onAdjustStation?: (chainageM: number, deltaM: number) => void,
|
||
isIrregular = false,
|
||
): HTMLElement {
|
||
const plan = interpolateSample(alignment.samples, chainage, "elevation_m");
|
||
const ground = interpolateSample(alignment.samples, chainage, "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 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);
|
||
// 거리: 바로 앞 측점(계획선 측점)까지의 간격.
|
||
const previous = alignment.stations
|
||
.filter((row) => row.chainage_m < chainage - 1e-6)
|
||
.reduce<number | null>(
|
||
(acc, row) => (acc === null ? row.chainage_m : Math.max(acc, row.chainage_m)),
|
||
null,
|
||
);
|
||
const distance = previous !== null ? chainage - previous : null;
|
||
|
||
// 테이블 행 순서(구배 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: (chainage + display.cumulative).toFixed(2) }, modifier: "" }, // 누가거리
|
||
{ value: { text: distance !== null ? distance.toFixed(2) : "" }, modifier: "" }, // 거리
|
||
{ value: { text: displayStationLabel(chainage, interval, display.station) }, 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${isIrregular ? " is-floating" : ""}`,
|
||
);
|
||
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(chainage, 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 = `${displayStationLabel(chainage, interval, display.station)} · ${(
|
||
chainage + display.cumulative
|
||
).toFixed(2)}m`;
|
||
return column;
|
||
}
|
||
|
||
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
|
||
const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options;
|
||
const display = options.stationDisplay ?? { station: 0, cumulative: 0 };
|
||
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`);
|
||
|
||
// 구조물(비정규) 측점 승격 변화점 = 사용자 변화점 중 규칙 측점과 겹치지 않는 것.
|
||
const stationKeys = new Set(alignment.stations.map((row) => row.chainage_m.toFixed(3)));
|
||
const structureChainages = new Set(
|
||
alignment.pvi
|
||
.filter((node) => node.source === "user" && !stationKeys.has(node.chainage_m.toFixed(3)))
|
||
.map((node) => node.chainage_m.toFixed(3)),
|
||
);
|
||
// 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다.
|
||
// 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의
|
||
// chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다.
|
||
const structureStationKeys = new Set(
|
||
(options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)),
|
||
);
|
||
// 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시).
|
||
const pipeStationKeys = new Set(
|
||
(options.irregularStations ?? [])
|
||
.filter((entry) => isPipeStation(entry))
|
||
.map((entry) => entry.chainage_m.toFixed(3)),
|
||
);
|
||
const selectedIrregularEntry = options.irregularStations?.find(
|
||
(entry) => irregularStationId(entry.id) === options.selectedStationId,
|
||
);
|
||
const selectedStationRow = options.selectedStationId
|
||
? alignment.stations.find((row) => row.station_id === options.selectedStationId)
|
||
: undefined;
|
||
const selectedChainageM =
|
||
selectedIrregularEntry?.chainage_m ?? selectedStationRow?.chainage_m ?? null;
|
||
table.append(
|
||
...buildSegmentRows(alignment, x, fontSize, rowHeight, structureChainages, selectedChainageM),
|
||
);
|
||
buildStationRows(alignment, stationInterval, display).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 structural = structureStationKeys.has(station.chainage_m.toFixed(3));
|
||
const cell = element(
|
||
"span",
|
||
`b05-profile-table__cell${structural ? " is-structure" : ""}`,
|
||
structural ? "" : spec.cell(stationIndex),
|
||
);
|
||
placeCell(row, centers[stationIndex], cell);
|
||
});
|
||
table.append(row);
|
||
});
|
||
table.append(...buildCurveRows(options, centers, structureStationKeys, pipeStationKeys));
|
||
|
||
// 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.
|
||
// 규칙 측점은 종점 이동을 반영한 `centers[index]`, 비정규 측점은 `x(chainage)`를 중심으로 쓴다.
|
||
const selectedIrregular = selectedIrregularEntry;
|
||
const selectedRegularIndex = selectedStationRow
|
||
? alignment.stations.indexOf(selectedStationRow)
|
||
: -1;
|
||
if (selectedIrregular) {
|
||
// 표는 그대로 두고 선택된 값만 위에 얹는다 — 이웃 셀을 숨기면 고를 때마다 표가 비어
|
||
// 보인다(2026-08-02 사용자 지시).
|
||
table.append(
|
||
buildSelectedColumn(
|
||
selectedIrregular.chainage_m,
|
||
alignment,
|
||
x(selectedIrregular.chainage_m),
|
||
cellWidth,
|
||
stationInterval,
|
||
display,
|
||
options.onAdjustStation,
|
||
true,
|
||
),
|
||
);
|
||
} else if (selectedRegularIndex >= 0) {
|
||
table.append(
|
||
buildSelectedColumn(
|
||
alignment.stations[selectedRegularIndex].chainage_m,
|
||
alignment,
|
||
centers[selectedRegularIndex],
|
||
cellWidth,
|
||
stationInterval,
|
||
display,
|
||
options.onAdjustStation,
|
||
false,
|
||
),
|
||
);
|
||
}
|
||
return table;
|
||
}
|