Files
Aislo/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts
T

389 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* =============================================================================
* M02_DF_LinkPanel.ts
* 상세 내용 판 — 선 규칙: 선을 고르면 어디서 → 어디로 · 규칙 고르기(없음 · 표에서 찾기 · 조건 통과 · 바꾸기 · 끝수) ·
* 지나가기 전 값 → 지난 뒤 값 미리 보기 · 겹침/안 맞음 알림
* 모양은 `docs/raw/M01_쉬운단가/흐름도_정함.md` 정함 7 · M02_DF_Model 의 CfRule 그대로 · 단추 · 칸은 공용 작은 크기
* 글 [한, 영] 짝은 아래 TEXT
* ========================================================================== */
import { currentLanguageIndex } from "@ui/ui_template_locale";
import { createButton, el } from "@ui/ui_template_elements";
import type { CfValues } from "./M02_DF_Eval";
import {
portsOut,
reaches,
type CfGateOp,
type CfLogic,
type CfPort,
type CfRound,
type CfRule,
} from "./M02_DF_Model";
import {
GATE_OPS,
SCALE_OPS,
evalSafe,
gateReport,
labelOf,
portText,
preview,
unitOf,
valueOf,
} from "./M02_DF_Detail";
import { suggestPlaces } from "./M02_DF_UnitPick";
import "./M02_DF_LinkPanel.css";
const TEXT = {
Title: ["선 규칙", "Line rule"],
Kinds: ["규칙 고르기", "Pick a rule"],
K_none: ["없음", "None"],
K_lookup: ["표에서 찾기", "Look up in table"],
K_gate: ["조건 통과", "Pass if"],
K_scale: ["바꾸기", "Change"],
K_round: ["끝수", "Rounding"],
Label: ["선 위 글", "Text on the line"],
Before: ["지나가기 전 값", "Value before"],
After: ["지난 뒤 값", "Value after"],
Col: ["조건 칸", "Condition column"],
Match: ["맞출 알약", "Pill to match"],
Take: ["꺼낼 칸", "Column to take"],
AddCond: ["+ 조건 칸", "+ Condition"],
Remove: ["지움", "Remove"],
NoHeader: [
"보낸 쪽이 표가 아니거나 표를 아직 못 불러옴 — 표를 불러온 뒤 칸을 고름",
"The sender is not a table or it is not loaded yet",
],
Pick: ["고르기", "pick"],
GateHint: [
"알약 값이 아래 조건을 지나면 보냄 — 아니면 넘기지 않음",
"Sent only when the pill passes",
],
Value: ["값", "Value"],
ScaleHint: ["지나가는 값을 바꿈", "Changes the value on its way"],
Places: ["소수 {n}자리", "{n} decimals"],
Suggest: ["표준 제안: 소수 {n}자리", "Standard suggests {n} decimals"],
Apply: ["적용", "Apply"],
WarnBoth: [
"같은 알약에서 나간 조건 선 {n}개가 한꺼번에 통과함 — 겹침",
"{n} conditional lines pass at once — overlap",
],
WarnNone: [
"같은 알약에서 나간 조건 선 {n}개 중 어느 것도 안 맞음",
"None of the {n} conditional lines match",
],
WarnLookup: [
"조건 칸이 없음 — 표에서 줄을 못 고름",
"No condition column — a row can't be picked",
],
WarnTake: ["꺼낼 칸을 고르지 않음", "No column to take"],
Count: ["선", "Line"],
} as const;
const tk = (key: keyof typeof TEXT, n?: number | string): string =>
TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace("{n}", String(n ?? ""));
type Kind = "none" | CfRule["kind"];
const KINDS: Kind[] = ["none", "lookup", "gate", "scale", "round"];
const SEP = "\u0001";
interface Ctx {
lg: CfLogic;
linkId: string;
readOnly: boolean;
headers: string[];
change(lg: CfLogic): void;
}
const toKey = (p: CfPort): string => `${p.box}${SEP}${p.name}`;
const fromKey = (k: string): CfPort => {
const [box, ...rest] = k.split(SEP);
return { box, name: rest.join(SEP) };
};
function setRule(ctx: Ctx, rule: CfRule | undefined): void {
ctx.change({
...ctx.lg,
links: ctx.lg.links.map((l) => {
if (l.id !== ctx.linkId) return l;
const { rule: _old, ...rest } = l;
return rule ? { ...rest, rule } : rest;
}),
});
}
function select(
ctx: Ctx,
options: { value: string; text: string }[],
value: string,
onPick: (v: string) => void,
aria: string,
): HTMLElement {
const s = el("select", { className: "ui-select ui-select--sm", attrs: { "aria-label": aria } });
for (const o of options) s.append(el("option", { attrs: { value: o.value }, text: o.text }));
if (!options.some((o) => o.value === value))
s.prepend(el("option", { attrs: { value }, text: value || tk("Pick") }));
s.value = value;
s.disabled = ctx.readOnly;
s.addEventListener("change", () => onPick(s.value));
return s;
}
function numberBox(
ctx: Ctx,
value: number | string,
onCommit: (v: number | string) => void,
aria: string,
): HTMLElement {
const i = el("input", {
className: "ui-input ui-input--sm m02-dflink-num",
attrs: { type: "text", "aria-label": aria },
});
i.value = String(value);
i.readOnly = ctx.readOnly;
i.addEventListener("change", () => {
const t = i.value.trim().replace(/,/g, "");
onCommit(t !== "" && Number.isFinite(Number(t)) ? Number(t) : t);
});
return i;
}
/** 맞출 알약 — 이 선이 닿는 상자와 그 뒤 상자(돌고 도는 선이 됨)는 뺌 */
const pillOptions = (ctx: Ctx): { value: string; text: string }[] => {
const to = ctx.lg.links.find((x) => x.id === ctx.linkId)?.to.box ?? "";
return ctx.lg.boxes
.filter((b) => b.id !== to && !reaches(ctx.lg, to, b.id))
.flatMap((b) =>
portsOut(b).map((p) => ({
value: toKey({ box: b.id, name: p.name }),
text: portText(ctx.lg, { box: b.id, name: p.name }),
})),
);
};
const row = (...children: (HTMLElement | string)[]): HTMLElement =>
el("div", { className: "m02-dflink-row", children });
/* ── 규칙마다 ─────────────────────────────────────────────────────────── */
function lookupEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "lookup" }>): HTMLElement[] {
const cols = [
...new Set([...ctx.headers, ...rule.conds.map((c) => c.col), rule.col].filter(Boolean)),
];
const colOpts = cols.map((c) => ({ value: c, text: c }));
const pills = pillOptions(ctx);
const put = (next: Partial<typeof rule>): void => setRule(ctx, { ...rule, ...next });
const rows = rule.conds.map((c, i) =>
row(
select(
ctx,
colOpts,
c.col,
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, col: v } : x)) }),
tk("Col"),
),
"=",
select(
ctx,
pills,
toKey(c.from),
(v) => put({ conds: rule.conds.map((x, j) => (j === i ? { ...x, from: fromKey(v) } : x)) }),
tk("Match"),
),
ctx.readOnly
? ""
: createButton({
label: tk("Remove"),
variant: "ghost",
size: "sm",
onClick: () => put({ conds: rule.conds.filter((_, j) => j !== i) }),
}),
),
);
const out: HTMLElement[] = [...rows];
if (!ctx.readOnly) {
out.push(
row(
createButton({
label: tk("AddCond"),
variant: "ghost",
size: "sm",
disabled: !pills.length || !cols.length,
onClick: () =>
put({ conds: [...rule.conds, { col: cols[0], from: fromKey(pills[0].value) }] }),
}),
),
);
}
out.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Take") }),
select(ctx, colOpts, rule.col, (v) => put({ col: v }), tk("Take")),
),
);
if (!cols.length) out.push(el("p", { className: "m02-dflink-note", text: tk("NoHeader") }));
return out;
}
function gateEditor(ctx: Ctx, rule: Extract<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"),
),
select(
ctx,
GATE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => put({ op: v as CfGateOp }),
tk("Kinds"),
),
numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")),
),
];
}
function scaleEditor(ctx: Ctx, rule: Extract<CfRule, { kind: "scale" }>): HTMLElement[] {
return [
el("p", { className: "m02-dflink-note", text: tk("ScaleHint") }),
row(
select(
ctx,
SCALE_OPS.map((o) => ({ value: o, text: o })),
rule.op,
(v) => setRule(ctx, { ...rule, op: v as typeof rule.op }),
tk("Kinds"),
),
numberBox(
ctx,
rule.value,
(v) => setRule(ctx, { ...rule, value: typeof v === "number" ? v : rule.value }),
tk("Value"),
),
),
];
}
function roundEditor(
ctx: Ctx,
rule: Extract<CfRule, { kind: "round" }>,
unit: string,
): HTMLElement[] {
const put = (next: Partial<CfRound>): void => setRule(ctx, { ...rule, ...next });
const places = Array.from({ length: 7 }, (_, n) => ({ value: String(n), text: tk("Places", n) }));
const hint = el("span", { className: "m02-dflink-note" });
void suggestPlaces(unit).then((n) => {
if (n === null || n === rule.places || ctx.readOnly) return;
hint.append(
tk("Suggest", n),
" ",
createButton({
label: tk("Apply"),
variant: "ghost",
size: "sm",
onClick: () => put({ places: n }),
}),
);
});
return [
row(
select(ctx, places, String(rule.places), (v) => put({ places: Number(v) }), tk("K_round")),
select(
ctx,
(["반올림", "올림", "버림"] as const).map((m) => ({ value: m, text: m })),
rule.method,
(v) => put({ method: v as CfRound["method"] }),
tk("K_round"),
),
hint,
),
];
}
const DEFAULT: Record<Exclude<Kind, "none">, (from: CfPort) => CfRule> = {
lookup: () => ({ kind: "lookup", conds: [], col: "" }),
gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }),
scale: () => ({ kind: "scale", op: "×", value: 1 }),
round: () => ({ kind: "round", places: 0, method: "반올림" }),
};
/** 선 규칙 판 그리기 — 선을 고르면 부름 · 바뀐 lg 는 on.change 로만 알림(그리기는 부른 쪽이 다시) */
export function renderLinkPanel(
host: HTMLElement,
lg: CfLogic,
linkId: string,
on: { change(lg: CfLogic): void },
opts: { values?: CfValues; readOnly?: boolean } = {},
): void {
const link = lg.links.find((l) => l.id === linkId);
host.classList.add("m02-dflink");
if (!link) return host.replaceChildren();
const ev = evalSafe(lg, opts.values);
const table = valueOf(ev, link.from);
const headers =
Array.isArray(table) && Array.isArray(table[0])
? table[0].map((h) => String(h ?? "")).filter(Boolean)
: [];
const ctx: Ctx = { lg, linkId, readOnly: !!opts.readOnly, headers, change: on.change };
const rule = link.rule;
const kind: Kind = rule?.kind ?? "none";
const parts: HTMLElement[] = [
el("div", {
className: "m02-dflink-head",
children: [
el("strong", { text: `${portText(lg, link.from)} → ${portText(lg, link.to)}` }),
el("span", { className: "m02-dflink-note", text: `(${unitOf(lg, link.from)})` }),
],
}),
row(
el("span", { className: "m02-dflink-label", text: tk("Kinds") }),
...KINDS.map((k) =>
createButton({
label: tk(`K_${k}` as "K_none"),
variant: k === kind ? "filled" : "ghost",
size: "sm",
disabled: ctx.readOnly,
onClick: () =>
k !== kind && setRule(ctx, k === "none" ? undefined : DEFAULT[k](link.from)),
}),
),
),
];
if (rule?.kind === "lookup") parts.push(...lookupEditor(ctx, rule));
if (rule?.kind === "gate") parts.push(...gateEditor(ctx, rule));
if (rule?.kind === "scale") parts.push(...scaleEditor(ctx, rule));
if (rule?.kind === "round") parts.push(...roundEditor(ctx, rule, unitOf(lg, link.from)));
const pv = preview(link, rule, ev);
parts.push(
row(
el("span", { className: "m02-dflink-label", text: tk("Before") }),
el("span", { className: "m02-dflink-value", text: pv.before }),
"→",
el("span", { className: "m02-dflink-label", text: tk("After") }),
el("span", { className: "m02-dflink-value", text: pv.after }),
),
row(
el("span", { className: "m02-dflink-label", text: tk("Label") }),
el("span", { text: labelOf(lg, rule) }),
),
);
const warns: string[] = [];
if (rule?.kind === "gate") {
const rep = gateReport(lg, link, ev);
if (rep.pass.length > 1) warns.push(tk("WarnBoth", rep.pass.length));
if (rep.total > 0 && rep.pass.length === 0) warns.push(tk("WarnNone", rep.total));
}
if (rule?.kind === "lookup") {
if (!rule.conds.length) warns.push(tk("WarnLookup"));
if (!rule.col) warns.push(tk("WarnTake"));
}
parts.push(...warns.map((w) => el("p", { className: "m02-dflink-warn", text: `⚠ ${w}` })));
host.replaceChildren(...parts);
}