Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015sDZSjFvoiDjdGpgj3muc3
364 lines
13 KiB
TypeScript
364 lines
13 KiB
TypeScript
/* =============================================================================
|
|
* A00_Common/design/design_profile_free.ts
|
|
* 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1).
|
|
*
|
|
* 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은
|
|
* 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽
|
|
* 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소 · Backspace = 마지막 점 빼기.
|
|
*
|
|
* 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고
|
|
* [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L
|
|
* (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다.
|
|
*
|
|
* 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다.
|
|
* ========================================================================== */
|
|
|
|
import type {
|
|
AlignmentBase,
|
|
AlignmentEdits,
|
|
ProfileAlignment,
|
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
|
import {
|
|
buildAlignment,
|
|
chainageKey,
|
|
controlElevationAt,
|
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Alignment";
|
|
import {
|
|
baseElevationAt,
|
|
defaultCurveLength,
|
|
} from "../../B05_Profile/B05_Profile_UI_Profile_Straighten";
|
|
|
|
/** 두 chainage 를 같은 측점으로 볼 허용 오차(m). */
|
|
const SAME_M = 1e-6;
|
|
/** 클릭 높이를 이 단위(m)로 끊는다. */
|
|
const ELEVATION_STEP_M = 0.01;
|
|
|
|
export interface FreeVertex {
|
|
chainage_m: number;
|
|
elevation_m: number;
|
|
}
|
|
|
|
/** range = 범위 고르는 중 · armed = 시작 세로선을 기다림 · drawing = 점 찍는 중. */
|
|
export type FreePhase = "range" | "armed" | "drawing";
|
|
|
|
/** 그래프 세로 창 — 그릴 때 기준(`data-yw-*`)과 지금 걸린 변환. */
|
|
export interface ChartWindow {
|
|
center: number;
|
|
pxPerM: number;
|
|
zero: number;
|
|
scale: number;
|
|
offset: number;
|
|
}
|
|
|
|
/** 세로 창 변환 글(`translate(0 a) scale(1 b)`)을 읽는다 — 없으면 그릴 때 그대로. */
|
|
export function parseWindowTransform(text: string | null): { scale: number; offset: number } {
|
|
const num = "([-+0-9.eE]+)";
|
|
const match = new RegExp(`translate\\(\\s*0[ ,]+${num}\\s*\\)\\s*scale\\(\\s*1[ ,]+${num}`).exec(
|
|
text ?? "",
|
|
);
|
|
if (!match) return { scale: 1, offset: 0 };
|
|
const offset = Number(match[1]);
|
|
const scale = Number(match[2]);
|
|
return Number.isFinite(offset) && Number.isFinite(scale) && scale > 0
|
|
? { scale, offset }
|
|
: { scale: 1, offset: 0 };
|
|
}
|
|
|
|
/** 그래프 y(px) → 표고(m). 과장 배율은 `pxPerM` 에 들어 있다. */
|
|
export function elevationAtChartY(view: ChartWindow, y: number): number {
|
|
const baked = (y - view.offset) / view.scale;
|
|
return view.center + (view.zero - baked) / view.pxPerM;
|
|
}
|
|
|
|
/** 표고(m) → 그래프 y(px). `elevationAtChartY` 의 역. */
|
|
export function chartYAtElevation(view: ChartWindow, elevationM: number): number {
|
|
return view.scale * (view.zero - (elevationM - view.center) * view.pxPerM) + view.offset;
|
|
}
|
|
|
|
/** 가장 가까운 측점 세로선. 범위를 주면 그 안(양 끝 포함)에서만 고른다. */
|
|
export function snapStation(
|
|
stations: number[],
|
|
chainageM: number,
|
|
range: [number, number] | null = null,
|
|
): number | null {
|
|
let best: number | null = null;
|
|
for (const station of stations) {
|
|
if (range && (station < range[0] - SAME_M || station > range[1] + SAME_M)) continue;
|
|
if (best === null || Math.abs(station - chainageM) < Math.abs(best - chainageM)) {
|
|
best = station;
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
/** 두 점 사이 기울기(%). */
|
|
export function gradePct(from: FreeVertex, to: FreeVertex): number {
|
|
const span = to.chainage_m - from.chainage_m;
|
|
return Math.abs(span) > 1e-12 ? ((to.elevation_m - from.elevation_m) / span) * 100 : 0;
|
|
}
|
|
|
|
/** 임도 기준 최대 기울기를 넘는가 — 판정은 선형 계산의 위반 판정(`violations`)과 같은 식. */
|
|
export function overGrade(pct: number, maxPct: number): boolean {
|
|
return Math.abs(pct) > maxPct + 1e-6;
|
|
}
|
|
|
|
/** 점들을 잇는 꺾은선 위 표고(chainage 오름차순 꺾은선). */
|
|
function elevationOnPath(path: FreeVertex[], chainageM: number): number {
|
|
for (let index = 1; index < path.length; index += 1) {
|
|
const left = path[index - 1];
|
|
const right = path[index];
|
|
if (chainageM > right.chainage_m + SAME_M) continue;
|
|
const span = right.chainage_m - left.chainage_m;
|
|
const ratio = span > 1e-12 ? (chainageM - left.chainage_m) / span : 0;
|
|
return left.elevation_m + (right.elevation_m - left.elevation_m) * ratio;
|
|
}
|
|
return path[path.length - 1].elevation_m;
|
|
}
|
|
|
|
/**
|
|
* 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를
|
|
* 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음).
|
|
*/
|
|
export function buildFreeEdits(
|
|
base: AlignmentBase,
|
|
edits: AlignmentEdits,
|
|
vertices: FreeVertex[],
|
|
): AlignmentEdits {
|
|
const path = [...vertices].sort((a, b) => a.chainage_m - b.chainage_m);
|
|
if (path.length < 2) return edits;
|
|
const fromM = path[0].chainage_m;
|
|
const toM = path[path.length - 1].chainage_m;
|
|
if (toM - fromM <= SAME_M) return edits;
|
|
const inner = new Map<string, number>();
|
|
buildAlignment(base, edits).pvi.forEach((node) => {
|
|
if (node.chainage_m > fromM + SAME_M && node.chainage_m < toM - SAME_M) {
|
|
inner.set(chainageKey(node.chainage_m), node.chainage_m);
|
|
}
|
|
});
|
|
path
|
|
.slice(1, -1)
|
|
.forEach((vertex) => inner.set(chainageKey(vertex.chainage_m), vertex.chainage_m));
|
|
const offsets = { ...edits.station_offsets };
|
|
const radii = { ...edits.curve_radii };
|
|
inner.forEach((chainage, key) => {
|
|
const target = elevationOnPath(path, chainage);
|
|
offsets[key] = Number((target - baseElevationAt(base, chainage)).toFixed(6));
|
|
delete radii[key];
|
|
});
|
|
for (let index = 1; index < path.length - 1; index += 1) {
|
|
const gradeIn = gradePct(path[index - 1], path[index]) / 100;
|
|
const gradeOut = gradePct(path[index], path[index + 1]) / 100;
|
|
const deltaGrade = Math.abs(gradeOut - gradeIn);
|
|
if (deltaGrade < 1e-9) continue;
|
|
radii[chainageKey(path[index].chainage_m)] = Number(
|
|
(defaultCurveLength(base, deltaGrade) / deltaGrade).toFixed(6),
|
|
);
|
|
}
|
|
return { ...edits, station_offsets: offsets, curve_radii: radii };
|
|
}
|
|
|
|
export interface FreeSegment {
|
|
from: FreeVertex;
|
|
to: FreeVertex;
|
|
grade_pct: number;
|
|
over: boolean;
|
|
/** 마지막 점 → 마우스 자리(아직 안 찍음). */
|
|
preview: boolean;
|
|
}
|
|
|
|
/** 오버레이 표시 자료 — 화면은 이것만 그린다. */
|
|
export interface FreeMarks {
|
|
/** 고른 범위(강조 띠) — 둘 다 고르기 전엔 null. */
|
|
band: [number, number] | null;
|
|
/** 세로 강조선 chainage(범위 첫 측점 또는 범위 양 끝). */
|
|
edges: number[];
|
|
segments: FreeSegment[];
|
|
/** 찍힌 점(시작 점 포함). */
|
|
dots: FreeVertex[];
|
|
}
|
|
|
|
export interface FreeDrawDeps {
|
|
base: () => AlignmentBase | null;
|
|
alignment: () => ProfileAlignment | null;
|
|
edits: () => AlignmentEdits;
|
|
/** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). */
|
|
apply: (next: AlignmentEdits) => void;
|
|
/** 모드를 끈다(적용 뒤 · Esc). */
|
|
close: () => void;
|
|
/** 단계 · 안내가 바뀜. */
|
|
changed: () => void;
|
|
}
|
|
|
|
export interface FreeDraw {
|
|
phase: () => FreePhase;
|
|
reset: () => void;
|
|
/** 그래프 클릭 — 날 chainage · 표고와 그래프 측점선 목록. 먹었으면 true. */
|
|
click: (chainageM: number, elevationM: number, stations: number[]) => boolean;
|
|
/** 마우스 자리 — 미리 보기 대상. 그래프 밖이면 null. */
|
|
hover: (pointer: { chainageM: number; elevationM: number; stations: number[] } | null) => void;
|
|
/** Enter · Escape · Backspace — 먹었으면 true. */
|
|
key: (key: string) => boolean;
|
|
marks: () => FreeMarks;
|
|
hint: () => string;
|
|
}
|
|
|
|
export function createFreeDraw(deps: FreeDrawDeps): FreeDraw {
|
|
let phase: FreePhase = "range";
|
|
let pending: number | null = null;
|
|
let range: [number, number] | null = null;
|
|
let anchor: number | null = null;
|
|
let points: FreeVertex[] = [];
|
|
let target: FreeVertex | null = null;
|
|
|
|
function reset(): void {
|
|
phase = "range";
|
|
pending = null;
|
|
range = null;
|
|
anchor = null;
|
|
points = [];
|
|
target = null;
|
|
}
|
|
|
|
/** 그 측점의 지금 계획고(변화점이면 라운드 중심 z — 직선화와 같은 값). */
|
|
function heightAt(chainageM: number): number | null {
|
|
const alignment = deps.alignment();
|
|
return alignment ? controlElevationAt(alignment, chainageM) : null;
|
|
}
|
|
|
|
const finishM = (): number | null =>
|
|
range && anchor !== null ? (anchor === range[0] ? range[1] : range[0]) : null;
|
|
const same = (a: number | null, b: number | null): boolean =>
|
|
a !== null && b !== null && Math.abs(a - b) < SAME_M;
|
|
|
|
/** 지나온 점(시작 포함) — 마지막 것이 다음 선의 출발점. */
|
|
function path(): FreeVertex[] {
|
|
if (anchor === null) return [];
|
|
const z = heightAt(anchor);
|
|
return z === null ? [] : [{ chainage_m: anchor, elevation_m: z }, ...points];
|
|
}
|
|
|
|
/** 클릭 · 마우스 자리 → 다음 점 후보. 끝 세로선이면 그 계획고, 뒤로 가면 null. */
|
|
function candidate(chainageM: number, elevationM: number, stations: number[]): FreeVertex | null {
|
|
if (phase !== "drawing" || !range) return null;
|
|
const station = snapStation(stations, chainageM, range);
|
|
const end = finishM();
|
|
if (station === null || end === null) return null;
|
|
if (same(station, end)) {
|
|
const z = heightAt(end);
|
|
return z === null ? null : { chainage_m: end, elevation_m: z };
|
|
}
|
|
const walked = path();
|
|
const last = walked[walked.length - 1];
|
|
if (!last || (station - last.chainage_m) * Math.sign(end - last.chainage_m) <= SAME_M) {
|
|
return null;
|
|
}
|
|
const z = Math.round(elevationM / ELEVATION_STEP_M) * ELEVATION_STEP_M;
|
|
return { chainage_m: station, elevation_m: Number(z.toFixed(3)) };
|
|
}
|
|
|
|
function finish(): void {
|
|
const base = deps.base();
|
|
const end = finishM();
|
|
const z = end === null ? null : heightAt(end);
|
|
if (!base || end === null || z === null) return;
|
|
const vertices = [...path(), { chainage_m: end, elevation_m: z }];
|
|
const next = buildFreeEdits(base, deps.edits(), vertices);
|
|
reset();
|
|
deps.apply(next);
|
|
deps.close();
|
|
}
|
|
|
|
return {
|
|
phase: () => phase,
|
|
reset,
|
|
click(chainageM, elevationM, stations) {
|
|
if (phase === "range") {
|
|
const station = snapStation(stations, chainageM);
|
|
if (station === null) return true;
|
|
if (pending === null || same(pending, station)) pending = station;
|
|
else {
|
|
range = [Math.min(pending, station), Math.max(pending, station)];
|
|
pending = null;
|
|
phase = "armed";
|
|
}
|
|
} else if (phase === "armed" && range) {
|
|
const station = snapStation(stations, chainageM, range);
|
|
if (same(station, range[0]) || same(station, range[1])) {
|
|
anchor = station;
|
|
points = [];
|
|
phase = "drawing";
|
|
}
|
|
} else if (phase === "drawing") {
|
|
const next = candidate(chainageM, elevationM, stations);
|
|
if (!next) return true;
|
|
if (same(next.chainage_m, finishM())) {
|
|
finish();
|
|
return true;
|
|
}
|
|
points.push(next);
|
|
}
|
|
target = null;
|
|
deps.changed();
|
|
return true;
|
|
},
|
|
hover(pointer) {
|
|
target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null;
|
|
},
|
|
key(key) {
|
|
if (key === "Escape") {
|
|
reset();
|
|
deps.close();
|
|
return true;
|
|
}
|
|
if (key === "Enter") {
|
|
if (phase !== "drawing") return false;
|
|
finish();
|
|
return true;
|
|
}
|
|
if (key === "Backspace") {
|
|
if (phase !== "drawing") return false;
|
|
if (points.length) points.pop();
|
|
else {
|
|
anchor = null;
|
|
phase = "armed";
|
|
}
|
|
target = null;
|
|
deps.changed();
|
|
return true;
|
|
}
|
|
return false;
|
|
},
|
|
marks() {
|
|
const maxPct = deps.base()?.policy.max_grade_pct ?? Number.POSITIVE_INFINITY;
|
|
const dots = path();
|
|
const line = target ? [...dots, target] : dots;
|
|
const segments: FreeSegment[] = [];
|
|
for (let index = 1; index < line.length; index += 1) {
|
|
const grade = gradePct(line[index - 1], line[index]);
|
|
segments.push({
|
|
from: line[index - 1],
|
|
to: line[index],
|
|
grade_pct: grade,
|
|
over: overGrade(grade, maxPct),
|
|
preview: target !== null && index === line.length - 1,
|
|
});
|
|
}
|
|
return {
|
|
band: range,
|
|
edges: range ? [...range] : pending !== null ? [pending] : [],
|
|
segments,
|
|
dots,
|
|
};
|
|
},
|
|
hint() {
|
|
if (phase === "range") {
|
|
return pending === null
|
|
? "자유지정 — 범위 첫 세로 측점선을 고르세요"
|
|
: `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요`;
|
|
}
|
|
if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)";
|
|
return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (Backspace 점 빼기 · Esc 취소)";
|
|
},
|
|
};
|
|
}
|