/* ============================================================================= * 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"], } 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): 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): 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): HTMLElement[] { const put = (next: Partial): 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): 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) => ({ 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); }