/** * SheetView — renders a stat sheet SVG with live reactive text bindings. * * Parses the SVG once via DOMParser, then uses a Solid effect to * update only elements containing ${key} templates whenever * the stats store changes. */ import { Component, createMemo, createEffect, createRoot, onCleanup } from "solid-js"; import { useJournalStream } from "../stores/journalStream"; import { useJournalCompletions } from "./completions"; import { fullKey } from "./stat-helpers"; import type { StatDef } from "./completions"; import { parseSheet } from "./stat-sheet"; export const SheetView: Component<{ sheetId: string }> = (props) => { const stream = useJournalStream(); const comp = useJournalCompletions(); const statDefs = createMemo(() => comp.data.stats); const values = createMemo(() => stream.stats); /** Build a lookup: bare key -> StatDef (for scope-aware template resolution) */ const bareDefMap = createMemo(() => { const map = new Map(); for (const def of statDefs()) { if (!map.has(def.key)) { map.set(def.key, def); } } return map; }); /** * Resolve a bare key from an SVG template to its runtime value. * Uses StatDef scoping when available, falls back to player-first, global-second. */ function resolveBareKey(bareKey: string): string { const bareDef = bareDefMap().get(bareKey); if (bareDef) { const fk = fullKey(bareDef, stream.myName); return values()[fk] ?? bareDef.default ?? ""; } const pk = `${stream.myName}:${bareKey}`; if (values()[pk] !== undefined) return values()[pk]; return values()[bareKey] ?? ""; } const sheet = createMemo(() => { const s = comp.data.statSheets.find((sh) => sh.id === props.sheetId); return s ?? null; }); let containerRef: HTMLDivElement | undefined; let disposeEffect: (() => void) | undefined; createEffect(() => { const s = sheet(); const container = containerRef; disposeEffect?.(); disposeEffect = undefined; if (!container || !s) return; container.innerHTML = ""; const parsed = parseSheet(s.svg); // Force full-width, auto-height parsed.svgElement.setAttribute("width", "100%"); parsed.svgElement.removeAttribute("height"); parsed.svgElement.style.display = "block"; container.appendChild(parsed.svgElement); // Center template nodes so text stays centered as values change length for (const tpl of parsed.templates) { const el = tpl.el; try { const bbox = el.getBBox(); const cx = bbox.x + bbox.width / 2; el.setAttribute("text-anchor", "middle"); el.setAttribute("x", String(cx)); } catch { // getBBox may fail if the node has no layout — skip silently } } if (parsed.templates.length === 0) return; const dispose = createRoot((disposer) => { createEffect(() => { const v = values(); for (const tpl of parsed.templates) { let result = tpl.template; for (const key of tpl.keys) { result = result.replace(`\${${key}}`, resolveBareKey(key)); } tpl.el.textContent = result; } }); return disposer; }); disposeEffect = dispose; }); onCleanup(() => disposeEffect?.()); const fallback = (

未找到属性卡: {props.sheetId}

); return (
{sheet() ? (
) : ( fallback )}
); };