/* ============================================================================= * 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"], PickOne: ["고르세요", "choose"], 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): 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): 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 known = list.some((v) => key(v) === key(c.value)); const options = [ ...(known ? [] : [{ value: "", text: tk("PickOne") }]), ...list.map((v) => ({ value: key(v), text: String(v) })), ]; return [ select( ctx, options, known ? 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): HTMLElement[] { const g = gateConds(rule); const put = (conds: CfGateCond[], join = g.join): void => setRule(ctx, makeGate(conds, join)); const edit = (i: number, patch: Partial): 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: () => { const from = first?.from ?? { box: "", name: "" }; // 새 줄 기본 값 — 목록이 있는 알약이면 그 첫 값 · 아니면 0 const value = valueChoices(ctx.lg, from, { values: ctx.detail.values })?.[0] ?? 0; put([...g.conds, { from, op: "=", value }]); }, }), ...(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): 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, unit: string, ): HTMLElement[] { const put = (next: Partial): 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, (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); }