From de2720c46b1851b85e889219ab2cc1564c699fc3 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 09:37:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(M01-=EC=89=AC=EC=9A=B4=EB=8B=A8=EA=B0=80):?= =?UTF-8?q?=20=EC=B9=B4=EB=93=9C=20=EA=B7=B8=EB=A6=AC=EA=B8=B0=20Cards=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20=EB=AC=B6=EC=9D=8C=C2=B7=EC=86=8C?= =?UTF-8?q?=EA=B3=84=C2=B7=EB=AC=B8=EC=9E=A5=20=EC=B9=B8=C2=B7=EC=9E=A0?= =?UTF-8?q?=EA=B8=88=20=EC=B9=B4=EB=93=9C=C2=B7=EC=A7=80=EC=9B=80=C2=B7?= =?UTF-8?q?=E2=96=B2=E2=96=BC=C2=B7=EA=B2=BD=EA=B3=A0=C2=B7=EB=8D=94?= =?UTF-8?q?=ED=95=98=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c --- .../EasyCalc/M01_EasyCalc_Cards.css | 192 +++++++ M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts | 517 ++++++++++++++++++ 2 files changed, 709 insertions(+) create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css create mode 100644 M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css new file mode 100644 index 000000000..2d5692659 --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.css @@ -0,0 +1,192 @@ +.m01-easycards { + display: flex; + flex-direction: column; + gap: var(--spacing-12); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-easycards-group { + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); +} + +.m01-easycards-group__head, +.m01-easycards-group--empty { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: var(--spacing-8); + min-height: 44px; + padding: var(--spacing-8) var(--spacing-16); +} + +.m01-easycards-group__head { + cursor: pointer; +} + +.m01-easycards-group--empty { + border-style: dashed; + color: var(--color-text-muted); +} + +.m01-easycards-group__title { + font-weight: var(--font-weight-bold); +} + +.m01-easycards-group__sum { + font-weight: var(--font-weight-medium); +} + +.m01-easycards-list { + display: flex; + flex-direction: column; + margin: 0; + padding: 0; + list-style: none; +} + +.m01-easycards-card { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-16); + border-top: 1px solid var(--color-border); +} + +.m01-easycards-card--locked { + border-left: 4px dashed var(--color-border); + background: var(--color-surface-raised); +} + +.m01-easycards-line { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-easycards-sentence { + display: flex; + flex: 1 1 24em; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-4); + min-width: 0; +} + +.m01-easycards-tag { + font-weight: var(--font-weight-bold); +} + +.m01-easycards-name { + font-weight: var(--font-weight-medium); +} + +.m01-easycards-name--pick { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-inputs); + background: var(--color-surface); + color: inherit; + font: inherit; + cursor: pointer; +} + +.m01-easycards-name--pick:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-easycards .ui-input, +.m01-easycards .ui-select { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-easycards-qty { + flex: none; + width: 7em; +} + +.m01-easycards-text { + flex: none; + width: 10em; +} + +.m01-easycards-select { + flex: none; + width: auto; +} + +.m01-easycards-auto, +.m01-easycards-why { + color: var(--color-text-secondary); +} + +.m01-easycards-amount { + margin-left: auto; + font-weight: var(--font-weight-bold); + white-space: nowrap; +} + +.m01-easycards-amount--wait { + opacity: 0.5; + font-weight: var(--font-weight-regular); +} + +.m01-easycards-tools { + display: flex; + gap: var(--spacing-4); +} + +.m01-easycards-tools .ui-btn, +.m01-easycards-locknote .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-easycards-warn { + margin: 0; + padding: var(--spacing-4) var(--spacing-8); + border-left: 4px solid var(--color-warning); + font-weight: var(--font-weight-medium); +} + +.m01-easycards-locknote { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-easycards-lock { + padding: 0 var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + font-size: var(--text-body-sm); + font-weight: var(--font-weight-bold); +} + +.m01-easycards-add { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-easycards-add .ui-btn { + min-height: 44px; + font-size: var(--text-body); +} + +@media (max-width: 640px) { + .m01-easycards-amount { + margin-left: 0; + } + + .m01-easycards-tools { + width: 100%; + } +} diff --git a/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts new file mode 100644 index 000000000..4e6d5aebd --- /dev/null +++ b/M01_MasterData/EasyCalc/M01_EasyCalc_Cards.ts @@ -0,0 +1,517 @@ +/* ============================================================================= + * M01_EasyCalc_Cards.ts + * 쉬운 단가산출 카드 그리기 — 묶음 + 소계 · 문장 칸 · 잠금 카드 · 지움 · ▲▼ · 카드 안 경고 · 더하기 단추 + * 모양은 `docs/raw/M01_쉬운단가/설계.md` 3절 그대로 · 바뀐 doc 는 on.change 로만 알림(그리기는 부른 쪽이 다시) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog } from "@ui/ui_template_elements"; +import type { CalcAnswer, CalcLine } from "../M01_MasterData_UI_Logic_Api"; +import { formatNumber } from "../M01_MasterData_UI_Logic_Edit"; +import { formatMoney } from "../M01_MasterData_UI_Logic_Money"; +import { + addCard, + checkCard, + moveCard, + removeCard, + updateCard, + type CostKind, + type EasyCard, + type EasyDoc, + type PickResult, +} from "./M01_EasyCalc_Model"; +import { openEasyPick } from "./M01_EasyCalc_Pick"; +import "./M01_EasyCalc_Cards.css"; + +const TEXT = { + Group_person: ["사람", "Workers"], + Group_material: ["재료", "Materials"], + Group_machine: ["기계·경비", "Machines"], + Group_logic: ["다른 일위대가", "Other unit prices"], + Group_percent: ["덧붙임", "Add-ons"], + Subtotal: ["소계", "Subtotal"], + Won: ["원", "KRW"], + Empty: ["아직 없음", "none yet"], + Pending: ["계산 중", "calculating"], + Tag_person: ["사람", "Worker"], + Tag_material: ["재료", "Material"], + Tag_machine: ["기계", "Machine"], + Tag_logic: ["다른 일위대가", "Other unit price"], + Tag_percent: ["덧붙임", "Add-on"], + Pre_person: [" 이 ", " works "], + Post_person: [" 일함", ""], + Pre_pick: [" 를 ", " uses "], + Post_material: [" 씀", ""], + Post_machine: [" 씀", ""], + Post_logic: ["", ""], + Pre_percent: [" 의 ", " of "], + Mid_percent: [" % 를 ", " % as "], + Post_percent: [" 로 더함", ""], + Slot: ["넣을 자리", "put into"], + Pick_Empty: ["고르기", "choose"], + Repick: ["다른 것으로 다시 고르기", "Choose another"], + Eg_qty: ["예: 0.072", "e.g. 0.072"], + Eg_percent: ["예: 2", "e.g. 2"], + Eg_name: ["예: 공구손료", "e.g. tool wear"], + Qty: ["수량", "Amount"], + Name: ["이름", "Name"], + Auto: ["자동", "auto"], + Locked: ["잠김", "Locked"], + ToNumber: ["숫자로 바꾸기", "Make it a number"], + ToNumber_Wait: ["계산이 끝난 뒤 가능", "Available after calculation"], + ToNumber_Ask: [ + "자동 값을 그대로 숫자로 바꿈 · 이후 자동으로 달라지지 않음", + "Freeze the auto value as a number", + ], + ToNumber_Ok: ["바꾸기", "Convert"], + Remove: ["지움", "Remove"], + Up: ["위로", "Move up"], + Down: ["아래로", "Move down"], + Add_person: ["+ 사람", "+ Worker"], + Add_material: ["+ 재료", "+ Material"], + Add_machine: ["+ 기계", "+ Machine"], + Add_logic: ["+ 다른 일위대가", "+ Other unit price"], + Add_percent: ["+ 덧붙임", "+ Add-on"], + Base_노무비: ["사람 품값", "labor cost"], + Base_재료비: ["재료값", "material cost"], + Base_경비: ["기계·경비", "machine & expenses"], + Base_사람줄합: ["사람 줄의 합", "sum of worker lines"], +} as const; + +const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +type Group = "person" | "material" | "machine" | "logic" | "percent"; +const GROUPS: Group[] = ["person", "material", "machine", "logic", "percent"]; +type PercentBase = NonNullable; +const BASES: PercentBase[] = ["노무비", "재료비", "경비", "사람줄합"]; +const COSTS: CostKind[] = ["노무비", "재료비", "경비"]; + +interface Ctx { + doc: EasyDoc; + lines: Map; + readOnly: boolean; + on: { change(doc: EasyDoc): void }; + /** 숫자 아님으로 친 글 — 다시 그려도 칸에 남김 */ + badText: Map; +} + +const badTexts = new WeakMap>(); +const closedGroups = new WeakMap>(); +let blankSeq = 0; +let picking = false; + +const groupOf = (card: EasyCard): Group => { + if (card.kind !== "locked") return card.kind; + if (card.source?.part === "덧줄") return "percent"; + const kind = (card.raw as { 종류?: string } | null)?.종류; + return kind === "인력" ? "person" : kind === "재료" ? "material" : "logic"; +}; + +const partOf = (card: EasyCard): "호표" | "덧줄" => + card.source?.part ?? (card.kind === "percent" ? "덧줄" : "호표"); + +/** 카드 → 서버 계산 줄 — 호표 줄 차례 뒤에 덧줄 차례 · 줄 수가 안 맞으면(옛 응답) 없음 */ +function lineMap(doc: EasyDoc, ans: CalcAnswer | null): Map { + const out = new Map(); + if (!ans || !ans.ok || !ans.lines) return out; + const ordered = [ + ...doc.cards.filter((c) => partOf(c) === "호표"), + ...doc.cards.filter((c) => partOf(c) === "덧줄"), + ]; + if (ordered.length !== ans.lines.length) return out; + ordered.forEach((card, i) => out.set(card.id, ans.lines![i])); + return out; +} + +const parseQty = (text: string): number | null => { + const t = text.replace(/[,\s]/g, ""); + if (t === "") return null; + return /^[+-]?(\d+\.?\d*|\.\d+)$/.test(t) ? Number(t) : Number.NaN; +}; + +/** 검사가 막혀도 그리기는 계속 */ +function warnOf(ctx: Ctx, card: EasyCard): string | null { + if (ctx.doc.isBase) return null; + try { + return checkCard(card, ctx.doc); + } catch { + return null; + } +} + +function patchCard(ctx: Ctx, card: EasyCard, patch: Partial): void { + ctx.on.change(updateCard(ctx.doc, card.id, patch)); +} + +/** 같은 묶음 안 이웃을 지나갈 때까지 한 칸씩 — 묶음 밖 카드가 사이에 있어도 눈에 보이게 옮겨짐 */ +function moveInGroup(doc: EasyDoc, card: EasyCard, step: -1 | 1): EasyDoc { + const same = doc.cards.filter((c) => groupOf(c) === groupOf(card)); + const neighbor = same[same.indexOf(card) + step]; + if (!neighbor) return doc; + const at = (d: EasyDoc, id: string): number => d.cards.findIndex((c) => c.id === id); + let next = doc; + for (let n = doc.cards.length; n > 0; n--) { + if ((at(next, card.id) - at(next, neighbor.id)) * step >= 0) break; + const moved = moveCard(next, card.id, step); + if (moved === next) break; + next = moved; + } + return next; +} + +async function repick(ctx: Ctx, card: EasyCard): Promise { + if (picking || card.kind === "locked" || card.kind === "percent") return; + picking = true; + try { + const pick = await openEasyPick(card.kind); + if (!pick) return; + let next = updateCard(ctx.doc, card.id, { + name: pick.name, + spec: pick.spec, + unit: pick.unit, + element: pick.element, + }); + const have = new Set(next.inputs.map((i) => i.이름)); + const extra = (pick.needInputs ?? []).filter((i) => !have.has(i.이름)); + if (extra.length) next = { ...next, inputs: [...next.inputs, ...extra] }; + ctx.on.change(next); + } finally { + picking = false; + } +} + +async function addByPick(ctx: Ctx, kind: PickResult["kind"]): Promise { + if (picking) return; + picking = true; + try { + const pick = await openEasyPick(kind); + if (pick) ctx.on.change(addCard(ctx.doc, pick)); + } finally { + picking = false; + } +} + +function addBlankPercent(ctx: Ctx): void { + const card: EasyCard = { + id: `blank-${++blankSeq}`, + kind: "percent", + name: "", + spec: "", + unit: "%", + qty: null, + qtyFormula: null, + cost: "경비", + percentBase: "노무비", + element: null, + lockedWhy: null, + source: null, + raw: null, + }; + ctx.on.change({ ...ctx.doc, cards: [...ctx.doc.cards, card] }); +} + +async function lockToNumber(ctx: Ctx, card: EasyCard, value: number): Promise { + const ask = `${tc("ToNumber_Ask")} (${formatNumber(value)})`; + if (await showConfirmDialog(ask, tc("ToNumber_Ok"))) { + patchCard(ctx, card, { qty: value, qtyFormula: null, lockedWhy: null }); + } +} + +/* ------------------------------ 칸 만들기 ------------------------------ */ + +const text = (cls: string, value: string): HTMLSpanElement => + el("span", { className: cls, text: value }); + +function qtyInput(ctx: Ctx, card: EasyCard): HTMLInputElement { + const bad = ctx.badText.get(card.id); + const shown = card.qty === null ? "" : Number.isNaN(card.qty) ? (bad ?? "") : String(card.qty); + const input = el("input", { + className: "ui-input m01-easycards-qty", + attrs: { + type: "text", + inputmode: "decimal", + placeholder: tc(card.kind === "percent" ? "Eg_percent" : "Eg_qty"), + "aria-label": tc("Qty"), + }, + }); + input.value = shown; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + const qty = parseQty(input.value); + if (Number.isNaN(qty)) ctx.badText.set(card.id, input.value); + else ctx.badText.delete(card.id); + if (Object.is(qty, card.qty)) return; + patchCard(ctx, card, { qty }); + }); + return input; +} + +function nameWidget(ctx: Ctx, card: EasyCard): HTMLElement { + if (card.kind === "percent") { + const input = el("input", { + className: "ui-input m01-easycards-text", + attrs: { type: "text", placeholder: tc("Eg_name"), "aria-label": tc("Name") }, + }); + input.value = card.name; + if (ctx.readOnly) input.readOnly = true; + input.addEventListener("change", () => { + if (input.value !== card.name) patchCard(ctx, card, { name: input.value.trim() }); + }); + return input; + } + const label = [card.name, card.spec].filter(Boolean).join(" ") || tc("Pick_Empty"); + if (ctx.readOnly || card.kind === "locked") return text("m01-easycards-name", label); + const button = el("button", { + className: "m01-easycards-name m01-easycards-name--pick", + text: `${label} ▾`, + attrs: { type: "button", "aria-label": `${label} — ${tc("Repick")}` }, + }); + button.addEventListener("click", () => void repick(ctx, card)); + return button; +} + +function selectBox( + ctx: Ctx, + values: T[], + current: T | null, + label: (v: T) => string, + onPick: (v: T) => void, + aria: string, +): HTMLElement { + if (ctx.readOnly) return text("m01-easycards-name", current ? label(current) : ""); + const select = el("select", { + className: "ui-select m01-easycards-select", + attrs: { "aria-label": aria }, + }); + if (current === null) + select.append(el("option", { attrs: { value: "" }, text: tc("Pick_Empty") })); + for (const v of values) select.append(el("option", { attrs: { value: v }, text: label(v) })); + select.value = current ?? ""; + select.addEventListener("change", () => { + if (select.value) onPick(select.value as T); + }); + return select; +} + +function amountText(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + if (!line) return text("m01-easycards-amount m01-easycards-amount--wait", `= ${tc("Pending")}`); + return text("m01-easycards-amount", `= ${formatMoney(line.금액)} ${tc("Won")}`); +} + +function toolButtons(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement | null { + if (ctx.readOnly) return null; + const at = group.indexOf(card); + const move = (label: string, glyph: string, step: -1 | 1, off: boolean) => { + const b = createButton({ + label: glyph, + variant: "ghost", + size: "sm", + disabled: off, + onClick: () => ctx.on.change(moveInGroup(ctx.doc, card, step)), + }); + b.setAttribute("aria-label", label); + return b; + }; + return el("div", { + className: "m01-easycards-tools", + children: [ + move(tc("Up"), "▲", -1, at <= 0), + move(tc("Down"), "▼", 1, at >= group.length - 1), + createButton({ + label: tc("Remove"), + variant: "ghost", + size: "sm", + onClick: () => ctx.on.change(removeCard(ctx.doc, card.id)), + }), + ], + }); +} + +/** 한 카드 = 문장 한 줄(좁으면 두 줄로 접힘) */ +function cardView(ctx: Ctx, card: EasyCard, group: EasyCard[]): HTMLElement { + const g = groupOf(card); + const locked = card.kind === "locked"; + const name = nameWidget(ctx, card); + const parts: (HTMLElement | string)[] = [text("m01-easycards-tag", `【${tc(`Tag_${g}`)}】`)]; + + if (g === "percent") { + const base = card.percentBase; + parts.push( + selectBox( + ctx, + BASES, + locked ? null : base, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { percentBase: v }), + tc("Slot"), + ), + tc("Pre_percent"), + locked ? lockedValue(ctx, card) : qtyInput(ctx, card), + locked ? "" : tc("Mid_percent"), + name, + tc("Post_percent"), + ); + } else { + parts.push( + name, + g === "person" ? tc("Pre_person") : tc("Pre_pick"), + locked ? lockedValue(ctx, card) : qtyInput(ctx, card), + text("m01-easycards-unit", ` ${card.unit}`), + tc(`Post_${g}` as "Post_person"), + ); + } + + const slot = + g === "percent" && !locked + ? el("span", { + className: "m01-easycards-slot", + children: [ + ` · ${tc("Slot")} `, + selectBox( + ctx, + COSTS, + card.cost, + (v) => tc(`Base_${v}`), + (v) => patchCard(ctx, card, { cost: v }), + tc("Slot"), + ), + ], + }) + : null; + + const head = el("div", { + className: "m01-easycards-line", + children: [ + el("div", { + className: "m01-easycards-sentence", + children: slot ? [...parts, slot] : parts, + }), + amountText(ctx, card), + toolButtons(ctx, card, group) ?? "", + ], + }); + const rows: HTMLElement[] = [head]; + + const warn = warnOf(ctx, card); + if (warn) rows.push(el("p", { className: "m01-easycards-warn", text: `⚠ ${warn}` })); + if (locked) rows.push(lockedNote(ctx, card)); + return el("li", { + className: `m01-easycards-card${locked ? " m01-easycards-card--locked" : ""}`, + children: rows, + }); +} + +/** 잠금 카드 값 자리 — 「자동 0.50」 */ +function lockedValue(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const value = line ? formatNumber(line.수량) : tc("Pending"); + return text("m01-easycards-auto", `(${tc("Auto")} ${value})`); +} + +function lockedNote(ctx: Ctx, card: EasyCard): HTMLElement { + const line = ctx.lines.get(card.id); + const note: (HTMLElement | string)[] = [ + text("m01-easycards-lock", `🔒 ${tc("Locked")}`), + text("m01-easycards-why", card.lockedWhy ?? ""), + ]; + if (!ctx.readOnly) { + const button = createButton({ + label: tc("ToNumber"), + variant: "ghost", + size: "sm", + disabled: !line, + onClick: () => line && void lockToNumber(ctx, card, line.수량), + }); + if (!line) button.title = tc("ToNumber_Wait"); + note.push(button); + } + return el("div", { className: "m01-easycards-locknote", children: note }); +} + +/* ------------------------------ 묶음 · 전체 ------------------------------ */ + +function groupView(ctx: Ctx, host: HTMLElement, group: Group, cards: EasyCard[]): HTMLElement { + const title = tc(`Group_${group}`); + if (!cards.length) { + return el("section", { + className: "m01-easycards-group m01-easycards-group--empty", + children: [ + text("m01-easycards-group__title", title), + text("m01-easycards-group__none", tc("Empty")), + ], + }); + } + const found = cards.map((c) => ctx.lines.get(c.id)).filter((l): l is CalcLine => !!l); + const sum = + found.length === cards.length + ? `${tc("Subtotal")} ${formatMoney(found.reduce((s, l) => s + l.금액, 0))} ${tc("Won")}` + : `${tc("Subtotal")} ${tc("Pending")}`; + const details = el("details", { + className: "m01-easycards-group", + children: [ + el("summary", { + className: "m01-easycards-group__head", + children: [ + text("m01-easycards-group__title", title), + text( + `m01-easycards-group__sum${found.length === cards.length ? "" : " m01-easycards-amount--wait"}`, + sum, + ), + ], + }), + el("ul", { + className: "m01-easycards-list", + children: cards.map((c) => cardView(ctx, c, cards)), + }), + ], + }); + const closed = closedGroups.get(host) ?? new Set(); + closedGroups.set(host, closed); + details.open = !closed.has(group); + details.addEventListener("toggle", () => { + if (details.open) closed.delete(group); + else closed.add(group); + }); + return details; +} + +function addBar(ctx: Ctx): HTMLElement { + const add = (key: keyof typeof TEXT, run: () => void) => + createButton({ label: tc(key), variant: "ghost", onClick: run }); + return el("div", { + className: "m01-easycards-add", + children: [ + add("Add_person", () => void addByPick(ctx, "person")), + add("Add_material", () => void addByPick(ctx, "material")), + add("Add_machine", () => void addByPick(ctx, "machine")), + add("Add_logic", () => void addByPick(ctx, "logic")), + add("Add_percent", () => addBlankPercent(ctx)), + ], + }); +} + +/** 카드 묶음 그리기 — ans 는 줄 금액 · null 이면 「계산 중」 */ +export function renderCards( + host: HTMLElement, + doc: EasyDoc, + ans: CalcAnswer | null, + on: { change(doc: EasyDoc): void }, +): void { + const badText = badTexts.get(host) ?? new Map(); + badTexts.set(host, badText); + const ctx: Ctx = { doc, lines: lineMap(doc, ans), readOnly: doc.isBase, on, badText }; + host.classList.add("m01-easycards"); + host.replaceChildren( + ...GROUPS.map((g) => + groupView( + ctx, + host, + g, + doc.cards.filter((c) => groupOf(c) === g), + ), + ), + ...(ctx.readOnly ? [] : [addBar(ctx)]), + ); +}