diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 80fb0f252..7497c4a10 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -50,13 +50,6 @@ font-size: var(--text-body-sm); } -.m02-dfpanel .ui-btn, -.m02-dfpanel .ui-input, -.m02-dfpanel .ui-select { - min-height: 44px; - font-size: var(--text-body); -} - .m02-dfpanel-form { align-items: flex-end; padding: var(--spacing-8) var(--spacing-12); @@ -92,8 +85,7 @@ display: inline-flex; align-items: center; gap: var(--spacing-4); - min-height: 44px; - padding: 0 var(--spacing-12); + padding: var(--spacing-4) var(--spacing-12); border: 2px solid; border-radius: var(--radius-pills); background: var(--color-surface); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index a38ce4aba..c0c3de97c 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -15,6 +15,9 @@ import type { Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model"; +import { parseRound, wrapRound, type RoundSpec } from "./M02_DF_Round"; +import { createRoundRow } from "./M02_DF_RoundRow"; +import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import "./M02_DF_FuncPanel.css"; const TEXT = { @@ -126,6 +129,8 @@ interface State { json: string; cell: string; form: VarForm | null; + /** 단위 표준이 제안한 출력 소수자리 */ + suggest: number | null; parts: { head: HTMLElement; got: HTMLElement; @@ -317,20 +322,19 @@ function applyMarks(st: State): void { function drawHead(st: State): void { const box = boxOf(st); - const unit = el("input", { - className: "ui-input m02-dfpanel-unit", - attrs: { type: "text", "aria-label": tf("Unit") }, - }); - unit.value = box?.unit ?? "-"; - if (st.readOnly) unit.readOnly = true; - unit.addEventListener("change", () => { - const v = unit.value.trim() || "-"; - unit.value = v; - patchBox(st, (b) => ({ - ...b, - unit: v, - func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, - })); + const unit = createUnitPick({ + value: box?.unit ?? "-", + readOnly: st.readOnly, + extra: st.lg.resultUnit ? [st.lg.resultUnit] : [], + ariaLabel: tf("Unit"), + onPick: (v) => { + patchBox(st, (x) => ({ + ...x, + unit: v, + func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func, + })); + void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st))); + }, }); st.parts.head.replaceChildren( el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), @@ -372,8 +376,21 @@ function formField( cls = "", ): HTMLElement { const form = st.form!; + if (key === "unit") { + return el("label", { + className: "m02-dfpanel-fieldbox", + children: [ + el("span", { className: "m02-dfpanel-note", text: tf(label) }), + createUnitPick({ + value: form.unit || "-", + ariaLabel: tf(label), + onPick: (u) => ((form.unit = u), drawVars(st)), + }), + ], + }); + } const input = el("input", { - className: `ui-input m02-dfpanel-field ${cls}`, + className: `ui-input ui-input--sm m02-dfpanel-field ${cls}`, attrs: { type: "text", "aria-label": tf(label) }, }); input.value = form[key]; @@ -443,7 +460,7 @@ function drawVars(st: State): void { ); } else { const type = el("select", { - className: "ui-select m02-dfpanel-field", + className: "ui-select ui-select--sm m02-dfpanel-field", attrs: { "aria-label": tf("VarType") }, }); for (const [v, k] of [ @@ -520,6 +537,7 @@ function drawBar(st: State): void { el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), ], }), + ...[roundRow(st)].filter((x): x is HTMLElement => !!x), el("div", { className: "m02-dfpanel-row", children: [ @@ -538,6 +556,34 @@ function drawBar(st: State): void { ); } +/** 출력 칸 수식에 끝수 씌움(칸 값만 있으면 그 값을 감쌈) */ +function writeRound(st: State, spec: RoundSpec | null): void { + const f = funcOf(st); + const cell = f?.sheet.칸[f.output.cell]; + if (!f || !cell) return; + const text = cell.식 ?? String(cell.값 ?? ""); + const inner = parseRound(text)?.inner ?? text; + patchFunc(st, (x) => { + const sheet = structuredClone(x.sheet); + sheet.칸[x.output.cell] = { 식: wrapRound(inner, spec) }; + return { ...x, sheet }; + }); +} + +function roundRow(st: State): HTMLElement | null { + const f = funcOf(st); + const cell = f?.sheet.칸[f.output.cell]; + const formula = cell + ? (cell.식 ?? (cell.값 === undefined ? undefined : String(cell.값))) + : undefined; + return createRoundRow({ + formula, + suggest: st.suggest, + readOnly: st.readOnly, + onApply: (spec) => writeRound(st, spec), + }); +} + /** 출력 칸 하나 정하기 — 다시 고르면 바뀜 */ function setOutput(st: State, cell: string): void { const f = funcOf(st); @@ -681,6 +727,7 @@ export function renderFuncPanel( json: "", cell: "A1", form: null, + suggest: null, parts, observer: null, redraw: () => { diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts new file mode 100644 index 000000000..c5165238b --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts @@ -0,0 +1,33 @@ +/* ============================================================================= + * M02_DF_Round.ts + * 끝수 — 출력 칸 수식을 ROUND 계열로 감쌈 · 감싼 것을 읽음 · DOM 없음 + * 소수자리 제안은 단위 표준(M02_DF_UnitPick.suggestPlaces) · 주 ① 반올림 · ② 항목 값 우선 + * ========================================================================== */ + +export type RoundMode = "반올림" | "올림" | "버림"; + +export const ROUND_FN: Record = { + 반올림: "ROUND", + 올림: "ROUNDUP", + 버림: "ROUNDDOWN", +}; + +export interface RoundSpec { + places: number; + mode: RoundMode; +} + +const WRAPPED = /^(ROUND|ROUNDUP|ROUNDDOWN)\((.*),\s*(-?\d+)\s*\)$/s; + +/** 칸 수식(앞 = 뺀 글) → 끝수 · 안 감싸였으면 null */ +export function parseRound(formula: string): { spec: RoundSpec; inner: string } | null { + const m = WRAPPED.exec(formula.trim()); + if (!m) return null; + const mode = (Object.keys(ROUND_FN) as RoundMode[]).find((k) => ROUND_FN[k] === m[1]); + return mode ? { spec: { places: Number(m[3]), mode }, inner: m[2] } : null; +} + +/** 안쪽 글에 끝수를 씌움 — spec 이 null 이면 벗김 */ +export function wrapRound(inner: string, spec: RoundSpec | null): string { + return spec ? `${ROUND_FN[spec.mode]}(${inner}, ${spec.places})` : inner; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts new file mode 100644 index 000000000..82042b2e9 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts @@ -0,0 +1,77 @@ +/* ============================================================================= + * M02_DF_RoundRow.ts + * 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추) + * 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import { parseRound, ROUND_FN, type RoundMode, type RoundSpec } from "./M02_DF_Round"; + +const TEXT = { + Label: ["끝수", "Rounding"], + None: ["안 씀", "none"], + Places: ["소수 {n}자리", "{n} decimals"], + Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], + Apply: ["적용", "Apply"], + Places_aria: ["소수자리", "Decimal places"], + Mode_aria: ["방법", "Method"], +} as const; + +const tr = (key: keyof typeof TEXT, n?: number): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); + +export interface RoundRowOptions { + /** 출력 칸 수식(앞 = 뺀 글) — 없으면 줄을 안 그림 */ + formula: string | undefined; + /** 단위 표준이 제안한 소수자리 */ + suggest: number | null; + readOnly?: boolean; + /** null = 끝수 벗김 */ + onApply(spec: RoundSpec | null): void; +} + +export function createRoundRow(opts: RoundRowOptions): HTMLElement | null { + if (opts.formula === undefined) return null; + const now = parseRound(opts.formula)?.spec ?? null; + const places = el("select", { + className: "ui-select ui-select--sm", + attrs: { "aria-label": tr("Places_aria") }, + }); + places.append(el("option", { attrs: { value: "" }, text: tr("None") })); + for (let n = 0; n <= 6; n++) { + places.append(el("option", { attrs: { value: String(n) }, text: tr("Places", n) })); + } + places.value = now ? String(now.places) : ""; + const mode = el("select", { + className: "ui-select ui-select--sm", + attrs: { "aria-label": tr("Mode_aria") }, + }); + for (const m of Object.keys(ROUND_FN) as RoundMode[]) { + mode.append(el("option", { attrs: { value: m }, text: m })); + } + mode.value = now?.mode ?? "반올림"; + if (opts.readOnly) places.disabled = mode.disabled = true; + const emit = (): void => + opts.onApply( + places.value === "" ? null : { places: Number(places.value), mode: mode.value as RoundMode }, + ); + places.addEventListener("change", emit); + mode.addEventListener("change", () => places.value !== "" && emit()); + const row = el("div", { + className: "m02-dfpanel-row", + children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, mode], + }); + if (opts.suggest !== null && !now && !opts.readOnly) { + row.append( + el("span", { className: "m02-dfpanel-note", text: tr("Suggest", opts.suggest) }), + createButton({ + label: tr("Apply"), + variant: "ghost", + size: "sm", + onClick: () => opts.onApply({ places: opts.suggest!, mode: "반올림" }), + }), + ); + } + return row; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css new file mode 100644 index 000000000..cf2f43126 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.css @@ -0,0 +1,56 @@ +.m02-df-unitpick { + position: relative; + display: inline-block; +} + +.m02-df-unitpick__panel { + position: absolute; + top: 100%; + left: 0; + z-index: 50; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 14em; + max-height: 20em; + padding: var(--spacing-8); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface); + box-shadow: var(--shadow-lg); +} + +.m02-df-unitpick__list { + margin: 0; + padding: 0; + list-style: none; + overflow-y: auto; +} + +.m02-df-unitpick__item { + display: block; + width: 100%; + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-sm); + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.m02-df-unitpick__item.is-on, +.m02-df-unitpick__item:hover { + background: var(--color-surface-raised); +} + +.m02-df-unitpick__item.is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-unitpick__empty { + padding: var(--spacing-8); + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts new file mode 100644 index 000000000..aabe898b4 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_UnitPick.ts @@ -0,0 +1,181 @@ +/* ============================================================================= + * 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; +}