/* ============================================================================= * A00_Common/design/logic/design_profile_free.ts * 종단 [자유지정] 계산 — 범위 · 클릭 점 · 측점선 붙음 · 높이 변환 · 기울기 판정 · 적용(215-1). * * 흐름: 세로 측점선 둘로 범위를 고름 → 시작(또는 끝) 세로선 근처 클릭 = 그 측점 계획고에 이은 * 채 시작 → 클릭마다 점 하나(가로 = 범위 안 가장 가까운 측점선 · 높이 = 클릭한 높이) → 반대쪽 * 세로선 근처 클릭 또는 Enter = 그 측점 계획고에 이어 끝. Esc = 취소(범위 첫 측점만 = 시작만 · 271-3) · ↶ ↷(Backspace = ↶) = * 자유지정 안 한 걸음씩(범위 · 시작 · 점 · 367-3) — 큰 이력과 따로 · 끝나면 큰 이력 한 걸음. * * 적용은 직선화와 같은 길 — 편집 델타(`station_offsets` · `curve_radii`)만 만들어 캐시에 쓰고 * [저장] 때 서버가 다시 셈한다. 꺾이는 점 종단곡선 = 직선화 틸팅과 같은 기본 길이 L * (R = L / |대수차|). 양 끝 세로선의 라운드는 직선화처럼 그대로 둔다. * * 화면(`design_profile_free_view.ts`)은 클릭 · 마우스 · 키만 넘기고 여기 표시 자료를 그린다. * ========================================================================== */ import type { AlignmentBase, AlignmentEdits, ProfileAlignment, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { buildAlignment, chainageKey, controlElevationAt, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Alignment"; import { baseElevationAt, defaultCurveLength, dropAutoEndRadii, pinEnds, } from "../../../B05_Profile/logic/B05_Profile_UI_Profile_Straighten"; import { isStraightNode } from "../../../B05_Profile/logic/B05_Profile_Profile_Straight"; /** 두 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; } /** * 꺾은선을 계획고 편집으로 바꾼다 — 범위 안 변화점 · 클릭 점을 그 선 위로 옮기고 옛 라운드를 * 지운 뒤, 꺾이는 클릭 점에만 종단곡선을 넣는다. 양 끝 점은 손대지 않는다(직선화와 같음) · * 끝 R 이 저장 자동 R 이면 지워 기본 L(271-1). */ export function buildFreeEdits( base: AlignmentBase, edits: AlignmentEdits, vertices: FreeVertex[], autoRadii: Readonly> = {}, ): 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(); const current = buildAlignment(base, edits); current.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 }; // 끝이 꼭짓점 아닌 측점이면 지금 높이로 꼭짓점(426) — 직선화와 같음 pinEnds(base, current, [fromM, toM]).forEach(([key, value]) => { offsets[key] = Number(value.toFixed(6)); }); dropAutoEndRadii(radii, [fromM, toM], autoRadii); 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 (isStraightNode(path[index - 1], path[index], path[index + 1])) 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; /** 편집 적용 — 직선화 결과와 같은 길(캐시 · 되돌리기 · 막기 규칙). false = 막힘(열린 채). */ apply: (next: AlignmentEdits) => boolean | void; /** 저장 자동 R — 끝 R 이 이것과 같으면 지움(271-1). */ autoRadii?: () => Readonly>; /** 모드를 끈다(적용 뒤 · 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; /** 자유지정 안 한 걸음 되돌림 · 다시함(367-3) — 큰 이력과 따로. */ undo: () => void; redo: () => void; canUndo: () => boolean; canRedo: () => 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; /** range 단계 첫 측점 뒤 마우스 측점 — 실시간 띠 끝(271-3). */ let hoverStation: number | null = null; /** 자유지정 안 이력(367-3) — 상태 바뀐 클릭만 쌓음 · 새 클릭이 future 비움. */ interface Snap { phase: FreePhase; pending: number | null; range: [number, number] | null; anchor: number | null; points: FreeVertex[]; } let past: Snap[] = []; let future: Snap[] = []; const snap = (): Snap => ({ phase, pending, range, anchor, points: [...points] }); function step(from: Snap[], to: Snap[]): void { const state = from.pop(); if (!state) return; to.push(snap()); ({ phase, pending, range, anchor, points } = state); target = null; deps.changed(); } function reset(): void { phase = "range"; pending = null; range = null; anchor = null; points = []; target = null; hoverStation = null; past = []; future = []; } /** 그 측점의 지금 계획고(변화점이면 라운드 중심 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, deps.autoRadii?.()); // 막히면(기울기 · 흙덮기) 자유지정 열린 채 — 그린 점 그대로(367-3) if (deps.apply(next) === false) return; reset(); deps.close(); } return { phase: () => phase, reset, click(chainageM, elevationM, stations) { const before = snap(); 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); } if (JSON.stringify(snap()) !== JSON.stringify(before)) { past.push(before); future = []; } target = null; deps.changed(); return true; }, hover(pointer) { target = pointer ? candidate(pointer.chainageM, pointer.elevationM, pointer.stations) : null; hoverStation = pointer && phase === "range" && pending !== null ? snapStation(pointer.stations, pointer.chainageM) : null; }, key(key) { // range 단계 첫 측점만 고른 채 Esc = 시작만 취소 · 모드 유지(271-3) if (key === "Escape" && phase === "range" && pending !== null) { pending = null; hoverStation = null; deps.changed(); return true; } if (key === "Escape") { reset(); deps.close(); return true; } if (key === "Enter") { if (phase !== "drawing") return false; finish(); return true; } if (key === "Backspace") { if (!past.length) return false; step(past, future); return true; } return false; }, undo: () => step(past, future), redo: () => step(future, past), canUndo: () => past.length > 0, canRedo: () => future.length > 0, 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, }); } // 범위 고르는 중엔 첫 측점 → 마우스 측점까지 띠(271-3) · 둘째 클릭 뒤엔 고른 범위로 고정 const live = pending !== null && hoverStation !== null && !same(pending, hoverStation) ? ([Math.min(pending, hoverStation), Math.max(pending, hoverStation)] as [number, number]) : null; const band = range ?? live; return { band, edges: band ? [...band] : pending !== null ? [pending] : [], segments, dots, }; }, hint() { if (phase === "range") { return pending === null ? "자유지정 — 범위 첫 세로 측점선을 고르세요" : `${pending.toFixed(1)}m 선택 — 범위 끝 세로 측점선을 고르세요 (Esc 시작 취소)`; } if (phase === "armed") return "시작(또는 끝) 세로선 근처를 눌러 시작하세요 (Esc 취소)"; return "클릭마다 점 · 반대쪽 세로선 또는 Enter 로 끝 (↶ · Backspace 되돌리기 · Esc 취소)"; }, }; }