feat(M02-흐름도): 공용 단위 고르기 UnitPick · 끝수 줄 · 함수 판 단위 고르기로 바꿈

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 19:42:27 +09:00
co-authored by Claude Sonnet 5.5
parent 4cd2930ff3
commit 348003cf5a
6 changed files with 411 additions and 25 deletions
@@ -50,13 +50,6 @@
font-size: var(--text-body-sm); 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 { .m02-dfpanel-form {
align-items: flex-end; align-items: flex-end;
padding: var(--spacing-8) var(--spacing-12); padding: var(--spacing-8) var(--spacing-12);
@@ -92,8 +85,7 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: var(--spacing-4); gap: var(--spacing-4);
min-height: 44px; padding: var(--spacing-4) var(--spacing-12);
padding: 0 var(--spacing-12);
border: 2px solid; border: 2px solid;
border-radius: var(--radius-pills); border-radius: var(--radius-pills);
background: var(--color-surface); background: var(--color-surface);
+63 -16
View File
@@ -15,6 +15,9 @@ import type {
Workbook, Workbook,
} from "../../A00_Common/spreadsheet/spreadsheet"; } from "../../A00_Common/spreadsheet/spreadsheet";
import type { CfBox, CfFunc, CfLogic, CfVariable } from "./M02_DF_Model"; 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"; import "./M02_DF_FuncPanel.css";
const TEXT = { const TEXT = {
@@ -126,6 +129,8 @@ interface State {
json: string; json: string;
cell: string; cell: string;
form: VarForm | null; form: VarForm | null;
/** 단위 표준이 제안한 출력 소수자리 */
suggest: number | null;
parts: { parts: {
head: HTMLElement; head: HTMLElement;
got: HTMLElement; got: HTMLElement;
@@ -317,20 +322,19 @@ function applyMarks(st: State): void {
function drawHead(st: State): void { function drawHead(st: State): void {
const box = boxOf(st); const box = boxOf(st);
const unit = el("input", { const unit = createUnitPick({
className: "ui-input m02-dfpanel-unit", value: box?.unit ?? "-",
attrs: { type: "text", "aria-label": tf("Unit") }, readOnly: st.readOnly,
}); extra: st.lg.resultUnit ? [st.lg.resultUnit] : [],
unit.value = box?.unit ?? "-"; ariaLabel: tf("Unit"),
if (st.readOnly) unit.readOnly = true; onPick: (v) => {
unit.addEventListener("change", () => { patchBox(st, (x) => ({
const v = unit.value.trim() || "-"; ...x,
unit.value = v; unit: v,
patchBox(st, (b) => ({ func: x.func ? { ...x.func, output: { ...x.func.output, unit: v } } : x.func,
...b, }));
unit: v, void suggestPlaces(v).then((n) => ((st.suggest = n), drawBar(st)));
func: b.func ? { ...b.func, output: { ...b.func.output, unit: v } } : b.func, },
}));
}); });
st.parts.head.replaceChildren( st.parts.head.replaceChildren(
el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }), el("strong", { className: "m02-dfpanel-title", text: box?.name ?? "" }),
@@ -372,8 +376,21 @@ function formField(
cls = "", cls = "",
): HTMLElement { ): HTMLElement {
const form = st.form!; 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", { 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) }, attrs: { type: "text", "aria-label": tf(label) },
}); });
input.value = form[key]; input.value = form[key];
@@ -443,7 +460,7 @@ function drawVars(st: State): void {
); );
} else { } else {
const type = el("select", { const type = el("select", {
className: "ui-select m02-dfpanel-field", className: "ui-select ui-select--sm m02-dfpanel-field",
attrs: { "aria-label": tf("VarType") }, attrs: { "aria-label": tf("VarType") },
}); });
for (const [v, k] of [ for (const [v, k] of [
@@ -520,6 +537,7 @@ function drawBar(st: State): void {
el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }),
], ],
}), }),
...[roundRow(st)].filter((x): x is HTMLElement => !!x),
el("div", { el("div", {
className: "m02-dfpanel-row", className: "m02-dfpanel-row",
children: [ 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 { function setOutput(st: State, cell: string): void {
const f = funcOf(st); const f = funcOf(st);
@@ -681,6 +727,7 @@ export function renderFuncPanel(
json: "", json: "",
cell: "A1", cell: "A1",
form: null, form: null,
suggest: null,
parts, parts,
observer: null, observer: null,
redraw: () => { redraw: () => {
@@ -0,0 +1,33 @@
/* =============================================================================
* M02_DF_Round.ts
* 끝수 — 출력 칸 수식을 ROUND 계열로 감쌈 · 감싼 것을 읽음 · DOM 없음
* 소수자리 제안은 단위 표준(M02_DF_UnitPick.suggestPlaces) · 주 ① 반올림 · ② 항목 값 우선
* ========================================================================== */
export type RoundMode = "반올림" | "올림" | "버림";
export const ROUND_FN: Record<RoundMode, string> = {
반올림: "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;
}
@@ -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;
}
@@ -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);
}
@@ -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<string, number>;
}
type UnitSource = () => Promise<UnitStandard>;
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<string, Record<number, number>> = {};
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<UnitStandard> | null = null;
/** 표준 표 읽는 길 바꾸기(서버에서 고친 표를 읽을 때) — 목록을 다시 받음 */
export function setUnitSource(next: UnitSource): void {
source = next;
cache = null;
}
/** 표준 단위 + 원 — 같은 글은 한 번 · 「없음」 은 뺌 */
export function loadStandard(): Promise<UnitStandard> {
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<number | null> {
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;
}