diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts index 1b118c2d..b17abb83 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Page.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Page.ts @@ -193,12 +193,28 @@ export async function renderB05Route(root: HTMLElement): Promise { } async function restoreSections(routeId: number): Promise { + let detail: SectionDetailResponse; try { - renderSections(await fetchSectionDetail(activeProjectId, routeId), routeId); + detail = await fetchSectionDetail(activeProjectId, routeId); } catch { + // 종횡단 데이터 자체가 없는 경우(생성 실패·최초 진입)는 빈 안내로 둔다. currentSectionDetail = null; profilePanel.clear(); viewer.renderStationLines([], 0); + return; + } + try { + renderSections(detail, routeId); + } catch (error) { + // 렌더 실패를 "데이터 없음"으로 감추면 원인을 알 수 없게 된다. 조회는 성공했으므로 + // 빈 안내로 되돌리지 않고 실패 사실을 그대로 드러낸다. + console.error("B05 종단면도 렌더 실패", error); + showToast( + error instanceof Error + ? `종단면도 표시 실패: ${error.message}` + : "종단면도 표시에 실패했습니다.", + "error", + ); } } diff --git a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts index a8194209..cad30f50 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Alignment.ts @@ -287,7 +287,16 @@ function trapezoidWeights(chainage: number[]): number[] { return weights; } -export function buildAlignment(base: AlignmentBase, edits: AlignmentEdits): ProfileAlignment { +/** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */ +function normalizeEdits(edits: AlignmentEdits | undefined): AlignmentEdits { + return { + station_offsets: edits?.station_offsets ?? {}, + curve_radii: edits?.curve_radii ?? {}, + }; +} + +export function buildAlignment(base: AlignmentBase, input: AlignmentEdits): ProfileAlignment { + const edits = normalizeEdits(input); const warnings: string[] = []; const nodes = resolvePvi(base, edits.station_offsets); const pviS = nodes.map((node) => node.chainage_m); @@ -492,5 +501,6 @@ export function setCurveRadius( } export function hasEdits(edits: AlignmentEdits): boolean { - return Object.keys(edits.station_offsets).length > 0 || Object.keys(edits.curve_radii).length > 0; + const safe = normalizeEdits(edits); + return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0; } 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 b3ba3bd9..a9bba231 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Panel.ts @@ -37,7 +37,11 @@ import { toAlignmentBase, } from "./B05_wf2_Route_UI_Profile_Alignment"; import { createEditOverlay, createProfileEditStore } from "./B05_wf2_Route_UI_Profile_Edit"; -import { createProfileTable } from "./B05_wf2_Route_UI_Profile_Table"; +import { + createProfileTable, + tableCellWidthFor, + TABLE_TARGET_FONT_PX, +} from "./B05_wf2_Route_UI_Profile_Table"; import "../B06_wf3_ProfileCross/B06_wf3_ProfileCross_UI_Style.css"; const COLLAPSED_KEY = "b05-route-profile-collapsed"; @@ -47,12 +51,30 @@ const MIN_CHART_HEIGHT = 100; /** 테이블 12행. 행 높이와 셀 폭에서 글자 크기를 정하는 데 쓴다. */ const TABLE_ROW_COUNT = 12; +/** + * 저장된 계획선 선형을 읽되 **모양을 먼저 검증한다**. + * + * 곡선 기준이 길이(L)에서 반경(R)으로 바뀌기 전에 만들어진 데이터는 `policy`에 + * `default_curve_radius_m`가 없어 그대로 쓰면 계산 도중 터진다. 그런 데이터는 + * 편집 기능을 끄고(지반선·계획선 차트만 표시) 재계산을 안내하는 편이 안전하다. + */ function readAlignment(data: LongitudinalSection): ProfileAlignment | null { const candidate = data.profile_alignment as ProfileAlignment | undefined; if (!candidate?.base_pvi?.length || !candidate.samples?.length) return null; + if (!Number.isFinite(candidate.policy?.default_curve_radius_m)) return null; + if (!candidate.stations || !candidate.segments || !candidate.curves) return null; + candidate.edits = { + station_offsets: candidate.edits?.station_offsets ?? {}, + curve_radii: candidate.edits?.curve_radii ?? {}, + }; return candidate; } +/** 저장분이 구버전이라 편집을 붙일 수 없는 상태인가 (재계산 안내용). */ +function hasLegacyAlignment(data: LongitudinalSection): boolean { + return Boolean(data.profile_alignment) && readAlignment(data) === null; +} + /** 편집 결과를 종단면도 렌더러가 받는 계획선 형태로 감싼다. */ function toDesignProfile( alignment: ProfileAlignment, @@ -112,12 +134,27 @@ function chainageMapper(data: LongitudinalSection, width: number): (chainage: nu return (chainage: number) => LONG_PAD.left + (chainage / maxChainage) * plotWidth; } +/** 측점 사이 여백 — 이웃 셀끼리 붙어 보이지 않게 띄운다. */ +const CELL_GAP_PX = 2; + /** 이웃 측점과 겹치지 않는 테이블 셀 폭 (글자 크기를 정하는 기준이기도 하다). */ function stationCellWidth(data: LongitudinalSection, x: (chainage: number) => number): number { const stations = data.stations; if (stations.length < 2) return 72; const span = x(stations[stations.length - 1].chainage_m) - x(stations[0].chainage_m); - return Math.max(30, Math.min(span / (stations.length - 1) - 2, 96)); + return Math.max(30, Math.min(span / (stations.length - 1) - CELL_GAP_PX, 96)); +} + +/** + * 테이블 값이 목표 글자 크기로 잘리지 않으려면 캔버스가 최소 이만큼 넓어야 한다. + * + * 종단면도 렌더러의 최소 폭(`longitudinalMinimumWidth`)은 그래프 아래 **측점 라벨**만 + * 안 겹치면 되는 기준이라 열이 48px밖에 안 돼, 7자리 값(`3000.00`)을 담기엔 좁다. + * 가로 스크롤로 훑는 화면이므로 폭을 늘려 글자 크기를 확보하는 편이 낫다. + */ +function tableMinimumWidth(stationCount: number): number { + const column = tableCellWidthFor(TABLE_TARGET_FONT_PX) + CELL_GAP_PX; + return LONG_PAD.left + LONG_PAD.right + Math.max(1, stationCount) * column; } export function createRouteProfilePanel( @@ -151,7 +188,16 @@ export function createRouteProfilePanel( function renderBalance(): void { balanceBar.replaceChildren(); - if (!alignment) return; + if (!alignment) { + if (detail && hasLegacyAlignment(detail.longitudinal)) { + const note = document.createElement("span"); + note.className = "b05-route-profile__balance-warning"; + note.textContent = + "⚠ 계획선 데이터가 구버전 형식입니다 — [최적 경로 계산]을 다시 실행하세요."; + balanceBar.append(note); + } + return; + } const { balance, policy, violations } = alignment; const entries: Array<[string, string, string?]> = [ ["절토", `${balance.cut_area_m2.toFixed(1)} m²`, "cut"], @@ -241,7 +287,10 @@ export function createRouteProfilePanel( renderBalance(); const longitudinal = detail.longitudinal; - const minimumWidth = longitudinalMinimumWidth(longitudinal, stationInterval); + const minimumWidth = Math.max( + longitudinalMinimumWidth(longitudinal, stationInterval), + alignment ? tableMinimumWidth(longitudinal.stations.length) : 0, + ); const width = Math.max(Math.max(1, body.clientWidth - 30), minimumWidth); const canvas = document.createElement("div"); canvas.className = "b05-profile__canvas"; 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 5e4b1f67..86f24164 100644 --- a/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts +++ b/B05_wf2_Route/B05_wf2_Route_UI_Profile_Table.ts @@ -45,25 +45,40 @@ interface SegmentRowSpec { cell: (segment: AlignmentSegment) => string; } -/** 구간 블록이 이 폭보다 좁으면 글자가 겹쳐 읽을 수 없으므로 생략한다. */ -const MIN_SEGMENT_WIDTH = 32; const FONT_MIN_PX = 9; const FONT_MAX_PX = 16; /** 행 높이 대비 글자 크기 비율 (위아래 여백 확보). */ const FONT_PER_ROW_HEIGHT = 0.52; -/** 셀 폭 대비 글자 크기 비율 — "409.96"(6자) + `-` 여유가 잘리지 않는 값. */ -const FONT_PER_CELL_WIDTH = 0.145; +/** 한 셀에 들어가는 가장 긴 값의 글자 수 — 누가거리 `3000.00`, 측점 `150+0.0`. */ +const MAX_VALUE_CHARS = 7; +/** 숫자 한 글자의 대략적인 폭 (em 단위). 대부분의 산세리프에서 0.55~0.6em이다. */ +const CHAR_WIDTH_EM = 0.6; +const CELL_PADDING_PX = 4; +/** 가로 여유가 있을 때 목표로 삼는 글자 크기. 캔버스 최소 폭 산정의 기준이 된다. */ +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 fitted = Math.min(rowHeight * FONT_PER_ROW_HEIGHT, cellWidth * FONT_PER_CELL_WIDTH); + 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; @@ -123,6 +138,7 @@ function buildStationRows(alignment: ProfileAlignment, interval: number): Statio function buildSegmentRows( alignment: ProfileAlignment, x: (chainage: number) => number, + fontPx: number, ): HTMLElement[] { const curveByPvi = new Map( alignment.curves.filter((curve) => !curve.omitted).map((curve) => [curve.pvi_index, curve]), @@ -134,8 +150,11 @@ function buildSegmentRows( const endCurve = curveByPvi.get(segment.index + 1); const left = x(startCurve ? startCurve.evc_m : segment.from_m); const span = x(endCurve ? endCurve.bvc_m : segment.to_m) - left; - if (span < MIN_SEGMENT_WIDTH) return; - const node = element("span", "b05-profile-table__segment", spec.cell(segment)); + const text = spec.cell(segment); + // 측점을 편집해 구간이 잘게 쪼개지면 글자가 안 들어가 테두리만 남은 빈 칸이 된다. + // 그럴 땐 블록 자체를 만들지 않는다(값은 계획고·거리 행과 그래프에서 읽을 수 있다). + if (!segmentTextFits(text, span, fontPx)) return; + const node = element("span", "b05-profile-table__segment", text); node.style.left = `${left}px`; node.style.width = `${span}px`; node.title = @@ -208,7 +227,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 행 이름표는 가장 긴 "구배 L (m)"이 잘리지 않을 만큼만 차지한다. table.style.setProperty("--b05-table-label-width", `${Math.round(fontSize * 5.2 + 14)}px`); - table.append(...buildSegmentRows(alignment, x)); + table.append(...buildSegmentRows(alignment, x, fontSize)); buildStationRows(alignment, stationInterval).forEach((spec, index) => { const row = createRow( `${spec.modifier ? `is-${spec.modifier}` : ""}${index === 0 ? " is-group-start" : ""}`,