Merge remote-tracking branch 'origin/sub_laptop_2' into main_laptop_1

This commit is contained in:
2026-10-01 23:38:56 +09:00
11 changed files with 1526 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,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);
+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)})` }),
],
}),