/* ============================================================================= * M02_DF_UnitPick.ts * 단가산출 흐름도 공용 단위 고르기 — 치면 걸러진 목록 · 고르기만(손글 안 받음) · 「-」 = 없음 * 목록 = 단위 표준(산림품셈 1-2-2) 단위 + 부른 쪽이 주는 마스터 결과단위 등 + 원 * 표준 표 읽는 길은 `setUnitSource` 로 바꿔 끼움(기본 = 처음 값 `_standard/단위표준.json`) — 다른 판도 이것을 씀 * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el } from "@ui/ui_template_elements"; import "./M02_DF_UnitPick.css"; const TEXT = { None: ["없음", "none"], Filter: ["단위를 쳐서 찾음", "Type to filter units"], Empty: ["맞는 단위가 없음 — 목록에서만 고름", "No match — pick from the list only"], Pick: ["단위 고르기", "Pick a unit"], } as const; const tu = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; /** 「없음」 을 뜻하는 단위 글 */ export const NO_UNIT = "-"; /** 표준 표에서 읽은 것 — 단위 목록 · 단위마다 소수자리 제안(단위수량 쪽에서 가장 많이 쓴 자리) */ export interface UnitStandard { units: string[]; places: Record; } type UnitSource = () => Promise; interface StandardRow { "규격 단위"?: string | null; "단위수량 단위"?: string | null; "단위수량 소수자리"?: number | null; } /** 처음 값 — 번들에 든 표준 표(단위표준.json) */ const bundled: UnitSource = async () => { const table = (await import("./_standard/단위표준.json")).default as { 줄: StandardRow[] }; const units = table.줄 .flatMap((r) => [r["규격 단위"], r["단위수량 단위"]]) .filter((u): u is string => !!u); const count: Record> = {}; for (const r of table.줄) { const u = r["단위수량 단위"]; const p = r["단위수량 소수자리"]; if (!u || u === "없음" || typeof p !== "number") continue; count[u] = { ...count[u], [p]: (count[u]?.[p] ?? 0) + 1 }; } const places = Object.fromEntries( Object.entries(count).map(([u, c]) => [ u, Number(Object.entries(c).sort((a, b) => b[1] - a[1])[0][0]), ]), ); return { units, places }; }; let source: UnitSource = bundled; let cache: Promise | null = null; /** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */ export function setUnitSource(next: UnitSource): void { source = next; cache = null; } /** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */ export function loadStandard(): Promise { cache ??= source().then((std) => { const set = new Set([...std.units, "원"]); set.delete("없음"); set.delete(NO_UNIT); return { units: [...set], places: std.places }; }); return cache; } /** 단위를 고르면 표준 표에서 소수자리 제안 — 없으면 null */ export async function suggestPlaces(unit: string): Promise { return (await loadStandard()).places[unit] ?? null; } export interface UnitPickOptions { /** 지금 단위 — 「-」 = 없음 */ value: string; onPick(unit: string): void; /** 더 보일 단위(마스터 결과단위 등) */ extra?: string[]; readOnly?: boolean; ariaLabel?: string; } /** 단위 고르기 — 단추를 누르면 걸러지는 목록 · 목록에서 고르기만 됨 */ export function createUnitPick(opts: UnitPickOptions): HTMLElement { const label = (u: string): string => (u === NO_UNIT ? `${NO_UNIT} (${tu("None")})` : u); const root = el("span", { className: "m02-df-unitpick" }); const open = createButton({ label: opts.value || NO_UNIT, variant: "ghost", size: "sm", disabled: opts.readOnly, onClick: () => toggle(), }); open.setAttribute("aria-label", `${opts.ariaLabel ?? tu("Pick")}: ${opts.value || NO_UNIT}`); open.setAttribute("aria-haspopup", "listbox"); root.append(open); if (opts.readOnly) return root; const input = el("input", { className: "ui-input ui-input--sm m02-df-unitpick__filter", attrs: { type: "text", placeholder: tu("Filter"), "aria-label": tu("Filter") }, }); const list = el("ul", { className: "m02-df-unitpick__list", attrs: { role: "listbox" } }); const panel = el("div", { className: "m02-df-unitpick__panel", children: [input, list] }); panel.hidden = true; root.append(panel); let all: string[] = []; let shown: string[] = []; let at = 0; const pick = (u: string): void => { close(); if (u !== (opts.value || NO_UNIT)) opts.onPick(u); }; const draw = (): void => { const q = input.value.trim().toLowerCase(); shown = all.filter((u) => u === NO_UNIT || !q || u.toLowerCase().includes(q)); if (q && !NO_UNIT.includes(q)) shown = shown.filter((u) => u !== NO_UNIT); at = Math.min(at, Math.max(0, shown.length - 1)); list.replaceChildren( ...(shown.length ? shown.map((u, i) => { const b = el("button", { className: `m02-df-unitpick__item${i === at ? " is-on" : ""}${u === (opts.value || NO_UNIT) ? " is-now" : ""}`, text: label(u), attrs: { type: "button", role: "option" }, }); b.addEventListener("click", () => pick(u)); return el("li", { children: [b] }); }) : [el("li", { className: "m02-df-unitpick__empty", text: tu("Empty") })]), ); }; const close = (): void => { panel.hidden = true; document.removeEventListener("pointerdown", outside, true); }; const outside = (e: Event): void => { if (!root.contains(e.target as Node)) close(); }; function toggle(): void { if (!panel.hidden) return close(); panel.hidden = false; document.addEventListener("pointerdown", outside, true); input.value = ""; void loadStandard().then(({ units }) => { const mine = [...(opts.extra ?? []), ...units]; all = [NO_UNIT, ...new Set(mine.filter((u) => u && u !== NO_UNIT))]; at = 0; draw(); input.focus(); }); } input.addEventListener("input", () => ((at = 0), draw())); input.addEventListener("keydown", (e) => { if (e.key === "Escape") return (e.stopPropagation(), close(), open.focus()); if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); at = Math.max(0, Math.min(shown.length - 1, at + (e.key === "ArrowDown" ? 1 : -1))); return draw(); } if (e.key === "Enter" && shown[at] !== undefined) { e.preventDefault(); pick(shown[at]); } }); return root; }