/* ============================================================================= * B05_Profile_UI_RouteEdit_ProfileStrip.ts * 계획노선 편집 창 지도 아래 **종단 띠**(PLAN 75-1) — 지금 노선 전체 연장의 원지반선 · 계획선 · * 측점 눈금 한 줄. * * 「종단 반영」(추천 창과 같은 `profileMemory`) 켬 = 계획선은 지금 노드로 서버가 다시 셈한 균형 * 종단(`/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; /** 추천 창 「종단 반영」과 한 벌 — 이 띠의 켬/끔 단추도 같은 값을 바꾼다. */ profileMemory: { 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, profileMemory } = params; const toggle = root.querySelector("[data-strip-toggle]")!; 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 = profileMemory.withProfile; toggle.setAttribute("aria-pressed", String(on)); toggle.textContent = `종단 반영 ${on ? "켬" : "끔"}`; 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 = on ? "균형 종단을 셈하는 중…" : "지반선을 읽는 중…"; const started = performance.now(); 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 seconds = ((performance.now() - started) / 1000).toFixed(1); const total = profile.stationsM[profile.stationsM.length - 1]; graph.innerHTML = profileSvg(profile, true); shownOn = on; note.textContent = `${on ? "계획선 = 균형 종단" : "지반선만(계획고 = 지반고)"} · ` + `연장 ${total.toFixed(1)}m · 측점 ${profile.stationsM.length}개 · ${seconds}초`; } 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"); } } toggle.addEventListener("click", () => { profileMemory.withProfile = !profileMemory.withProfile; refresh(); }); return { refresh, settle: async () => { refresh(); await pending; return shownOn ?? profileMemory.withProfile; }, }; }