diff --git a/B05_Profile/B05_Profile_UI_Profile_Table.ts b/B05_Profile/B05_Profile_UI_Profile_Table.ts index 347b6678..85edbee0 100644 --- a/B05_Profile/B05_Profile_UI_Profile_Table.ts +++ b/B05_Profile/B05_Profile_UI_Profile_Table.ts @@ -75,6 +75,33 @@ const CELL_PADDING_PX = 2; export const TABLE_TARGET_FONT_PX = 12; /** 곡선 셀이 이웃과 겹칠 때 세로(회전) 표기로 줄이는 셀 폭(px). */ const CURVE_ROTATED_WIDTH_PX = 16; +/** + * 같은 측점으로 볼 누가거리 차이(m). + * + * 구조물 목록의 누가거리(`85.59`)와 계획선 측점의 누가거리(`85.595`)는 소수 셋째 자리에서 + * 어긋난다 — 정본을 만든 경로가 달라 최대 0.05m 벌어진다(`_Profile_Edit` 의 같은 상수 참조). + * 문자열 키로 맞추면 구조물 열을 못 알아봐 값이 그대로 펼쳐졌다(2026-09-04 실측). + */ +const SAME_STATION_TOLERANCE_M = 0.1; + +/** + * 대수차가 이 값보다 작으면 **직선**으로 본다(%). + * + * 직선화하면 그 자리의 좌·우 기울기가 같아져 대수차가 0 이 되고, `L = R × |대수차|` 를 + * 뒤집은 R 은 수억 m 로 튄다. 직선 구간에는 반경이 없으므로 표에는 빈 칸으로 둔다 + * (2026-09-04 사용자 지시). 진짜 완만한 변화점(대수차 0.9% 수준)은 그대로 남는다. + */ +const STRAIGHT_DELTA_PCT = 1e-4; + +/** 직선화로 대수차가 사라진 자리인가 — 표기 대상에서 뺀다. */ +function isStraightCurve(curve: AlignmentCurve): boolean { + return Math.abs(curve.delta_pct) < STRAIGHT_DELTA_PCT; +} + +/** 구조물(비정규) 측점 누가거리 목록에 이 측점이 들어 있는가 — 허용 오차로 본다. */ +function isNearChainage(chainageM: number, list: ReadonlyArray): boolean { + return list.some((entry) => Math.abs(entry - chainageM) < SAME_STATION_TOLERANCE_M); +} /** 주어진 글자 크기로 가장 긴 값을 자르지 않고 담는 데 필요한 셀 폭. */ export function tableCellWidthFor(fontPx: number): number { @@ -301,10 +328,10 @@ function curveTitle(curve: AlignmentCurve): string { function buildCurveRows( options: ProfileTableOptions, centers: number[], - /** 구조물 측점 chainage 키(소수 3자리) — 이 열의 곡선 입력은 기본 표기하지 않는다. */ - structureStationKeys: ReadonlySet, - /** 배관 구조물 chainage 키 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ - pipeStationKeys: ReadonlySet, + /** 구조물 측점 누가거리 — 이 열의 곡선 입력은 기본 표기하지 않는다. */ + structureChainageList: ReadonlyArray, + /** 배관 구조물 누가거리 — R이 필수라 곡선 L·R은 **항상** 표기한다(2026-08-04 지시). */ + pipeChainageList: ReadonlyArray, /** 기본 글자 크기(px)와 행 높이(px) — 회전 셀의 글자 축소 산식에 쓴다. */ fontPx: number, rowHeightPx: number, @@ -330,10 +357,15 @@ function buildCurveRows( // 그대로 서고, 글자 크기는 행 높이에 맞춰 줄인다(구배 행 is-rotated와 같은 방식). const curveEntries = alignment.stations.map((station, index) => { const key = station.chainage_m.toFixed(3); - const isPipe = pipeStationKeys.has(key); + const isPipe = isNearChainage(station.chainage_m, pipeChainageList); // 구조물 측점 열은 곡선 입력도 기본 표기하지 않는다(빈 칸으로 격자만 유지). // 예외: 배관 측점은 R이 필수 입력이라 곡선 L·R을 항상 보인다. - const curve = structureStationKeys.has(key) && !isPipe ? undefined : curveByChainage.get(key); + const found = + isNearChainage(station.chainage_m, structureChainageList) && !isPipe + ? undefined + : curveByChainage.get(key); + // 직선화된 자리는 반경이 없다 — 빈 칸으로 둔다. + const curve = found && !isStraightCurve(found) ? found : undefined; return { center: centers[index], curve, rotated: false }; }); const withCurve = curveEntries.filter((entry) => entry.curve); @@ -471,7 +503,9 @@ function buildSelectedColumn( 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 curveAt = alignment.curves.find((entry) => Math.abs(entry.chainage_m - chainage) < 0.01); + // 값 열 오버레이도 같은 규칙 — 직선화된 자리는 곡선 L·R 을 비운다. + const curve = curveAt && !isStraightCurve(curveAt) ? curveAt : undefined; // 거리: 바로 앞 측점(계획선 측점)까지의 간격. const previous = alignment.stations .filter((row) => row.chainage_m < chainage - 1e-6) @@ -567,15 +601,11 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 확정 시 종단 정본에 병합된 구조물 측점은 alignment.stations에 규칙 측점처럼 끼어 있다. // 그 열의 기본값은 표기하지 않는다(2026-08-04 사용자 지시) — 사이드바 구조물 목록의 // chainage와 일치하는 측점이 대상이고, 값은 선택 시 값 열 오버레이(하이라이트)가 보여준다. - const structureStationKeys = new Set( - (options.irregularStations ?? []).map((entry) => entry.chainage_m.toFixed(3)), - ); + const structureChainageList = (options.irregularStations ?? []).map((entry) => entry.chainage_m); // 배관 구조물은 R이 필수라 곡선 행만은 기본 표기 예외다(2026-08-04 사용자 지시). - const pipeStationKeys = new Set( - (options.irregularStations ?? []) - .filter((entry) => isPipeStation(entry)) - .map((entry) => entry.chainage_m.toFixed(3)), - ); + const pipeChainageList = (options.irregularStations ?? []) + .filter((entry) => isPipeStation(entry)) + .map((entry) => entry.chainage_m); const selectedIrregularEntry = options.irregularStations?.find( (entry) => irregularStationId(entry.id) === options.selectedStationId, ); @@ -596,7 +626,7 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { // 값이 없는 측점(절토고/성토고 중 한쪽)도 빈 칸을 만들어야 세로 구분선이 끊기지 않는다. alignment.stations.forEach((station, stationIndex) => { // 구조물 측점 열은 기본값을 비운다 — 격자(빈 셀)만 남기고 값은 선택 오버레이가 맡는다. - const structural = structureStationKeys.has(station.chainage_m.toFixed(3)); + const structural = isNearChainage(station.chainage_m, structureChainageList); const cell = element( "span", `b05-profile-table__cell${structural ? " is-structure" : ""}`, @@ -607,7 +637,14 @@ export function createProfileTable(options: ProfileTableOptions): HTMLElement { table.append(row); }); table.append( - ...buildCurveRows(options, centers, structureStationKeys, pipeStationKeys, fontSize, rowHeight), + ...buildCurveRows( + options, + centers, + structureChainageList, + pipeChainageList, + fontSize, + rowHeight, + ), ); // 선택된 측점(규칙·비정규 공용)을 **값 열 오버레이**로 강조한다.