diff --git a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts index d93ae65e..139251a4 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_IrregularStations.ts @@ -184,9 +184,19 @@ export function createIrregularStationsSection( const structure = structureField.value.trim(); const chainage_m = chainageOf(station, safeRemainder); if (editingId) { - const target = stations.find((entry) => entry.id === editingId); - if (target) - Object.assign(target, { station, remainder: safeRemainder, chainage_m, structure }); + // 그 자리에서 변경(Object.assign)하면 Page가 보관한 이전 목록의 객체도 함께 바뀌어 + // "이동 전 chainage"를 잃는다 → 옛 위치의 계획고·곡선 편집을 정리하지 못한다. + // 새 객체로 교체해, Page가 이전 위치를 감지하고 그 편집을 지우게 한다. + const index = stations.findIndex((entry) => entry.id === editingId); + if (index >= 0) { + stations[index] = { + id: editingId, + station, + remainder: safeRemainder, + chainage_m, + structure, + }; + } } else { stations.push({ id: String(nextId++), diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 26f30f4b..da8ed4d6 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -169,10 +169,15 @@ export async function renderB05Route(root: HTMLElement): Promise { const activeProjectId: string = projectId; const viewer = createRouteViewer(); - const profilePanel = createRouteProfilePanel(activeProjectId, (stationId) => { - viewer.markers.selectStation(stationId); - syncIrregularSelection(stationId); - }); + const profilePanel = createRouteProfilePanel( + activeProjectId, + (stationId) => { + viewer.markers.selectStation(stationId); + syncIrregularSelection(stationId); + }, + // [초기선 복원] 시 추가한 비정규 측점도 함께 지운다. + () => panel.irregularStations.clear(), + ); /** * 그래프·3D에서 비정규 측점을 고르면 사이드바 입력 폼에 로드해 수정/삭제할 수 있게 한다. 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 1c4b1847..a13a130c 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -252,6 +252,8 @@ function computeProfileLayout( export function createRouteProfilePanel( projectId: string, onSelectStation: (stationId: string) => void, + /** [초기선 복원] 클릭 시 함께 실행(비정규 측점 등 다른 조작값도 초기화하려고 Page가 넘긴다). */ + onResetAll?: () => void, ) { const root = document.createElement("section"); root.className = "b05-route-profile"; @@ -323,13 +325,16 @@ export function createRouteProfilePanel( .join("\n"); balanceBar.append(warning); } - if (store.edited()) { + if (store.edited() || irregularStations.length) { const reset = document.createElement("button"); reset.type = "button"; reset.className = "b05-route-profile__balance-reset"; reset.textContent = "초기선 복원"; - reset.title = "모든 편집을 지우고 자동 산출된 계획선으로 되돌립니다."; - reset.addEventListener("click", () => store.resetAll()); + reset.title = "모든 편집과 추가한 비정규 측점을 지우고 자동 산출된 계획선으로 되돌립니다."; + reset.addEventListener("click", () => { + store.resetAll(); + onResetAll?.(); + }); balanceBar.append(reset); } if (store.dirty()) { 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 d70207be..5983543d 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -197,6 +197,7 @@ 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); @@ -205,9 +206,16 @@ function buildSegmentRows( const span = x(segment.to_m) - left; const text = spec.cell(segment); const node = element("span", "b05-profile-table__segment", ""); - // 구간이 짧아 글자가 안 들어가면 숫자만 비운다. 블록을 지우면 그 자리가 다시 - // 틈으로 남아 빈 셀처럼 보이므로, 칸은 유지하고 값은 툴팁으로 읽게 한다. - if (segmentTextFits(text, span, fontPx)) node.textContent = text; + // 값은 항상 표기한다. 가로로 안 들어가면 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 = @@ -335,6 +343,9 @@ function interpolateSample( return last[key]; } +/** 값 열 한 칸의 내용: 단일 값이거나, 변화점에서 좌/우로 갈린 두 값. */ +type CellValue = { text: string } | { left: string; right: string }; + /** * 선택된 비정규 측점의 **값 열 오버레이**. 규칙 측점 열과 같은 12행 순서로 값(계획고·지반고· * 누가거리·측점 등, 계획선 샘플 보간)을 쌓아 테이블 위에 겹친다. 구조물 이름은 사이드바 입력에 @@ -352,21 +363,38 @@ function buildIrregularColumn( 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<{ text: string; modifier: string; plan?: boolean }> = [ - { text: "", modifier: "" }, // 구배 L - { text: "", modifier: "" }, // 구배 H - { text: "", modifier: "grade" }, // 구배 S - { text: cut !== null ? cut.toFixed(2) : "", modifier: "cut" }, // 절토고 - { text: fill !== null ? fill.toFixed(2) : "", modifier: "fill" }, // 성토고 - { text: plan !== null ? plan.toFixed(2) : "", modifier: "plan", plan: true }, // 계획고 - { text: ground !== null ? ground.toFixed(2) : "", modifier: "" }, // 지반고 - { text: station.chainage_m.toFixed(2), modifier: "" }, // 누가거리 - { text: "", modifier: "" }, // 거리 - { text: stationLabel(station.chainage_m, interval), modifier: "" }, // 측점 - { text: "", modifier: "" }, // 곡선 L - { text: "", modifier: "" }, // 곡선 R + 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`; @@ -389,8 +417,15 @@ function buildIrregularColumn( 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.text; + cell.textContent = row.value.text; } column.append(cell); }); @@ -401,7 +436,8 @@ function buildIrregularColumn( export function createProfileTable(options: ProfileTableOptions): HTMLElement { const { alignment, stationInterval, width, height, cellWidth, labelWidth, rowCount, x } = options; const table = element("div", "b05-profile-table"); - const fontSize = fitFontSize(height / Math.max(rowCount, 1), cellWidth); + const rowHeight = height / Math.max(rowCount, 1); + const fontSize = fitFontSize(rowHeight, cellWidth); // 종점이 앞 측점과 겹치면 오른쪽으로 민 셀 중심(측점 값·곡선 셀 공용). 구배 블록은 구간을 // 덮는 넓은 범위라 겹치지 않으므로 x를 그대로 쓴다. const centers = stationCellCenters(alignment.stations, x, cellWidth, width); @@ -414,7 +450,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { 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)); + 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" : ""}`, diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Style.css b/B05_wf2_Route/B05_wf2_Route_UI_Style.css index 41361acd..f309da8b 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Style.css +++ b/B05_wf2_Route/B05_wf2_Route_UI_Style.css @@ -607,6 +607,16 @@ color: rgb(180 83 9); } +/* 구배 블록 값. 가로로 안 들어가면 90도 회전해 좁은 블록에도 값을 표기한다. */ +.b05-profile-table__segment-value { + line-height: 1; + white-space: nowrap; +} + +.b05-profile-table__segment-value.is-rotated { + transform: rotate(-90deg); +} + .b05-profile-table__curve { z-index: 2; } @@ -674,9 +684,31 @@ align-items: center; justify-content: center; min-height: 0; + overflow: hidden; border-bottom: 1px solid color-mix(in srgb, var(--color-border) 70%, transparent); color: var(--color-text); font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +/* 변화점에서 좌/우로 갈린 값: 세로 구분선으로 반씩 나누고 각 반쪽은 폰트를 줄여 셀에 맞춘다. */ +.b05-profile-table__irregular-col-cell.is-split { + gap: 0; +} + +.b05-profile-table__irregular-col-half { + display: flex; + flex: 1 1 0; + align-items: center; + justify-content: center; + min-width: 0; + height: 100%; + overflow: hidden; + font-size: 0.72em; +} + +.b05-profile-table__irregular-col-half:first-child { + border-right: 1px solid color-mix(in srgb, var(--color-border) 85%, transparent); } .b05-profile-table__irregular-col-cell:last-child {