/* ============================================================================= * B05_Profile_UI_RouteEdit_ProfileStrip.ts * 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 · * 측점 눈금 한 줄. * * 보여 주기만 한다(PLAN 77-5) — 종단 여부는 지금 추천 갈래가 정한다(`withProfile`). 종단 갈래 = * 계획선은 지금 노드로 서버가 다시 낸 균형 종단(`/route/profile-preview` — [확인] 체인과 같은 셈). * 예상노선 추적 = 지반선만(계획고 = 지반고 · `/route/elevations`). [확인]도 같은 규칙을 탄다 * (`_Apply` 의 `balanced_profile`). * * 갱신은 노드를 놓을 때마다(끌기 중 아님) · 최신 요청만 채택(seq) · 기다리는 동안 옛 그림은 흐리게. * 계산 자리 = 서버 단독 — 여기는 받은 값을 그리기만 한다. * ========================================================================== */ import { fetchProfilePreview, fetchRouteElevations, type RouteReplanVertex, } from "./B05_Profile_Api_Replan"; import { parseBalance, profileSvg } from "./B05_Profile_UI_RouteEdit_Recommend_Profile"; import { lengthOf, pointsAt, stationChainages } from "./B05_Profile_UI_RouteEdit_Slope"; type Vertex = [number, number]; /** 지반선만 그릴 때 측점 간격(m) — 균형 종단(서버 `STATION_M`)과 같음. */ const STATION_M = 20; export interface ProfileStripParams { root: HTMLElement; projectId: string; /** 지금 추천 갈래가 종단을 반영하는가(예상노선 추적만 아님). */ withProfile: () => boolean; /** 지금 편집값 — 노드(서버로) · 그려진 노선(지반선 측점) · 지금 추천 제약. */ now: () => { vertices: RouteReplanVertex[]; line: ReadonlyArray; criteria: unknown }; } export interface ProfileStrip { /** 노선이 바뀌었다(놓음 · 되돌리기 · 추천 적용) — 다시 묻는다. */ refresh: () => void; /** [확인] 직전 — 지금 노선으로 띠를 맞추고 응답을 기다려, 띠에 보인 계획선이 균형 종단인지 * 돌려준다(보인 것 = 적용된 것). 띠가 못 그렸으면 갈래 값. */ settle: () => Promise; } export function createProfileStrip(params: ProfileStripParams): ProfileStrip { const { root, projectId } = params; const note = root.querySelector("[data-strip-note]")!; const graph = root.querySelector("[data-strip-graph]")!; let seq = 0; /** 마지막으로 그렸거나 묻는 중인 값 — 같은 노선 · 같은 갈래 종단이면 다시 묻지 않는다. */ let lastKey = ""; /** 마지막 요청(끝날 때까지) · 띠에 그려진 계획선이 균형 종단인가(못 그렸으면 null). */ let pending: Promise = Promise.resolve(); let shownOn: boolean | null = null; async function groundOnly(line: ReadonlyArray) { const stationsM = stationChainages(lengthOf(line), STATION_M); const z = await fetchRouteElevations(projectId, pointsAt(line, stationsM)); const known = stationsM.flatMap((s, i) => (z[i] === null ? [] : [[s, z[i]!] as const])); if (known.length < 2) throw new Error("노선 위 지반고를 읽지 못했습니다."); return { stationsM: known.map(([s]) => s), groundM: known.map(([, h]) => h), planM: [] as number[], }; } function refresh(): void { const on = params.withProfile(); const { vertices, line, criteria } = params.now(); const key = JSON.stringify([on, vertices, criteria]); if (vertices.length < 2 || line.length < 2 || key === lastKey) return; lastKey = key; pending = draw(on, vertices, line, criteria); } async function draw( on: boolean, vertices: RouteReplanVertex[], line: ReadonlyArray, criteria: unknown, ): Promise { const ticket = ++seq; root.classList.add("is-waiting"); note.textContent = "종단을 읽는 중…"; try { const profile = on ? parseBalance(await fetchProfilePreview(projectId, vertices, criteria)) : await groundOnly(line); if (ticket !== seq || !root.isConnected) return; // 그 사이 노선이 또 바뀌었다. if (!profile) throw new Error("균형 종단을 세우지 못했습니다."); const total = profile.stationsM[profile.stationsM.length - 1]; graph.innerHTML = profileSvg(profile, true); shownOn = on; note.textContent = `${on ? "원지반선 · 계획 종단선" : "원지반선(계획고 = 지반고)"} · 연장 ${total.toFixed(1)}m`; } catch (error) { if (ticket !== seq) return; lastKey = ""; // 실패 — 다음 번에 다시 묻는다. shownOn = null; graph.innerHTML = ""; note.textContent = error instanceof Error ? error.message : "종단을 읽지 못했습니다."; } finally { if (ticket === seq) root.classList.remove("is-waiting"); } } return { refresh, settle: async () => { refresh(); await pending; return shownOn ?? params.withProfile(); }, }; }