/* ============================================================================= * M01_SL_Entry.ts * 시트 로직(시험) — 들어가는 단추 · 큰 창 · 시작 화면 · 고치는 화면 틀(위 다이어그램 · 아래 시트 판 · 결과) · 위 띠 * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · 이 폴더째 지워도 됨(폴더 밖 참조는 LogicLab.ts 두 줄 + 서버 등록 한 줄). * 1걸음 = 틀 — 다이어그램 · 시트 판 · 결과 · 저장 · 지우기 · 실행 취소 · 가져오기는 2걸음에 이음. * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, createInputField, createSelectField, createTag, el, showConfirmDialog, showToast, } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; import { listEasy, plainError, type EasyListItem } from "../EasyCalc/M01_EasyCalc_Api"; import { slList, type SlListItem } from "./M01_SL_Api"; import { newLogic, type SheetLogic } from "./M01_SL_Model"; import "./M01_SL_Entry.css"; const TEXT = { Open: ["시트 로직(시험)", "Sheet logic (trial)"], Title: ["시트 로직", "Sheet logic"], CloseWindow: ["창 닫기", "Close window"], Close: ["닫기", "Close"], Loading: ["불러오는 중", "Loading"], LoadFailed: ["목록을 못 불러옴 — {v}", "Could not load the list — {v}"], Trial: ["시험판 — 임시 폴더에 저장", "Trial — saved in a temporary folder"], Start_Mine: ["내 시트 로직", "My sheet logics"], Start_MineNone: ["아직 없음", "None yet"], Start_New: ["새로 만들기(빈 판)", "New (blank)"], Start_Import: ["기존 로직 가져오기", "Import an existing logic"], Start_Trial: ["먼저 볼 시험 로직", "Trial logics to start with"], Start_Search: ["이름으로 찾기 — 예: 돌쌓기", "Search by name — e.g. stone wall"], Start_Count: ["{n} 개 중 {m} 개 보임 — 더 좁혀 찾기", "Showing {m} of {n} — narrow the search"], Start_None: ["찾은 것 없음", "Nothing found"], Per: ["{u} 당", "per {u}"], Name: ["이름", "Name"], NamePlaceholder: ["예: 돌쌓기 내 방식", "e.g. my stone wall"], Unit: ["결과 단위", "Result unit"], Undo: ["실행 취소", "Undo"], Save: ["저장", "Save"], Delete: ["지우기", "Delete"], Unsaved: ["저장 안 함", "Not saved"], Diagram: ["상자와 선", "Boxes and lines"], Panel: ["고른 상자의 시트", "Sheet of the chosen box"], Result: ["결과", "Result"], NotYet: ["준비 중", "Coming soon"], Leave_Ask: [ "저장 안 한 것이 있음 — 저장하지 않고 닫을까요?", "There are unsaved changes — close without saving?", ], Leave_Ok: ["저장 안 하고 닫기", "Close without saving"], } as const; type TextKey = keyof typeof TEXT; const te = (key: TextKey, vars: Record = {}): string => Object.entries(vars).reduce( (text, [k, v]) => text.replace(`{${k}}`, String(v)), (TEXT[key][currentLanguageIndex as 0 | 1] ?? TEXT[key][0]) as string, ); /** 「기존 로직 가져오기」 에 먼저 보이는 시험 로직 여섯(정함 9) */ const TRIAL_KEYS = ["GF000208", "GF000219", "GF000025", "GF000249", "GF000133", "GC000051"]; /** 찾은 것 목록에 한 번에 보이는 줄 수 */ const SHOWN = 50; /** 「원/㎡」 → 「㎡」 · 「100㎡」 은 그대로 앞에 1 을 안 붙임 */ const amountOf = (resultUnit: string): string => { const unit = resultUnit.replace(/^원\//, ""); return /^\d/.test(unit) ? unit : `1${unit}`; }; const isMoney = (resultUnit: string): boolean => resultUnit.startsWith("원/"); /* ponytail: SL Api(sub4) · Model(sub7) 속이 「준비 중」 인 동안만 대신함 — 채워지면 두 catch 를 지움 */ const loadMine = (): Promise => slList().catch(() => []); const blankLogic = (name: string, unit: string): SheetLogic | null => { try { return newLogic(name, unit); } catch { return null; } }; /** 「로직 만들기(시험)」 옆에 놓는 단추 — 누르면 큰 창 */ export function createSheetLogicButton(): HTMLButtonElement { return createButton({ label: te("Open"), variant: "filled", onClick: openSheetLogic }); } function openSheetLogic(): void { let body: HTMLElement; /** 고치는 화면이 열려 있을 때만 */ let paper: { dirty(): boolean; sl: SheetLogic | null } | null = null; let logics: EasyListItem[] | null = null; let query = ""; const mayLeave = async (): Promise => !paper?.dirty() || showConfirmDialog(te("Leave_Ask"), te("Leave_Ok")); const notYet = (): void => showToast(te("NotYet"), "info"); /* ---------- 시작 화면 ---------- */ const importRow = (item: EasyListItem): HTMLElement => { const row = el("button", { className: "m01-sl__item", attrs: { type: "button", title: item.where }, children: [ el("span", { className: "m01-sl__item-name", text: item.name }), el("span", { className: "m01-sl__item-unit", text: isMoney(item.resultUnit) ? te("Per", { u: amountOf(item.resultUnit) }) : item.resultUnit, }), ], }); row.addEventListener("click", notYet); return row; }; const mineRow = (item: SlListItem): HTMLElement => { const row = el("button", { className: "m01-sl__item", attrs: { type: "button" }, children: [ el("span", { className: "m01-sl__item-name", text: item.이름 }), el("span", { className: "m01-sl__item-unit", text: item.결과단위 }), ], }); row.addEventListener("click", notYet); return row; }; const showStart = async (): Promise => { paper = null; const status = el("p", { className: "m01-sl__muted", text: te("Loading") }); const trialHost = el("div", { className: "m01-sl__list" }); const foundHost = el("div", { className: "m01-sl__list" }); const mineHost = el("div", { className: "m01-sl__list" }); void loadMine().then((mine) => mineHost.replaceChildren( ...(mine.length ? mine.map(mineRow) : [muted(te("Start_MineNone"))]), ), ); const search = createInputField({ type: "search", placeholder: te("Start_Search"), value: query, onInput: (v) => { query = v; drawFound(); }, }); const drawFound = (): void => { if (!logics) return; const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean); const hits = words.length ? logics.filter((x) => words.every((w) => x.name.toLowerCase().includes(w))) : []; status.textContent = !words.length ? "" : !hits.length ? te("Start_None") : hits.length > SHOWN ? te("Start_Count", { n: hits.length.toLocaleString(), m: SHOWN }) : ""; foundHost.replaceChildren(...hits.slice(0, SHOWN).map(importRow)); }; body.replaceChildren( el("p", { className: "m01-sl__trial-note", text: te("Trial") }), el("div", { className: "m01-sl__start", children: [ el("div", { className: "m01-sl__start-col", children: [ bigButton(te("Start_New"), () => showPaper()), el("h4", { text: te("Start_Mine") }), mineHost, ], }), el("div", { className: "m01-sl__start-col", children: [ el("h4", { text: te("Start_Import") }), el("h5", { text: te("Start_Trial") }), trialHost, search.root, status, foundHost, ], }), ], }), ); try { logics ??= await listEasy(""); } catch (error) { status.textContent = te("LoadFailed", { v: plainError(error) }); return; } const byKey = new Map(logics.map((x) => [x.key, x])); trialHost.replaceChildren( ...TRIAL_KEYS.flatMap((k) => { const item = byKey.get(k); return item ? [importRow(item)] : []; }), ); status.textContent = ""; drawFound(); }; /** 결과 단위 후보 — 품셈 로직에 다섯 번 넘게 쓰인 「원/…」 차례(설명 붙은 드문 단위는 뺌) */ const unitChoices = (): string[] => { const count = new Map(); for (const x of logics ?? []) { if (isMoney(x.resultUnit)) count.set(x.resultUnit, (count.get(x.resultUnit) ?? 0) + 1); } const units = [...count.entries()] .filter(([u, n]) => n >= 5 && !/[\s()「·]/.test(u)) .sort((a, b) => b[1] - a[1]) .map(([u]) => u); return units.length ? units : ["원/㎡"]; }; /* ---------- 고치는 화면 ---------- */ const showPaper = (): void => { let dirty = false; const units = unitChoices(); paper = { dirty: () => dirty, sl: blankLogic("", units[0]) }; const unsaved = createTag(te("Unsaved"), "warning"); unsaved.hidden = true; const touched = (): void => { dirty = true; unsaved.hidden = false; }; const name = createInputField({ label: te("Name"), placeholder: te("NamePlaceholder"), onInput: touched, }); name.root.classList.add("m01-sl__name"); const unit = createSelectField({ label: te("Unit"), options: units.map((u) => ({ value: u, text: u.replace(/^원\//, "") })), value: units[0], onChange: touched, }); const undo = createButton({ label: te("Undo"), variant: "ghost", onClick: notYet }); undo.disabled = true; const close = createButton({ label: te("Close"), variant: "ghost", onClick: () => void (async () => { if (await mayLeave()) void showStart(); })(), }); body.replaceChildren( el("div", { className: "m01-sl__strip", children: [ name.root, unit.root, unsaved, el("div", { className: "m01-sl__actions", children: [ undo, createButton({ label: te("Save"), onClick: notYet }), createButton({ label: te("Delete"), variant: "danger", onClick: notYet }), close, ], }), ], }), el("p", { className: "m01-sl__trial-note", text: te("Trial") }), el("div", { className: "m01-sl__layout", children: [ part(te("Diagram"), "m01-sl__diagram"), part(te("Panel"), "m01-sl__panel"), part(te("Result"), "m01-sl__result"), ], }), ); name.input.focus(); }; // 화면 함수들을 다 만든 뒤에 엶 — mount 가 바로 showStart 를 부름 openModal({ title: te("Title"), closeLabel: te("CloseWindow"), dialogClass: "m01-sl", beforeClose: mayLeave, mount: (host) => { body = host; void showStart(); }, }); } const muted = (text: string): HTMLElement => el("p", { className: "m01-sl__muted", text }); /** 고치는 화면의 자리 하나 — 제목 + 몸(2걸음에 다이어그램 · 시트 판 · 결과가 들어옴) */ function part(title: string, className: string): HTMLElement { return el("section", { className: `m01-sl__part ${className}`, children: [ el("h4", { text: title }), el("div", { className: "m01-sl__part-body", children: [muted(te("NotYet"))] }), ], }); } function bigButton(label: string, onClick: () => void): HTMLButtonElement { const button = createButton({ label, variant: "filled", onClick }); button.classList.add("m01-sl__big"); return button; }