This commit is contained in:
2026-07-24 15:49:45 +09:00
parent 4b14e64782
commit a0a553fd94
6 changed files with 239 additions and 111 deletions
@@ -161,6 +161,12 @@ export function createIrregularStationsSection(
});
}
/** 잔여거리는 측점 간격을 넘을 수 없다(넘으면 다음 측점이 됨). max·검증에 함께 쓴다. */
function intervalMax(): number {
const interval = callbacks.getInterval();
return interval > 0 ? interval : 20;
}
function commit(): void {
const station = Number.parseInt(stationField.value, 10);
const remainder = Number.parseFloat(remainderField.value || "0");
@@ -168,7 +174,10 @@ export function createIrregularStationsSection(
stationField.focus();
return;
}
const safeRemainder = Number.isFinite(remainder) && remainder >= 0 ? remainder : 0;
// 잔여거리는 [0, 측점간격] 범위로 클램프한다.
const safeRemainder = Number.isFinite(remainder)
? Math.min(Math.max(remainder, 0), intervalMax())
: 0;
const structure = structureField.value.trim();
const chainage_m = chainageOf(station, safeRemainder);
if (editingId) {
@@ -189,6 +198,14 @@ export function createIrregularStationsSection(
callbacks.onChange([...stations]);
}
// 측점 간격은 옵션에서 바뀔 수 있으므로, 잔여거리의 max를 입력 직전에 현재 간격으로 맞춘다.
const syncRemainderMax = (): void => {
remainderField.max = String(intervalMax());
};
remainderField.addEventListener("focus", syncRemainderMax);
remainderField.addEventListener("input", syncRemainderMax);
syncRemainderMax();
primary.addEventListener("click", commit);
remove.addEventListener("click", () => {
if (!editingId) return;
+23 -5
View File
@@ -174,13 +174,23 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
syncIrregularSelection(stationId);
});
/** 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다. */
/**
* 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다.
* 선택은 3D·그래프·사이드바가 서로를 갱신하므로, `selectionSyncing` 가드로 재진입을 막아
* 무한 재귀(스택 오버플로우·프리즈)를 방지한다.
*/
function syncIrregularSelection(stationId: string | null): void {
if (selectionSyncing) return;
const prefix = irregularStationId("");
if (!stationId?.startsWith(prefix)) return;
const id = stationId.slice(prefix.length);
const station = irregularStations.find((entry) => entry.id === id);
panel.irregularStations.selectByChainage(station ? station.chainage_m : null);
selectionSyncing = true;
try {
panel.irregularStations.selectByChainage(station ? station.chainage_m : null);
} finally {
selectionSyncing = false;
}
}
let confirmedSurface: SurfaceModelSummary | null = null;
let latest: RouteLatestResponse | null = null;
@@ -190,6 +200,8 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
let stale = false;
let restoring = true;
let irregularStations: IrregularStation[] = [];
// 선택 동기화 재진입 가드(3D↔그래프↔사이드바 상호 갱신의 무한 재귀 차단).
let selectionSyncing = false;
const panel = createRoutePanel({
onSolve: () => void solve(),
@@ -207,9 +219,15 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
onInputChange: markStale,
onIrregularChange: (stations) => applyIrregularStations(stations),
onIrregularSelect: (station) => {
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
if (selectionSyncing) return;
selectionSyncing = true;
try {
const id = station ? irregularStationId(station.id) : null;
viewer.markers.selectStation(id);
profilePanel.setSelectedStation(id);
} finally {
selectionSyncing = false;
}
},
});
+12 -16
View File
@@ -241,17 +241,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
minUphillGrade.wrapper,
minDownhillGrade.wrapper,
);
const help = document.createElement("details");
help.innerHTML =
"<summary>최적경로란?</summary><p>배치 포인트와 경사·곡선·회피 조건을 만족하며 비용을 최소화한 경로입니다.</p>";
conditions.body.append(
algorithmLabel,
gradeLabel,
paved.wrapper,
avoidPass.wrapper,
details,
help,
);
conditions.body.append(algorithmLabel, gradeLabel, paved.wrapper, avoidPass.wrapper, details);
const sectionOptions = section(L("B05_Route_Group_SectionOptions"));
const stationInterval = numberField(L("B05_Route_Field_StationInterval"));
@@ -290,11 +280,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
startElevationOffset.wrapper,
endElevationOffset.wrapper,
);
const gradeHelp = document.createElement("details");
gradeHelp.innerHTML =
"<summary>계획선이란?</summary><p>공사 후 노면이 될 높이입니다. 직선과 종단곡선만으로 구성되며, " +
"절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.</p>";
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced, gradeHelp);
gradeLine.body.append(terrainLabel, criteriaNote, gradeAdvanced);
// 비정규 측점(구조물 측점) — 측점번호+잔여거리로 추가/수정/삭제. 목록 변경은 Page로 올려
// 그래프·테이블·3D에 반영한다. chainage 환산 기준인 측점간격은 실시간 조회한다.
@@ -369,6 +355,16 @@ export function createRoutePanel(callbacks: PanelCallbacks) {
actionRow,
);
// 컨테이너 제목(h3) 클릭 시 본문을 접거나 편다(제목은 남긴다). 본문 안의 details 등 별도
// 접힘 항목은 손대지 않으므로 열려 있으면 그대로 보인다.
root.addEventListener("click", (event) => {
const heading = (event.target as HTMLElement).closest("h3");
const container = heading?.parentElement;
if (heading && container?.classList.contains("b05-route__panel-section")) {
container.classList.toggle("is-collapsed");
}
});
return {
root,
viewControls,
@@ -393,11 +393,12 @@ export function createRouteProfilePanel(
labelWidth: LONG_PAD.left,
rowCount: TABLE_ROW_COUNT,
x,
// 비정규 측점은 규칙 격자를 건드리지 않고 주석(파선 세로선+라벨+구조물)으로 얹는다.
// 비정규 측점은 규칙 격자를 건드리지 않고, 선택된 측점만 값 열로 오버레이한다.
irregularStations: irregularStations.filter(
(entry) =>
entry.chainage_m >= 0 && entry.chainage_m <= maxChainageOf(longitudinal) + 1e-6,
),
selectedStationId,
onCurveRadiusChange: (curve, radius) =>
applyEdits(setCurveRadius(store.edits(), curve, radius ?? 0)),
})
+130 -51
View File
@@ -18,7 +18,11 @@ import type {
ProfileAlignment,
} from "./B05_wf2_Route_UI_Profile_Alignment";
import { stationLabel } from "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Section_Common";
import { irregularLabel, type IrregularStation } from "./B05_wf2_Route_UI_IrregularStations";
import {
irregularLabel,
irregularStationId,
type IrregularStation,
} from "./B05_wf2_Route_UI_IrregularStations";
export interface ProfileTableOptions {
alignment: ProfileAlignment;
@@ -33,8 +37,10 @@ export interface ProfileTableOptions {
rowCount: number;
/** 종단면도와 공유하는 chainage → x(px) 매핑. */
x: (chainageM: number) => number;
/** 규칙 격자 밖 비정규 측점(구조물). 격자를 건드리지 않고 주석으로 얹는다. */
/** 규칙 격자 밖 비정규 측점(구조물). 선택된 측점만 값 열로 오버레이한다. */
irregularStations?: IrregularStation[];
/** 현재 선택된 측점 id. 비정규 측점이면 그 측점의 값 열을 테이블에 겹쳐 보여준다. */
selectedStationId?: string | null;
onCurveRadiusChange: (curve: AlignmentCurve, radiusM: number | null) => void;
}
@@ -238,9 +244,13 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
const lengthRow = createRow(
"b05-profile-table__row--curve is-group-start",
"곡선 L",
"m 종단곡선 길이 (R × |대수차|)",
"m 종단곡선 길이 (입력) — L·R은 서로 연동",
);
const radiusRow = createRow(
"b05-profile-table__row--curve",
"곡선 R",
"m 종단곡선 반경 (입력) — L·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]),
);
@@ -250,21 +260,22 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
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);
// 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");
@@ -274,42 +285,98 @@ function buildCurveRows(options: ProfileTableOptions, centers: number[]): HTMLEl
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];
}
/**
* 비정규 측점 주석층: 규칙 격자 위에 파선 세로선 + (구조물)라벨을 얹는다.
* 라벨은 이웃과 가까우면 상·하 2슬롯으로 번갈아 내려 겹침을 피한다(2행 스태거).
*/
function buildIrregularAnnotations(
stations: IrregularStation[],
x: (chainageM: number) => number,
cellWidth: number,
): HTMLElement[] {
const sorted = [...stations].sort((a, b) => a.chainage_m - b.chainage_m);
const nodes: HTMLElement[] = [];
let previousX = -Infinity;
let row = 0;
sorted.forEach((station) => {
const centerX = x(station.chainage_m);
const line = element("div", "b05-profile-table__irregular-line");
line.style.left = `${centerX}px`;
nodes.push(line);
/** 정렬된 계획선 샘플에서 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];
}
row = centerX - previousX < cellWidth ? (row + 1) % 2 : 0;
previousX = centerX;
const tag = element("div", `b05-profile-table__irregular-tag is-row-${row}`);
tag.style.left = `${centerX}px`;
tag.append(
element("span", "b05-profile-table__irregular-name", irregularLabel(station)),
element("span", "b05-profile-table__irregular-desc", station.structure || "구조물"),
/**
* 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고·
* 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에
* 있으므로 여기엔 넣지 않고, 세로 점선도 두지 않는다.
*/
function buildIrregularColumn(
station: IrregularStation,
alignment: ProfileAlignment,
centerX: number,
cellWidth: number,
interval: number,
): 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;
// 테이블 행 순서(구배 3 · 측점값 7 · 곡선 2)와 정확히 같게 채운다. 없는 값은 공백.
const rows: Array<[string, string]> = [
["", ""], // 구배 L
["", ""], // 구배 H
["", "grade"], // 구배 S
[cut !== null ? cut.toFixed(2) : "", "cut"], // 절토고
[fill !== null ? fill.toFixed(2) : "", "fill"], // 성토고
[plan !== null ? plan.toFixed(2) : "", "plan"], // 계획고
[ground !== null ? ground.toFixed(2) : "", ""], // 지반고
[station.chainage_m.toFixed(2), ""], // 누가거리
["", ""], // 거리
[stationLabel(station.chainage_m, interval), ""], // 측점
["", ""], // 곡선 L
["", ""], // 곡선 R
];
const column = element("div", "b05-profile-table__irregular-col");
column.style.left = `${centerX}px`;
column.style.width = `${cellWidth}px`;
rows.forEach(([text, modifier]) => {
const cell = element(
"div",
`b05-profile-table__irregular-col-cell${modifier ? ` is-${modifier}` : ""}`,
text,
);
tag.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m${
station.structure ? `\n구조물: ${station.structure}` : ""
}`;
nodes.push(tag);
column.append(cell);
});
return nodes;
column.title = `비정규 측점 ${irregularLabel(station)} · ${station.chainage_m.toFixed(2)}m`;
return column;
}
export function createProfileTable(options: ProfileTableOptions): HTMLElement {
@@ -343,8 +410,20 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement {
table.append(row);
});
table.append(...buildCurveRows(options, centers));
if (options.irregularStations?.length) {
table.append(...buildIrregularAnnotations(options.irregularStations, x, cellWidth));
// 선택된 비정규 측점만 값 열로 오버레이한다(세로 점선·구조물 태그 없음).
const selectedIrregular = options.irregularStations?.find(
(entry) => irregularStationId(entry.id) === options.selectedStationId,
);
if (selectedIrregular) {
table.append(
buildIrregularColumn(
selectedIrregular,
alignment,
x(selectedIrregular.chainage_m),
cellWidth,
stationInterval,
),
);
}
return table;
}
+54 -37
View File
@@ -186,6 +186,13 @@
margin: 0;
color: var(--color-text);
font-size: var(--text-body-sm);
cursor: pointer;
user-select: none;
}
/* 제목 클릭으로 접힌 컨테이너: 본문만 감추고 제목은 남긴다. */
.b05-route__panel-section.is-collapsed .b05-route__panel-body {
display: none;
}
.b05-route__panel-body,
@@ -580,55 +587,65 @@
text-align: center;
}
/* ─── 비정규 측점 주석 (규칙 격자 위에 얹는 파선 세로선 + 라벨) ──────────── */
.b05-profile-table__irregular-line {
/* 숫자 입력의 상·하 토글(스피너) 제거 — 항상 사용자가 값 직접 입력. */
.b05-profile-table__no-spin::-webkit-outer-spin-button,
.b05-profile-table__no-spin::-webkit-inner-spin-button {
margin: 0;
-webkit-appearance: none;
appearance: none;
}
.b05-profile-table__no-spin {
-moz-appearance: textfield;
appearance: textfield;
}
/* ─── 선택된 비정규 측점 값 열 오버레이 (규칙 열과 같은 12행) ──────────────
세로 점선·구조물 태그 없이, 선택 시에만 값 열을 테이블 위에 겹쳐 보여준다. */
.b05-profile-table__irregular-col {
position: absolute;
z-index: 6;
top: 0;
bottom: 0;
width: 0;
border-left: 1px dashed var(--color-royal-amethyst, rgb(139 92 246));
display: flex;
flex-direction: column;
box-sizing: border-box;
border-inline: 1px solid var(--color-royal-amethyst, rgb(109 40 217));
background: color-mix(
in srgb,
var(--color-royal-amethyst, rgb(109 40 217)) 14%,
var(--color-surface)
);
transform: translateX(-50%);
pointer-events: none;
}
.b05-profile-table__irregular-tag {
position: absolute;
z-index: 7;
.b05-profile-table__irregular-col-cell {
display: flex;
flex-direction: column;
flex: 1 1 0;
align-items: center;
max-width: 84px;
padding: 1px 4px;
border: 1px solid
color-mix(in srgb, var(--color-royal-amethyst, rgb(109 40 217)) 55%, transparent);
border-radius: 3px;
background: var(--color-surface-raised);
line-height: 1.15;
transform: translateX(-50%);
}
/* 2행 스태거: 가까운 라벨은 아래 슬롯으로 내려 겹침을 피한다. */
.b05-profile-table__irregular-tag.is-row-0 {
top: 2px;
}
.b05-profile-table__irregular-tag.is-row-1 {
top: calc(2px + 2.4em);
}
.b05-profile-table__irregular-name {
color: var(--color-royal-amethyst, rgb(109 40 217));
font-weight: var(--font-weight-medium);
justify-content: center;
min-height: 0;
border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent);
color: var(--color-text);
font-variant-numeric: tabular-nums;
}
.b05-profile-table__irregular-desc {
max-width: 100%;
overflow: hidden;
color: var(--color-text-body);
font-size: 0.85em;
text-overflow: ellipsis;
white-space: nowrap;
.b05-profile-table__irregular-col-cell:last-child {
border-bottom: 0;
}
.b05-profile-table__irregular-col-cell.is-cut {
color: rgb(220 38 38);
}
.b05-profile-table__irregular-col-cell.is-fill {
color: rgb(37 99 235);
}
.b05-profile-table__irregular-col-cell.is-plan {
color: var(--color-royal-amethyst, rgb(109 40 217));
font-weight: var(--font-weight-medium);
}
/* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */