/* ============================================================================= * B05_Profile_UI_RouteEdit_Measure.ts * 계획노선 위 **두 점 사이 구간 재기** — 길이와 종단기울기(계획서 0-9 ⑤). * * Shift+클릭으로 a·b 를 찍는다. 직선·곡선을 가리지 않는다 — 그려지는 폴리라인 위라면 어디든 * 짚을 수 있고, 누가거리는 노선 길이 표기와 같은 방식으로 잰다. * * 지반고는 **찍는 순간에만** 서버에 묻는다(`/route/elevations`). 확정된 지표면을 읽기만 하는 * 통로라 「편집 중에는 계산이 안 나간다」(계획서 0-2 확정 7)와 부딪히지 않는다 — 다만 노드를 * 끄는 동안에는 한 번도 부르지 않는다. * ========================================================================== */ import { fetchRouteElevations } from "./B05_Profile_Api_Replan"; import { routePointAtScreen, type RoutePointHit } from "./B05_Profile_UI_RouteEdit_Input"; import { formatStation } from "./B05_Profile_Util_Station"; type Vertex = [number, number]; /** 구간 재기로 노선을 짚었다고 볼 거리(px). */ const MEASURE_HIT_PX = 14; interface MeasurePoint extends RoutePointHit { /** 그 자리의 지반고(m). 아직 못 물었거나 지표면 밖이면 null. */ z: number | null; } export interface MeasureToolParams { projectId: string; /** 규칙 측점 간격(m) — 측점 표기에 쓴다. */ stationIntervalM: number; /** 지금 그려지는 노선(원호 포함). 편집으로 바뀌므로 함수로 받는다. */ line: () => Vertex[]; toScreen: (vertex: Vertex) => [number, number]; /** 창이 닫혔나 — 늦게 온 응답을 죽은 화면에 적지 않으려고. */ isClosed: () => boolean; /** 상태가 바뀌었다 — 호출부가 상태줄을 다시 적고 다시 그린다. */ onChange: () => void; } export interface MeasureMark { point: Vertex; /** 시점에서 노선을 따라간 거리(m) — 그리기가 **이 값으로** 구간을 자른다(계획서 0-9 ㉕). */ chainageM: number; } export interface MeasureTool { /** 찍힌 자리(0~2개) — 그리기가 쓴다. */ marks: () => MeasureMark[]; /** 잰 값 한 줄. 찍은 것이 없으면 빈 문자열. */ hint: () => string; /** 재고 있나 — 작은 창을 띄울지 정하는 값. */ active: () => boolean; /** 한 번 찍기. 두 점이 차면 지반고를 한 번만 물어 온다. */ pick: (px: number, py: number) => Promise; /** 잰 것을 지운다 — 작은 창을 닫을 때(계획서 0-9 ㉔). */ clear: () => void; } export function createMeasureTool(params: MeasureToolParams): MeasureTool { /** 찍은 두 점. 셋째를 찍으면 새 구간의 시작이 된다. */ let picked: MeasurePoint[] = []; const hint = (): string => { if (picked.length === 0) return ""; const first = picked[0]; if (picked.length === 1) { return `구간 재기 — 시작 ${formatStation(first.chainageM, params.stationIntervalM)}. 한 점 더.`; } const second = picked[1]; const span = Math.abs(second.chainageM - first.chainageM); const head = `구간 ${formatStation(first.chainageM, params.stationIntervalM)} → ` + `${formatStation(second.chainageM, params.stationIntervalM)} · 길이 ${span.toFixed(1)}m`; if (first.z === null || second.z === null || span <= 1e-6) { return `${head} · 지반고를 못 읽어 기울기는 못 냅니다.`; } // 기울기는 **노선을 따라간 길이** 기준이다 — 직선거리로 나누면 곡선부에서 과대평가된다. const rise = second.z - first.z; return ( `${head} · 지반고 ${first.z.toFixed(1)} → ${second.z.toFixed(1)}m` + ` · 종단기울기 ${((rise / span) * 100).toFixed(1)}%` ); }; return { marks: () => picked.map((entry) => ({ point: entry.point, chainageM: entry.chainageM })), hint, active: () => picked.length > 0, clear() { if (picked.length === 0) return; picked = []; params.onChange(); }, async pick(px, py) { const hit = routePointAtScreen(params.line(), params.toScreen, px, py, MEASURE_HIT_PX); if (!hit) { picked = []; // 노선을 빗나가면 재던 것을 접는다. params.onChange(); return; } picked = picked.length >= 2 ? [{ ...hit, z: null }] : [...picked, { ...hit, z: null }]; params.onChange(); if (picked.length < 2) return; const asked = picked; try { const heights = await fetchRouteElevations( params.projectId, asked.map((entry) => entry.point), ); if (params.isClosed() || picked !== asked) return; // 그 사이 다시 찍었으면 버린다. asked.forEach((entry, index) => { entry.z = heights[index] ?? null; }); } catch { /* 지반고를 못 읽으면 길이만 낸다 — `hint` 가 그렇게 말한다. */ } params.onChange(); }, }; }