Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
401 lines
14 KiB
TypeScript
401 lines
14 KiB
TypeScript
/* =============================================================================
|
||
* 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 {
|
||
portsOut,
|
||
reaches,
|
||
type CfGateOp,
|
||
type CfLogic,
|
||
type CfPort,
|
||
type CfRound,
|
||
type CfRule,
|
||
} from "./M02_DF_Model";
|
||
import {
|
||
GATE_OPS,
|
||
commas,
|
||
SCALE_OPS,
|
||
evalSafe,
|
||
labelOf,
|
||
linkNotes,
|
||
portText,
|
||
preview,
|
||
unitOf,
|
||
valueOf,
|
||
type DetailOptions,
|
||
} 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"],
|
||
Range: ["{n} (범위)", "{n} (range)"],
|
||
} 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", inputmode: "decimal", "aria-label": aria },
|
||
});
|
||
const shown = (): string => (typeof value === "number" ? commas(value) : String(value));
|
||
i.value = shown();
|
||
i.readOnly = ctx.readOnly;
|
||
i.addEventListener("focus", () => (i.value = String(value)));
|
||
i.addEventListener("blur", () => (i.value = shown()));
|
||
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.col].filter(Boolean))];
|
||
const colOpts = cols.map((c) => ({ value: c, text: c }));
|
||
// 조건 칸 — 「이름·아래」 「이름·위」 짝은 「이름 (범위)」 한 줄만(고르면 칸 이름 = 이름 · Rule 이 아래 < 값 ≤ 위 로 찾음)
|
||
const range = (c: string): string | null => {
|
||
const m = /^(.+)·(아래|위)$/.exec(c);
|
||
return m && cols.includes(`${m[1]}·아래`) && cols.includes(`${m[1]}·위`) ? m[1] : null;
|
||
};
|
||
const bases = [...new Set(cols.map(range).filter((x): x is string => !!x))];
|
||
const condOpts = [
|
||
...cols.filter((c) => !range(c) && !bases.includes(c)).map((c) => ({ value: c, text: c })),
|
||
...bases.map((x) => ({ value: x, text: tk("Range", x) })),
|
||
];
|
||
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,
|
||
condOpts,
|
||
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 || !condOpts.length,
|
||
onClick: () =>
|
||
put({
|
||
conds: [...rule.conds, { col: condOpts[0].value, 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: DetailOptions & { 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);
|
||
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, 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(rule) }),
|
||
),
|
||
);
|
||
|
||
const warns: string[] = linkNotes(link, ev);
|
||
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);
|
||
}
|