feat(M02-흐름도): 함수 판 식 보기 · 칸 연결 표 · 칸 색 · 조건 만들기 연결 + 선 조건 여러 줄(그리고/또는 · 값 목록 고르기 · 범위 검사)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
This commit is contained in:
2026-10-01 23:37:13 +09:00
co-authored by Claude Sonnet 5.5
parent fd976dcde3
commit dcb023f4e6
5 changed files with 506 additions and 28 deletions
@@ -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,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);
+136 -22
View File
@@ -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)})` }),
],
}),