/* ============================================================================= * B05_Profile_UI_Drainage_Live.ts * 배수유역 자동/수동 다시 셈(347-2) — 묶음 drainage 단계 · ⚙ 설정 창 · 흐림 + [업데이트] · 구석 * 「계산 중」 · 「지금 노선으로 셈」 요청(차례 번호 · 끊기). 규칙은 `logic/B05_Profile_Drainage_Live.ts`. * * - 단계(`stage`)는 계산 묶음(`design_pipeline` · 붙이기 = sub7)이 노선 초안 · 관이 바뀔 때 부름 — * 기다리지 않고 null 을 돌려줌(배수유역은 뒤 단계 · 출력 없음). * - 노선 초안이 있으면 「낡음」 — 자동 = 3 s 조용한 뒤 셈 · 수동 = 칸만 흐림 + 가운데 [업데이트]. * - 다시 조작하면 처음으로 — 떠 있는 요청을 끊고(AbortController) 다시 흐림 또는 다시 3 s. * - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림. * - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로. * ========================================================================== */ import { API_BASE_URL } from "@config/config_frontend"; import { createProgressCircle } from "@ui/ui_template_progress"; import { openModal } from "@ui/ui_template_modal"; import { readStateRaw, writeStateRaw } from "../A00_Common/b_page_state"; 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, liveRequestBody, parseDrainageMode, QUIET_MS, type DrainageMode, } 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"; const MODE_KEY = "drainage-mode"; export interface DrainageLiveDeps { /** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */ root: HTMLElement; /** 제안 목록을 이 앞에 둠. */ basinList: HTMLElement; pipeRequest: () => DetailPipeInput[]; runner: DetailRunner; /** [적용] 답이 화면에 오른 뒤 — 세션 초안에 담음. */ rememberPipes: () => void; } export interface DrainageLive { /** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */ stage: StageFn<"drainage">; /** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */ refresh(): 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; } export function readDrainageMode(): DrainageMode { return parseDrainageMode(readStateRaw(MODE_KEY)); } 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 suggest = createDrainageSuggest(deps.pipeRequest, (points) => { const projectId = deps.runner.projectId(); if (!projectId) return; void deps.runner .run(() => computeDetailBasins(projectId, points)) .then((ok) => ok && deps.rememberPipes()); }); deps.basinList.before(suggest.root); let mode = readDrainageMode(); let route: unknown = null; let stale = false; let generation = 0; let timer = 0; let inflight: AbortController | null = null; const veil = document.createElement("div"); veil.className = "b05-drainage__veil"; veil.hidden = true; const update = document.createElement("button"); update.type = "button"; update.className = "b05-drainage__analyze b05-drainage__update"; update.textContent = "업데이트"; update.title = "노선 · 관 자리가 바뀌어 배수유역이 낡았습니다 — 지금 노선으로 다시 셉니다."; veil.append(update); const busy = createProgressCircle({ size: 22, label: "계산 중" }); busy.root.classList.add("b05-drainage__busy"); busy.root.hidden = true; viewport.append(veil, busy.root); const gear = document.createElement("button"); gear.type = "button"; gear.className = "b05-drainage__analyze b05-drainage__tool b05-drainage__gear"; gear.textContent = "⚙"; gear.title = "배수유역 계산 설정 — 수동(기본) / 자동"; header.append(gear); const paint = (): void => { deps.root.classList.toggle("is-stale", stale); veil.hidden = !stale || mode === "auto" || inflight !== null; busy.root.hidden = inflight === null; }; function cancel(): void { window.clearTimeout(timer); timer = 0; inflight?.abort(); inflight = null; } function refresh(): void { const projectId = deps.runner.projectId(); if (!projectId) return; cancel(); const sent = generation; const controller = new AbortController(); inflight = controller; paint(); const body = liveRequestBody(sent, route, deps.pipeRequest()); void deps.runner .run(async () => { const response = await fetchLive(projectId, body, controller.signal); // 옛 차례 · 서버가 그만둔 답 — 감싸기가 조용히 버리게 끊김으로 돌려줌. if (!acceptLive(response, sent, generation)) throw new DOMException("superseded", "AbortError"); return response; }, true) .then((applied) => { if (inflight !== controller) return; inflight = null; if (applied) stale = false; paint(); }); } /** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */ function invalidate(): void { generation += 1; cancel(); stale = true; if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS); paint(); } function openSettings(): void { openModal({ title: "배수유역 계산", closeLabel: "닫기", mount(body, close) { const choices: Array<[DrainageMode, string, string]> = [ ["manual", "수동(기본)", "노선 · 관 자리가 바뀌면 칸만 흐려지고 [업데이트]를 누를 때 셈"], ["auto", "자동", "조작이 3초 멈추면 뒤에서 조용히 셈(화면을 막지 않음)"], ]; choices.forEach(([value, label, hint]) => { const row = document.createElement("label"); row.className = "b05-drainage__mode"; const input = document.createElement("input"); input.type = "radio"; input.name = "b05-drainage-mode"; input.checked = mode === value; input.addEventListener("change", () => { setMode(value); close(); }); const text = document.createElement("span"); text.textContent = `${label} — ${hint}`; row.append(input, text); body.append(row); }); }, }); } function setMode(next: DrainageMode): void { mode = next; writeStateRaw(MODE_KEY, next); window.clearTimeout(timer); timer = 0; if (stale && next === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS); paint(); } update.addEventListener("click", refresh); gear.addEventListener("click", openSettings); return { stage(ctx) { route = ctx.inputs.route; if (draftMakesStale(route) || stale) invalidate(); return null; }, refresh, suggest, markFresh() { cancel(); stale = false; paint(); }, get stale() { return stale; }, }; }