/* ============================================================================= * D03_PlanAnalysis_Draw.ts * D 예정노선 수동 직선 그리기 셈(PLAN 562-1 · 요구 389 12 · 13) — 구간 길이 · 원지반 기울기 · 길이 고정 · * 기울기 고정 · 둘 다(양각기) · 선 위 거리 ↔ 점 · 측점. 높이는 인자 `heightAt(x, y)`(NaN = 지형 밖). * DOM · fetch · sessionStorage 없음. * * ⚠ 파이썬·TS 짝 — `D03_PlanAnalysis_Draw.py` 같은 이름(snake). 한쪽을 고치면 다른 쪽도 고치고 거울 시험 * `resources/tester/test_562_1_plan_draw.py`. 화면은 그리는 즉시 이 짝으로 · [저장]은 서버가 다시 셈. * ========================================================================== */ export type Vertex = [number, number]; export type HeightAt = (x: number, y: number) => number; /** 기울기 고정 반직선 걸음(m) · 이분 횟수 · 양각기 원 표본 수. */ const SLOPE_STEP_M = 1; const BISECT = 40; const COMPASS_SAMPLES = 360; const EPS = 1e-9; /** 수평 길이(m). */ export function segmentLength(a: Vertex, b: Vertex): number { const dx = b[0] - a[0]; const dy = b[1] - a[1]; return Math.sqrt(dx * dx + dy * dy); } /** 원지반 기울기(%) = (z끝 − z시작) / 수평 길이 × 100 · + 오르막 · null = 지형 밖 · 길이 0. */ export function groundSlopePct(a: Vertex, b: Vertex, heightAt: HeightAt): number | null { const length = segmentLength(a, b); if (length < EPS) return null; const za = heightAt(a[0], a[1]); const zb = heightAt(b[0], b[1]); if (!Number.isFinite(za) || !Number.isFinite(zb)) return null; return ((zb - za) / length) * 100; } /** 길이 고정 — 마우스 방향으로 그 길이 점 · 마우스 = 시작이면 null. */ export function fixedLengthPoint(from: Vertex, cursor: Vertex, lengthM: number): Vertex | null { const d = segmentLength(from, cursor); if (d < EPS) return null; return [ from[0] + ((cursor[0] - from[0]) / d) * lengthM, from[1] + ((cursor[1] - from[1]) / d) * lengthM, ]; } /** 기울기 고정 — 마우스 방향 반직선 위 |기울기| 가 목표가 되는 자리 중 마우스까지 거리에 가장 * 가까운 자리(1 m 걸음 · 이분 · 같으면 앞 자리) · `maxM` 안에 없으면 null · 지형 밖에 닿으면 거기까지의 * 자리만. */ export function fixedSlopePoint( from: Vertex, cursor: Vertex, slopePct: number, heightAt: HeightAt, maxM: number = 1000, ): Vertex | null { const d = segmentLength(from, cursor); const z0 = heightAt(from[0], from[1]); if (d < EPS || !Number.isFinite(z0)) return null; const ux = (cursor[0] - from[0]) / d; const uy = (cursor[1] - from[1]) / d; const grade = Math.abs(slopePct) / 100; const at = (t: number): Vertex => [from[0] + ux * t, from[1] + uy * t]; const f = (t: number): number => { const p = at(t); return Math.abs(heightAt(p[0], p[1]) - z0) - grade * t; }; const roots: number[] = []; let lo = 0; let flo = NaN; for (let i = 1; i * SLOPE_STEP_M <= maxM; i += 1) { const t = i * SLOPE_STEP_M; const ft = f(t); if (!Number.isFinite(ft)) break; if (ft === 0) { roots.push(t); lo = t; flo = NaN; continue; } if (!Number.isNaN(flo) && flo < 0 !== ft < 0) { let a = lo; let b = t; let fa = flo; let ok = true; for (let k = 0; k < BISECT; k += 1) { const mid = (a + b) / 2; const fm = f(mid); if (!Number.isFinite(fm)) { ok = false; break; } if (fm < 0 === fa < 0) { a = mid; fa = fm; } else b = mid; } if (ok) roots.push((a + b) / 2); } lo = t; flo = ft; } let best: number | null = null; for (const t of roots) if (best === null || Math.abs(t - d) < Math.abs(best - d)) best = t; return best === null ? null : at(best); } /** 둘 다(양각기) — 반지름 `lengthM` 원 위에서 |기울기| = 목표인 후보들(방위 표본 · 이분 · 동쪽부터 * 반시계). */ export function compassCandidates( from: Vertex, lengthM: number, slopePct: number, heightAt: HeightAt, ): Vertex[] { const z0 = heightAt(from[0], from[1]); if (!(lengthM > EPS) || !Number.isFinite(z0)) return []; const rise = (Math.abs(slopePct) / 100) * lengthM; const step = (2 * Math.PI) / COMPASS_SAMPLES; const at = (angle: number): Vertex => [ from[0] + Math.cos(angle) * lengthM, from[1] + Math.sin(angle) * lengthM, ]; const h = (angle: number): number => { const p = at(angle); return Math.abs(heightAt(p[0], p[1]) - z0) - rise; }; const values: number[] = []; for (let k = 0; k < COMPASS_SAMPLES; k += 1) values.push(h(k * step)); const out: Vertex[] = []; for (let k = 0; k < COMPASS_SAMPLES; k += 1) { const hk = values[k]; const hn = values[(k + 1) % COMPASS_SAMPLES]; if (!Number.isFinite(hk) || !Number.isFinite(hn)) continue; if (hk === 0) { out.push(at(k * step)); continue; } if (hn === 0 || hk < 0 === hn < 0) continue; let lo = k * step; let hi = lo + step; let flo = hk; let ok = true; for (let i = 0; i < BISECT; i += 1) { const mid = (lo + hi) / 2; const fm = h(mid); if (!Number.isFinite(fm)) { ok = false; break; } if (fm < 0 === flo < 0) { lo = mid; flo = fm; } else hi = mid; } if (ok) out.push(at((lo + hi) / 2)); } return out; } /** 둘 다 — 커서에 가장 가까운 양각기 후보 · 없으면 null. */ export function compassPoint( from: Vertex, cursor: Vertex, lengthM: number, slopePct: number, heightAt: HeightAt, ): Vertex | null { let best: Vertex | null = null; let bestD = Infinity; for (const p of compassCandidates(from, lengthM, slopePct, heightAt)) { const d = segmentLength(p, cursor); if (d < bestD) { best = p; bestD = d; } } return best; } /** 선 길이(m) — 앞에서부터 더함. */ export function lineLength(line: ReadonlyArray): number { let total = 0; for (let i = 1; i < line.length; i += 1) total += segmentLength(line[i - 1], line[i]); return total; } /** 선 위 거리 → 점(0 · 끝 밖은 끝에서 멈춤) · 선 점 하나 이상. */ export function pointAtDistance(line: ReadonlyArray, m: number): Vertex { if (m <= 0) return [line[0][0], line[0][1]]; let acc = 0; for (let i = 1; i < line.length; i += 1) { const a = line[i - 1]; const b = line[i]; const seg = segmentLength(a, b); if (seg > 0 && acc + seg >= m) { const t = (m - acc) / seg; return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; } acc += seg; } const last = line[line.length - 1]; return [last[0], last[1]]; } /** 점 → 선 위 거리(m) — 가장 가까운 선 위 자리(같으면 앞 구간). */ export function distanceAlong(line: ReadonlyArray, point: Vertex): number { let best = 0; let bestD = Infinity; let acc = 0; for (let i = 1; i < line.length; i += 1) { const a = line[i - 1]; const b = line[i]; const seg = segmentLength(a, b); let t = 0; if (seg > 0) { t = ((point[0] - a[0]) * (b[0] - a[0]) + (point[1] - a[1]) * (b[1] - a[1])) / (seg * seg); t = Math.min(1, Math.max(0, t)); } const p: Vertex = [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t]; const d = segmentLength(p, point); if (d < bestD) { bestD = d; best = acc + seg * t; } acc += seg; } return best; } /** 선 조각 — `fromM`~`toM` 사이(끝점 + 그 사이 꺾임점). */ export function sliceLine(line: ReadonlyArray, fromM: number, toM: number): Vertex[] { const lo = Math.min(fromM, toM); const hi = Math.max(fromM, toM); const out: Vertex[] = [pointAtDistance(line, lo)]; let acc = 0; for (let i = 1; i < line.length - 1; i += 1) { acc += segmentLength(line[i - 1], line[i]); if (acc > lo + EPS && acc < hi - EPS) out.push([line[i][0], line[i][1]]); } out.push(pointAtDistance(line, hi)); return out; } /** 측점 누가 — No.0(0 m) · 간격 배수 · 끝(선마다 0 부터). */ export function stationChainages(lengthM: number, intervalM: number = 20): number[] { const out = [0]; for (let k = 1; k * intervalM < lengthM - 1e-6; k += 1) out.push(k * intervalM); if (lengthM > 1e-6) out.push(lengthM); return out; }