Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1
This commit is contained in:
@@ -22,6 +22,7 @@ import { createCalcEngine } from "./spreadsheet_graph";
|
||||
import { createGrid } from "./spreadsheet_grid";
|
||||
import { createHistory } from "./spreadsheet_history";
|
||||
import { attachKeys } from "./spreadsheet_keys";
|
||||
import type { ConditionOptions } from "./spreadsheet_condition";
|
||||
import type { LinkOptions, Links } from "./spreadsheet_links";
|
||||
import { attachMenu } from "./spreadsheet_menu";
|
||||
import { attachMouse } from "./spreadsheet_mouse";
|
||||
@@ -62,6 +63,8 @@ export interface SpreadsheetOptions {
|
||||
/** 미리보기 수정(카드 안 작은 표) — 도구 모음 · 오른쪽 클릭 메뉴 없이 수식 입력 줄 · 칸 고침 · 단축키 ·
|
||||
* 알약 막대만. 다른 화면 모양은 그대로 */
|
||||
preview?: boolean;
|
||||
/** 칸 우클릭 「조건 만들기」 — 이것을 줄 때만 붙음(늦게 불러옴) · 안 주면 화면 그대로 */
|
||||
conditions?: ConditionOptions;
|
||||
}
|
||||
|
||||
export interface SpreadsheetHandle {
|
||||
@@ -165,6 +168,10 @@ export function createSpreadsheet(
|
||||
grid.render();
|
||||
select(ctx.selection);
|
||||
loadLinks();
|
||||
if (opts.conditions && !readOnly)
|
||||
void import("./spreadsheet_condition").then((m) => {
|
||||
if (root.isConnected) others.push(m.attachCondition(ctx, opts.conditions!));
|
||||
});
|
||||
|
||||
function notify(message: string): void {
|
||||
toast.textContent = message;
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
.ss-cond-menu {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
padding: var(--spacing-4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.ss-cond {
|
||||
min-width: min(46rem, 96vw);
|
||||
}
|
||||
|
||||
.ss-cond-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-8);
|
||||
padding: var(--spacing-4) 0;
|
||||
}
|
||||
|
||||
.ss-cond-label {
|
||||
min-width: 5em;
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.ss-cond-pick {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
}
|
||||
|
||||
.ss-cond-cell,
|
||||
.ss-cond-value {
|
||||
width: 7em;
|
||||
}
|
||||
|
||||
.ss-cond .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.ss-cond-note {
|
||||
margin: 0 0 var(--spacing-8);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border-left: 4px solid var(--color-warning);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.ss-cond-formula {
|
||||
flex: 1 1 20em;
|
||||
overflow-wrap: anywhere;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.ss-cond-foot {
|
||||
margin-top: var(--spacing-8);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition.ts
|
||||
* 엑셀 「조건 만들기」 — 칸 우클릭 → 줄 판: 만약 [칸·알약] [비교] [값] → [칸·알약·값] · 아니면 만약 … · 아니면 → 고른 칸에 IF 식(중첩 IF)
|
||||
* 엑셀로 쓴 IF 도 같은 줄로 다시 엶(`formulaToDraft`) · 못 바꾸는 식은 「식 그대로 고침」 안내
|
||||
* 부르는 쪽이 `conditions` 선택을 줄 때만 붙음(`attachCondition` · 늦게 불러옴) → 그 밖 화면은 그대로
|
||||
* 앞 절반(DraftToFormula · formulaToDraft)은 DOM 을 모름 — 시험이 이 구역만 떼어 Node 로 돌림
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import { openModal } from "@ui/ui_template_modal";
|
||||
import type { PartHandle, SpreadsheetContext } from "./spreadsheet_view_types";
|
||||
import { toA1 } from "./spreadsheet_address";
|
||||
import {
|
||||
COND_OPS,
|
||||
CELL,
|
||||
draftToFormula,
|
||||
formulaToDraft,
|
||||
type CondBranch,
|
||||
type CondDraft,
|
||||
type CondOp,
|
||||
type CondOperand,
|
||||
} from "./spreadsheet_condition_text";
|
||||
import "./spreadsheet_condition.css";
|
||||
|
||||
/** 부르는 쪽이 주는 것 — valuesFor(a1) = 그 칸에 올 수 있는 값 목록(없으면 null → 값 칸) · pills = 이름 붙은 칸 */
|
||||
export interface ConditionOptions {
|
||||
valuesFor(a1: string): (string | number)[] | null;
|
||||
pills?(): { name: string; cell: string }[];
|
||||
}
|
||||
|
||||
export { COND_OPS, draftToFormula, formulaToDraft };
|
||||
export type { CondBranch, CondDraft, CondOp, CondOperand };
|
||||
|
||||
/* ── 글 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
const TEXT = {
|
||||
Menu: ["조건 만들기", "Make a condition"],
|
||||
Title: ["조건 만들기", "Make a condition"],
|
||||
If: ["만약", "If"],
|
||||
ElseIf: ["아니면 만약", "Else if"],
|
||||
Then: ["→", "→"],
|
||||
Otherwise: ["아니면 →", "Otherwise →"],
|
||||
AddElseIf: ["+ 아니면 만약", "+ Else if"],
|
||||
Remove: ["지움", "Remove"],
|
||||
Make: ["만들기", "Make"],
|
||||
Cancel: ["취소", "Cancel"],
|
||||
Close: ["닫기", "Close"],
|
||||
CellPick: ["칸 직접", "A cell"],
|
||||
ValuePick: ["값", "A value"],
|
||||
Cell: ["칸 주소", "Cell address"],
|
||||
Value: ["값", "Value"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
Target: ["식이 들어갈 칸", "Formula goes into"],
|
||||
AsIs: [
|
||||
"이 식은 조건 줄로 바꿀 수 없음 — 식 그대로 고침",
|
||||
"This formula can't be shown as lines — edit the formula as it is",
|
||||
],
|
||||
} as const;
|
||||
|
||||
const tc = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 줄 판 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const OWN = "\u0001cell";
|
||||
const OWN_VALUE = "\u0001value";
|
||||
|
||||
function select(
|
||||
options: { value: string; text: string }[],
|
||||
value: string,
|
||||
onPick: (v: string) => void,
|
||||
aria: string,
|
||||
): HTMLSelectElement {
|
||||
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 }));
|
||||
s.value = value;
|
||||
s.addEventListener("change", () => onPick(s.value));
|
||||
return s;
|
||||
}
|
||||
|
||||
/** 칸 고르기 — 이름 붙은 칸(알약) 아니면 칸 주소 직접 */
|
||||
function cellPick(
|
||||
opts: ConditionOptions,
|
||||
value: string,
|
||||
onChange: (cell: string) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const hit = pills.find((p) => p.cell === value);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
];
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const sel = select(options, hit ? value : OWN, (v) => v !== OWN && onChange(v), aria);
|
||||
box.append(sel);
|
||||
if (!hit) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell"), placeholder: "B2" },
|
||||
});
|
||||
input.value = value;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tc("BadCell");
|
||||
if (m) onChange(`${m[1]}${m[2]}`);
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
/** 값 쪽 고르기 — 칸 · 알약 · 값(목록이 있으면 고르기) */
|
||||
function operandPick(
|
||||
opts: ConditionOptions,
|
||||
o: CondOperand,
|
||||
hint: string | null,
|
||||
onChange: (o: CondOperand) => void,
|
||||
aria: string,
|
||||
): HTMLElement {
|
||||
const box = el("span", { className: "ss-cond-pick" });
|
||||
const pills = opts.pills?.() ?? [];
|
||||
const isPill = o.kind === "cell" && pills.some((p) => p.cell === o.cell);
|
||||
const options = [
|
||||
...pills.map((p) => ({ value: p.cell, text: `${p.name} (${p.cell})` })),
|
||||
{ value: OWN, text: tc("CellPick") },
|
||||
{ value: OWN_VALUE, text: tc("ValuePick") },
|
||||
];
|
||||
const mode = o.kind === "value" ? OWN_VALUE : isPill ? o.cell : OWN;
|
||||
box.append(
|
||||
select(
|
||||
options,
|
||||
mode,
|
||||
(v) => {
|
||||
if (v === OWN_VALUE) onChange({ kind: "value", value: 0 });
|
||||
else if (v === OWN) onChange({ kind: "cell", cell: "B2" });
|
||||
else onChange({ kind: "cell", cell: v });
|
||||
},
|
||||
aria,
|
||||
),
|
||||
);
|
||||
if (o.kind === "cell" && !isPill) {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-cell",
|
||||
attrs: { type: "text", "aria-label": tc("Cell") },
|
||||
});
|
||||
input.value = o.cell;
|
||||
input.addEventListener("change", () => {
|
||||
const m = CELL.exec(input.value.trim().toUpperCase());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
if (m) onChange({ kind: "cell", cell: `${m[1]}${m[2]}` });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
if (o.kind === "value") {
|
||||
const list = hint ? opts.valuesFor(hint) : null;
|
||||
if (list && list.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
box.append(
|
||||
select(
|
||||
list.map((v) => ({ value: key(v), text: String(v) })),
|
||||
key(o.value),
|
||||
(k) => {
|
||||
const hitv = list.find((v) => key(v) === k);
|
||||
if (hitv !== undefined) onChange({ kind: "value", value: hitv });
|
||||
},
|
||||
tc("Value"),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm ss-cond-value",
|
||||
attrs: { type: "text", "aria-label": tc("Value") },
|
||||
});
|
||||
input.value = String(o.value);
|
||||
input.addEventListener("change", () => {
|
||||
const t = input.value.trim();
|
||||
onChange({ kind: "value", value: t !== "" && Number.isFinite(Number(t)) ? Number(t) : t });
|
||||
});
|
||||
box.append(input);
|
||||
}
|
||||
}
|
||||
return box;
|
||||
}
|
||||
|
||||
function openJob(
|
||||
ctx: SpreadsheetContext,
|
||||
opts: ConditionOptions,
|
||||
a1: string,
|
||||
draft: CondDraft | null,
|
||||
existing: string | null,
|
||||
): void {
|
||||
openModal({
|
||||
title: `${tc("Title")} — ${a1}`,
|
||||
closeLabel: tc("Close"),
|
||||
dialogClass: "ss-cond",
|
||||
mount: (body, close) => {
|
||||
let d: CondDraft = draft ?? {
|
||||
branches: [
|
||||
{
|
||||
left: "B1",
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
otherwise: { kind: "value", value: 0 },
|
||||
};
|
||||
if (existing !== null && !draft) {
|
||||
body.append(
|
||||
el("p", { className: "ss-cond-note", text: tc("AsIs") }),
|
||||
el("code", { className: "ss-cond-formula", text: `=${existing}` }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const draw = (): void => {
|
||||
const set = (next: CondDraft): void => ((d = next), draw());
|
||||
const rows = d.branches.map((b, i) => {
|
||||
const edit = (patch: Partial<CondBranch>): void =>
|
||||
set({ ...d, branches: d.branches.map((x, j) => (j === i ? { ...x, ...patch } : x)) });
|
||||
return el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc(i ? "ElseIf" : "If") }),
|
||||
cellPick(opts, b.left, (cell) => edit({ left: cell }), tc("If")),
|
||||
select(
|
||||
COND_OPS.map((o) => ({ value: o, text: o })),
|
||||
b.op,
|
||||
(v) => edit({ op: v as CondOp }),
|
||||
"op",
|
||||
),
|
||||
operandPick(opts, b.right, b.left, (right) => edit({ right }), tc("Value")),
|
||||
tc("Then"),
|
||||
operandPick(opts, b.then, null, (then) => edit({ then }), tc("Then")),
|
||||
d.branches.length > 1
|
||||
? createButton({
|
||||
label: tc("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => set({ ...d, branches: d.branches.filter((_, j) => j !== i) }),
|
||||
})
|
||||
: "",
|
||||
],
|
||||
});
|
||||
});
|
||||
const last = d.branches[d.branches.length - 1];
|
||||
body.replaceChildren(
|
||||
...rows,
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
createButton({
|
||||
label: tc("AddElseIf"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
set({
|
||||
...d,
|
||||
branches: [
|
||||
...d.branches,
|
||||
{
|
||||
left: last.left,
|
||||
op: "=",
|
||||
right: { kind: "value", value: "" },
|
||||
then: { kind: "value", value: 0 },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row",
|
||||
children: [
|
||||
el("span", { className: "ss-cond-label", text: tc("Otherwise") }),
|
||||
operandPick(
|
||||
opts,
|
||||
d.otherwise,
|
||||
null,
|
||||
(otherwise) => set({ ...d, otherwise }),
|
||||
tc("Otherwise"),
|
||||
),
|
||||
],
|
||||
}),
|
||||
el("div", {
|
||||
className: "ss-cond-row ss-cond-foot",
|
||||
children: [
|
||||
el("code", {
|
||||
className: "ss-cond-formula",
|
||||
text: `${tc("Target")} ${a1}: =${draftToFormula(d)}`,
|
||||
}),
|
||||
createButton({
|
||||
label: tc("Make"),
|
||||
variant: "filled",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
const sheet = ctx.sheet();
|
||||
const { 값: _v, ...keep } = sheet.칸[a1] ?? {};
|
||||
ctx.dispatch({
|
||||
종류: "칸",
|
||||
시트: sheet.id,
|
||||
칸: { [a1]: { ...keep, 식: draftToFormula(d) } },
|
||||
});
|
||||
close();
|
||||
},
|
||||
}),
|
||||
createButton({ label: tc("Cancel"), variant: "ghost", size: "sm", onClick: close }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
};
|
||||
draw();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 칸 우클릭에 「조건 만들기」 한 줄 — conditions 를 준 편집기에만 붙음 */
|
||||
export function attachCondition(ctx: SpreadsheetContext, opts: ConditionOptions): PartHandle {
|
||||
let menu: HTMLElement | null = null;
|
||||
const close = (): void => {
|
||||
menu?.remove();
|
||||
menu = null;
|
||||
document.removeEventListener("pointerdown", away, true);
|
||||
};
|
||||
const away = (e: Event): void => {
|
||||
if (menu && !menu.contains(e.target as Node)) close();
|
||||
};
|
||||
const onContext = (e: MouseEvent): void => {
|
||||
const hit = ctx.grid.hitTest(e.clientX, e.clientY);
|
||||
if (!hit || hit.kind !== "cell" || ctx.readOnly) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
close();
|
||||
const a1 = toA1(hit.r, hit.c);
|
||||
const sheet = ctx.sheet();
|
||||
const cell = sheet.칸[a1];
|
||||
menu = el("div", { className: "ss-cond-menu" });
|
||||
menu.style.left = `${e.clientX}px`;
|
||||
menu.style.top = `${e.clientY}px`;
|
||||
menu.append(
|
||||
createButton({
|
||||
label: tc("Menu"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => {
|
||||
close();
|
||||
const existing = cell?.식 ?? null;
|
||||
const draft = existing !== null ? formulaToDraft(existing) : null;
|
||||
openJob(ctx, opts, a1, draft, existing !== null && !draft ? existing : null);
|
||||
},
|
||||
}),
|
||||
);
|
||||
document.body.append(menu);
|
||||
document.addEventListener("pointerdown", away, true);
|
||||
};
|
||||
ctx.root.addEventListener("contextmenu", onContext, true);
|
||||
return {
|
||||
root: null,
|
||||
refresh() {},
|
||||
destroy() {
|
||||
ctx.root.removeEventListener("contextmenu", onContext, true);
|
||||
close();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
/* =============================================================================
|
||||
* spreadsheet_condition_text.ts
|
||||
* 「조건 만들기」 의 글 쪽(DOM 없음) — 줄들 ↔ IF 식 글 · 바깥 쉼표 가름 · 시험이 이 파일만 떼어 Node 로 돌림
|
||||
* ========================================================================== */
|
||||
|
||||
export type CondOp = "=" | "≠" | "≥" | "≤" | ">" | "<";
|
||||
export type CondOperand =
|
||||
{ kind: "cell"; cell: string } | { kind: "value"; value: number | string };
|
||||
export interface CondBranch {
|
||||
/** 비교하는 칸 */
|
||||
left: string;
|
||||
op: CondOp;
|
||||
right: CondOperand;
|
||||
then: CondOperand;
|
||||
}
|
||||
export interface CondDraft {
|
||||
branches: CondBranch[];
|
||||
otherwise: CondOperand;
|
||||
}
|
||||
|
||||
export const XL_OP: Record<CondOp, string> = {
|
||||
"=": "=",
|
||||
"≠": "<>",
|
||||
"≥": ">=",
|
||||
"≤": "<=",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const FROM_XL: Record<string, CondOp> = {
|
||||
"=": "=",
|
||||
"<>": "≠",
|
||||
">=": "≥",
|
||||
"<=": "≤",
|
||||
">": ">",
|
||||
"<": "<",
|
||||
};
|
||||
export const COND_OPS: CondOp[] = ["=", "≠", "≥", "≤", ">", "<"];
|
||||
|
||||
export const CELL = /^\$?([A-Z]{1,3})\$?(\d+)$/;
|
||||
|
||||
/* ── 글 ↔ 줄 (DOM 없음) ───────────────────────────────────────────────── */
|
||||
|
||||
export const operandText = (o: CondOperand): string =>
|
||||
o.kind === "cell"
|
||||
? o.cell
|
||||
: typeof o.value === "number"
|
||||
? String(o.value)
|
||||
: `"${o.value.replace(/"/g, '""')}"`;
|
||||
|
||||
/** 줄들 → 식 글(앞 = 뺌) — 아니면 만약 은 안쪽 IF */
|
||||
export function draftToFormula(d: CondDraft): string {
|
||||
return d.branches.reduceRight(
|
||||
(rest, b) =>
|
||||
`IF(${b.left}${XL_OP[b.op]}${operandText(b.right)},${operandText(b.then)},${rest})`,
|
||||
operandText(d.otherwise),
|
||||
);
|
||||
}
|
||||
|
||||
/** 바깥 쉼표로 가름 — 따옴표 · 괄호 안은 건너뜀 */
|
||||
export function splitArgs(s: string): string[] {
|
||||
const out: string[] = [];
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
let from = 0;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (ch === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (ch === "(") depth++;
|
||||
else if (ch === ")") depth--;
|
||||
else if (ch === "," && depth === 0) {
|
||||
out.push(s.slice(from, i));
|
||||
from = i + 1;
|
||||
}
|
||||
}
|
||||
out.push(s.slice(from));
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseOperand(raw: string): CondOperand | null {
|
||||
const t = raw.trim();
|
||||
const cell = CELL.exec(t);
|
||||
if (cell) return { kind: "cell", cell: `${cell[1]}${cell[2]}` };
|
||||
if (/^-?\d+(\.\d+)?$/.test(t)) return { kind: "value", value: Number(t) };
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(t);
|
||||
return q ? { kind: "value", value: q[1].replace(/""/g, '"') } : null;
|
||||
}
|
||||
|
||||
/** 바깥 괄호 하나로 감싼 IF( … ) 의 안쪽 글 — 아니면 null */
|
||||
export function ifBody(f: string): string | null {
|
||||
const t = f.trim();
|
||||
if (!/^IF\(/i.test(t) || !t.endsWith(")")) return null;
|
||||
let depth = 0;
|
||||
let quote = false;
|
||||
for (let i = 2; i < t.length; i++) {
|
||||
if (t[i] === '"') quote = !quote;
|
||||
else if (quote) continue;
|
||||
else if (t[i] === "(") depth++;
|
||||
else if (t[i] === ")" && --depth === 0 && i !== t.length - 1) return null;
|
||||
}
|
||||
return t.slice(3, -1);
|
||||
}
|
||||
|
||||
/** 식 글(앞 = 뺌) → 줄들 — 이 판이 만드는 꼴(칸 비교 · 단순 값 · 아니면 쪽 중첩)만 읽고 나머지는 null */
|
||||
export function formulaToDraft(formula: string): CondDraft | null {
|
||||
const body = ifBody(formula);
|
||||
if (body === null) return null;
|
||||
const args = splitArgs(body);
|
||||
if (args.length !== 3) return null;
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(args[0]);
|
||||
if (!m) return null;
|
||||
const left = parseOperand(m[1]);
|
||||
const right = parseOperand(m[3]);
|
||||
const then = parseOperand(args[1]);
|
||||
if (!left || left.kind !== "cell" || !right || !then) return null;
|
||||
const branch: CondBranch = { left: left.cell, op: FROM_XL[m[2]], right, then };
|
||||
const plain = parseOperand(args[2]);
|
||||
if (plain) return { branches: [branch], otherwise: plain };
|
||||
const rest = formulaToDraft(args[2]);
|
||||
return rest ? { branches: [branch, ...rest.branches], otherwise: rest.otherwise } : null;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_CellTable.ts
|
||||
* 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기
|
||||
* 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과)
|
||||
* 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import type { CfBox, CfLogic } from "./M02_DF_Model";
|
||||
import { portText } from "./M02_DF_Detail";
|
||||
|
||||
const TEXT = {
|
||||
Title: ["칸 연결 표", "Cell connections"],
|
||||
Cell: ["칸", "Cell"],
|
||||
Pill: ["연결 알약", "Pill"],
|
||||
Kind: ["종류", "Kind"],
|
||||
Line: ["이어진 선", "Line"],
|
||||
In: ["입력", "Input"],
|
||||
Var: ["변수", "Variable"],
|
||||
Out: ["결과", "Result"],
|
||||
None: ["아직 연결된 칸이 없음", "No cell is connected yet"],
|
||||
Unplaced: ["칸 없음", "no cell"],
|
||||
NoLine: ["-", "-"],
|
||||
Release: ["풀기", "Release"],
|
||||
BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"],
|
||||
} as const;
|
||||
|
||||
const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
export type CellKind = "in" | "var" | "out";
|
||||
|
||||
export interface CellTableHandlers {
|
||||
/** 표에서 칸을 바꿈 — key = 알약 이름 */
|
||||
move(kind: CellKind, key: string, cell: string): void;
|
||||
/** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */
|
||||
release(kind: CellKind, key: string): void;
|
||||
}
|
||||
|
||||
interface Row {
|
||||
kind: CellKind;
|
||||
key: string;
|
||||
cell: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
function rowsOf(lg: CfLogic, box: CfBox): Row[] {
|
||||
const f = box.func;
|
||||
if (!f) return [];
|
||||
const into = (port: string): string => {
|
||||
const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port);
|
||||
return l ? portText(lg, l.from) : tt("NoLine");
|
||||
};
|
||||
const out = (): string => {
|
||||
const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name);
|
||||
return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine");
|
||||
};
|
||||
return [
|
||||
...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })),
|
||||
...f.variables.map((v): Row => ({
|
||||
kind: "var",
|
||||
key: v.name,
|
||||
cell: v.cell ?? "",
|
||||
line: tt("NoLine"),
|
||||
})),
|
||||
{ kind: "out", key: f.output.name, cell: f.output.cell, line: out() },
|
||||
];
|
||||
}
|
||||
|
||||
/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */
|
||||
export function renderCellTable(
|
||||
host: HTMLElement,
|
||||
lg: CfLogic,
|
||||
box: CfBox,
|
||||
readOnly: boolean,
|
||||
handlers: CellTableHandlers,
|
||||
): void {
|
||||
host.classList.add("m02-dfcells");
|
||||
const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key);
|
||||
const head = el("tr", {
|
||||
children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) =>
|
||||
el("th", { text: t }),
|
||||
),
|
||||
});
|
||||
const body = rows.map((r) => {
|
||||
const input = el("input", {
|
||||
className: "ui-input ui-input--sm m02-dfcells__cell",
|
||||
attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") },
|
||||
});
|
||||
input.value = r.cell;
|
||||
input.readOnly = readOnly;
|
||||
input.addEventListener("change", () => {
|
||||
const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim());
|
||||
input.classList.toggle("is-bad", !m);
|
||||
input.title = m ? "" : tt("BadCell");
|
||||
if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`);
|
||||
});
|
||||
return el("tr", {
|
||||
className: `m02-dfcells__row is-${r.kind}`,
|
||||
children: [
|
||||
el("td", { children: [input] }),
|
||||
el("td", { text: r.key }),
|
||||
el("td", {
|
||||
children: [
|
||||
el("span", {
|
||||
className: "m02-dfcells__kind",
|
||||
text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
el("td", { text: r.line }),
|
||||
el("td", {
|
||||
children:
|
||||
readOnly || (r.kind !== "var" && !r.cell)
|
||||
? []
|
||||
: [
|
||||
createButton({
|
||||
label: tt("Release"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => handlers.release(r.kind, r.key),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-dfcells__title", text: tt("Title") }),
|
||||
el("table", {
|
||||
className: "m02-dfcells__table",
|
||||
children: [el("thead", { children: [head] }), el("tbody", { children: body })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FormulaView.ts
|
||||
* 함수 상자 「식 보기」 — 결과 칸과 그 칸이 기대는 중간 칸의 식을 칸 주소 → 연결 알약 이름으로 · ×, ÷ · 값 들어간 식 · = 결과 ·
|
||||
* IF 는 만약 / 아니면 줄 + 지금 타는 갈래(now) · 앞 절반은 DOM 을 모름(시험이 Node 로 돌림) · 상세 내용 함수 판과 왼쪽 텍스트 수식이 함께 씀
|
||||
* 모양은 `docs/raw/M01_쉬운단가/흐름도_적용_정함.md` 약속 8 · 글 [한, 영] 짝은 아래 TEXT
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { el } from "@ui/ui_template_elements";
|
||||
import { parseA1 } from "../../A00_Common/spreadsheet/spreadsheet_address";
|
||||
import { recalcWorkbook } from "../../A00_Common/spreadsheet/spreadsheet_recalc";
|
||||
import { ifBody, splitArgs } from "../../A00_Common/spreadsheet/spreadsheet_condition_text";
|
||||
import type { CalcValue } from "../../A00_Common/spreadsheet/spreadsheet_types";
|
||||
import type { CfBox } from "./M02_DF_Model";
|
||||
|
||||
export type CellScalar = number | string | boolean | null;
|
||||
|
||||
export interface FormulaBranch {
|
||||
when: string;
|
||||
then: string;
|
||||
/** 지금 타는 갈래 */
|
||||
now: boolean;
|
||||
}
|
||||
|
||||
export interface FormulaLine {
|
||||
/** 이 식이 든 칸의 이름(연결 알약 이름 · 없으면 칸 주소) */
|
||||
name: string;
|
||||
/** 이름으로 쓴 식 — 「석공량 × (1 + 증가율 ÷ 100)」 */
|
||||
named: string;
|
||||
/** 값 넣은 식 — 「0.15 × (1 + 4 ÷ 100) = 0.156」 */
|
||||
valued: string;
|
||||
/** IF 면 만약 / 아니면 줄 */
|
||||
branches?: FormulaBranch[];
|
||||
}
|
||||
|
||||
const TEXT = {
|
||||
Title: ["식 보기", "Formulas"],
|
||||
Empty: ["식이 든 결과 칸이 아직 없음", "No result cell with a formula yet"],
|
||||
Else: ["아니면", "Otherwise"],
|
||||
If: ["만약", "If"],
|
||||
Now: ["지금", "now"],
|
||||
} as const;
|
||||
|
||||
const tv = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0];
|
||||
|
||||
/* ── 칸 · 이름 ──────────────────────────────────────────────────────────── */
|
||||
|
||||
const REF = /(?<![A-Za-z0-9_.'!])\$?([A-Z]{1,3})\$?(\d+)(?![A-Za-z0-9_(])/g;
|
||||
|
||||
/** 따옴표 안 글은 그대로 두고 바깥 글만 바꿈 */
|
||||
function outside(text: string, fn: (part: string) => string): string {
|
||||
return text
|
||||
.split(/("(?:[^"]|"")*")/)
|
||||
.map((p, i) => (i % 2 ? p : fn(p)))
|
||||
.join("");
|
||||
}
|
||||
|
||||
const upper = (a1: string): string => a1.toUpperCase().replace(/\$/g, "");
|
||||
|
||||
/** 칸 주소 → 이름 — 받는 알약 · 변수 · 결과 칸 이름(연결 없는 칸은 주소 그대로) */
|
||||
export function cellNames(box: CfBox): Record<string, string> {
|
||||
const f = box.func;
|
||||
const out: Record<string, string> = {};
|
||||
if (!f) return out;
|
||||
for (const i of f.inputs) if (i.cell) out[upper(i.cell)] = i.port;
|
||||
for (const v of f.variables) if (v.cell) out[upper(v.cell)] = v.name;
|
||||
if (f.output.cell) out[upper(f.output.cell)] = f.output.name;
|
||||
return out;
|
||||
}
|
||||
|
||||
const refsIn = (formula: string): string[] => {
|
||||
const found: string[] = [];
|
||||
outside(formula, (p) => p.replace(REF, (m, c: string, r: string) => (found.push(`${c}${r}`), m)));
|
||||
return found;
|
||||
};
|
||||
|
||||
const OPS: [RegExp, string][] = [
|
||||
[/\*/g, " × "],
|
||||
[/\//g, " ÷ "],
|
||||
[/<>/g, " ≠ "],
|
||||
[/>=/g, " ≥ "],
|
||||
[/<=/g, " ≤ "],
|
||||
[/(?<![<>≠≥≤])=/g, " = "],
|
||||
[/\+/g, " + "],
|
||||
[/(?<=[\w가-힣)])-(?=[\w가-힣(])/g, " − "],
|
||||
];
|
||||
|
||||
/** 보기 좋게 — × ÷ ≠ ≥ ≤ 로 */
|
||||
function pretty(text: string): string {
|
||||
return outside(text, (p) => OPS.reduce((s, [re, to]) => s.replace(re, to), p))
|
||||
.replace(/\s{2,}/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const fmt = (n: number): string =>
|
||||
Number(n.toPrecision(12)).toLocaleString("ko-KR", { maximumFractionDigits: 6 });
|
||||
|
||||
function showScalar(v: CellScalar | undefined): string {
|
||||
if (v === undefined || v === null) return "0";
|
||||
if (typeof v === "number") return fmt(v);
|
||||
if (typeof v === "boolean") return v ? "TRUE" : "FALSE";
|
||||
return `"${v}"`;
|
||||
}
|
||||
|
||||
/** 이름으로 쓴 식 */
|
||||
export function namedText(formula: string, names: Record<string, string>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => names[`${c}${r}`] ?? `${c}${r}`),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** 값 넣은 식(값이 문자면 따옴표) */
|
||||
export function valuedText(formula: string, values: Record<string, CellScalar>): string {
|
||||
return pretty(
|
||||
outside(formula, (p) =>
|
||||
p.replace(REF, (_m, c: string, r: string) => showScalar(values[`${c}${r}`])),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/* ── 갈래 ───────────────────────────────────────────────────────────────── */
|
||||
|
||||
const num = (v: CellScalar | undefined): number | null => {
|
||||
if (typeof v === "number") return v;
|
||||
if (typeof v === "string" && v.trim() !== "" && Number.isFinite(Number(v))) return Number(v);
|
||||
return null;
|
||||
};
|
||||
|
||||
/** 조건 한 줄 `왼 비교 오른` 의 참거짓 — 못 읽으면 null */
|
||||
function holds(cond: string, values: Record<string, CellScalar>): boolean | null {
|
||||
const m = /^\s*(.+?)\s*(>=|<=|<>|=|>|<)\s*(.+?)\s*$/.exec(cond);
|
||||
if (!m) return null;
|
||||
const read = (t: string): CellScalar | undefined => {
|
||||
const x = t.trim();
|
||||
const cell = /^\$?([A-Z]{1,3})\$?(\d+)$/.exec(x);
|
||||
if (cell) return values[`${cell[1]}${cell[2]}`];
|
||||
const q = /^"((?:[^"]|"")*)"$/.exec(x);
|
||||
if (q) return q[1].replace(/""/g, '"');
|
||||
return Number.isFinite(Number(x)) ? Number(x) : undefined;
|
||||
};
|
||||
const a = read(m[1]);
|
||||
const b = read(m[3]);
|
||||
if (a === undefined || b === undefined) return null;
|
||||
const [l, r] = num(a) !== null && num(b) !== null ? [num(a)!, num(b)!] : [String(a), String(b)];
|
||||
switch (m[2]) {
|
||||
case "=":
|
||||
return l === r;
|
||||
case "<>":
|
||||
return l !== r;
|
||||
case ">=":
|
||||
return l >= r;
|
||||
case "<=":
|
||||
return l <= r;
|
||||
case ">":
|
||||
return l > r;
|
||||
default:
|
||||
return l < r;
|
||||
}
|
||||
}
|
||||
|
||||
/** IF 사슬 → 갈래들 — IF 가 아니면 undefined */
|
||||
function branchesOf(
|
||||
formula: string,
|
||||
names: Record<string, string>,
|
||||
values: Record<string, CellScalar>,
|
||||
): FormulaBranch[] | undefined {
|
||||
const out: FormulaBranch[] = [];
|
||||
let rest = formula;
|
||||
let taken = false;
|
||||
for (;;) {
|
||||
const body = ifBody(rest);
|
||||
if (body === null) break;
|
||||
const a = splitArgs(body);
|
||||
if (a.length !== 3) return undefined;
|
||||
const ok = holds(a[0], values) === true && !taken;
|
||||
if (ok) taken = true;
|
||||
out.push({ when: namedText(a[0], names), then: namedText(a[1], names), now: ok });
|
||||
rest = a[2];
|
||||
}
|
||||
if (!out.length) return undefined;
|
||||
out.push({ when: tv("Else"), then: namedText(rest, names), now: !taken });
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ── 줄 ─────────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 줄들 — 결과 칸이 기대는 칸의 식이 먼저 · 결과 칸이 마지막 */
|
||||
export function formulaLines(box: CfBox, values: Record<string, CellScalar>): FormulaLine[] {
|
||||
const f = box.func;
|
||||
if (!f?.output.cell) return [];
|
||||
const names = cellNames(box);
|
||||
const cells = f.sheet.칸;
|
||||
const order: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
const visit = (a1: string): void => {
|
||||
const key = upper(a1);
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const formula = cells[key]?.식;
|
||||
if (formula === undefined) return;
|
||||
for (const r of refsIn(formula)) visit(r);
|
||||
order.push(key);
|
||||
};
|
||||
visit(f.output.cell);
|
||||
return order.map((key) => {
|
||||
const formula = cells[key].식 as string;
|
||||
const result = showScalar(values[key]);
|
||||
const branches = branchesOf(formula, names, values);
|
||||
return {
|
||||
name: names[key] ?? key,
|
||||
named: namedText(formula, names),
|
||||
valued: `${valuedText(formula, values)} = ${result}`,
|
||||
...(branches ? { branches } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 시트 칸 값 — 받는 알약 칸에 들어온 값(arrived) · 변수 예시값을 넣고 엔진으로 셈 */
|
||||
export function cellValuesOf(
|
||||
box: CfBox,
|
||||
arrived: Record<string, unknown> | undefined,
|
||||
): Record<string, CellScalar> {
|
||||
const f = box.func;
|
||||
if (!f) return {};
|
||||
const sheet = structuredClone(f.sheet);
|
||||
const scalar = (v: unknown): number | string | boolean | undefined =>
|
||||
typeof v === "number" || typeof v === "string" || typeof v === "boolean" ? v : undefined;
|
||||
const put = (cell: string, v: number | string | boolean | undefined): void => {
|
||||
if (!parseA1(cell) || v === undefined) return;
|
||||
sheet.칸[upper(cell)] = { 값: v };
|
||||
};
|
||||
for (const i of f.inputs) if (i.cell) put(i.cell, scalar(arrived?.[i.port]));
|
||||
for (const v of f.variables) if (v.cell && v.example !== null) put(v.cell, v.example);
|
||||
const calc = recalcWorkbook({ 종류: "통합문서", 판: 1, 열: "DF", 서식: [{}], 시트: [sheet] });
|
||||
const out: Record<string, CellScalar> = {};
|
||||
for (const [key, cell] of Object.entries(sheet.칸)) {
|
||||
const c: CalcValue | undefined = calc[sheet.id]?.[key];
|
||||
out[key] = cell.식 === undefined ? (cell.값 ?? null) : c ? fromCalc(c) : null;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function fromCalc(v: CalcValue): CellScalar {
|
||||
if ("수" in v) return Number(v.수);
|
||||
if ("글" in v) return v.글;
|
||||
if ("참" in v) return v.참;
|
||||
return v.오류;
|
||||
}
|
||||
|
||||
/* ── 그리기 ─────────────────────────────────────────────────────────────── */
|
||||
|
||||
/** 식 보기 그리기 — 줄마다 이름 식 · 값 식 · 갈래(지금 갈래는 ▶ 와 「지금」 글) */
|
||||
export function renderFormulaView(host: HTMLElement, lines: FormulaLine[]): void {
|
||||
host.classList.add("m02-df-formula");
|
||||
host.replaceChildren(
|
||||
el("strong", { className: "m02-df-formula__title", text: tv("Title") }),
|
||||
...(lines.length
|
||||
? lines.map((l) =>
|
||||
el("div", {
|
||||
className: "m02-df-formula__line",
|
||||
children: [
|
||||
el("div", { children: [el("b", { text: `${l.name} = ` }), l.named] }),
|
||||
el("div", { className: "m02-df-formula__valued", text: l.valued }),
|
||||
...(l.branches
|
||||
? [
|
||||
el("ul", {
|
||||
className: "m02-df-formula__branches",
|
||||
children: l.branches.map((b, i) =>
|
||||
el("li", {
|
||||
className: b.now ? "is-now" : "",
|
||||
text: `${b.now ? "▶ " : ""}${i < l.branches!.length - 1 ? `${tv("If")} ${b.when} → ` : `${b.when} → `}${b.then}${b.now ? ` (${tv("Now")})` : ""}`,
|
||||
}),
|
||||
),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}),
|
||||
)
|
||||
: [el("p", { className: "m02-df-formula__empty", text: tv("Empty") })]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/* =============================================================================
|
||||
* M02_DF_FuncExtra.ts
|
||||
* 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것
|
||||
* 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음
|
||||
* ========================================================================== */
|
||||
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { showToast } from "@ui/ui_template_elements";
|
||||
import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition";
|
||||
import { renderCellTable } from "./M02_DF_CellTable";
|
||||
import { evalSafe, type DetailOptions } from "./M02_DF_Detail";
|
||||
import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView";
|
||||
import { TEXT } from "./M02_DF_FuncText";
|
||||
import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model";
|
||||
|
||||
export interface ExtraContext {
|
||||
lg: CfLogic;
|
||||
boxId: string;
|
||||
detail: DetailOptions;
|
||||
readOnly: boolean;
|
||||
place(port: string, cell: string): void;
|
||||
setOutput(cell: string): void;
|
||||
patch(make: (f: CfFunc) => CfFunc): void;
|
||||
used(f: CfFunc): Map<string, string>;
|
||||
}
|
||||
|
||||
const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0];
|
||||
const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId);
|
||||
|
||||
/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */
|
||||
export function drawFormula(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
const ev = evalSafe(x.lg, x.detail);
|
||||
renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId])));
|
||||
}
|
||||
|
||||
/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */
|
||||
export function drawCells(host: HTMLElement, x: ExtraContext): void {
|
||||
const box = boxOf(x);
|
||||
if (!box?.func) return;
|
||||
renderCellTable(host, x.lg, box, x.readOnly, {
|
||||
move: (kind, key, cell) => {
|
||||
if (kind === "in") return x.place(key, cell);
|
||||
if (kind === "out") return x.setOutput(cell);
|
||||
if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error");
|
||||
x.patch((f) => ({
|
||||
...f,
|
||||
variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)),
|
||||
}));
|
||||
},
|
||||
release: (kind, key) =>
|
||||
x.patch((f) =>
|
||||
kind === "in"
|
||||
? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) }
|
||||
: kind === "var"
|
||||
? { ...f, variables: f.variables.filter((v) => v.name !== key) }
|
||||
: { ...f, output: { ...f.output, cell: "" } },
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */
|
||||
export function conditionsOf(x: ExtraContext): ConditionOptions {
|
||||
return {
|
||||
valuesFor: (a1) => {
|
||||
const f = boxOf(x)?.func;
|
||||
const at = a1.toUpperCase();
|
||||
const v = f?.variables.find((y) => y.cell?.toUpperCase() === at);
|
||||
if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null;
|
||||
const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port;
|
||||
const link = port
|
||||
? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port)
|
||||
: undefined;
|
||||
return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null;
|
||||
},
|
||||
pills: () => {
|
||||
const f = boxOf(x)?.func;
|
||||
return [
|
||||
...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })),
|
||||
...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })),
|
||||
];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
}
|
||||
|
||||
.m02-dfpanel-chip.is-out {
|
||||
border-color: #f59e0b;
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
.m02-dfpanel-chip.is-var {
|
||||
border-color: #22a06b;
|
||||
border-color: #d99a00;
|
||||
}
|
||||
|
||||
/* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */
|
||||
@@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="var"] {
|
||||
box-shadow: inset 0 0 0 2px #22a06b;
|
||||
box-shadow: inset 0 0 0 2px #d99a00;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind="out"] {
|
||||
box-shadow: inset 0 0 0 2px #f59e0b;
|
||||
box-shadow: inset 0 0 0 2px #27ae60;
|
||||
}
|
||||
|
||||
.m02-dfpanel .aislo-cell[data-cf-kind]::after {
|
||||
@@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible {
|
||||
outline: 2px dashed var(--color-danger);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */
|
||||
.m02-dfpanel-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-12);
|
||||
}
|
||||
|
||||
.m02-dfpanel-body > .m02-dfpanel-sheet {
|
||||
flex: 1 1 28em;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells {
|
||||
flex: 0 1 24em;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.m02-dfcells__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-dfcells__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: var(--text-body-sm);
|
||||
}
|
||||
|
||||
.m02-dfcells__table th,
|
||||
.m02-dfcells__table td {
|
||||
padding: var(--spacing-4);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.m02-dfcells__cell {
|
||||
width: 5em;
|
||||
}
|
||||
|
||||
.m02-dfcells .ui-input.is-bad {
|
||||
border-color: var(--color-danger);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.m02-dfcells__kind {
|
||||
padding: 0 var(--spacing-8);
|
||||
border: 2px solid;
|
||||
border-radius: var(--radius-pills);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-in .m02-dfcells__kind {
|
||||
border-color: #3b82f6;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-var .m02-dfcells__kind {
|
||||
border-color: #d99a00;
|
||||
}
|
||||
|
||||
.m02-dfcells__row.is-out .m02-dfcells__kind {
|
||||
border-color: #27ae60;
|
||||
}
|
||||
|
||||
/* 식 보기 */
|
||||
.m02-df-formula {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-4);
|
||||
padding: var(--spacing-8) var(--spacing-12);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-cards);
|
||||
background: var(--color-surface-raised);
|
||||
}
|
||||
|
||||
.m02-df-formula__title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-formula__line {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding-top: var(--spacing-4);
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
.m02-df-formula__valued {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.m02-df-formula__branches {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-16);
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.m02-df-formula__branches .is-now {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
|
||||
.m02-df-formula__empty {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
Workbook,
|
||||
} from "../../A00_Common/spreadsheet/spreadsheet";
|
||||
import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model";
|
||||
import type { DetailOptions } from "./M02_DF_Detail";
|
||||
import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra";
|
||||
import { createRoundRow } from "./M02_DF_RoundRow";
|
||||
import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick";
|
||||
import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText";
|
||||
@@ -37,6 +39,8 @@ interface State {
|
||||
lg: CfLogic;
|
||||
on: Handlers;
|
||||
readOnly: boolean;
|
||||
/** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */
|
||||
detail: DetailOptions;
|
||||
handle: SpreadsheetHandle | null;
|
||||
/** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */
|
||||
snap: CalcValues;
|
||||
@@ -48,6 +52,8 @@ interface State {
|
||||
suggest: number | null;
|
||||
parts: {
|
||||
head: HTMLElement;
|
||||
formula: HTMLElement;
|
||||
cells: HTMLElement;
|
||||
got: HTMLElement;
|
||||
vars: HTMLElement;
|
||||
bar: HTMLElement;
|
||||
@@ -201,6 +207,20 @@ function problems(st: State): string[] {
|
||||
return out;
|
||||
}
|
||||
|
||||
/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */
|
||||
|
||||
/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */
|
||||
const extra = (st: State): ExtraContext => ({
|
||||
lg: st.lg,
|
||||
boxId: st.boxId,
|
||||
detail: st.detail,
|
||||
readOnly: st.readOnly,
|
||||
place: (port, cell) => placePort(st, port, cell),
|
||||
setOutput: (cell) => setOutput(st, cell),
|
||||
patch: (make) => patchFunc(st, make),
|
||||
used: (f) => usedCells(f),
|
||||
});
|
||||
|
||||
/* ------------------------------ 칸 위 표시 ------------------------------ */
|
||||
|
||||
function applyMarks(st: State): void {
|
||||
@@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise<void> {
|
||||
preview: true,
|
||||
tabs: false,
|
||||
readOnly: st.readOnly,
|
||||
conditions: conditionsOf(extra(st)),
|
||||
onChange: (b) => onBook(st, b),
|
||||
onSelect: (s) => {
|
||||
st.cell = s.칸;
|
||||
@@ -593,13 +614,14 @@ export function renderFuncPanel(
|
||||
lg: CfLogic,
|
||||
boxId: string,
|
||||
on: Handlers,
|
||||
opts: { readOnly?: boolean } = {},
|
||||
opts: DetailOptions & { readOnly?: boolean } = {},
|
||||
): void {
|
||||
const old = states.get(host);
|
||||
const readOnly = !!opts.readOnly;
|
||||
if (old && old.boxId === boxId && old.readOnly === readOnly) {
|
||||
old.lg = lg;
|
||||
old.on = on;
|
||||
old.detail = opts;
|
||||
const f = funcOf(old);
|
||||
if (old.handle && f && sheetJson(f) !== old.json) {
|
||||
old.json = sheetJson(f);
|
||||
@@ -615,6 +637,8 @@ export function renderFuncPanel(
|
||||
}
|
||||
const parts: State["parts"] = {
|
||||
head: el("div", { className: "m02-dfpanel-head" }),
|
||||
formula: el("div"),
|
||||
cells: el("div"),
|
||||
got: el("div", { className: "m02-dfpanel-row" }),
|
||||
vars: el("div", { className: "m02-dfpanel-vars" }),
|
||||
bar: el("div", { className: "m02-dfpanel-bar" }),
|
||||
@@ -626,6 +650,7 @@ export function renderFuncPanel(
|
||||
lg,
|
||||
on,
|
||||
readOnly,
|
||||
detail: opts,
|
||||
handle: null,
|
||||
snap: {},
|
||||
json: "",
|
||||
@@ -639,12 +664,22 @@ export function renderFuncPanel(
|
||||
drawGot(st);
|
||||
drawVars(st);
|
||||
drawBar(st);
|
||||
drawFormula(st.parts.formula, extra(st));
|
||||
drawCells(st.parts.cells, extra(st));
|
||||
applyMarks(st);
|
||||
},
|
||||
};
|
||||
states.set(host, st);
|
||||
host.classList.add("m02-dfpanel");
|
||||
host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet);
|
||||
host.replaceChildren(
|
||||
parts.head,
|
||||
parts.formula,
|
||||
parts.got,
|
||||
parts.vars,
|
||||
parts.bar,
|
||||
parts.msg,
|
||||
el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }),
|
||||
);
|
||||
parts.msg.hidden = true;
|
||||
st.redraw();
|
||||
void mountSheet(st);
|
||||
|
||||
@@ -9,8 +9,12 @@
|
||||
import { currentLanguageIndex } from "@ui/ui_template_locale";
|
||||
import { createButton, el } from "@ui/ui_template_elements";
|
||||
import {
|
||||
gateConds,
|
||||
makeGate,
|
||||
portsOut,
|
||||
reaches,
|
||||
valueChoices,
|
||||
type CfGateCond,
|
||||
type CfGateOp,
|
||||
type CfLogic,
|
||||
type CfPort,
|
||||
@@ -77,6 +81,12 @@ const TEXT = {
|
||||
],
|
||||
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
|
||||
Count: ["선", "Line"],
|
||||
And: ["그리고", "and"],
|
||||
Or: ["또는", "or"],
|
||||
If: ["조건", "If"],
|
||||
AddLine: ["+ 조건 줄", "+ Condition line"],
|
||||
Join: ["잇는 말", "Join with"],
|
||||
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
|
||||
Range: ["{n} (범위)", "{n} (range)"],
|
||||
} as const;
|
||||
|
||||
@@ -92,6 +102,7 @@ interface Ctx {
|
||||
linkId: string;
|
||||
readOnly: boolean;
|
||||
headers: string[];
|
||||
detail: DetailOptions;
|
||||
change(lg: CfLogic): void;
|
||||
}
|
||||
|
||||
@@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTML
|
||||
return out;
|
||||
}
|
||||
|
||||
/** 보낸 알약이 입력 상자의 숫자 변수면 그 범위 */
|
||||
function rangeOf(ctx: Ctx, p: CfPort): [number, number] | null {
|
||||
const v = ctx.lg.boxes
|
||||
.find((b) => b.id === p.box)
|
||||
?.input?.variables.find((x) => x.name === p.name);
|
||||
return v?.type === "number" && v.range ? v.range : null;
|
||||
}
|
||||
|
||||
/** 값 칸 — 목록이 있으면 고르기 · 없으면 숫자 칸(범위 밖이면 한 줄 알림) */
|
||||
function gateValue(ctx: Ctx, c: CfGateCond, onPick: (v: number | string) => void): HTMLElement[] {
|
||||
const list = valueChoices(ctx.lg, c.from, { values: ctx.detail.values });
|
||||
if (list?.length) {
|
||||
const key = (v: string | number): string => `${typeof v}:${v}`;
|
||||
const options = list.map((v) => ({ value: key(v), text: String(v) }));
|
||||
return [
|
||||
select(
|
||||
ctx,
|
||||
options,
|
||||
key(c.value),
|
||||
(k) => {
|
||||
const hit = list.find((v) => key(v) === k);
|
||||
if (hit !== undefined) onPick(hit);
|
||||
},
|
||||
tk("Value"),
|
||||
),
|
||||
];
|
||||
}
|
||||
const range = rangeOf(ctx, c.from);
|
||||
const out: HTMLElement[] = [numberBox(ctx, c.value, onPick, tk("Value"))];
|
||||
const n = Number(c.value);
|
||||
if (range && Number.isFinite(n) && (n < range[0] || n > range[1])) {
|
||||
out.push(
|
||||
el("span", {
|
||||
className: "m02-dflink-warn",
|
||||
text: `⚠ ${tk("OutRange", `${range[0]} ~ ${range[1]}`)}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function gateEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "gate" }>): HTMLElement[] {
|
||||
const put = (next: Partial<typeof rule>): 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"),
|
||||
const g = gateConds(rule);
|
||||
const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join));
|
||||
const edit = (i: number, patch: Partial<CfGateCond>): void =>
|
||||
put(g.conds.map((c, j) => (j === i ? { ...c, ...patch } : c)));
|
||||
const out: HTMLElement[] = [el("p", { className: "m02-dflink-note", text: tk("GateHint") })];
|
||||
g.conds.forEach((c, i) => {
|
||||
out.push(
|
||||
row(
|
||||
el("span", {
|
||||
className: "m02-dflink-label",
|
||||
text: i ? tk(g.join === "그리고" ? "And" : "Or") : tk("If"),
|
||||
}),
|
||||
select(
|
||||
ctx,
|
||||
pillOptions(ctx),
|
||||
toKey(c.from),
|
||||
(v) => {
|
||||
const from = fromKey(v);
|
||||
edit(i, {
|
||||
from,
|
||||
value: valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0,
|
||||
});
|
||||
},
|
||||
tk("Match"),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
c.op,
|
||||
(v) => edit(i, { op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
),
|
||||
...gateValue(ctx, c, (v) => edit(i, { value: v })),
|
||||
ctx.readOnly || g.conds.length < 2
|
||||
? ""
|
||||
: createButton({
|
||||
label: tk("Remove"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () => put(g.conds.filter((_, j) => j !== i)),
|
||||
}),
|
||||
),
|
||||
select(
|
||||
ctx,
|
||||
GATE_OPS.map((o) => ({ value: o, text: o })),
|
||||
rule.op,
|
||||
(v) => put({ op: v as CfGateOp }),
|
||||
tk("Kinds"),
|
||||
);
|
||||
});
|
||||
if (!ctx.readOnly) {
|
||||
const first = g.conds[0];
|
||||
out.push(
|
||||
row(
|
||||
createButton({
|
||||
label: tk("AddLine"),
|
||||
variant: "ghost",
|
||||
size: "sm",
|
||||
onClick: () =>
|
||||
put([...g.conds, { from: first?.from ?? { box: "", name: "" }, op: "=", value: 0 }]),
|
||||
}),
|
||||
...(g.conds.length > 1
|
||||
? [
|
||||
el("span", { className: "m02-dflink-label", text: tk("Join") }),
|
||||
select(
|
||||
ctx,
|
||||
[
|
||||
{ value: "그리고", text: tk("And") },
|
||||
{ value: "또는", text: tk("Or") },
|
||||
],
|
||||
g.join,
|
||||
(v) => put(g.conds, v as "그리고" | "또는"),
|
||||
tk("Join"),
|
||||
),
|
||||
]
|
||||
: []),
|
||||
),
|
||||
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
|
||||
),
|
||||
];
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
|
||||
@@ -323,7 +428,7 @@ function roundEditor(
|
||||
|
||||
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
|
||||
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
|
||||
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
|
||||
gate: (from) => makeGate([{ from, op: "≥", value: 0 }]),
|
||||
scale: () => ({ kind: "scale", op: "×", value: 1 }),
|
||||
round: () => ({ kind: "round", places: 0, method: "반올림" }),
|
||||
};
|
||||
@@ -345,14 +450,23 @@ export function renderLinkPanel(
|
||||
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 ctx: Ctx = {
|
||||
lg,
|
||||
linkId,
|
||||
readOnly: !!opts.readOnly,
|
||||
headers,
|
||||
detail: opts,
|
||||
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("strong", {
|
||||
text: `${portText(lg, link.from)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${portText(lg, link.to)}`,
|
||||
}),
|
||||
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
|
||||
],
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""단가산출 흐름도 「식 보기」(`M02_DF_FormulaView.ts`) · 엑셀 「조건 만들기」 글 쪽(`spreadsheet_condition_text.ts`).
|
||||
|
||||
화면 TS 를 그 자리에서 트랜스파일해 node 로 돌림 — 상대 경로 TS(엔진 포함)는 이어 트랜스파일 · 화면 부품(@ui 의 elements · locale · modal)은 빈 흉내 · 엔진이 쓰는 @ui/sheet 는 그대로.
|
||||
견본 함수 상자는 이 시험 안에 둠.
|
||||
"""
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
VIEW = ROOT / "M02_MasterTemplete" / "DataFlow" / "M02_DF_FormulaView.ts"
|
||||
TEXT = ROOT / "A00_Common" / "spreadsheet" / "spreadsheet_condition_text.ts"
|
||||
|
||||
RUNNER = r"""
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const [root, viewFile, textFile] = process.argv.slice(1);
|
||||
const ts = require(path.join(root, "config", "node_modules", "typescript"));
|
||||
const stub = new Proxy({}, { get: (_t, k) => (k === "currentLanguageIndex" ? 0 : () => undefined) });
|
||||
const cache = {};
|
||||
function load(f) {
|
||||
if (cache[f]) return cache[f].exports;
|
||||
const js = ts.transpileModule(fs.readFileSync(f, "utf8"), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 },
|
||||
}).outputText;
|
||||
const box = (cache[f] = { exports: {} });
|
||||
const req = (id) => {
|
||||
if (id.startsWith(".") || id.startsWith("@ui/sheet/")) {
|
||||
const p = id.startsWith("@ui/sheet/")
|
||||
? path.join(root, "ui_template", "sheet", id.slice("@ui/sheet/".length))
|
||||
: path.resolve(path.dirname(f), id);
|
||||
return fs.existsSync(p + ".ts") ? load(p + ".ts") : stub;
|
||||
}
|
||||
return stub;
|
||||
};
|
||||
new Function("exports", "module", "require", js)(box.exports, box, req);
|
||||
return box.exports;
|
||||
}
|
||||
const v = load(viewFile);
|
||||
const t = load(textFile);
|
||||
const jobs = JSON.parse(fs.readFileSync(0, "utf8"));
|
||||
const out = jobs.map((job) => {
|
||||
if (job.op === "lines") {
|
||||
const values = v.cellValuesOf(job.box, job.arrived);
|
||||
return { values, lines: v.formulaLines(job.box, values) };
|
||||
}
|
||||
if (job.op === "toFormula") return t.draftToFormula(job.draft);
|
||||
if (job.op === "toDraft") return t.formulaToDraft(job.formula);
|
||||
return null;
|
||||
});
|
||||
process.stdout.write(JSON.stringify(out));
|
||||
"""
|
||||
|
||||
|
||||
def run(jobs: list[dict]) -> list:
|
||||
if shutil.which("node") is None:
|
||||
pytest.skip("node 가 없음")
|
||||
done = subprocess.run(
|
||||
["node", "-e", RUNNER, str(ROOT), str(VIEW), str(TEXT)],
|
||||
input=json.dumps(jobs, ensure_ascii=False),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
check=True,
|
||||
)
|
||||
return json.loads(done.stdout)
|
||||
|
||||
|
||||
def func_box(cells: dict, inputs: list, variables: list, out_cell: str, out_name: str) -> dict:
|
||||
return {
|
||||
"id": "f1",
|
||||
"kind": "function",
|
||||
"name": "석공 품",
|
||||
"color": "",
|
||||
"unit": "인",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"func": {
|
||||
"sheet": {"id": "s1", "이름": "석공 품", "칸": cells},
|
||||
"inputs": inputs,
|
||||
"variables": variables,
|
||||
"output": {"cell": out_cell, "name": out_name, "unit": "인"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def var(name: str, cell: str, example) -> dict:
|
||||
return {"name": name, "type": "number", "unit": "-", "example": example, "cell": cell}
|
||||
|
||||
|
||||
# 석공량(받는 알약 B1) · 높이(변수 B2) → B3 = 석공량 × (1 + 높이 ÷ 100)
|
||||
PLAIN = func_box(
|
||||
{
|
||||
"A1": {"값": "석공량"},
|
||||
"B3": {"식": "B1*(1+B2/100)"},
|
||||
},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B3",
|
||||
"석공 인",
|
||||
)
|
||||
|
||||
|
||||
def test_이름_식과_값_식():
|
||||
(got,) = run([{"op": "lines", "box": PLAIN, "arrived": {"석공량": 0.15}}])
|
||||
assert got["values"]["B3"] == pytest.approx(0.156)
|
||||
(line,) = got["lines"]
|
||||
assert line["name"] == "석공 인"
|
||||
assert line["named"] == "석공량 × (1 + 높이 ÷ 100)"
|
||||
assert line["valued"] == "0.15 × (1 + 4 ÷ 100) = 0.156"
|
||||
assert "branches" not in line
|
||||
|
||||
|
||||
def test_중간_칸이_먼저_결과_칸이_마지막():
|
||||
box = func_box(
|
||||
{"B3": {"식": "B1*B2"}, "B4": {"식": "B3+1"}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[var("높이", "B2", 4)],
|
||||
"B4",
|
||||
"합",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 2}}])
|
||||
assert [x["name"] for x in got["lines"]] == ["B3", "합"] # 연결 없는 칸은 주소 그대로
|
||||
assert got["lines"][1]["named"] == "B3 + 1"
|
||||
assert got["lines"][1]["valued"] == "8 + 1 = 9"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("ground", "now"),
|
||||
[("토사", [True, False]), ("암", [False, True])],
|
||||
)
|
||||
def test_IF_줄과_지금_갈래(ground, now):
|
||||
box = func_box(
|
||||
{"B3": {"식": 'IF(B4="토사",B1*2,B1*3)'}},
|
||||
[{"port": "석공량", "cell": "B1"}],
|
||||
[{"name": "지반", "type": "choice", "unit": "-", "example": ground, "cell": "B4"}],
|
||||
"B3",
|
||||
"결과",
|
||||
)
|
||||
(got,) = run([{"op": "lines", "box": box, "arrived": {"석공량": 10}}])
|
||||
(line,) = got["lines"]
|
||||
assert [b["now"] for b in line["branches"]] == now
|
||||
assert line["branches"][0]["when"] == '지반 = "토사"'
|
||||
assert line["branches"][0]["then"] == "석공량 × 2"
|
||||
assert line["branches"][1] == {"when": "아니면", "then": "석공량 × 3", "now": now[1]}
|
||||
assert line["valued"].endswith("= 20" if ground == "토사" else "= 30")
|
||||
|
||||
|
||||
def test_조건_만들기_식과_다시_줄():
|
||||
draft = {
|
||||
"branches": [
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "=",
|
||||
"right": {"kind": "value", "value": "토사"},
|
||||
"then": {"kind": "cell", "cell": "C1"},
|
||||
},
|
||||
{
|
||||
"left": "B1",
|
||||
"op": "≥",
|
||||
"right": {"kind": "value", "value": 3},
|
||||
"then": {"kind": "value", "value": 0.5},
|
||||
},
|
||||
],
|
||||
"otherwise": {"kind": "value", "value": 0},
|
||||
}
|
||||
(formula,) = run([{"op": "toFormula", "draft": draft}])
|
||||
assert formula == 'IF(B1="토사",C1,IF(B1>=3,0.5,0))'
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back == draft
|
||||
|
||||
|
||||
@pytest.mark.parametrize("formula", ["SUM(B1:B2)", "IF(B1+1>2,1,2)", "IF(B1>2,B2*2,0)"])
|
||||
def test_못_바꾸는_식은_null(formula):
|
||||
(back,) = run([{"op": "toDraft", "formula": formula}])
|
||||
assert back is None
|
||||
Reference in New Issue
Block a user