Files
Aislo/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts
T
eomsangdonandClaude Opus 5 6bcd6d1209 feat(B05/B06): 하단 간격·접힘 거터·B06 sticky Y축·배관 곡선 상시 표기(27~30)
- 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>
2026-08-04 19:53:42 +09:00

626 lines
29 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 {
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;
}