- 화면이 처음 열릴 때 「낡음」을 모르면 [자동 제안]이 409 오류로 끝나던 흠 — 곧바로 drainage/live 로 다시 - 되돌리기가 관 자리만 되살리고 속성은 지금 것을 끌고 가 [배관 배치 초기화] 뒤 되돌려도 세월교가 사라지던 흠 — 되살린 걸음의 관 초안(세션)이 같은 자리면 그 속성 · id 로(adoptDraftPipes) - 시험 — 349 흐름(409 → 다시) · 347 pipe id(초안 속성 되살림) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VmC34fsxxbweEG3RUjs3Mn
355 lines
15 KiB
TypeScript
355 lines
15 KiB
TypeScript
/* =============================================================================
|
|
* 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.
|
|
* 묶음이 같은 노선 · 관으로 다시 돌면(입력 지문 같음) 그대로 둠 — 떠 있는 답을 받음.
|
|
* - 초안이 없어지면(되돌리기 · 버림) 저장 배수유역을 다시 받아(열 때와 같은 길 · 계산 아님) 그 답의
|
|
* 낡음대로 — 떠 있던 초안용 답은 버림 · 자동도 3 s 셈 대신 저장분.
|
|
* - 서버가 「낡음」으로 답하면(노선 [저장] 뒤 등) 같은 흐림 + [업데이트] · 자동이면 3 s 뒤.
|
|
* - 답은 패널 요청 감싸기(`DetailRunner`, quiet)로 올림 — 옛 차례 · 서버가 그만둔 답은 버림.
|
|
* - 덮개 · 잠금 없음 — 다른 칸 조작은 그대로.
|
|
* - [자동 제안](369) — 고름 창 ① 전체 자동(all) ② 지정 포함 추가(add) · [배관 배치 초기화] = 세류선 교차
|
|
* 관만 · 셋 다 초안(세션) · [저장] 때 정본 · 답이 오면 사진첩 한 걸음(늦은 답도 앞 걸음에 안 묻힘) ·
|
|
* 낡은 동안 [자동 제안] 끔 · 노선 초안 중이거나 저장 분석이 낡았으면(옛 노선) 「지금 노선으로 셈」 길에
|
|
* 방식을 실어 보냄(저장 분석으로 셈 = 옛 노선 자리 · 409).
|
|
* ========================================================================== */
|
|
|
|
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 { 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,
|
|
inputKey,
|
|
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";
|
|
import { pageHistory } from "./B05_Profile_UI_Profile_History";
|
|
|
|
const MODE_KEY = "drainage-mode";
|
|
|
|
export interface DrainageLiveDeps {
|
|
/** 패널 뿌리 — 머리(⚙) · 지도(흐림 · 구석 「계산 중」)를 이 안에서 찾음. */
|
|
root: HTMLElement;
|
|
/** 제안 목록을 이 앞에 둠. */
|
|
basinList: HTMLElement;
|
|
pipeRequest: () => DetailPipeInput[];
|
|
runner: DetailRunner;
|
|
/** [적용] · [자동 제안] 답이 화면에 오른 뒤 — 세션 초안에 담음. */
|
|
rememberPipes: () => void;
|
|
/** 저장 배수유역 다시 받기(열 때와 같은 길 · 조용히) — 답을 화면에 올렸으면 true. */
|
|
loadSaved: () => Promise<boolean>;
|
|
}
|
|
|
|
export interface DrainageLive {
|
|
/** 계산 묶음 drainage 단계 — 노선 초안 · 관이 바뀐 차례에 부름(출력 없음). */
|
|
stage: StageFn<"drainage">;
|
|
/** 낡음을 지금 노선으로 다시 셈(자동 · [업데이트]와 같은 길). */
|
|
refresh(): void;
|
|
/** 서버 답이 「낡음」 — 흐림 + [업데이트](자동이면 3 s 뒤 셈). 이미 낡음이면 그대로. */
|
|
markStale(): void;
|
|
/** [확정]이 서버에서 다시 셈 — 낡음 지움(떠 있는 요청 · 기다림도 끊음). */
|
|
markFresh(): void;
|
|
/** [배관 배치 초기화] — 세류선 교차 관만으로 초안(369 · 저장분은 [저장] 때). */
|
|
resetToStreams(): void;
|
|
readonly stale: boolean;
|
|
suggest: DrainageSuggest;
|
|
}
|
|
|
|
/** 「지금 노선으로 셈」 — 서버 `post_drainage_live` · 작업본에 안 씀. */
|
|
async function fetchLive(
|
|
projectId: string,
|
|
body: ReturnType<typeof liveRequestBody> | (Proposal & { generation: number }),
|
|
signal: AbortSignal,
|
|
): Promise<DetailBasinResponse & { status?: string; generation?: number }> {
|
|
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));
|
|
}
|
|
|
|
const L = (key: keyof typeof ui_locales): string => ui_locales[key][currentLanguageIndex];
|
|
|
|
/** 서버 「낡음」 글 머리 — 짝 `B04_PreProcess_Router_Basins.STALE_MESSAGE`. */
|
|
const STALE_PREFIX = "배수유역 낡음";
|
|
|
|
/** [자동 제안] · [배관 배치 초기화] 요청 — 관 목록 없음(null) = 세류선 교차만. */
|
|
type Proposal = { points: DetailPipeInput[] | null; auto?: "all" | "add" };
|
|
|
|
export function createDrainageLive(deps: DrainageLiveDeps): DrainageLive {
|
|
const viewport = deps.root.querySelector<HTMLElement>(".b05-drainage__viewport") as HTMLElement;
|
|
const header = deps.root.querySelector<HTMLElement>(".b05-drainage__header") as HTMLElement;
|
|
const propose = header.querySelector<HTMLButtonElement>(".b05-drainage__propose");
|
|
/** 관 초안 요청 — 답이 화면에 오르면 세션 초안 + 사진첩 한 걸음(400 ms 넘은 답도 제 걸음). */
|
|
const draft = (request: (projectId: string) => Promise<DetailBasinResponse>): void => {
|
|
const projectId = deps.runner.projectId();
|
|
if (!projectId) return;
|
|
void deps.runner
|
|
.run(() => request(projectId))
|
|
.then((ok) => {
|
|
if (!ok) return;
|
|
deps.rememberPipes();
|
|
pageHistory()?.record();
|
|
});
|
|
};
|
|
const suggest = createDrainageSuggest(deps.pipeRequest, (points) =>
|
|
draft((projectId) => computeDetailBasins(projectId, points)),
|
|
);
|
|
deps.basinList.before(suggest.root);
|
|
let mode = readDrainageMode();
|
|
let route: unknown = null;
|
|
let stale = false;
|
|
/** 마지막 단계에 노선 초안이 있었나 — 없어지면 저장분으로 돌아옴. */
|
|
let drafted = false;
|
|
/** 저장분 다시 받기 답이 「낡음」이었나(`markStale` 이 세움). */
|
|
let savedStale = false;
|
|
/** 저장 분석이 옛 노선(서버 「낡음」 답 · [확정] 전까지) — [자동 제안]은 지금 노선으로 셈. */
|
|
let analysisStale = false;
|
|
let generation = 0;
|
|
/** 마지막 단계 입력 지문 — 같으면 묶음이 다시 돈 것뿐. */
|
|
let key = "";
|
|
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 && mode === "manual");
|
|
suggest.setStale(stale);
|
|
if (propose) {
|
|
propose.disabled = stale;
|
|
propose.title = L(stale ? "B05_Drainage_Btn_Propose_Stale" : "B05_Drainage_Btn_Propose_Tip");
|
|
}
|
|
veil.hidden = !stale || mode === "auto" || inflight !== null;
|
|
busy.root.hidden = inflight === null;
|
|
};
|
|
|
|
function cancel(): void {
|
|
window.clearTimeout(timer);
|
|
timer = 0;
|
|
inflight?.abort();
|
|
inflight = null;
|
|
}
|
|
|
|
/** 지금 노선으로 셈 — `proposal` 이면 그 관 목록 · 방식으로(답 = 관 초안 · 사진첩 한 걸음). */
|
|
function refresh(proposal?: Proposal): void {
|
|
const projectId = deps.runner.projectId();
|
|
if (!projectId) return;
|
|
cancel();
|
|
const sent = { generation: ++generation, key };
|
|
const controller = new AbortController();
|
|
inflight = controller;
|
|
paint();
|
|
const body = { ...liveRequestBody(sent.generation, route, deps.pipeRequest()), ...proposal };
|
|
void 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;
|
|
inflight = null;
|
|
if (applied) stale = false;
|
|
if (applied && proposal) {
|
|
deps.rememberPipes();
|
|
pageHistory()?.record();
|
|
}
|
|
paint();
|
|
});
|
|
}
|
|
|
|
/** [자동 제안] · [배관 배치 초기화] — 저장 분석이 지금 노선과 같으면 빠른 길(detail-basins) ·
|
|
* 그 길이 「낡음」(409 · 화면이 아직 몰랐던 낡음)이면 곧바로 지금 노선으로 셈 길로 다시. */
|
|
function proposeDraft(proposal: Proposal): void {
|
|
if (analysisStale || draftMakesStale(route)) return refresh(proposal);
|
|
draft(async (projectId) => {
|
|
try {
|
|
return await computeDetailBasins(projectId, proposal.points, proposal.auto);
|
|
} catch (error) {
|
|
if (!(error instanceof Error) || !error.message.startsWith(STALE_PREFIX)) throw error;
|
|
analysisStale = true;
|
|
queueMicrotask(() => refresh(proposal)); // 이 요청은 조용히 버림(뒤 차례가 받음)
|
|
throw new DOMException("stale", "AbortError");
|
|
}
|
|
});
|
|
}
|
|
|
|
/** 평면 · 관 자리 조작 — 처음으로(요청 끊기 · 다시 흐림 또는 다시 3 s). */
|
|
function invalidate(): void {
|
|
cancel();
|
|
stale = true;
|
|
if (mode === "auto") timer = window.setTimeout(refresh, QUIET_MS);
|
|
paint();
|
|
}
|
|
|
|
/** 초안이 없어짐 — 저장분을 다시 받아 그 답의 낡음대로(흐림은 답이 올 때까지 그대로). */
|
|
function backToSaved(): void {
|
|
cancel();
|
|
const sent = ++generation;
|
|
savedStale = false; // 답이 「낡음」이면 apply 가 markStale 로 세움
|
|
paint();
|
|
void deps.loadSaved().then((applied) => {
|
|
if (sent !== generation) return;
|
|
stale = !applied || savedStale;
|
|
if (stale && 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 openPropose(): void {
|
|
if (stale) return;
|
|
openModal({
|
|
title: L("B05_Drainage_Propose_Title"),
|
|
closeLabel: "닫기",
|
|
mount(body, close) {
|
|
const choices: Array<["all" | "add", string, string]> = [
|
|
["all", L("B05_Drainage_Propose_All"), L("B05_Drainage_Propose_All_Hint")],
|
|
["add", L("B05_Drainage_Propose_Add"), L("B05_Drainage_Propose_Add_Hint")],
|
|
];
|
|
choices.forEach(([value, label, hint], order) => {
|
|
const row = document.createElement("label");
|
|
row.className = "b05-drainage__mode";
|
|
const input = document.createElement("input");
|
|
input.type = "radio";
|
|
input.name = "b05-drainage-propose";
|
|
input.addEventListener("change", () => {
|
|
close();
|
|
proposeDraft({ points: deps.pipeRequest(), auto: value });
|
|
});
|
|
const text = document.createElement("span");
|
|
text.textContent = `${order === 0 ? "①" : "②"} ${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);
|
|
propose?.addEventListener("click", openPropose);
|
|
|
|
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: () => refresh(),
|
|
suggest,
|
|
markStale() {
|
|
savedStale = true;
|
|
analysisStale = true;
|
|
if (stale) return;
|
|
stale = true;
|
|
if (mode === "auto" && !inflight) timer = window.setTimeout(refresh, QUIET_MS);
|
|
paint();
|
|
},
|
|
markFresh() {
|
|
cancel();
|
|
stale = false;
|
|
analysisStale = false;
|
|
paint();
|
|
},
|
|
resetToStreams: () => proposeDraft({ points: null }),
|
|
get stale() {
|
|
return stale;
|
|
},
|
|
};
|
|
}
|