Files
Aislo/B05_Profile/B05_Profile_UI_Profile_Alignment.ts
T
eomsangdonandClaude Fable 5 54954a05e5 refactor(B05,B06): B05_wf2_Route -> B05_Profile, B06_wf3_ProfileCross -> B06_Section 동시 개명
- 한몸으로 동작하는 두 페이지라 한 커밋으로 처리 (상호 참조 다수)
- B05 37파일 + B06 20파일 접두사 개명 (git mv, 이력 보존)
- 참조 치환 91파일: import 경로, 라우트 슬러그(b05-profile/b06-section),
  라우트 키(B05_PROFILE/B06_SECTION), B03 자동 체인, storage 상수, pyproject 제외 경로
- 로직 변경 없음. typecheck·백엔드 import 검증 통과

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 10:03:11 +09:00

527 lines
19 KiB
TypeScript

/* =============================================================================
* 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<string, number>;
/** 변화점별 종단곡선 반경(m). 지정하지 않으면 정책 기본 반경을 쓴다. */
curve_radii: Record<string, number>;
}
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<string, number>,
): Array<AlignmentNode & { source: "auto" | "user" }> {
const baseS = base.basePvi.map((node) => node.chainage_m);
const baseZ = base.basePvi.map((node) => node.elevation_m);
const nodes = new Map<number, AlignmentNode & { source: "auto" | "user" }>();
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<string, number>,
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<number>(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<number>(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;
}