/* ============================================================================= * M02_MasterTemplete_Labor_Detail.ts * 일위대가 상세(39-3) — 구조물 열마다 하나(`labor/<열>.json`) · UI_Main 종류 `labor` 가 이 부품을 붙임 * (양식 길 읽기 · [저장] · [다시 읽기]는 UI_Main). 차례 = 정보(단위 · 본뜬) → 담은 로직 → 자재목록 → 다이어그램. * · 담은 로직 줄 누름 = 고름(하이라이트 · 다시 그려도 유지 · 47-5) + 표 아래 그 줄 상세(50-4 · M01 시험계산 표) · * 같은 줄 다시 누르면 닫힘 · [옵션] = 입력 모달(`Labor_Input`) · * [확인] = 화면 캐시만 · 저장은 [저장] * · 금액 = 서버 calc(기준 ③ · 최신 응답만 · 저장 안 함) · 고칠 때마다 뒤에서 다시 셈 * · 같은 로직 두 번 · 같은 자재 두 번 막음 · 빈 일위대가도 저장 됨 * · 오른쪽 「원문」 접는 패널(47-4 · 상세만 · 공용 `ui_template_side_panels`) = 고른 줄 로직의 출처 원문 * · 서고 모드(50-3 · `mode`) = 단가승격 · 사전조합 — 이름 칸 · 단가산출 로직 표 · 원문만(자재 · 면적 · 다이어그램 없음) · * 승격은 로직 한 줄(입력값 고정 · 더하기 · 순서 · 수량 없음) · 사전조합 = [추가] · 수량 손 입력(기본 1) · 순서 · 삭제 * ========================================================================== */ import { createButton, createInputField, el, showToast } from "@ui/ui_template_elements"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { ROUTES } from "@config/config_frontend"; import { navigateTo } from "../A00_Common/router"; import { setPendingLogicKey } from "../M01_MasterData/M01_MasterData_UI_Side"; import { createSourceView } from "../M01_MasterData/M01_MasterData_UI_LogicLab_Source"; import { answerView } from "../M01_MasterData/M01_MasterData_UI_Logic_Calc"; import type { CalcAnswer } from "../M01_MasterData/M01_MasterData_UI_Logic_Api"; import { formatMoney } from "../M01_MasterData/M01_MasterData_UI_Logic_Money"; import type { Workbook } from "../A00_Common/spreadsheet/spreadsheet"; import { recalcWorkbook } from "../A00_Common/spreadsheet/spreadsheet_recalc"; import { getActiveLayer, getActiveOwner, readTemplate, saveTemplate, type ItemLogic, type Layer, } from "./M02_MasterTemplete_Api_Fetch"; import { linkAreaCell } from "./M02_MasterTemplete_AreaCellPick"; import { lineId, MONEY_KEYS, settle, type LaborRef } from "./M02_MasterTemplete_Labor_Calc"; import { areaLinks, calcLabor, fetchLogicInputs, normalizeLabor, type LaborCalc, type LaborCalcLine, type LaborCost, type LaborDoc, type LaborQty, type LaborRow, } from "./M02_MasterTemplete_Labor_Api"; import { buildLaborDiagram, diagramTitle, renderComboDiagram, } from "./M02_MasterTemplete_Labor_Diagram"; import { openLaborInput, type GoArea } from "./M02_MasterTemplete_Labor_Input"; import { openQtyModal } from "./M02_MasterTemplete_Labor_QtyModal"; import { openLogicPicker } from "./M02_MasterTemplete_Labor_Pick"; import { kindOf, logicTable, matTable, qtyShort } from "./M02_MasterTemplete_Labor_Rows"; import { tc } from "./M02_MasterTemplete_Labor_Text"; import { keepKnown } from "./M02_Labor_Values_Model"; import { pickMaterial } from "./M02_Material_Pick"; import { createUnitPick, NO_UNIT } from "./DataFlow/M02_DF_UnitPick"; import "../M01_MasterData/M01_MasterData_UI_Logic_Style.css"; import "../M01_MasterData/M01_MasterData_UI_LogicLab_Style.css"; import "@ui/ui_template_general_layout.css"; import "./M02_MasterTemplete_Labor.css"; export interface LaborDetailOptions { layer: Layer; column: string; readOnly: boolean; /** 저장 안 한 고침이 생기거나 사라짐 */ onChanged: (dirty: boolean) => void; /** 「면적산출에서 잇기」 — 그 열 면적산출 상세로(옮겨 가면 참 · 취소 = 거짓 · 없으면 단추 없음) */ goArea: GoArea | null; /** 구조물 조합 카드 갈래(43-4) — 단가산출 로직 표 + 자재목록만 · 위로 · 아래로 · 로직 추가 · * 자재 추가 · 정보(단위 · 본뜬) · 다이어그램 없음 · 옵션 · 삭제 · 줄 · 수량 모달은 상세와 같음 */ card?: boolean; /** 일위대가 조합 서고(50-3) — 단가승격 · 사전조합 문서(`column` = 키) */ mode?: "promote" | "precombo"; } export interface LaborDetailHandle { getDoc: () => LaborDoc; /** [엑셀에서 고르기]로 바뀐 면적산출 문서를 저장(일위대가 [저장] 앞) · 저장했으면 참(없으면 거짓) — * 서버가 면적 저장 때 그 열 일위대가 계산값을 다시 적어 일위대가 판이 바뀜 → 부르는 쪽이 판을 다시 읽음 */ saveArea: () => Promise; destroy: () => void; } export function mountLaborDetail( host: HTMLElement, doc: unknown, opt: LaborDetailOptions, ): LaborDetailHandle { const cur = normalizeLabor(doc, opt.column); const saved = JSON.stringify(cur); const edit = !opt.readOnly; const lib = opt.mode ?? null; const promote = lib === "promote"; const logics = new Map(); let calc: LaborCalc | null = null; let fail: string | null = null; let seq = 0; let timer = 0; let alive = true; /** 고른 줄 로직 키(47-5) — 키로 둬서 순서가 바뀌어도 따라감 */ let picked: string | null = null; /** 그 열 면적산출의 알약 → 칸 · 값(저장본 · 서버 calc 와 같은 것) */ let links = new Map(); const cells = (): Map => new Map([...links].map(([k, v]) => [k, v.칸])); const given = (): Record => Object.fromEntries([...links].flatMap(([k, v]) => (v.값 === null ? [] : [[k, v.값]]))); /** 그 열 면적산출 문서 · 판 — [엑셀에서 고르기]로 이으면 바뀜(일위대가 [저장] 때 함께 저장) */ let area: { doc: Workbook; 판: string } | null = null; let areaDirty = false; /** 알약 키 → 이름 · 단위(calc 답 `알약`) — 칸에 이을 때 연결 정보 */ let pills = new Map(); const dirty = (): boolean => areaDirty || JSON.stringify(cur) !== saved; const logicHost = el("div"); const matHost = el("div"); const dgHost = el("div"); const dgBox = el("details", { className: "m01lab__box m01-logic__section m02-labor__dg", attrs: { open: "" }, children: [el("summary", { text: diagramTitle() }), dgHost], }); /** 원문 패널(47-4 · 카드 갈래엔 없음) — 열려 있을 때만 찾음 · 고른 줄 없으면 안내 한 줄 */ const source = createSourceView(); const sourceHost = el("div", { attrs: { "data-labor-source": "" } }); const side = opt.card ? null : createSidePanels({ prefKey: "m02-labor-side", panels: [{ id: "source", title: tc("Labor_Source"), body: sourceHost }], onChange: () => showSource(), }); function showSource(): void { if (side?.current() !== "source") return; if (!picked) return void sourceHost.replaceChildren( el("p", { className: "m01-logic__muted", text: tc("Labor_SourceHint") }), ); source.show(logics.get(picked)?.출처 ?? ""); if (sourceHost.firstChild !== source.root) sourceHost.replaceChildren(source.root); } const lineOf = (key: string) => calc?.줄.find((l) => lineId(l) === key); const matOf = (code: string) => calc?.자재.find((m) => m.코드 === code); /** 고른 줄 상세(50-4) — 단가산출 = M01 시험계산 표(재료 · 노무 · 경비 줄 + 기준 합) · * 단가승격 = 원 로직 표 + 원 로직 키 + 기준(1 단위) · 사전조합 = 안 줄 목록 한 층 + 기준 · * 셋 다 아래 반영 합 · 고른 줄 없으면 비움 */ const lineDet = el("div", { className: "m02-labor__linedet", attrs: { "data-labor-linedet": "" }, }); const sums = (title: string, money: Partial | null | undefined): HTMLElement[] => [ el("h4", { text: title }), el("dl", { className: "m01-logic__sums", children: MONEY_KEYS.flatMap((k) => [ el("dt", { text: k === "계" ? tc("Prev_Sum") : k }), el("dd", { className: "m01-logic__money", text: money ? formatMoney(money[k] ?? 0) : "—", }), ]), }), ]; /** 사전조합 안 줄 — 구분 · 이름 · 수량 · 금액 넷(= 그 줄 1 단위 기준 × 수량) */ const innerTable = (inner: NonNullable): HTMLElement => el("table", { className: "m01-logic__grid", attrs: { "data-labor-inner": "" }, children: [ el("thead", { children: [ el("tr", { children: [tc("Sub"), tc("Col_Name"), tc("Labor_Qty"), ...MONEY_KEYS].map((h) => el("th", { text: h === "계" ? tc("Prev_Sum") : h }), ), }), ], }), el("tbody", { children: inner.map((x) => el("tr", { children: [ el("td", { text: kindOf({ 로직: "", 참조: x.참조 }) }), el("td", { text: `${x.참조.키} ${x.이름 ?? ""}${x.까닭 ? ` · ${x.까닭}` : ""}`.trim(), }), el("td", { text: `${x.수량} ${x.단위 ?? ""}`.trim() }), ...MONEY_KEYS.map((k) => el("td", { className: "m01-logic__money", text: x.기준 ? formatMoney(x.기준[k] * x.수량) : "—", }), ), ], }), ), }), ], }); function showLine(): void { if (!picked) return void lineDet.replaceChildren(); const row = cur.담은로직.find((r) => lineId(r) === picked); const line = lineOf(picked); const logic = logics.get(picked); const name = row?.참조 ? (line?.이름 ?? "") : (logic?.이름 ?? ""); const head = el("h4", { text: tc("Labor_LineDetail").replace("{value}", `${row?.참조?.키 ?? picked} ${name}`.trim()), }); if (!line) return void lineDet.replaceChildren( head, el("p", { className: "m01-logic__muted", text: fail ?? "…" }), ); const stop: CalcAnswer | null = line.까닭 ? { ok: false, reason: line.까닭 } : null; const unit = `${tc("Labor_LineBase")} (1 ${line.분모 ?? ""})`.trim(); const body: HTMLElement[] = !row?.참조 ? answerView( stop ?? { ok: true, lines: line.줄, sums: line.기준 ?? undefined, result: line.결과, middle: {}, }, false, (logic?.결과단위 ?? "").startsWith("원"), ) : line.안 ? [ innerTable(line.안), ...(stop ? answerView(stop, false, true) : []), ...sums(unit, line.기준), ] : [ el("p", { className: "m01-logic__muted", text: `${tc("Labor_FromLogic")} ${line.원로직 ?? "—"}`, }), ...answerView(stop ?? { ok: true, lines: line.줄, middle: {} }, false, true), ...sums(unit, line.기준), ]; // 반영 = 기준 × 수량 ÷ 배(표 아래 줄과 같음) · 수량 없으면 「—」 · 돈 아닌 로직은 없음 const applied = line.기준 ? sums(tc("Labor_LineApplied"), line.반영) : []; lineDet.replaceChildren(head, ...body, ...applied); } const drawDg = (): void => { if (opt.card || lib || !dgBox.open) return; // 카드 갈래 · 서고 모드엔 다이어그램 없음 const data = buildLaborDiagram({ rows: cur.담은로직.filter((r) => !r.참조), // ponytail: 참조 줄은 다이어그램에 안 그림 — 그릴 때 카드 꼴 더함 logics: [...logics.values()], calc, mats: cur.자재.length, }); renderComboDiagram(dgHost, data, { focusInput: (key) => { const i = cur.담은로직.findIndex((r) => r.로직 === key); if (i >= 0) openRow(i); }, // 로직 카드 → 단가산출 로직 화면(떠날 때 물음은 UI_Main 의 leave guard) openLogic: (key) => { setPendingLogicKey(key); navigateTo(ROUTES.M01_MASTER_DATA); }, }); }; dgBox.addEventListener("toggle", drawDg); const draw = (): void => { logicHost.replaceChildren( logicTable( { rows: cur.담은로직, logics, calc, fail, cells: cells(), selected: picked }, // 읽기만이면 순서 · 삭제 없음([옵션] = 보기 모달은 둠) edit && !promote ? { open: pick, option: openRow, qty: openQty, ...(opt.card ? {} : { move }), remove: removeRow, alias: setAlias, } : { open: pick, option: openRow }, ), lineDet, ); showLine(); matHost.replaceChildren( matTable( { mats: cur.자재, calc, fail, cells: cells() }, edit ? { open: openMat, remove: removeMat } : { open: openMat }, ), ); drawDg(); showSource(); }; /** 서버 다시 셈 — 늦게 온 답은 버림(최신 응답만) */ const recalc = (): void => { window.clearTimeout(timer); timer = window.setTimeout(async () => { const mine = ++seq; calc = null; fail = null; draw(); try { // 저장 안 한 칸 연결이 있으면 그 칸 값으로(없으면 서버가 저장된 면적산출을 읽음) const got = await calcLabor(opt.layer, opt.column, cur, areaDirty ? given() : undefined); if (!alive || mine !== seq) return; calc = got; pills = new Map( (got.알약 as { 키: string; 이름?: string; 단위?: string }[]).map((p) => [p.키, p]), ); } catch (error) { if (!alive || mine !== seq) return; fail = error instanceof Error ? error.message : tc("Labor_Calc_Fail"); } draw(); }, 150); }; /** `qtyOnly` = 수량만 바뀜 — 기준은 그대로라 짝 셈(`Labor_Calc.settle`)으로 바로 곱함(서버 안 부름) */ const changed = (qtyOnly = false): void => { opt.onChanged(dirty()); if (qtyOnly && calc) { calc = settle(calc, cur, given()); draw(); } else recalc(); }; /** 칸 잇기 · 풀기(`cell` null) — 화면 문서만(저장은 [저장]) · 됐으면 참 */ const linkCell = (pill: string, cell: string | null, replace = false): boolean => { if (!area) { showToast(tc("Labor_NoArea"), "error"); return false; } const info = pills.get(pill); const next = linkAreaCell( area.doc, pill, cell, { ...(info?.이름 ? { 이름: info.이름 } : {}), ...(info?.단위 ? { 단위: info.단위 } : {}), }, replace, // 그 칸 다른 알약 — 기본 함께 · [바꾸기]면 뺌(물음은 시트 안) ); if (!next) { showToast(tc("Labor_BadCell"), "error"); return false; } next.계산값 = recalcWorkbook(next); // 식 칸 값도 바로 — 저장 때 서버가 다시 셈 area.doc = next; links = areaLinks(next); areaDirty = true; return true; }; /** 수량산출 모달(43-1) — 로직 줄 · 자재 줄 같음 · 손 · 칸 잇기 · 풀기로 `target.수량` 을 바꿈 */ const openQtyFor = ( title: string, target: { 수량: LaborQty }, line: { 수량?: number | null; 수량출처?: string; 알약?: string | null } | undefined, unit: string, ): void => { const pill = line?.알약 ?? null; openQtyModal({ title, now: qtyShort(line, unit, cells()), linked: !!pill && links.has(pill), hand: target.수량?.손 ?? null, unit, readOnly: !edit, sheet: { doc: area?.doc ?? null, layer: opt.layer, column: opt.column, pill }, goArea: opt.goArea, ...(lib ? { handOnly: true } : {}), done: (r) => { if (r.kind === "keep") return; if (r.kind === "hand") target.수량 = { 손: r.손 }; else if (r.kind === "follow") target.수량 = null; else { const cell = r.kind === "cell" ? r.pick.칸 : null; if (!pill || !linkCell(pill, cell, r.kind === "cell" && r.pick.바꾸기 === true)) return; target.수량 = null; // 칸을 따름 } changed(true); }, }); }; /** 아직 안 받은 로직 입력 목록만 받음 */ const loadLogics = async (): Promise => { const want = cur.담은로직.map((r) => r.로직).filter((k) => k && !logics.has(k)); // 참조 줄 = 로직 없음 if (!want.length) return; try { const got = await fetchLogicInputs(want); for (const l of got.로직) logics.set(l.로직, l); } catch (error) { showToast(error instanceof Error ? error.message : tc("Load_Failed"), "error"); } }; function move(i: number, d: number): void { const j = i + d; if (j < 0 || j >= cur.담은로직.length) return; [cur.담은로직[i], cur.담은로직[j]] = [cur.담은로직[j], cur.담은로직[i]]; changed(); } /** 줄 고름(47-5) — 하이라이트(표만 · 다이어그램 안 다시 그림) + 줄 상세(50-4) + 원문 패널(47-4) · * 고른 줄 다시 누름 = 닫음 · 화면 캐시 안 바뀜 */ function pick(i: number): void { const key = lineId(cur.담은로직[i]) ?? ""; picked = picked === key ? null : key; for (const tr of logicHost.querySelectorAll("[data-labor-logic],[data-labor-opt]")) tr.classList.toggle("is-selected", (tr.dataset.laborLogic ?? tr.dataset.laborOpt) === picked); showLine(); showSource(); } function removeRow(i: number): void { if (lineId(cur.담은로직[i]) === picked) picked = null; cur.담은로직.splice(i, 1); changed(); } /** 별칭(44-6) — 화면 캐시만(저장은 [저장]) · 금액과 무관이라 다시 셈 · 다시 그림 없음 */ function setAlias(i: number, value: string | null): void { cur.담은로직[i].별칭 = value; opt.onChanged(dirty()); } function removeMat(i: number): void { cur.자재.splice(i, 1); changed(); } function openRow(i: number): void { const row: LaborRow = cur.담은로직[i]; const logic = logics.get(row.로직) ?? null; openLaborInput({ title: `${row.로직} ${logic?.이름 ?? ""}`.trim(), logic, values: row.입력값, memo: row.메모, readOnly: !edit, done: (next) => { const values = logic ? keepKnown(logic.입력, next.입력값).kept : next.입력값; const same = JSON.stringify(values) === JSON.stringify(row.입력값); row.입력값 = values; row.메모 = next.메모; changed(same); // 입력값이 그대로면(메모만) 서버 안 부름 }, }); } /** 수량산출 칸 누름 — 로직 줄 */ function openQty(i: number): void { const row = cur.담은로직[i]; const line = lineOf(lineId(row) ?? ""); openQtyFor( `${row.참조?.키 ?? row.로직} ${(row.참조 ? line?.이름 : logics.get(row.로직)?.이름) ?? ""}`.trim(), row, line, line?.분모 ?? "", ); } /** 자재 줄 누름 — 수량산출 모달 */ function openMat(i: number): void { const mat = cur.자재[i]; openQtyFor(`${mat.코드} ${mat.이름 ?? ""}`.trim(), mat, matOf(mat.코드), mat.단위 ?? ""); } /** 서고 탭(50-3 ④) — 참조 줄 더함(같은 참조 두 번 막음 · 위 층만은 서버도 막음) */ const library = { layer: opt.layer, owner: opt.layer === getActiveLayer() ? getActiveOwner() : null, pick: (ref: LaborRef, name: string) => { const id = lineId({ 참조: ref }); if (cur.담은로직.some((r) => lineId(r) === id)) return showToast(tc("Labor_AlreadyRef").replace("{value}", `${ref.키} ${name}`), "error"); const 수량 = lib === "precombo" ? { 손: 1 } : null; // 사전조합 줄 기본 1(51-6) cur.담은로직.push({ 로직: "", 참조: ref, 별칭: null, 메모: null, 입력값: {}, 수량 }); changed(); }, }; /** 사전조합 [추가] — 서고 줄만 고르기(위 층만 · 자기 자신 · 순환은 [저장] 때 서버 422) */ const addRef = createButton({ label: tc("Lib_AddRow"), variant: "filled", onClick: () => openLogicPicker(() => undefined, { ...library, only: true }), }); const addLogic = createButton({ label: tc("AddLogic"), variant: "filled", onClick: () => openLogicPicker((x) => { if (cur.담은로직.some((r) => r.로직 === x.키)) return showToast(tc("Labor_Already").replace("{value}", `${x.키} ${x.이름}`), "error"); cur.담은로직.push({ 로직: x.키, 별칭: null, 메모: null, 입력값: {}, 수량: null }); void loadLogics().then(() => changed()); }, library), }); const addMat = createButton({ label: tc("Labor_AddMat"), variant: "filled", onClick: () => pickMaterial((m) => { if (cur.자재.some((x) => x.코드 === m.코드)) return showToast( tc("Labor_AlreadyMat").replace("{value}", `${m.코드} ${m.이름 ?? ""}`), "error", ); cur.자재.push({ 코드: m.코드, 이름: m.이름, 규격: m.규격, 단위: m.단위, 수량: null }); changed(); }), }); // 단위 = 표준 단위 목록에서 고르기(지금 값이 목록에 없어도 선택지로 둠) · 저장은 [저장] const unit = { root: el("div", { className: "ui-field", attrs: { "data-labor-field": "단위" } }), }; const unit0 = cur.단위; const drawUnit = (): void => unit.root.replaceChildren( el("label", { className: "ui-field__label", text: tc("Labor_Unit") }), createUnitPick({ value: cur.단위 || NO_UNIT, extra: [unit0, cur.단위].filter((u): u is string => !!u), readOnly: !edit, ariaLabel: tc("Labor_Unit"), onPick: (u) => { cur.단위 = u === NO_UNIT ? null : u; opt.onChanged(dirty()); drawUnit(); }, }), ); drawUnit(); const origin = createInputField({ type: "text", label: tc("Labor_Origin") }); origin.input.value = cur.본뜬?.키 ? `${cur.본뜬.층} ${cur.본뜬.키}` : tc("Origin_Self"); origin.input.readOnly = true; const section = (title: string, button: HTMLElement | null, body: HTMLElement): HTMLElement => el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01-logic__section-head", children: [el("h3", { text: title }), ...(edit && button ? [button] : [])], }), body, ], }); // 서고 모드 이름 칸(문서 `이름` · 목록에 보이는 이름) const nameField = createInputField({ type: "text", label: tc("Lib_Name") }); nameField.root.dataset.laborField = "이름"; nameField.input.value = String((cur as { 이름?: unknown }).이름 ?? ""); nameField.input.readOnly = !edit; nameField.input.addEventListener("input", () => { (cur as { 이름?: string }).이름 = nameField.input.value.trim(); opt.onChanged(dirty()); }); const editor = (body: HTMLElement): HTMLElement => side ? el("div", { className: "ui-side-panels-row", children: [body, side.root] }) : body; host.replaceChildren( editor( el("div", { className: "m01-logic__editor m01-combo m02-labor__edit", attrs: { "data-labor": opt.column }, children: [ ...(opt.card ? [] : [ el("section", { className: "m01lab__box m01-logic__section", children: [ el("div", { className: "m01lab__info", children: [...(lib ? [nameField.root] : []), unit.root, origin.root], }), ], }), ]), // 승격 = 로직 한 줄(새로 만들 때 고름) · 사전조합 = [추가] 서고 줄만 고르기(51-6) section( tc("Logics_Title"), opt.card || promote ? null : lib ? addRef : addLogic, logicHost, ), ...(lib ? [] : [section(tc("Labor_Mats"), opt.card ? null : addMat, matHost)]), ...(opt.card || lib ? [] : [dgBox]), ], }), ), ); draw(); // 로직 입력 목록 · 면적산출 이은 칸(없으면 빈 것 — 새 구조물)을 받은 뒤 첫 계산 void Promise.all([ loadLogics(), lib // 서고 모드엔 면적산출 없음 ? undefined : readTemplate(opt.layer, "area", opt.column, null).then( (got) => { area = { doc: got.문서 as Workbook, 판: got.판 ?? "" }; links = areaLinks(got.문서); }, () => undefined, ), ]).then(() => { if (alive) recalc(); }); return { // 서고 모드 = 문서 종류 그대로 · 열 · 자재 없음 getDoc: () => { if (!lib) return cur; const out: Record = { ...cur, 종류: promote ? "단가승격" : "사전조합" }; delete out.열; delete out.자재; return out as unknown as LaborDoc; }, saveArea: async () => { if (!area || !areaDirty) return false; const info = await saveTemplate(opt.layer, "area", opt.column, null, area.판, area.doc); area.판 = info.판; areaDirty = false; return true; }, destroy: () => { alive = false; window.clearTimeout(timer); host.replaceChildren(); }, }; }