Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D2nUMUMTsqm93PbQtipo6c
515 lines
17 KiB
TypeScript
515 lines
17 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 {
|
||
gateConds,
|
||
makeGate,
|
||
portsOut,
|
||
reaches,
|
||
valueChoices,
|
||
type CfGateCond,
|
||
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"],
|
||
And: ["그리고", "and"],
|
||
Or: ["또는", "or"],
|
||
If: ["조건", "If"],
|
||
AddLine: ["+ 조건 줄", "+ Condition line"],
|
||
Join: ["잇는 말", "Join with"],
|
||
OutRange: ["범위 {n} 밖", "Outside the range {n}"],
|
||
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[];
|
||
detail: DetailOptions;
|
||
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 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 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)),
|
||
}),
|
||
),
|
||
);
|
||
});
|
||
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"),
|
||
),
|
||
]
|
||
: []),
|
||
),
|
||
);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
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) => makeGate([{ 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,
|
||
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)} → ${rule ? `(${labelOf(rule)}) → ` : ""}${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);
|
||
}
|