diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index c96bdb60..14d39743 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -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; diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 7116e2e6..cae712b8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -174,13 +174,23 @@ export async function renderB05Route(root: HTMLElement): Promise { 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 { 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 { 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; + } }, }); diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts index 8932cb18..ea3301b8 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Panel.ts @@ -241,17 +241,7 @@ export function createRoutePanel(callbacks: PanelCallbacks) { minUphillGrade.wrapper, minDownhillGrade.wrapper, ); - const help = document.createElement("details"); - help.innerHTML = - "최적경로란?

배치 포인트와 경사·곡선·회피 조건을 만족하며 비용을 최소화한 경로입니다.

"; - 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 = - "계획선이란?

공사 후 노면이 될 높이입니다. 직선과 종단곡선만으로 구성되며, " + - "절토량과 성토량이 균형을 이루도록(적분값 0) 자동 산출됩니다.

"; - 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, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts index f5ab0f7c..4887e7ec 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -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)), }) diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts index 69546770..46f5b7de 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -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; } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 080c4048..c38caabf 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -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); } /* ─── 계획고 편집 버튼 (크기 유지·상시 표시·밝은 글자) ───────────────────── */