/* ============================================================================= * B05_Profile_UI_Drainage_Live.ts * 배수유역 [업데이트](요구 326-6 · 347-2) — 묶음 drainage 단계 · 낡음 표시 · 구석 「계산 중」 · * 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`. * * - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 — * 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음). * - 노선 초안이 있으면 「낡음」 — 저절로 셈하지 않음 · 옛 그림 그대로 · [업데이트] 단추에 표시만 * (508-2 [3D 업데이트] 꼴). 낡은 동안 제안 [적용] · [자동 제안]은 끔(새 답이 오면 다시 켬). * - [업데이트] = 늘 지금 노선(초안 · 저장 노선)으로 배수유역을 처음부터 셈 — 노선이 그대로여도. * 뒤에서 조용히(덮개 · 잠금 없음) · 답이 오면 그림을 갈아 끼움. * - 다시 조작하면 떠 있는 요청을 끊고(AbortController) 다시 낡음 · 서버에도 끊김을 알려 셈을 멈춤 * (`/drainage/live/cancel` · 520-2). 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠. * - 저장 노선 [업데이트] 답은 서버가 남김(520-2) — 그 뒤 관 조작은 세부 유역만 자동. * - 초안이 없어지면(되돌리기 · 버림) 저장분의 마지막 낡음으로 표시를 바로 바꾸고 저장 배수유역을 다시 * 받아(열 때와 같은 길 · 계산 아님) 그 답의 낡음대로 — 떠 있던 초안용 답은 버림. * - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 낡음 표시. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { currentLanguageIndex, ui_locales } from "@ui/ui_template_locale"; import { createProgressCircle } from "@ui/ui_template_progress"; import { setDrainageStale } from "../A00_Common/design/design_structure_ford_calc"; import type { StageFn } from "../A00_Common/design/logic/design_pipeline_types"; import { computeDetailBasins, type DetailBasinResponse, type DetailPipeInput, } from "../B04_PreProcess/B04_PreProcess_Api_Fetch"; import { acceptLive, draftMakesStale, inputKey, liveRequestBody, } from "./logic/B05_Profile_Drainage_Live"; import type { DetailRunner } from "./B05_Profile_UI_Drainage_Run"; import { createDrainageSuggest, type DrainageSuggest } from "./B05_Profile_UI_Drainage_Suggest"; export interface DrainageLiveDeps { /** 패널 뿌리 — 머리([자동 제안]) · 지도(구석 「계산 중」)를 이 안에서 찾음. */ root: HTMLElement; /** [업데이트] 단추(머리 줄 · `_Drainage_Chrome`). */ updateButton: HTMLButtonElement; /** 제안 목록을 이 앞에 둠. */ basinList: HTMLElement; pipeRequest: () => DetailPipeInput[]; runner: DetailRunner; /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담고 사진첩 한 걸음(패널이 콜백으로 이음). */ rememberPipes: () => void; /** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */ loadSaved: () => Promise; /** 노선 · 관 자리가 바뀌어 낡음 — [자동 제안] 목록 닫기(510). */ onStale?: () => void; } export interface DrainageLive { /** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */ stage: StageFn<"drainage">; /** [업데이트] — 지금 노선으로 처음부터 셈 · 답을 화면에 올렸으면 true. */ refresh(): Promise; /** 서버 답이 「낡음」 — 낡음 표시. 이미 낡음이면 그대로. */ markStale(): void; /** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청도 끊음). */ markFresh(): void; readonly stale: boolean; suggest: DrainageSuggest; } /** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */ async function fetchLive( projectId: string, body: ReturnType, signal: AbortSignal, ): Promise { const response = await fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), signal, }); const payload = await response.json(); if (!response.ok) throw new Error(payload.message ?? `HTTP ${response.status}`); return payload; } /** 끊은 차례를 서버에 알림 — 서버가 다음 단계 사이에서 셈을 멈춤(답 안 기다림 · 실패 무시). */ function cancelLive(projectId: string, generation: number): void { void fetch(`${API_BASE_URL}/projects/${projectId}/drainage/live/cancel`, { method: "POST", credentials: "include", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ generation }), }).catch(() => undefined); } const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex]; const UPDATE_LABEL = "업데이트"; const UPDATE_TIP = "지금 노선으로 배수유역을 처음부터 다시 셉니다 — 뒤에서 셈 · 끝나면 그림을 바꿈."; const UPDATE_STALE_TIP = "노선 · 관 자리가 바뀌어 배수유역도가 낡았습니다 — 누르면 지금 노선으로 다시 셈."; export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive { const viewport = deps.root.querySelector(".b05-drainage__viewport") as HTMLElement; const header = deps.root.querySelector(".b05-drainage__header") as HTMLElement; const propose = header.querySelector(".b05-drainage__propose"); const update = deps.updateButton; const suggest = createDrainageSuggest(deps.pipeRequest, (points) => { const projectId = deps.runner.projectId(); if (!projectId) return; void deps.runner .run(() => computeDetailBasins(projectId, points)) .then((ok) => { if (ok) deps.rememberPipes(); }); }); deps.basinList.before(suggest.root); let route: unknown = null; let stale = false; /** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */ let drafted = false; /** 저장분의 마지막 낡음 — 저장분 답이 「낡음」이면 `markStale` 이 세우고 저장 노선 [업데이트] 답이 걷음. */ let savedStale = false; let generation = 0; /** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */ let key = ""; let inflight: AbortController | null = null; /** 떠 있는 요청의 차례 — 끊을 때 서버에 알림. */ let inflightGeneration = 0; const busy = createProgressCircle({ size: 22, label: "계산 중" }); busy.root.classList.add("b05-drainage__busy"); busy.root.hidden = true; viewport.append(busy.root); const paint = (): void => { deps.root.classList.toggle("is-stale", stale); setDrainageStale(stale); // 관 카드 설계유량 가림(요구 328-3) update.classList.toggle("is-pending", stale); update.disabled = inflight !== null; update.textContent = inflight ? "계산 중…" : UPDATE_LABEL; update.title = stale ? UPDATE_STALE_TIP : UPDATE_TIP; suggest.setStale(stale); if (propose) { propose.disabled = stale; propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip"); } busy.root.hidden = inflight === null; }; function cancel(): void { if (!inflight) return; inflight.abort(); inflight = null; const projectId = deps.runner.projectId(); if (projectId) cancelLive(projectId, inflightGeneration); } /** 지금 노선으로 처음부터 셈 — 노선 초안이 있으면 그 노선 · 없으면 저장 노선. */ function refresh(): Promise { const projectId = deps.runner.projectId(); if (!projectId) return Promise.resolve(false); cancel(); const sent = { generation: ++generation, key }; const sentRoute = route; const controller = new AbortController(); inflight = controller; inflightGeneration = sent.generation; paint(); const body = liveRequestBody(sent.generation, route, deps.pipeRequest()); return deps.runner .run(async () => { const response = await fetchLive(projectId, body, controller.signal); // 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌. if (!acceptLive(response, sent, { generation, key })) throw new DOMException("superseded", "AbortError"); return response; }, true) .then((applied) => { if (inflight !== controller) return applied; inflight = null; if (applied) stale = false; if (applied && !draftMakesStale(sentRoute)) savedStale = false; // 서버가 남김(520-2) paint(); return applied; }); } /** 평면 · 관 자리 조작 — 떠 있는 요청을 끊고 낡음(저절로 셈하지 않음). */ function invalidate(): void { cancel(); stale = true; deps.onStale?.(); paint(); } /** 초안이 없어짐 — 표시는 저장분의 마지막 낡음으로 바로 · 저장분을 다시 받아 그 답의 낡음대로. */ function backToSaved(): void { cancel(); const sent = ++generation; stale = savedStale; savedStale = false; // 답이 「낡음」이면 apply 가 markStale 로 세움 paint(); void deps.loadSaved().then((applied) => { if (sent !== generation) return; stale = !applied || savedStale; paint(); }); } update.addEventListener("click", () => void refresh()); paint(); return { stage(ctx) { route = ctx.inputs.route; const next = inputKey(route, ctx.inputs.pipes); if (next === key) return null; key = next; const wasDraft = drafted; drafted = draftMakesStale(route); if (drafted || (stale && !wasDraft)) invalidate(); else if (wasDraft) backToSaved(); return null; }, refresh, suggest, markStale() { savedStale = true; if (stale) return; stale = true; paint(); }, markFresh() { cancel(); stale = false; paint(); }, get stale() { return stale; }, }; }