diff --git a/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts new file mode 100644 index 000000000..1e8eac043 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_CellTable.ts @@ -0,0 +1,135 @@ +/* ============================================================================= + * M02_DF_CellTable.ts + * 함수 판 「칸 연결 표」 — 칸 · 연결 알약 · 입력 / 변수 / 결과 · 이어진 선 · 표에서 바로 칸 바꾸기 · 풀기 + * 결과 칸은 하나 · 색은 시트 칸 표시와 같은 쪽(입력 파랑 · 변수 노랑 · 결과 초록) + 글자(입력 · 변수 · 결과) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el } from "@ui/ui_template_elements"; +import type { CfBox, CfLogic } from "./M02_DF_Model"; +import { portText } from "./M02_DF_Detail"; + +const TEXT = { + Title: ["칸 연결 표", "Cell connections"], + Cell: ["칸", "Cell"], + Pill: ["연결 알약", "Pill"], + Kind: ["종류", "Kind"], + Line: ["이어진 선", "Line"], + In: ["입력", "Input"], + Var: ["변수", "Variable"], + Out: ["결과", "Result"], + None: ["아직 연결된 칸이 없음", "No cell is connected yet"], + Unplaced: ["칸 없음", "no cell"], + NoLine: ["-", "-"], + Release: ["풀기", "Release"], + BadCell: ["칸 주소가 아님 — 예: B2", "Not a cell address — e.g. B2"], +} as const; + +const tt = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +export type CellKind = "in" | "var" | "out"; + +export interface CellTableHandlers { + /** 표에서 칸을 바꿈 — key = 알약 이름 */ + move(kind: CellKind, key: string, cell: string): void; + /** 풀기 — 입력은 칸만 비우고 알약은 남김 · 변수는 지움 · 결과는 칸만 비움 */ + release(kind: CellKind, key: string): void; +} + +interface Row { + kind: CellKind; + key: string; + cell: string; + line: string; +} + +function rowsOf(lg: CfLogic, box: CfBox): Row[] { + const f = box.func; + if (!f) return []; + const into = (port: string): string => { + const l = lg.links.find((x) => x.to.box === box.id && x.to.name === port); + return l ? portText(lg, l.from) : tt("NoLine"); + }; + const out = (): string => { + const names = lg.links.filter((l) => l.from.box === box.id && l.from.name === f.output.name); + return names.length ? names.map((l) => portText(lg, l.to)).join(", ") : tt("NoLine"); + }; + return [ + ...f.inputs.map((i): Row => ({ kind: "in", key: i.port, cell: i.cell, line: into(i.port) })), + ...f.variables.map((v): Row => ({ + kind: "var", + key: v.name, + cell: v.cell ?? "", + line: tt("NoLine"), + })), + { kind: "out", key: f.output.name, cell: f.output.cell, line: out() }, + ]; +} + +/** 칸 연결 표 그리기 — 바뀜은 handlers 로만 알림(그리기는 부른 쪽이 다시) */ +export function renderCellTable( + host: HTMLElement, + lg: CfLogic, + box: CfBox, + readOnly: boolean, + handlers: CellTableHandlers, +): void { + host.classList.add("m02-dfcells"); + const rows = rowsOf(lg, box).filter((r) => r.kind === "out" || r.key); + const head = el("tr", { + children: [tt("Cell"), tt("Pill"), tt("Kind"), tt("Line"), ""].map((t) => + el("th", { text: t }), + ), + }); + const body = rows.map((r) => { + const input = el("input", { + className: "ui-input ui-input--sm m02-dfcells__cell", + attrs: { type: "text", "aria-label": `${r.key} ${tt("Cell")}`, placeholder: tt("Unplaced") }, + }); + input.value = r.cell; + input.readOnly = readOnly; + input.addEventListener("change", () => { + const m = /^([A-Za-z]{1,3})(\d+)$/.exec(input.value.trim()); + input.classList.toggle("is-bad", !m); + input.title = m ? "" : tt("BadCell"); + if (m) handlers.move(r.kind, r.key, `${m[1].toUpperCase()}${m[2]}`); + }); + return el("tr", { + className: `m02-dfcells__row is-${r.kind}`, + children: [ + el("td", { children: [input] }), + el("td", { text: r.key }), + el("td", { + children: [ + el("span", { + className: "m02-dfcells__kind", + text: tt(r.kind === "in" ? "In" : r.kind === "var" ? "Var" : "Out"), + }), + ], + }), + el("td", { text: r.line }), + el("td", { + children: + readOnly || (r.kind !== "var" && !r.cell) + ? [] + : [ + createButton({ + label: tt("Release"), + variant: "ghost", + size: "sm", + onClick: () => handlers.release(r.kind, r.key), + }), + ], + }), + ], + }); + }); + host.replaceChildren( + el("strong", { className: "m02-dfcells__title", text: tt("Title") }), + el("table", { + className: "m02-dfcells__table", + children: [el("thead", { children: [head] }), el("tbody", { children: body })], + }), + ); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts new file mode 100644 index 000000000..6d302fea5 --- /dev/null +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncExtra.ts @@ -0,0 +1,85 @@ +/* ============================================================================= + * M02_DF_FuncExtra.ts + * 함수 판 보조 — 식 보기 그리기 · 칸 연결 표 손잡이 · 우클릭 「조건 만들기」 에 주는 것 + * 판 본체(M02_DF_FuncPanel)가 700줄 안에 들도록 뗌 · 상태는 모르고 손잡이(ExtraContext)만 받음 + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { showToast } from "@ui/ui_template_elements"; +import type { ConditionOptions } from "../../A00_Common/spreadsheet/spreadsheet_condition"; +import { renderCellTable } from "./M02_DF_CellTable"; +import { evalSafe, type DetailOptions } from "./M02_DF_Detail"; +import { cellValuesOf, formulaLines, renderFormulaView } from "./M02_DF_FormulaView"; +import { TEXT } from "./M02_DF_FuncText"; +import { valueChoices, type CfFunc, type CfLogic } from "./M02_DF_Model"; + +export interface ExtraContext { + lg: CfLogic; + boxId: string; + detail: DetailOptions; + readOnly: boolean; + place(port: string, cell: string): void; + setOutput(cell: string): void; + patch(make: (f: CfFunc) => CfFunc): void; + used(f: CfFunc): Map; +} + +const busy = (): string => TEXT.Busy[currentLanguageIndex === 1 ? 1 : 0]; +const boxOf = (x: ExtraContext) => x.lg.boxes.find((b) => b.id === x.boxId); + +/** 식 보기 — 칸 식을 고치면 부른 쪽이 다시 부름 */ +export function drawFormula(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + const ev = evalSafe(x.lg, x.detail); + renderFormulaView(host, formulaLines(box, cellValuesOf(box, ev?.arrived?.[x.boxId]))); +} + +/** 칸 연결 표 — 표에서 칸 바꾸기 · 풀기(입력/변수/결과 모두 같은 규칙: 이미 쓰는 칸 막음 · 결과 칸 하나) */ +export function drawCells(host: HTMLElement, x: ExtraContext): void { + const box = boxOf(x); + if (!box?.func) return; + renderCellTable(host, x.lg, box, x.readOnly, { + move: (kind, key, cell) => { + if (kind === "in") return x.place(key, cell); + if (kind === "out") return x.setOutput(cell); + if (box.func && x.used(box.func).has(cell)) return void showToast(busy(), "error"); + x.patch((f) => ({ + ...f, + variables: f.variables.map((v) => (v.name === key ? { ...v, cell } : v)), + })); + }, + release: (kind, key) => + x.patch((f) => + kind === "in" + ? { ...f, inputs: f.inputs.map((i) => (i.port === key ? { ...i, cell: "" } : i)) } + : kind === "var" + ? { ...f, variables: f.variables.filter((v) => v.name !== key) } + : { ...f, output: { ...f.output, cell: "" } }, + ), + }); +} + +/** 우클릭 「조건 만들기」 에 주는 것 — 칸에 올 값 목록(알약이면 valueChoices · 변수면 고르기 목록) · 이름 붙은 칸 */ +export function conditionsOf(x: ExtraContext): ConditionOptions { + return { + valuesFor: (a1) => { + const f = boxOf(x)?.func; + const at = a1.toUpperCase(); + const v = f?.variables.find((y) => y.cell?.toUpperCase() === at); + if (v) return v.type === "choice" && v.choices?.length ? [...v.choices] : null; + const port = f?.inputs.find((i) => i.cell.toUpperCase() === at)?.port; + const link = port + ? x.lg.links.find((l) => l.to.box === x.boxId && l.to.name === port) + : undefined; + return link ? valueChoices(x.lg, link.from, { values: x.detail.values }) : null; + }, + pills: () => { + const f = boxOf(x)?.func; + return [ + ...(f?.inputs ?? []).filter((i) => i.cell).map((i) => ({ name: i.port, cell: i.cell })), + ...(f?.variables ?? []).filter((v) => v.cell).map((v) => ({ name: v.name, cell: v.cell! })), + ]; + }, + }; +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css index 7497c4a10..77a5471f1 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.css @@ -111,11 +111,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel-chip.is-out { - border-color: #f59e0b; + border-color: #27ae60; } .m02-dfpanel-chip.is-var { - border-color: #22a06b; + border-color: #d99a00; } /* 판 높이 크게 — 안쪽 세로 스크롤 없이 보이게 */ @@ -133,11 +133,11 @@ button.m02-dfpanel-chip:focus-visible { } .m02-dfpanel .aislo-cell[data-cf-kind="var"] { - box-shadow: inset 0 0 0 2px #22a06b; + box-shadow: inset 0 0 0 2px #d99a00; } .m02-dfpanel .aislo-cell[data-cf-kind="out"] { - box-shadow: inset 0 0 0 2px #f59e0b; + box-shadow: inset 0 0 0 2px #27ae60; } .m02-dfpanel .aislo-cell[data-cf-kind]::after { @@ -170,3 +170,112 @@ button.m02-dfpanel-chip:focus-visible { outline: 2px dashed var(--color-danger); outline-offset: -2px; } + +/* 시트 + 칸 연결 표 — 좁으면 표가 아래로 */ +.m02-dfpanel-body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-12); +} + +.m02-dfpanel-body > .m02-dfpanel-sheet { + flex: 1 1 28em; + min-width: 0; +} + +.m02-dfcells { + flex: 0 1 24em; + display: flex; + flex-direction: column; + gap: var(--spacing-4); + min-width: 0; +} + +.m02-dfcells__title { + font-weight: var(--font-weight-bold); +} + +.m02-dfcells__table { + width: 100%; + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m02-dfcells__table th, +.m02-dfcells__table td { + padding: var(--spacing-4); + border-bottom: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m02-dfcells__cell { + width: 5em; +} + +.m02-dfcells .ui-input.is-bad { + border-color: var(--color-danger); + border-style: dashed; +} + +.m02-dfcells__kind { + padding: 0 var(--spacing-8); + border: 2px solid; + border-radius: var(--radius-pills); + white-space: nowrap; +} + +.m02-dfcells__row.is-in .m02-dfcells__kind { + border-color: #3b82f6; +} + +.m02-dfcells__row.is-var .m02-dfcells__kind { + border-color: #d99a00; +} + +.m02-dfcells__row.is-out .m02-dfcells__kind { + border-color: #27ae60; +} + +/* 식 보기 */ +.m02-df-formula { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-8) var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m02-df-formula__title { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__line { + display: flex; + flex-direction: column; + gap: 2px; + padding-top: var(--spacing-4); + border-top: 1px dashed var(--color-border); +} + +.m02-df-formula__valued { + color: var(--color-text-secondary); +} + +.m02-df-formula__branches { + margin: 0; + padding-left: var(--spacing-16); + list-style: none; +} + +.m02-df-formula__branches .is-now { + font-weight: var(--font-weight-bold); +} + +.m02-df-formula__empty { + margin: 0; + color: var(--color-text-muted); +} diff --git a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts index 45e3b2be5..17f9cb13a 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_FuncPanel.ts @@ -14,6 +14,8 @@ import type { Workbook, } from "../../A00_Common/spreadsheet/spreadsheet"; import type { CfBox, CfFunc, CfLogic, CfRound, CfVariable } from "./M02_DF_Model"; +import type { DetailOptions } from "./M02_DF_Detail"; +import { conditionsOf, drawCells, drawFormula, type ExtraContext } from "./M02_DF_FuncExtra"; import { createRoundRow } from "./M02_DF_RoundRow"; import { createUnitPick, suggestPlaces } from "./M02_DF_UnitPick"; import { ERR_KEY, FRAMES, TEXT, tf } from "./M02_DF_FuncText"; @@ -37,6 +39,8 @@ interface State { lg: CfLogic; on: Handlers; readOnly: boolean; + /** 셈에 넘길 것 — 값 묶음 · 다른 로직들 */ + detail: DetailOptions; handle: SpreadsheetHandle | null; /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ snap: CalcValues; @@ -48,6 +52,8 @@ interface State { suggest: number | null; parts: { head: HTMLElement; + formula: HTMLElement; + cells: HTMLElement; got: HTMLElement; vars: HTMLElement; bar: HTMLElement; @@ -201,6 +207,20 @@ function problems(st: State): string[] { return out; } +/* ------------------------------ 식 보기 · 칸 연결 표 · 조건 만들기 ------------------------------ */ + +/** 보조 파일(M02_DF_FuncExtra)에 넘기는 손잡이 */ +const extra = (st: State): ExtraContext => ({ + lg: st.lg, + boxId: st.boxId, + detail: st.detail, + readOnly: st.readOnly, + place: (port, cell) => placePort(st, port, cell), + setOutput: (cell) => setOutput(st, cell), + patch: (make) => patchFunc(st, make), + used: (f) => usedCells(f), +}); + /* ------------------------------ 칸 위 표시 ------------------------------ */ function applyMarks(st: State): void { @@ -563,6 +583,7 @@ async function mountSheet(st: State): Promise { preview: true, tabs: false, readOnly: st.readOnly, + conditions: conditionsOf(extra(st)), onChange: (b) => onBook(st, b), onSelect: (s) => { st.cell = s.칸; @@ -593,13 +614,14 @@ export function renderFuncPanel( lg: CfLogic, boxId: string, on: Handlers, - opts: { readOnly?: boolean } = {}, + opts: DetailOptions & { readOnly?: boolean } = {}, ): void { const old = states.get(host); const readOnly = !!opts.readOnly; if (old && old.boxId === boxId && old.readOnly === readOnly) { old.lg = lg; old.on = on; + old.detail = opts; const f = funcOf(old); if (old.handle && f && sheetJson(f) !== old.json) { old.json = sheetJson(f); @@ -615,6 +637,8 @@ export function renderFuncPanel( } const parts: State["parts"] = { head: el("div", { className: "m02-dfpanel-head" }), + formula: el("div"), + cells: el("div"), got: el("div", { className: "m02-dfpanel-row" }), vars: el("div", { className: "m02-dfpanel-vars" }), bar: el("div", { className: "m02-dfpanel-bar" }), @@ -626,6 +650,7 @@ export function renderFuncPanel( lg, on, readOnly, + detail: opts, handle: null, snap: {}, json: "", @@ -639,12 +664,22 @@ export function renderFuncPanel( drawGot(st); drawVars(st); drawBar(st); + drawFormula(st.parts.formula, extra(st)); + drawCells(st.parts.cells, extra(st)); applyMarks(st); }, }; states.set(host, st); host.classList.add("m02-dfpanel"); - host.replaceChildren(parts.head, parts.got, parts.vars, parts.bar, parts.msg, parts.sheet); + host.replaceChildren( + parts.head, + parts.formula, + parts.got, + parts.vars, + parts.bar, + parts.msg, + el("div", { className: "m02-dfpanel-body", children: [parts.sheet, parts.cells] }), + ); parts.msg.hidden = true; st.redraw(); void mountSheet(st); diff --git a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts index 5f08dcf38..f8efa089d 100644 --- a/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts +++ b/M02_MasterTemplete/DataFlow/M02_DF_LinkPanel.ts @@ -9,8 +9,12 @@ 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, @@ -77,6 +81,12 @@ const TEXT = { ], 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; @@ -92,6 +102,7 @@ interface Ctx { linkId: string; readOnly: boolean; headers: string[]; + detail: DetailOptions; change(lg: CfLogic): void; } @@ -240,28 +251,122 @@ function lookupEditor(ctx: Ctx, rule: Extract): HTML 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): 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"), + 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)), + }), ), - select( - ctx, - GATE_OPS.map((o) => ({ value: o, text: o })), - rule.op, - (v) => put({ op: v as CfGateOp }), - tk("Kinds"), + ); + }); + 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"), + ), + ] + : []), ), - numberBox(ctx, rule.value, (v) => put({ value: v }), tk("Value")), - ), - ]; + ); + } + return out; } function scaleEditor(ctx: Ctx, rule: Extract): HTMLElement[] { @@ -323,7 +428,7 @@ function roundEditor( const DEFAULT: Record, (from: CfPort) => CfRule> = { lookup: () => ({ kind: "lookup", conds: [], col: "" }), - gate: (from) => ({ kind: "gate", from, op: "≥", value: 0 }), + gate: (from) => makeGate([{ from, op: "≥", value: 0 }]), scale: () => ({ kind: "scale", op: "×", value: 1 }), round: () => ({ kind: "round", places: 0, method: "반올림" }), }; @@ -345,14 +450,23 @@ export function renderLinkPanel( 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 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)} → ${portText(lg, link.to)}` }), + 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)})` }), ], }),