diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts new file mode 100644 index 000000000..6ab1911b7 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_Detail.ts @@ -0,0 +1,254 @@ +/* ============================================================================= + * M02_DF_Detail.ts + * 상세 내용 판(선 · 알약) 공용 — 값 읽기 · 규칙 미리 보기 · 겹침/안 맞음 · 흐름 줄 · 끊김 까닭 · DOM 없음 + * 모델의 traceBack · traceForward · breaks · applyRule · ruleLabel 이 아직 준비 중이면 같은 뜻의 작은 셈으로 메움 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { breaks, evalLogic, type CfEvalResult, type CfValue, type CfValues } from "./M02_DF_Eval"; +import { + portsIn, + portsOut, + traceBack, + traceForward, + type CfBox, + type CfGateOp, + type CfLink, + type CfLogic, + type CfPort, + type CfRound, + type CfRule, + type CfStep, +} from "./M02_DF_Model"; +import { applyRule, ruleLabel } from "./M02_DF_Rule"; + +const TEXT = { + Table: ["표 {n}줄", "table, {n} rows"], + Empty: ["값 없음", "no value"], + LabelLookup: ["표에서 「{col}」 찾기", "Look up “{col}” in the table"], + LabelGate: ["「{pill}」 {op} {value} 일 때만 보냄", "Send only when “{pill}” {op} {value}"], + LabelScale: ["값 {op} {value}", "value {op} {value}"], + LabelRound: ["소수 {places}자리 {method}", "{places} decimals, {method}"], + NoRule: ["규칙 없음 — 값이 그대로 감", "No rule — the value goes as is"], + Blocked: ["통과 안 됨", "does not pass"], + NoRowLater: ["표에서 맞는 줄은 계산 뒤에 보임", "The matching row shows after calculation"], + EmptyIn: ["빈 받는 자리 — 선이 없음", "Empty receiving spot — no line"], + NoCell: ["받은 알약을 칸에 놓지 않음", "The received pill is not placed in a cell"], + Dead: ["아직 어디로도 안 감", "Goes nowhere yet"], + NoValue: ["값이 아직 없음", "No value yet"], +} as const; + +export const td = (key: keyof typeof TEXT, fill: Record = {}): string => + Object.entries(fill).reduce( + (s, [k, v]) => s.replace(`{${k}}`, String(v)), + TEXT[key][currentLanguageIndex === 1 ? 1 : 0] as string, + ); + +export const GATE_OPS: CfGateOp[] = ["=", "≠", "≥", "≤", ">", "<"]; +export const SCALE_OPS: ("×" | "÷" | "+%" | "-%")[] = ["×", "÷", "+%", "-%"]; + +export const boxOf = (lg: CfLogic, id: string): CfBox | undefined => + lg.boxes.find((b) => b.id === id); + +export const portText = (lg: CfLogic, p: CfPort): string => + `${boxOf(lg, p.box)?.name ?? "?"} › ${p.name}`; + +/** 계산 한 번 — 못 하면 null */ +export function evalSafe(lg: CfLogic, values?: CfValues): CfEvalResult | null { + try { + return evalLogic(lg, values); + } catch { + return null; + } +} + +export const valueOf = (ev: CfEvalResult | null, p: CfPort): CfValue | undefined => + ev?.ports[p.box]?.[p.name]; + +/** 화면에 보일 값 글 — 표는 줄 수 */ +export function showValue(v: CfValue | undefined): string { + if (v === undefined || v === null) return td("Empty"); + if (Array.isArray(v)) return td("Table", { n: Math.max(0, v.length - 1) }); + return typeof v === "number" ? String(Number(v.toPrecision(12))) : String(v); +} + +export function unitOf(lg: CfLogic, p: CfPort): string { + const box = boxOf(lg, p.box); + return (box && portsOut(box).find((x) => x.name === p.name)?.unit) || box?.unit || "-"; +} + +/** 알약이 나가는(out) 쪽인가 */ +export const isOut = (box: CfBox, name: string): boolean => + portsOut(box).some((p) => p.name === name); + +/* ── 규칙 ─────────────────────────────────────────────────────────────── */ + +const num = (v: unknown): number | null => { + const n = typeof v === "number" ? v : Number(v); + return Number.isFinite(n) && v !== "" && v !== null ? n : null; +}; + +/** 조건 통과 — 숫자면 숫자끼리 · 아니면 글끼리 */ +export function gatePass(op: CfGateOp, v: unknown, value: number | string): boolean { + const a = num(v); + const b = num(value); + const [x, y] = a !== null && b !== null ? [a, b] : [String(v ?? ""), String(value)]; + switch (op) { + case "=": + return x === y; + case "≠": + return x !== y; + case "≥": + return x >= y; + case "≤": + return x <= y; + case ">": + return x > y; + default: + return x < y; + } +} + +export function roundValue(r: CfRound, v: number): number { + const k = 10 ** r.places; + const f = r.method === "올림" ? Math.ceil : r.method === "버림" ? Math.floor : Math.round; + return f(v * k) / k; +} + +/** 선 위 규칙 글 — 모델이 준비되면 그것, 아니면 같은 뜻의 글 */ +export function labelOf(lg: CfLogic, rule: CfRule | undefined): string { + if (!rule) return td("NoRule"); + try { + return ruleLabel(rule); + } catch { + if (rule.kind === "lookup") return td("LabelLookup", { col: rule.col }); + if (rule.kind === "gate") + return td("LabelGate", { pill: portText(lg, rule.from), op: rule.op, value: rule.value }); + if (rule.kind === "scale") return td("LabelScale", { op: rule.op, value: rule.value }); + return td("LabelRound", { places: rule.places, method: rule.method }); + } +} + +/** 지나가기 전 값 → 지난 뒤 값 미리 보기 */ +export function preview( + link: CfLink, + rule: CfRule | undefined, + ev: CfEvalResult | null, +): { before: string; after: string } { + const raw = valueOf(ev, link.from); + const before = showValue(raw); + if (!rule) return { before, after: before }; + const conds: Record = {}; + if (rule.kind === "lookup") for (const c of rule.conds) conds[c.col] = valueOf(ev, c.from); + try { + const r = applyRule(rule, raw, conds); + return { + before, + after: r.value === null ? (r.why ?? td("Empty")) : showValue(r.value as CfValue), + }; + } catch { + const n = num(raw); + if (rule.kind === "gate") + return { + before, + after: gatePass(rule.op, valueOf(ev, rule.from), rule.value) ? before : td("Blocked"), + }; + if (rule.kind === "lookup" || n === null) return { before, after: td("NoRowLater") }; + if (rule.kind === "round") return { before, after: showValue(roundValue(rule, n)) }; + const v = rule.value; + const out = + rule.op === "×" + ? n * v + : rule.op === "÷" + ? v + ? n / v + : NaN + : rule.op === "+%" + ? n * (1 + v / 100) + : n * (1 - v / 100); + return { before, after: Number.isFinite(out) ? showValue(out) : td("Empty") }; + } +} + +/** 같은 알약에서 나간 조건 선 — 지금 값으로 통과하는 수 */ +export function gateReport( + lg: CfLogic, + link: CfLink, + ev: CfEvalResult | null, +): { total: number; pass: string[] } { + const same = lg.links.filter( + (l) => + l.from.box === link.from.box && l.from.name === link.from.name && l.rule?.kind === "gate", + ); + const pass = same + .filter((l) => { + const r = l.rule as Extract; + return gatePass(r.op, valueOf(ev, r.from), r.value); + }) + .map((l) => l.id); + return { total: same.length, pass }; +} + +/* ── 흐름 · 끊김 ─────────────────────────────────────────────────────── */ + +/** 거슬러 오르는 길(dir "in" = 온 길) · 따라가는 길(dir "out") — 모델 것, 없으면 작은 셈 */ +export function trace(lg: CfLogic, p: CfPort, dir: "in" | "out"): CfStep[] { + try { + return dir === "in" ? traceBack(lg, p) : traceForward(lg, p); + } catch { + const out: CfStep[] = []; + const seen = new Set(); + const walk = (port: CfPort, back: boolean): void => { + const key = `${port.box}/${port.name}`; + if (seen.has(key)) return; + seen.add(key); + const box = boxOf(lg, port.box); + if (!box) return; + if (back) { + const into = lg.links.filter( + (l) => l.to.box === port.box && (isOut(box, port.name) || l.to.name === port.name), + ); + for (const l of into) { + out.push({ box: l.from.box, port: l.from.name, dir: "out", link: l.id, rule: l.rule }); + walk(l.from, true); + } + } else { + const outs = lg.links.filter((l) => l.from.box === port.box && l.from.name === port.name); + for (const l of outs) { + out.push({ box: l.to.box, port: l.to.name, dir: "in", link: l.id, rule: l.rule }); + const next = boxOf(lg, l.to.box); + for (const o of next ? portsOut(next) : []) walk({ box: next!.id, name: o.name }, false); + } + } + }; + walk(p, dir === "in"); + return out; + } +} + +/** 그 상자(알약)에서 끊긴 곳 — 쉬운 말 */ +export function breakLines(lg: CfLogic, boxId: string, name?: string): string[] { + try { + return breaks(lg) + .filter((b) => b.box === boxId && (!name || !b.port || b.port === name)) + .map((b) => b.text); + } catch { + // 모델의 끊긴 곳 목록이 준비되기 전 — 같은 뜻의 작은 셈 + } + const box = boxOf(lg, boxId); + if (!box) return []; + const out: string[] = []; + for (const p of portsIn(box)) { + if (name && p.name !== name) continue; + if (!lg.links.some((l) => l.to.box === boxId && l.to.name === p.name)) out.push(td("EmptyIn")); + else if (box.func?.inputs.some((i) => i.port === p.name && !i.cell)) out.push(td("NoCell")); + } + for (const p of portsOut(box)) { + if (name && p.name !== name) continue; + if (!lg.links.some((l) => l.from.box === boxId && l.from.name === p.name)) out.push(td("Dead")); + } + return out; +} + +export const noValue = (): string => td("NoValue"); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index c0c3de97c..45e3b2be5 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -7,103 +7,18 @@ * 글 [한, 영] 짝은 아래 TEXT * ========================================================================== */ -import { currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import type { CalcValues, SpreadsheetHandle, 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 type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; +import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; import "./M02_DF_FuncPanel.css"; -const TEXT = { - Unit: ["단위", "Unit"], - Cell: ["고른 칸", "Selected cell"], - Got: ["받은 알약", "Received pills"], - GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], - NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], - Placed: ["놓임", "placed"], - Free: ["아직 안 놓은 알약", "not placed yet"], - Busy: ["이미 쓰는 칸", "That cell is already in use"], - Locked: [ - "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", - "This cell is filled by the wire — cannot edit", - ], - Vars: ["변수", "Variables"], - MakeVar: ["변수 만들기", "Make variable"], - NoVars: ["아직 변수가 없음", "No variables yet"], - VarName: ["이름", "Name"], - VarType: ["갈래", "Type"], - Number: ["숫자", "Number"], - Choice: ["선택", "Choice"], - Text: ["글", "Text"], - VarUnit: ["단위", "Unit"], - VarMin: ["최소", "Min"], - VarMax: ["최대", "Max"], - VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], - VarExample: ["예시값", "Example value"], - VarCell: ["붙을 칸", "Attach to"], - Make: ["만들기", "Make"], - Cancel: ["취소", "Cancel"], - Remove: ["지움", "Remove"], - NeedName: ["이름을 적음", "Enter a name"], - SameName: ["같은 이름이 이미 있음", "That name already exists"], - NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], - BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], - OutBtn: ["출력 칸으로", "Make output cell"], - OutNow: ["출력", "Output"], - OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], - OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], - Frames: ["틀", "Templates"], - FrameMul: ["곱하기", "Multiply"], - FrameMore: ["할증", "Markup"], - FrameDiv: ["나누기", "Divide"], - FrameIf: ["만약", "If"], - FrameMin: ["최소", "Min"], - FrameMax: ["최대", "Max"], - FrameUp: ["올림", "Round up"], - FrameLook: ["표에서 찾기", "Look up in table"], - FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], - ReadOnly: ["읽기만", "Read only"], - Loading: ["시트를 불러오는 중", "Loading sheet"], - Problem: ["⚠ ", "⚠ "], - Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], - Err_DIV0: ["0으로 나눔", "divides by zero"], - Err_NAME: ["알 수 없는 이름", "unknown name"], - Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], - Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], - Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], - Err_NA: ["찾는 값이 없음", "value not found"], - Err_OTHER: ["수식이 틀림", "formula is wrong"], -} as const; - -const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; - -/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ -const FRAMES: [keyof typeof TEXT, string][] = [ - ["FrameMul", "=B1*B2"], - ["FrameMore", "=B1*(1+B2/100)"], - ["FrameDiv", "=B1/B2"], - ["FrameIf", '=IF(B3="급",10,0)'], - ["FrameMin", "=MIN(B1,B2)"], - ["FrameMax", "=MAX(B1,B2)"], - ["FrameUp", "=ROUNDUP(B4,3)"], - ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], -]; - -const ERR_KEY: Record = { - "#DIV/0!": "Err_DIV0", - "#NAME?": "Err_NAME", - "#REF!": "Err_REF", - "#VALUE!": "Err_VALUE", - "#CYCLE!": "Err_CYCLE", - "#N/A": "Err_NA", -}; - type Handlers = { change(lg: CfLogic): void }; interface VarForm { @@ -537,7 +452,16 @@ function drawBar(st: State): void { el("span", { className: "m02-dfpanel-note", text: tf("OutOnly") }), ], }), - ...[roundRow(st)].filter((x): x is HTMLElement => !!x), + ...(f?.output.cell + ? [ + createRoundRow({ + round: f.output.round, + suggest: st.suggest, + readOnly: st.readOnly, + onApply: (round) => writeRound(st, round), + }), + ] + : []), el("div", { className: "m02-dfpanel-row", children: [ @@ -556,31 +480,11 @@ 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; +/** 출력 끝수 정하기 — 모델의 output.round (null = 벗김) */ +function writeRound(st: State, round: CfRound | null): void { 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), + const { round: _old, ...rest } = x.output; + return { ...x, output: round ? { ...rest, round } : rest }; }); } diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts new file mode 100644 index 000000000..b0d6e4643 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncText.ts @@ -0,0 +1,90 @@ +/* ============================================================================= + * M02_DF_FuncText.ts + * 함수 상자 판 글 [한, 영] 짝 · 틀 단추 수식 · 수식 오류 말 — 판 파일이 700줄 안에 들도록 뗌 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; + +export const TEXT = { + Unit: ["단위", "Unit"], + Cell: ["고른 칸", "Selected cell"], + Got: ["받은 알약", "Received pills"], + GotHint: ["누르거나 칸에 끌어 놓음", "Click, or drag onto a cell"], + NoGot: ["선으로 들어온 알약이 없음", "No pill has come in yet"], + Placed: ["놓임", "placed"], + Free: ["아직 안 놓은 알약", "not placed yet"], + Busy: ["이미 쓰는 칸", "That cell is already in use"], + Locked: [ + "받은 알약이 놓인 칸은 선의 값으로 채워짐 — 직접 못 고침", + "This cell is filled by the wire — cannot edit", + ], + Vars: ["변수", "Variables"], + MakeVar: ["변수 만들기", "Make variable"], + NoVars: ["아직 변수가 없음", "No variables yet"], + VarName: ["이름", "Name"], + VarType: ["갈래", "Type"], + Number: ["숫자", "Number"], + Choice: ["선택", "Choice"], + Text: ["글", "Text"], + VarUnit: ["단위", "Unit"], + VarMin: ["최소", "Min"], + VarMax: ["최대", "Max"], + VarChoices: ["선택 목록 (쉼표로 나눔)", "Choices (comma separated)"], + VarExample: ["예시값", "Example value"], + VarCell: ["붙을 칸", "Attach to"], + Make: ["만들기", "Make"], + Cancel: ["취소", "Cancel"], + Remove: ["지움", "Remove"], + NeedName: ["이름을 적음", "Enter a name"], + SameName: ["같은 이름이 이미 있음", "That name already exists"], + NotNumber: ["예시값은 숫자로 적음", "Example must be a number"], + BadRange: ["범위는 최소가 최대보다 작게", "Min must be below max"], + OutBtn: ["출력 칸으로", "Make output cell"], + OutNow: ["출력", "Output"], + OutNone: ["출력 칸을 아직 안 정함", "Output cell not set yet"], + OutOnly: ["출력은 칸 하나 — 다시 고르면 바뀜", "One output cell — pick again to change"], + Frames: ["틀", "Templates"], + FrameMul: ["곱하기", "Multiply"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + ReadOnly: ["읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + Problem: ["⚠ ", "⚠ "], + Unplaced: [" 받은 알약이 놓이지 않음", " pill is not placed on a cell"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +export const tf = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +export const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMul", "=B1*B2"], + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +export const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css new file mode 100644 index 000000000..867c22e06 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.css @@ -0,0 +1,82 @@ +.m02-dflink { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m02-dflink-head, +.m02-dflink-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m02-dflink-label { + font-weight: var(--font-weight-bold); +} + +.m02-dflink-note { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m02-dflink-value { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m02-dflink-num { + width: 7em; +} + +.m02-dflink-warn { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +/* 알약 판 */ +.m02-dfpill { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m02-dfpill-title { + font-weight: var(--font-weight-bold); +} + +.m02-dfpill-list { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + margin: 0; + padding: 0; + list-style: none; +} + +.m02-dfpill-step { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); + align-items: center; +} + +.m02-dfpill-rule { + padding: 0 var(--spacing-8); + border: 1px dashed var(--color-border); + border-radius: var(--radius-pills); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts new file mode 100644 index 000000000..32f1d9fb9 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -0,0 +1,388 @@ +/* ============================================================================= + * M02_DF_LinkPanel.ts + * 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) · + * 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기 + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfValues } from "./M02_DF_Eval"; +import { + portsOut, + reaches, + type CfGateOp, + type CfLogic, + type CfPort, + type CfRound, + type CfRule, +} from "./M02_DF_Model"; +import { + GATE_OPS, + SCALE_OPS, + evalSafe, + gateReport, + labelOf, + portText, + preview, + unitOf, + valueOf, +} from "./M02_DF_Detail"; +import { suggestPlaces } from "./M02_DF_UnitPick"; +import "./M02_DF_LinkPanel.css"; + +const TEXT = { + Title: ["선 규칙", "Line rule"], + Kinds: ["규칙 고르기", "Pick a rule"], + K_none: ["없음", "None"], + K_lookup: ["표에서 찾기", "Look up in table"], + K_gate: ["조건 통과", "Pass if"], + K_scale: ["바꾸기", "Change"], + K_round: ["끝수", "Rounding"], + Label: ["선 위 글", "Text on the line"], + Before: ["지나가기 전 값", "Value before"], + After: ["지난 뒤 값", "Value after"], + Col: ["조건 칸", "Condition column"], + Match: ["맞출 알약", "Pill to match"], + Take: ["꺼낼 칸", "Column to take"], + AddCond: ["+ 조건 칸", "+ Condition"], + Remove: ["지움", "Remove"], + NoHeader: [ + "보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름", + "The sender is not a table or it is not loaded yet", + ], + Pick: ["고르기", "pick"], + GateHint: [ + "알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음", + "Sent only when the pill passes", + ], + Value: ["값", "Value"], + ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"], + Places: ["소수 {n}자리", "{n} decimals"], + Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], + Apply: ["적용", "Apply"], + WarnBoth: [ + "같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침", + "{n} conditional lines pass at once — overlap", + ], + WarnNone: [ + "같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음", + "None of the {n} conditional lines match", + ], + WarnLookup: [ + "조건 칸이 없음 — 표에서 줄을 못 고름", + "No condition column — a row can't be picked", + ], + WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"], + Count: ["선", "Line"], +} as const; + +const tk = (key: keyof typeof TEXT, n?: number | string): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? "")); + +type Kind = "none" | CfRule["kind"]; +const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"]; +const SEP = "\u0001"; + +interface Ctx { + lg: CfLogic; + linkId: string; + readOnly: boolean; + headers: string[]; + change(lg: CfLogic): void; +} + +const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`; +const fromKey = (k: string): CfPort => { + const [box, ...rest] = k.split(SEP); + return { box, name: rest.join(SEP) }; +}; + +function setRule(ctx: Ctx, rule: CfRule | undefined): void { + ctx.change({ + ...ctx.lg, + links: ctx.lg.links.map((l) => { + if (l.id !== ctx.linkId) return l; + const { rule: _old, ...rest } = l; + return rule ? { ...rest, rule } : rest; + }), + }); +} + +function select( + ctx: Ctx, + options: { value: string; text: string }[], + value: string, + onPick: (v: string) => void, + aria: string, +): HTMLElement { + const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } }); + for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text })); + if (!options.some((o) => o.value === value)) + s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") })); + s.value = value; + s.disabled = ctx.readOnly; + s.addEventListener("change", () => onPick(s.value)); + return s; +} + +function numberBox( + ctx: Ctx, + value: number | string, + onCommit: (v: number | string) => void, + aria: string, +): HTMLElement { + const i = el("input", { + className: "ui-input ui-input--sm m02-dflink-num", + attrs: { type: "text", "aria-label": aria }, + }); + i.value = String(value); + i.readOnly = ctx.readOnly; + i.addEventListener("change", () => { + const t = i.value.trim().replace(/,/g, ""); + onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t); + }); + return i; +} + +/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */ +const pillOptions = (ctx: Ctx): { value: string; text: string }[] => { + const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? ""; + return ctx.lg.boxes + .filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id)) + .flatMap((b) => + portsOut(b).map((p) => ({ + value: toKey({ box: b.id, name: p.name }), + text: portText(ctx.lg, { box: b.id, name: p.name }), + })), + ); +}; + +const row = (...children: (HTMLElement | string)[]): HTMLElement => + el("div", { className: "m02-dflink-row", children }); + +/* ── 규칙마다 ─────────────────────────────────────────────────────────── */ + +function lookupEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + const cols = [ + ...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)), + ]; + const colOpts = cols.map((c) => ({ value: c, text: c })); + const pills = pillOptions(ctx); + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + const rows = rule.conds.map((c, i) => + row( + select( + ctx, + colOpts, + c.col, + (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }), + tk("Col"), + ), + "=", + select( + ctx, + pills, + toKey(c.from), + (v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }), + tk("Match"), + ), + ctx.readOnly + ? "" + : createButton({ + label: tk("Remove"), + variant: "ghost", + size: "sm", + onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }), + }), + ), + ); + const out: HTMLElement[] = [...rows]; + if (!ctx.readOnly) { + out.push( + row( + createButton({ + label: tk("AddCond"), + variant: "ghost", + size: "sm", + disabled: !pills.length || !cols.length, + onClick: () => + put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }), + }), + ), + ); + } + out.push( + row( + el("span", { className: "m02-dflink-label", text: tk("Take") }), + select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")), + ), + ); + if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") })); + return out; +} + +function gateEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + return [ + el("p", { className: "m02-dflink-note", text: tk("GateHint") }), + row( + select( + ctx, + pillOptions(ctx), + toKey(rule.from), + (v) => put({ from: fromKey(v) }), + tk("Match"), + ), + select( + ctx, + GATE_OPS.map((o) => ({ value: o, text: o })), + rule.op, + (v) => put({ op: v as CfGateOp }), + tk("Kinds"), + ), + numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), + ), + ]; +} + +function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { + return [ + el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }), + row( + select( + ctx, + SCALE_OPS.map((o) => ({ value: o, text: o })), + rule.op, + (v) => setRule(ctx, { ...rule, op: v as typeof rule.op }), + tk("Kinds"), + ), + numberBox( + ctx, + rule.value, + (v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }), + tk("Value"), + ), + ), + ]; +} + +function roundEditor( + ctx: Ctx, + rule: Extract, + unit: string, +): HTMLElement[] { + const put = (next: Partial): void => setRule(ctx, { ...rule, ...next }); + const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) })); + const hint = el("span", { className: "m02-dflink-note" }); + void suggestPlaces(unit).then((n) => { + if (n === null || n === rule.places || ctx.readOnly) return; + hint.append( + tk("Suggest", n), + " ", + createButton({ + label: tk("Apply"), + variant: "ghost", + size: "sm", + onClick: () => put({ places: n }), + }), + ); + }); + return [ + row( + select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")), + select( + ctx, + (["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })), + rule.method, + (v) => put({ method: v as CfRound["method"] }), + tk("K_round"), + ), + hint, + ), + ]; +} + +const DEFAULT: Record, (from: CfPort) => CfRule> = { + lookup: () => ({ kind: "lookup", conds: [], col: "" }), + gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + scale: () => ({ kind: "scale", op: "×", value: 1 }), + round: () => ({ kind: "round", places: 0, method: "반올림" }), +}; + +/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderLinkPanel( + host: HTMLElement, + lg: CfLogic, + linkId: string, + on: { change(lg: CfLogic): void }, + opts: { values?: CfValues; readOnly?: boolean } = {}, +): void { + const link = lg.links.find((l) => l.id === linkId); + host.classList.add("m02-dflink"); + if (!link) return host.replaceChildren(); + const ev = evalSafe(lg, opts.values); + const table = valueOf(ev, link.from); + const headers = + Array.isArray(table) && Array.isArray(table[0]) + ? table[0].map((h) => String(h ?? "")).filter(Boolean) + : []; + const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change }; + const rule = link.rule; + const kind: Kind = rule?.kind ?? "none"; + const parts: HTMLElement[] = [ + el("div", { + className: "m02-dflink-head", + children: [ + el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }), + el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }), + ], + }), + row( + el("span", { className: "m02-dflink-label", text: tk("Kinds") }), + ...KINDS.map((k) => + createButton({ + label: tk(`K_${k}` as "K_none"), + variant: k === kind ? "filled" : "ghost", + size: "sm", + disabled: ctx.readOnly, + onClick: () => + k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)), + }), + ), + ), + ]; + if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule)); + if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule)); + if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule)); + if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from))); + + const pv = preview(link, rule, ev); + parts.push( + row( + el("span", { className: "m02-dflink-label", text: tk("Before") }), + el("span", { className: "m02-dflink-value", text: pv.before }), + "→", + el("span", { className: "m02-dflink-label", text: tk("After") }), + el("span", { className: "m02-dflink-value", text: pv.after }), + ), + row( + el("span", { className: "m02-dflink-label", text: tk("Label") }), + el("span", { text: labelOf(lg, rule) }), + ), + ); + + const warns: string[] = []; + if (rule?.kind === "gate") { + const rep = gateReport(lg, link, ev); + if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length)); + if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total)); + } + if (rule?.kind === "lookup") { + if (!rule.conds.length) warns.push(tk("WarnLookup")); + if (!rule.col) warns.push(tk("WarnTake")); + } + parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` }))); + host.replaceChildren(...parts); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts new file mode 100644 index 000000000..3c92bea06 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_PillPanel.ts @@ -0,0 +1,139 @@ +/* ============================================================================= + * M02_DF_PillPanel.ts + * 상세 내용 판 — 알약: 알약을 고르면 이름 · 값 · 단위 · 온 길(출처 사슬) · 가는 곳(이어진 선 · 받는 상자) · 끊김 까닭 + * 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 8 · 9 · M02_DF_Model 의 CfStep 그대로 · 읽기만(고치기는 선 · 상자 판에서) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfValues } from "./M02_DF_Eval"; +import type { CfLogic, CfStep } from "./M02_DF_Model"; +import { + boxOf, + breakLines, + evalSafe, + isOut, + labelOf, + portText, + showValue, + trace, + unitOf, + valueOf, +} from "./M02_DF_Detail"; +import "./M02_DF_LinkPanel.css"; + +const TEXT = { + Name: ["이름", "Name"], + Value: ["값", "Value"], + Unit: ["단위", "Unit"], + Kind_out: ["내보내는 알약", "Sending pill"], + Kind_in: ["받는 알약", "Receiving pill"], + From: ["온 길", "Where it came from"], + To: ["가는 곳", "Where it goes"], + Start: ["여기서 시작 — 더 거슬러 갈 곳이 없음", "It starts here — nothing before it"], + End: ["여기가 끝 — 더 가는 곳이 없음", "It ends here — nothing after it"], + Breaks: ["끊긴 곳", "Breaks"], + NoBreak: ["끊긴 곳 없음", "No breaks"], + Open: ["이 상자 보기", "Open this box"], + Line: ["선", "Line"], +} as const; + +const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const line = (label: string, ...value: (HTMLElement | string)[]): HTMLElement => + el("div", { + className: "m02-dflink-row", + children: [el("span", { className: "m02-dflink-label", text: label }), ...value], + }); + +function steps( + lg: CfLogic, + list: CfStep[], + empty: string, + onBox?: (id: string) => void, +): HTMLElement { + if (!list.length) return el("p", { className: "m02-dflink-note", text: empty }); + return el("ol", { + className: "m02-dfpill-list", + children: list.map((s) => { + const parts: (HTMLElement | string)[] = [ + s.dir === "out" ? "◀" : "▶", + onBox + ? createButton({ + label: portText(lg, { box: s.box, name: s.port }), + variant: "ghost", + size: "sm", + onClick: () => onBox(s.box), + }) + : portText(lg, { box: s.box, name: s.port }), + ]; + if (s.rule) + parts.push(el("span", { className: "m02-dfpill-rule", text: labelOf(lg, s.rule) })); + return el("li", { className: "m02-dfpill-step", children: parts }); + }), + }); +} + +/** 알약 판 그리기 — 알약을 고르면 부름 · on.pick 이 있으면 줄의 상자 이름을 눌러 그 상자로 감 */ +export function renderPillPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + port: string, + on: { pick?(boxId: string): void } = {}, + opts: { values?: CfValues } = {}, +): void { + host.classList.add("m02-dfpill"); + const box = boxOf(lg, boxId); + if (!box) return host.replaceChildren(); + const ev = evalSafe(lg, opts.values); + const out = isOut(box, port); + // 받는 알약의 값 = 그 자리로 들어오는 선의 보낸 알약 값(규칙 지나기 전) + const incoming = lg.links.find((l) => l.to.box === boxId && l.to.name === port); + const value = out + ? valueOf(ev, { box: boxId, name: port }) + : incoming + ? valueOf(ev, incoming.from) + : undefined; + const unit = out + ? unitOf(lg, { box: boxId, name: port }) + : incoming + ? unitOf(lg, incoming.from) + : box.unit; + const back = trace(lg, { box: boxId, name: port }, "in"); + const forward = trace(lg, { box: boxId, name: port }, "out"); + const breaks = breakLines(lg, boxId, port); + host.replaceChildren( + el("div", { + className: "m02-dflink-head", + children: [ + el("strong", { className: "m02-dfpill-title", text: `${box.name} › ${port}` }), + el("span", { className: "m02-dflink-note", text: tp(out ? "Kind_out" : "Kind_in") }), + ], + }), + line(tp("Name"), port), + line(tp("Value"), el("span", { className: "m02-dflink-value", text: showValue(value) })), + line(tp("Unit"), unit || "-"), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("From") }), + steps(lg, back, tp("Start"), on.pick), + ], + }), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("To") }), + steps(lg, forward, tp("End"), on.pick), + ], + }), + el("div", { + children: [ + el("div", { className: "m02-dflink-label", text: tp("Breaks") }), + ...(breaks.length + ? breaks.map((b) => el("p", { className: "m02-dflink-warn", text: `⚠ ${b}` })) + : [el("p", { className: "m02-dflink-note", text: tp("NoBreak") })]), + ], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts b/M02_MasterTemplete/DataFlow/M02_DF_Round.ts deleted file mode 100644 index c5165238b..000000000 --- a/M02_MasterTemplete/DataFlow/M02_DF_Round.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* ============================================================================= - * 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 index 82042b2e9..c1bf3fad8 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_RoundRow.ts @@ -1,12 +1,12 @@ /* ============================================================================= * M02_DF_RoundRow.ts * 끝수 고르기 줄 — 소수 몇 자리 · 반올림/올림/버림 · 단위 표준에서 자리 제안(적용 단추) - * 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 TEXT + * 모양은 M02_DF_Model 의 CfRound · 칸 · 단추는 공용 작은 크기 · 글 [한, 영] 짝은 아래 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"; +import type { CfRound } from "./M02_DF_Model"; const TEXT = { Label: ["끝수", "Rounding"], @@ -14,53 +14,54 @@ const TEXT = { Places: ["소수 {n}자리", "{n} decimals"], Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"], Apply: ["적용", "Apply"], - Places_aria: ["소수자리", "Decimal places"], - Mode_aria: ["방법", "Method"], + PlacesAria: ["소수자리", "Decimal places"], + ModeAria: ["방법", "Method"], } as const; +const METHODS: CfRound["method"][] = ["반올림", "올림", "버림"]; + 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; + /** 지금 끝수 — 없으면 undefined */ + round: CfRound | undefined; /** 단위 표준이 제안한 소수자리 */ suggest: number | null; readOnly?: boolean; /** null = 끝수 벗김 */ - onApply(spec: RoundSpec | null): void; + onApply(round: CfRound | null): void; } -export function createRoundRow(opts: RoundRowOptions): HTMLElement | null { - if (opts.formula === undefined) return null; - const now = parseRound(opts.formula)?.spec ?? null; +export function createRoundRow(opts: RoundRowOptions): HTMLElement { + const now = opts.round ?? null; const places = el("select", { className: "ui-select ui-select--sm", - attrs: { "aria-label": tr("Places_aria") }, + attrs: { "aria-label": tr("PlacesAria") }, }); 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", { + const method = el("select", { className: "ui-select ui-select--sm", - attrs: { "aria-label": tr("Mode_aria") }, + attrs: { "aria-label": tr("ModeAria") }, }); - 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; + for (const m of METHODS) method.append(el("option", { attrs: { value: m }, text: m })); + method.value = now?.method ?? "반올림"; + if (opts.readOnly) places.disabled = method.disabled = true; const emit = (): void => opts.onApply( - places.value === "" ? null : { places: Number(places.value), mode: mode.value as RoundMode }, + places.value === "" + ? null + : { places: Number(places.value), method: method.value as CfRound["method"] }, ); places.addEventListener("change", emit); - mode.addEventListener("change", () => places.value !== "" && emit()); + method.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], + children: [el("span", { className: "m02-dfpanel-label", text: tr("Label") }), places, method], }); if (opts.suggest !== null && !now && !opts.readOnly) { row.append( @@ -69,7 +70,7 @@ export function createRoundRow(opts: RoundRowOptions): HTMLElement | null { label: tr("Apply"), variant: "ghost", size: "sm", - onClick: () => opts.onApply({ places: opts.suggest!, mode: "반올림" }), + onClick: () => opts.onApply({ places: opts.suggest!, method: "반올림" }), }), ); }