/* ============================================================================= * B05_Profile_UI_Profile_Alignment.ts * 종단 계획선 선형(직선 + 측점 위 종단곡선) 프론트엔드 기하 계산. * * 백엔드 `B05_Profile_Engine_Grade_Alignment.py`와 **같은 식**을 쓴다. 사용자가 * 0.1m 버튼을 누를 때마다 서버를 오가지 않고 즉시 다시 그리기 위한 계산부이며, * 확정 시점에는 편집 델타만 서버로 보내 백엔드가 정본을 다시 만든다. * * 규칙: * - 변화점(PVI)은 기준 측점 위에만 놓인다 → 곡선 중심이 측점 수직선상에 있다. * - 종단곡선은 변화점 대칭이며 좌우에 직선이 반드시 남는다. * - 반경 R이 1차 값이고 곡선길이 L = R × |대수차| 로 따라온다 (편집해도 R은 유지). * - 편집 델타는 항상 **자동 선형(base_pvi) 기준**이라 키를 지우면 정확히 원복된다. * ========================================================================== */ export interface AlignmentPolicy { station_interval_m: number; curve_radius_ratio: number; curve_tangent_max_ratio: number; curve_skip_legal_exception: boolean; default_curve_radius_m: number; balance_tolerance_percent: number; edit_step_m: number; grade_violation_policy: string; max_grade_pct: number; curve_skip_delta_pct: number; paved: boolean; } export interface AlignmentNode { chainage_m: number; elevation_m: number; } export interface AlignmentPvi extends AlignmentNode { source: "auto" | "user"; kind: string; grade_in_pct: number | null; grade_out_pct: number | null; curve_l_m: number | null; curve_r_m: number | null; } export interface AlignmentSegment { index: number; from_m: number; to_m: number; length_m: number; height_m: number; grade_percent: number; } export interface AlignmentCurve { pvi_index: number; chainage_m: number; bvc_m: number; evc_m: number; bvc_elevation_m: number; evc_elevation_m: number; l_m: number; r_m: number; k: number; delta_pct: number; middle_ordinate_m: number; omitted: boolean; skip_allowed: boolean; omit_reason: string | null; } export interface AlignmentStationRow { station_id: string | null; chainage_m: number; distance_m: number; ground_elevation_m: number; plan_elevation_m: number; cut_m: number; fill_m: number; } export interface AlignmentSample { chainage_m: number; elevation_m: number; ground_elevation_m: number; difference_m: number; } export interface AlignmentBalance { cut_area_m2: number; fill_area_m2: number; net_area_m2: number; imbalance_percent: number; tolerance_percent: number; within_tolerance: boolean; } export interface AlignmentViolation { segment_index: number; type: string; value: number; limit: number; } export interface AlignmentEdits { station_offsets: Record; /** 변화점별 종단곡선 반경(m). 지정하지 않으면 정책 기본 반경을 쓴다. */ curve_radii: Record; } export interface ProfileAlignment { schema_version: number; policy: AlignmentPolicy; base_pvi: AlignmentNode[]; edits: AlignmentEdits; pvi: AlignmentPvi[]; segments: AlignmentSegment[]; curves: AlignmentCurve[]; stations: AlignmentStationRow[]; samples: AlignmentSample[]; balance: AlignmentBalance; violations: AlignmentViolation[]; warnings: string[]; } /** 자동 선형과 지반 종단 — 편집을 얹기 위한 고정 입력. */ export interface AlignmentBase { policy: AlignmentPolicy; basePvi: AlignmentNode[]; chainage: number[]; ground: number[]; stations: Array<{ station_id: string | null; chainage_m: number }>; } /** chainage를 편집 델타 dict의 키로 바꾼다 (백엔드 `chainage_key`와 동일 규칙). */ export function chainageKey(value: number): string { return value.toFixed(3); } export function emptyEdits(): AlignmentEdits { return { station_offsets: {}, curve_radii: {} }; } /** 오름차순 x 배열 위에서의 선형 보간 (범위 밖은 양 끝값으로 클램프). */ function interpolate(xs: number[], ys: number[], value: number): number { if (!xs.length) return 0; if (value <= xs[0]) return ys[0]; if (value >= xs[xs.length - 1]) return ys[ys.length - 1]; let low = 0; let high = xs.length - 1; while (high - low > 1) { const mid = (low + high) >> 1; if (xs[mid] <= value) low = mid; else high = mid; } const span = xs[high] - xs[low]; if (span <= 0) return ys[high]; return ys[low] + ((ys[high] - ys[low]) * (value - xs[low])) / span; } export function toAlignmentBase(alignment: ProfileAlignment): AlignmentBase { return { policy: alignment.policy, basePvi: alignment.base_pvi.map((node) => ({ ...node })), chainage: alignment.samples.map((sample) => sample.chainage_m), ground: alignment.samples.map((sample) => sample.ground_elevation_m), stations: alignment.stations.map((station) => ({ station_id: station.station_id, chainage_m: station.chainage_m, })), }; } /** 자동 변화점에 사용자 편집 측점을 합쳐 최종 변화점 집합을 만든다. */ function resolvePvi( base: AlignmentBase, offsets: Record, ): Array { const baseS = base.basePvi.map((node) => node.chainage_m); const baseZ = base.basePvi.map((node) => node.elevation_m); const nodes = new Map(); base.basePvi.forEach((node) => { nodes.set(Number(node.chainage_m.toFixed(3)), { ...node, source: "auto" }); }); Object.entries(offsets).forEach(([key, offset]) => { const chainage = Number(Number(key).toFixed(3)); if (!Number.isFinite(chainage) || !Number.isFinite(offset)) return; if (chainage < baseS[0] - 1e-6 || chainage > baseS[baseS.length - 1] + 1e-6) return; nodes.set(chainage, { chainage_m: chainage, elevation_m: interpolate(baseS, baseZ, chainage) + offset, source: "user", }); }); return [...nodes.values()].sort((a, b) => a.chainage_m - b.chainage_m); } interface WorkingCurve extends AlignmentCurve { grade_in: number; grade_out: number; } /** 각 변화점에 대칭 종단곡선을 삽입한다 (좌우 직선이 남도록 반쪽 길이를 제한). */ function buildCurves( pvi: AlignmentNode[], policy: AlignmentPolicy, curveRadii: Record, warnings: string[], ): WorkingCurve[] { const curves: WorkingCurve[] = []; const skipDelta = policy.curve_skip_delta_pct / 100; for (let index = 1; index < pvi.length - 1; index += 1) { const spanLeft = pvi[index].chainage_m - pvi[index - 1].chainage_m; const spanRight = pvi[index + 1].chainage_m - pvi[index].chainage_m; if (spanLeft <= 0 || spanRight <= 0) continue; const gradeIn = (pvi[index].elevation_m - pvi[index - 1].elevation_m) / spanLeft; const gradeOut = (pvi[index + 1].elevation_m - pvi[index].elevation_m) / spanRight; const delta = gradeOut - gradeIn; if (Math.abs(delta) < 1e-9) continue; const chainage = pvi[index].chainage_m; const key = chainageKey(chainage); const skipAllowed = !policy.paved && Math.abs(delta) <= skipDelta + 1e-12; const requested = curveRadii[key]; const radius = Number.isFinite(requested) && requested > 0 ? requested : policy.default_curve_radius_m; const desired = radius * Math.abs(delta); const halfLimit = Math.min(spanLeft, spanRight) * policy.curve_tangent_max_ratio; const half = Math.min(desired / 2, halfLimit); if (half <= 1e-9) continue; if (desired / 2 - half > 1e-6) { warnings.push( `${chainage.toFixed(1)}m: 인접 직선이 짧아 종단곡선 반경을 ${((half * 2) / Math.abs(delta)).toFixed(0)}m로 줄였습니다.`, ); } const length = half * 2; curves.push({ pvi_index: index, chainage_m: chainage, bvc_m: chainage - half, evc_m: chainage + half, bvc_elevation_m: 0, evc_elevation_m: 0, l_m: length, r_m: length / Math.abs(delta), k: length / (Math.abs(delta) * 100), delta_pct: delta * 100, middle_ordinate_m: (Math.abs(delta) * length) / 8, omitted: skipAllowed && policy.curve_skip_legal_exception, skip_allowed: skipAllowed, omit_reason: skipAllowed ? `비포장 대수차 ${policy.curve_skip_delta_pct.toFixed(0)}% 이하 (법정 생략 가능)` : null, grade_in: gradeIn, grade_out: gradeOut, }); } return curves; } /** 직선 + 종단곡선으로 구성된 계획선을 임의 chainage에서 평가한다. */ function evaluateAt( pviS: number[], pviZ: number[], curves: WorkingCurve[], chainage: number, ): number { for (const curve of curves) { if (curve.omitted) continue; if (chainage < curve.bvc_m || chainage > curve.evc_m) continue; const half = curve.l_m / 2; const local = chainage - curve.bvc_m; const startZ = pviZ[curve.pvi_index] - curve.grade_in * half; const delta = curve.grade_out - curve.grade_in; return startZ + curve.grade_in * local + (delta / (2 * curve.l_m)) * local * local; } return interpolate(pviS, pviZ, chainage); } /** 사다리꼴 적분 가중치(ds). */ function trapezoidWeights(chainage: number[]): number[] { const weights = new Array(chainage.length).fill(0); if (chainage.length < 2) return weights; for (let index = 1; index < chainage.length - 1; index += 1) { weights[index] = (chainage[index + 1] - chainage[index - 1]) / 2; } weights[0] = (chainage[1] - chainage[0]) / 2; weights[chainage.length - 1] = (chainage[chainage.length - 1] - chainage[chainage.length - 2]) / 2; return weights; } /** 저장분·초안이 부분적으로 비어 있어도 계산이 터지지 않도록 편집 맵을 채운다. */ 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); const pviZ = nodes.map((node) => node.elevation_m); const curves = buildCurves(nodes, base.policy, edits.curve_radii, warnings); const segments: AlignmentSegment[] = []; for (let index = 0; index < nodes.length - 1; index += 1) { const length = pviS[index + 1] - pviS[index]; const height = pviZ[index + 1] - pviZ[index]; segments.push({ index, from_m: pviS[index], to_m: pviS[index + 1], length_m: length, height_m: height, grade_percent: length > 0 ? (height / length) * 100 : 0, }); } curves.forEach((curve) => { curve.bvc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.bvc_m); curve.evc_elevation_m = evaluateAt(pviS, pviZ, curves, curve.evc_m); }); // 샘플 격자에 변화점(PVI)과 종단곡선 시·종점(BVC/EVC)을 **합쳐서** 그린다. // 격자만 쓰면 격자 사이에 놓인 변화점(배관 구조물 자리처럼 임의 chainage에 승격된 점)의 // 모서리를 선이 잘라먹어, 종단곡선 R이 안 보이고 편집 지점과 선이 어긋나 끊긴 것처럼 // 보인다(2026-08-03 사용자 보고). 곡선 구간 끝점까지 넣어야 직선→곡선 이음이 정확히 붙는다. const sampleChainages = new Set(base.chainage.map((value) => Number(value.toFixed(3)))); const first = base.chainage[0]; const last = base.chainage[base.chainage.length - 1]; for (const extra of [ ...pviS, ...curves.flatMap((curve) => [curve.bvc_m, curve.chainage_m, curve.evc_m]), ]) { if (extra >= first - 1e-6 && extra <= last + 1e-6) { sampleChainages.add(Number(extra.toFixed(3))); } } const samples: AlignmentSample[] = [...sampleChainages] .sort((a, b) => a - b) .map((chainage) => { const plan = evaluateAt(pviS, pviZ, curves, chainage); const ground = interpolate(base.chainage, base.ground, chainage); return { chainage_m: chainage, elevation_m: plan, ground_elevation_m: ground, difference_m: plan - ground, }; }); // 면적 가중치도 **합쳐진 샘플 격자**로 계산해야 한다 — base.chainage로 두면 추가된 // 변화점 샘플과 인덱스가 어긋나 절·성토 면적이 통째로 틀어진다. const weights = trapezoidWeights(samples.map((sample) => sample.chainage_m)); let cutArea = 0; let fillArea = 0; samples.forEach((sample, index) => { if (sample.difference_m < 0) cutArea += weights[index] * -sample.difference_m; else fillArea += weights[index] * sample.difference_m; }); const reference = Math.max(cutArea, fillArea); const imbalance = reference > 1e-9 ? (Math.abs(cutArea - fillArea) / reference) * 100 : 0; const stations: AlignmentStationRow[] = base.stations.map((station, index) => { const plan = evaluateAt(pviS, pviZ, curves, station.chainage_m); const ground = interpolate(base.chainage, base.ground, station.chainage_m); return { station_id: station.station_id, chainage_m: station.chainage_m, distance_m: index ? station.chainage_m - base.stations[index - 1].chainage_m : 0, ground_elevation_m: ground, plan_elevation_m: plan, cut_m: Math.max(ground - plan, 0), fill_m: Math.max(plan - ground, 0), }; }); const curveByPvi = new Map(curves.map((curve) => [curve.pvi_index, curve])); const pviRows: AlignmentPvi[] = nodes.map((node, index) => { const curve = curveByPvi.get(index); return { chainage_m: node.chainage_m, elevation_m: node.elevation_m, source: node.source, kind: index === 0 ? "bp" : index === nodes.length - 1 ? "ep" : "pvi", grade_in_pct: index ? segments[index - 1].grade_percent : null, grade_out_pct: index < segments.length ? segments[index].grade_percent : null, curve_l_m: curve ? curve.l_m : null, curve_r_m: curve ? curve.r_m : null, }; }); const violations: AlignmentViolation[] = segments .filter((segment) => Math.abs(segment.grade_percent) > base.policy.max_grade_pct + 1e-6) .map((segment) => ({ segment_index: segment.index, type: "grade_over", value: segment.grade_percent, limit: base.policy.max_grade_pct, })); const withinTolerance = imbalance <= base.policy.balance_tolerance_percent + 1e-9; return { schema_version: 1, policy: base.policy, base_pvi: base.basePvi, edits, pvi: pviRows, segments, curves: curves.map(({ grade_in: _in, grade_out: _out, ...rest }) => rest), stations, samples, balance: { cut_area_m2: cutArea, fill_area_m2: fillArea, net_area_m2: fillArea - cutArea, imbalance_percent: imbalance, tolerance_percent: base.policy.balance_tolerance_percent, within_tolerance: withinTolerance, }, violations, warnings, }; } /** * 계획선 위 지점의 계획고. * 측점 위라면 곡선까지 반영해 정확히 계산된 측점 행 값을 쓰고, 그 밖에서는 * 샘플 보간으로 폴백한다(편집 판정은 항상 측점 위에서 일어난다). */ export function planElevationAt(alignment: ProfileAlignment, chainageM: number): number { const station = alignment.stations.find((row) => Math.abs(row.chainage_m - chainageM) < 1e-6); if (station) return station.plan_elevation_m; return interpolate( alignment.samples.map((sample) => sample.chainage_m), alignment.samples.map((sample) => sample.elevation_m), chainageM, ); } const FEEDBACK_PASSES = 4; const FEEDBACK_TOLERANCE_M = 1e-4; /** * 측점 계획고를 delta만큼 올리거나 내린 편집 델타를 만든다. * * 변화점이 새로 생기면 종단곡선의 중앙종거만큼 계획고가 함께 내려가(또는 올라가) * 버튼 1클릭이 정확히 0.1m가 되지 않는다. **화면에 보이는 계획고**가 정확히 delta만큼 * 움직이도록 중앙종거 변화분을 몇 번 되먹여 보정한다. */ export function adjustStation( base: AlignmentBase, edits: AlignmentEdits, chainageM: number, delta: number, ): AlignmentEdits { const key = chainageKey(chainageM); const current = buildAlignment(base, edits); const target = planElevationAt(current, chainageM) + delta; const baseElevation = interpolate( base.basePvi.map((node) => node.chainage_m), base.basePvi.map((node) => node.elevation_m), chainageM, ); let offset = (edits.station_offsets[key] ?? planElevationAt(current, chainageM) - baseElevation) + delta; const withOffset = (value: number): AlignmentEdits => ({ ...edits, station_offsets: { ...edits.station_offsets, [key]: Number(value.toFixed(6)) }, }); let next = withOffset(offset); for (let pass = 0; pass < FEEDBACK_PASSES; pass += 1) { const error = target - planElevationAt(buildAlignment(base, next), chainageM); if (Math.abs(error) < FEEDBACK_TOLERANCE_M) break; offset += error; next = withOffset(offset); } return next; } /** * 직선 구간 전체를 평행이동한다 (기울기 유지, 양 끝 변화점 동시 이동). * * 측점 버튼은 그 점을 꺾는 조작이라 "구배는 그대로 두고 높이만" 옮길 수 없다. * 구간 양 끝에 같은 델타를 주면 그 직선의 기울기는 보존되고 인접 직선의 각도만 바뀐다. */ export function shiftSegment( base: AlignmentBase, edits: AlignmentEdits, segment: AlignmentSegment, delta: number, ): AlignmentEdits { const baseS = base.basePvi.map((node) => node.chainage_m); const baseZ = base.basePvi.map((node) => node.elevation_m); const current = buildAlignment(base, edits); const offsets = { ...edits.station_offsets }; [segment.from_m, segment.to_m].forEach((chainage) => { const key = chainageKey(chainage); const existing = offsets[key] ?? planElevationAt(current, chainage) - interpolate(baseS, baseZ, chainage); offsets[key] = Number((existing + delta).toFixed(6)); }); return { ...edits, station_offsets: offsets }; } /** * 곡선 행의 R 입력을 저장한다. R이 1차 값이므로 그대로 담고, 곡선길이는 * L = R × |대수차| 로 매 렌더마다 다시 계산된다(계획고를 편집해도 R은 유지된다). * 빈 값이면 키를 지워 정책 기본 반경으로 돌아간다. */ export function setCurveRadius( edits: AlignmentEdits, curve: AlignmentCurve, radiusM: number, ): AlignmentEdits { const key = chainageKey(curve.chainage_m); const curveRadii = { ...edits.curve_radii }; if (!Number.isFinite(radiusM) || radiusM <= 0) delete curveRadii[key]; else curveRadii[key] = Number(radiusM.toFixed(6)); return { ...edits, curve_radii: curveRadii }; } export function hasEdits(edits: AlignmentEdits): boolean { const safe = normalizeEdits(edits); return Object.keys(safe.station_offsets).length > 0 || Object.keys(safe.curve_radii).length > 0; }