mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 21:22:43 +00:00
382 lines
19 KiB
TypeScript
382 lines
19 KiB
TypeScript
/**
|
||
* Dependency spine island — the "how the 24 topics build on each other" map
|
||
* from the README, redrawn as data (nodes on a 7×6 grid, edges as SVG path
|
||
* strings) so it is themed, zoomable, and clickable.
|
||
*
|
||
* - Wheel over the figure zooms about the cursor (trackpad pinch arrives as a
|
||
* ctrl+wheel and behaves the same); dragging pans; the toolbar and +/-/0
|
||
* keys do the same. Zooming OUT at the minimum scale is deliberately left
|
||
* to the page, so a reader scrolling past the map is never trapped in it.
|
||
* - A node is a link to its topic. Routes come from Blume's own navigation
|
||
* endpoint at mount: the topic's landing page when the folder has an
|
||
* `index.mdx`, else its first solution page. A topic with no pages yet is
|
||
* drawn inert rather than linked to a 404, so the map stays honest as the
|
||
* campaign fills in without a rebuild of this file.
|
||
*
|
||
* Invariants: the first frame is the figure at HOME_ZOOM, centred, with no
|
||
* links (server and client render identically); the geometry is the README
|
||
* SVG's, to the pixel, so `viewBox` maths below is in that 1472×648 space.
|
||
*/
|
||
import { useEffect, useRef, useState, type CSSProperties, type PointerEvent } from "react";
|
||
import { withBase } from "blume/components/islands/base-path.ts";
|
||
|
||
export const client = "visible";
|
||
|
||
// ── figure data (README dependency-spine geometry) ───────────────
|
||
|
||
const W = 1472;
|
||
const H = 648;
|
||
const MAX_ZOOM = 6;
|
||
const NODE_W = 144;
|
||
const NODE_H = 48;
|
||
const COL_X = [56, 248, 472, 664, 888, 1080, 1272] as const;
|
||
const ROW_Y = [72, 144, 216, 288, 416, 488] as const;
|
||
|
||
type Kind = "entry" | "topic" | "focal";
|
||
|
||
interface Topic {
|
||
n: number;
|
||
name: string;
|
||
sub?: string;
|
||
/** Sync's group slug for the README topic name — the docs folder. */
|
||
slug: string;
|
||
kind: Kind;
|
||
col: number;
|
||
row: number;
|
||
}
|
||
|
||
const TOPICS: Topic[] = [
|
||
{ n: 5, name: "Binary Search", slug: "binary-search", kind: "entry", col: 0, row: 0 },
|
||
{ n: 3, name: "Two Pointers", slug: "two-pointers", kind: "entry", col: 0, row: 1 },
|
||
{ n: 4, name: "Sliding Window", slug: "sliding-window", kind: "topic", col: 1, row: 1 },
|
||
{ n: 6, name: "Stack", slug: "stack", kind: "entry", col: 0, row: 2 },
|
||
{ n: 7, name: "Monotonic Stack", slug: "monotonic-stack", kind: "topic", col: 1, row: 2 },
|
||
{ n: 1, name: "Hash Lookup", slug: "hash-table", kind: "entry", col: 0, row: 3 },
|
||
{ n: 2, name: "Prefix Sum", slug: "prefix-sum", kind: "topic", col: 1, row: 3 },
|
||
{ n: 11, name: "Tree DFS", slug: "tree-dfs", kind: "focal", col: 2, row: 0 },
|
||
{ n: 13, name: "Binary Search Tree", slug: "binary-search-tree", kind: "topic", col: 2, row: 1 },
|
||
{ n: 14, name: "Heap", sub: "priority queue", slug: "heap-priority-queue", kind: "topic", col: 3, row: 1 },
|
||
{ n: 15, name: "Trie", slug: "trie", kind: "topic", col: 3, row: 2 },
|
||
{ n: 12, name: "Tree BFS", slug: "tree-bfs", kind: "entry", col: 2, row: 3 },
|
||
{ n: 8, name: "Linked List", slug: "linked-list", kind: "entry", col: 2, row: 4 },
|
||
{ n: 9, name: "Hybrid Structures", slug: "hybrid-structures", kind: "topic", col: 3, row: 4 },
|
||
{ n: 10, name: "Matrix Index Math", slug: "matrix-index-math", kind: "topic", col: 2, row: 5 },
|
||
{ n: 16, name: "Graph DFS", slug: "graph-dfs", kind: "topic", col: 4, row: 0 },
|
||
{ n: 18, name: "Topological Sort", slug: "topological-sort", kind: "topic", col: 5, row: 0 },
|
||
{ n: 19, name: "Union-Find", slug: "union-find", kind: "topic", col: 4, row: 1 },
|
||
{ n: 20, name: "Backtracking", slug: "backtracking", kind: "topic", col: 5, row: 1 },
|
||
{ n: 21, name: "1-D DP", sub: "dynamic programming", slug: "1-d-dynamic-programming", kind: "focal", col: 5, row: 2 },
|
||
{ n: 22, name: "Multi-D / Grid DP", slug: "multi-d-grid-dp", kind: "topic", col: 6, row: 2 },
|
||
{ n: 17, name: "Graph BFS", slug: "graph-bfs", kind: "topic", col: 4, row: 3 },
|
||
{ n: 23, name: "Greedy", slug: "greedy", kind: "topic", col: 5, row: 3 },
|
||
{ n: 24, name: "Intervals", slug: "intervals", kind: "topic", col: 6, row: 3 },
|
||
];
|
||
|
||
/** "builds on" arrows, source → target, in the figure's coordinate space. */
|
||
const EDGES: [string, string][] = [
|
||
["3 → 4", "M200,168 H248"],
|
||
["6 → 7", "M200,240 H248"],
|
||
["1 → 2", "M200,312 H248"],
|
||
["8 → 9", "M616,440 H664"],
|
||
["16 → 18", "M1032,96 H1080"],
|
||
["21 → 22", "M1224,240 H1272"],
|
||
["23 → 24", "M1224,312 H1272"],
|
||
["11 → 16", "M616,84 H888"],
|
||
["12 → 17", "M616,312 H888"],
|
||
["11 → 13", "M544,120 V144"],
|
||
["16 → 19", "M960,120 V144"],
|
||
["18 → 20", "M1152,120 V144"],
|
||
["20 → 21", "M1152,192 V216"],
|
||
["21 → 23", "M1152,264 V288"],
|
||
["5 → 13", "M200,96 H416 Q424,96 424,104 V160 Q424,168 432,168 H472"],
|
||
["11 → 14", "M616,96 H640 Q648,96 648,104 V160 Q648,168 656,168 H664"],
|
||
["11 → 15", "M616,108 H624 Q632,108 632,116 V232 Q632,240 640,240 H664"],
|
||
["4 → 10", "M392,184 H432 Q440,184 440,192 V504 Q440,512 448,512 H472"],
|
||
["1 → 9", "M128,336 V560 Q128,568 136,568 H728 Q736,568 736,560 V464"],
|
||
];
|
||
|
||
const ZONES: [string, number, number, number, number, number][] = [
|
||
// label, x, y, w, h, label plate width
|
||
["PHASE I — LINEAR STRUCTURES", 40, 40, 368, 312, 168],
|
||
["PHASE III — HIERARCHICAL", 456, 40, 368, 312, 152],
|
||
["PHASE II — NODAL & GRID", 456, 384, 368, 168, 144],
|
||
["PHASES IV–V — RELATIONAL & DECISION-SPACE", 872, 40, 560, 312, 264],
|
||
];
|
||
|
||
// ── theme ────────────────────────────────────────────────────────
|
||
// Everything is a Blume token so the map follows the site's light/dark
|
||
// scheme instead of the README's fixed black; tints go through color-mix.
|
||
|
||
const fg = "var(--blume-foreground)";
|
||
const muted = "var(--blume-muted-foreground)";
|
||
const accent = "var(--blume-accent)";
|
||
const tint = (color: string, pct: number) => `color-mix(in oklab, ${color} ${pct}%, transparent)`;
|
||
|
||
const NODE_STYLE: Record<Kind, { fill: string; stroke: string; strokeWidth: number; tag: string }> = {
|
||
entry: { fill: tint(muted, 10), stroke: tint(muted, 70), strokeWidth: 1, tag: muted },
|
||
topic: { fill: tint(fg, 4), stroke: fg, strokeWidth: 1, tag: muted },
|
||
focal: { fill: tint(accent, 12), stroke: accent, strokeWidth: 1.2, tag: accent },
|
||
};
|
||
|
||
const mono: CSSProperties = { fontFamily: "var(--blume-font-mono, ui-monospace, monospace)" };
|
||
const sans: CSSProperties = { fontFamily: "var(--blume-font-sans, system-ui, sans-serif)" };
|
||
|
||
const toolButton: CSSProperties = {
|
||
width: "1.75rem",
|
||
height: "1.75rem",
|
||
border: "1px solid var(--blume-border)",
|
||
borderRadius: "9999px",
|
||
background: "var(--blume-background)",
|
||
color: muted,
|
||
font: "inherit",
|
||
lineHeight: 1,
|
||
cursor: "pointer",
|
||
};
|
||
|
||
// ── navigation lookup ────────────────────────────────────────────
|
||
|
||
interface NavGroup {
|
||
kind: "group";
|
||
path: string;
|
||
route?: string | null;
|
||
children: unknown[];
|
||
}
|
||
|
||
const isGroup = (v: unknown): v is NavGroup =>
|
||
typeof v === "object" && v !== null && (v as NavGroup).kind === "group" &&
|
||
typeof (v as NavGroup).path === "string" && Array.isArray((v as NavGroup).children);
|
||
|
||
/** The first page route under a sidebar entry, depth-first. */
|
||
const firstRoute = (items: unknown[]): string | null => {
|
||
for (const item of items) {
|
||
if (typeof item !== "object" || item === null) continue;
|
||
const v = item as { route?: unknown; children?: unknown };
|
||
if (typeof v.route === "string") return v.route;
|
||
if (Array.isArray(v.children)) {
|
||
const inner = firstRoute(v.children);
|
||
if (inner) return inner;
|
||
}
|
||
}
|
||
return null;
|
||
};
|
||
|
||
/** folder path (`/tree-dfs`) → the route a node should open. */
|
||
const topicRoutes = (nav: unknown): Record<string, string> => {
|
||
const out: Record<string, string> = {};
|
||
const sidebar = (nav as { sidebar?: unknown })?.sidebar;
|
||
if (!Array.isArray(sidebar)) return out;
|
||
for (const entry of sidebar) {
|
||
if (!isGroup(entry)) continue;
|
||
const route = entry.route ?? firstRoute(entry.children);
|
||
if (route) out[entry.path] = route;
|
||
}
|
||
return out;
|
||
};
|
||
|
||
// ── component ────────────────────────────────────────────────────
|
||
|
||
interface ViewBox { x: number; y: number; w: number; h: number }
|
||
/** The resting view — 1.6× on the centre so node labels read at the content width; 1× stays reachable by zooming out. */
|
||
const HOME_ZOOM = 1.6;
|
||
const HOME: ViewBox = { x: (W - W / HOME_ZOOM) / 2, y: (H - H / HOME_ZOOM) / 2, w: W / HOME_ZOOM, h: H / HOME_ZOOM };
|
||
const clamp = (v: number, lo: number, hi: number) => Math.min(Math.max(v, lo), hi);
|
||
|
||
export default function DependencySpine() {
|
||
const svgRef = useRef<SVGSVGElement>(null);
|
||
const [vb, setVbState] = useState<ViewBox>(HOME);
|
||
// The native wheel listener and back-to-back events read the latest view
|
||
// from the ref, which is written synchronously — state alone lags a render.
|
||
const vbRef = useRef(vb);
|
||
const setVb = (next: ViewBox) => {
|
||
vbRef.current = next;
|
||
setVbState(next);
|
||
};
|
||
const [routes, setRoutes] = useState<Record<string, string> | null>(null);
|
||
const drag = useRef<{ x: number; y: number; moved: boolean } | null>(null);
|
||
// The cursor reads this, not `drag`: a ref read during render never re-renders.
|
||
const [panning, setPanning] = useState(false);
|
||
|
||
useEffect(() => {
|
||
let live = true;
|
||
fetch(withBase("/api/docs/navigation.json"))
|
||
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
|
||
.then((nav) => live && setRoutes(topicRoutes(nav)))
|
||
.catch(() => live && setRoutes({}));
|
||
return () => { live = false; };
|
||
}, []);
|
||
|
||
/** Zoom by `factor` (>1 in) about a client-space point; omitted = centre. */
|
||
const zoomAt = (factor: number, clientX?: number, clientY?: number) => {
|
||
const rect = svgRef.current?.getBoundingClientRect();
|
||
const cur = vbRef.current;
|
||
if (!rect) return;
|
||
const px = clientX === undefined ? cur.x + cur.w / 2 : cur.x + ((clientX - rect.left) / rect.width) * cur.w;
|
||
const py = clientY === undefined ? cur.y + cur.h / 2 : cur.y + ((clientY - rect.top) / rect.height) * cur.h;
|
||
const w = clamp(cur.w / factor, W / MAX_ZOOM, W);
|
||
const f = cur.w / w;
|
||
const h = H * (w / W);
|
||
setVb({ x: clamp(px - (px - cur.x) / f, 0, W - w), y: clamp(py - (py - cur.y) / f, 0, H - h), w, h });
|
||
};
|
||
|
||
// React registers `wheel` as passive, so preventDefault has to go native.
|
||
useEffect(() => {
|
||
const svg = svgRef.current;
|
||
if (!svg) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
if (e.deltaY > 0 && vbRef.current.w >= W) return; // fully zoomed out: let the page scroll
|
||
e.preventDefault();
|
||
zoomAt(Math.exp(-e.deltaY * 0.0015), e.clientX, e.clientY);
|
||
};
|
||
svg.addEventListener("wheel", onWheel, { passive: false });
|
||
return () => svg.removeEventListener("wheel", onWheel);
|
||
}, []);
|
||
|
||
const onPointerDown = (e: PointerEvent<SVGSVGElement>) => {
|
||
if (e.button !== 0) return;
|
||
drag.current = { x: e.clientX, y: e.clientY, moved: false };
|
||
};
|
||
const onPointerMove = (e: PointerEvent<SVGSVGElement>) => {
|
||
const d = drag.current;
|
||
const rect = svgRef.current?.getBoundingClientRect();
|
||
if (!d || !rect) return;
|
||
const dx = e.clientX - d.x;
|
||
const dy = e.clientY - d.y;
|
||
if (!d.moved && Math.hypot(dx, dy) < 4) return; // a click, not a drag
|
||
if (!d.moved) {
|
||
// Capture only once it is a drag: capturing on pointerdown would retarget
|
||
// pointerup (and so the click) to the <svg>, and a node's link would never fire.
|
||
d.moved = true;
|
||
setPanning(true);
|
||
e.currentTarget.setPointerCapture(e.pointerId);
|
||
}
|
||
d.x = e.clientX;
|
||
d.y = e.clientY;
|
||
const cur = vbRef.current;
|
||
const scale = cur.w / rect.width;
|
||
setVb({ ...cur, x: clamp(cur.x - dx * scale, 0, W - cur.w), y: clamp(cur.y - dy * scale, 0, H - cur.h) });
|
||
};
|
||
const onPointerUp = (e: PointerEvent<SVGSVGElement>) => {
|
||
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||
setPanning(false);
|
||
// Cleared on the next tick so a node's click handler can still see `moved`.
|
||
setTimeout(() => { drag.current = null; }, 0);
|
||
};
|
||
|
||
const zoom = W / vb.w;
|
||
const atHome = vb.x === HOME.x && vb.y === HOME.y && vb.w === HOME.w;
|
||
|
||
return (
|
||
<figure
|
||
className="not-prose"
|
||
style={{ margin: "1.5rem 0", border: "1px solid var(--blume-border)", borderRadius: "var(--blume-radius)", background: tint("var(--blume-muted)", 30), overflow: "hidden" }}
|
||
aria-label="Dependency spine: how the 24 topics build on each other"
|
||
onKeyDown={(e) => {
|
||
if (e.key === "+" || e.key === "=") zoomAt(1.25);
|
||
else if (e.key === "-") zoomAt(0.8);
|
||
else if (e.key === "0") setVb(HOME);
|
||
else return;
|
||
e.preventDefault();
|
||
}}
|
||
tabIndex={0}
|
||
>
|
||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: "0.75rem", padding: "0.5rem 0.75rem", borderBottom: "1px solid var(--blume-border)" }}>
|
||
<span style={{ ...mono, fontSize: "0.7rem", letterSpacing: "0.12em", textTransform: "uppercase", color: muted, whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis" }}>
|
||
scroll to zoom · drag to pan · click a topic
|
||
</span>
|
||
<span style={{ display: "flex", alignItems: "center", gap: "0.35rem", flexShrink: 0 }}>
|
||
<span style={{ ...mono, fontSize: "0.7rem", color: muted, minWidth: "4ch", textAlign: "right", whiteSpace: "nowrap" }}>{zoom.toFixed(1)}×</span>
|
||
<button type="button" style={toolButton} aria-label="Zoom out" onClick={() => zoomAt(0.8)} disabled={zoom <= 1.001}>−</button>
|
||
<button type="button" style={toolButton} aria-label="Zoom in" onClick={() => zoomAt(1.25)} disabled={zoom >= MAX_ZOOM - 0.001}>+</button>
|
||
<button type="button" style={toolButton} aria-label="Reset zoom" onClick={() => setVb(HOME)} disabled={atHome}>⟲</button>
|
||
</span>
|
||
</div>
|
||
|
||
<svg
|
||
ref={svgRef}
|
||
viewBox={`${vb.x} ${vb.y} ${vb.w} ${vb.h}`}
|
||
xmlns="http://www.w3.org/2000/svg"
|
||
role="img"
|
||
aria-labelledby="dependency-spine-title dependency-spine-desc"
|
||
style={{ display: "block", width: "100%", height: "auto", touchAction: "none", cursor: panning ? "grabbing" : zoom > 1.001 ? "grab" : "default", userSelect: "none" }}
|
||
onPointerDown={onPointerDown}
|
||
onPointerMove={onPointerMove}
|
||
onPointerUp={onPointerUp}
|
||
onPointerCancel={onPointerUp}
|
||
onDoubleClick={() => setVb(HOME)}
|
||
>
|
||
<title id="dependency-spine-title">LeetCode topic dependency spine</title>
|
||
<desc id="dependency-spine-desc">
|
||
Dependency graph of 24 LeetCode study topics across five phases, showing which topics build on which, with Tree DFS and 1-D dynamic programming as the two central hubs.
|
||
</desc>
|
||
<defs>
|
||
<marker id="spine-arrow" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
|
||
<polygon points="0 0, 8 3, 0 6" style={{ fill: muted }} />
|
||
</marker>
|
||
</defs>
|
||
|
||
{ZONES.map(([label, x, y, w, h, plate]) => (
|
||
<g key={label}>
|
||
<rect x={x} y={y} width={w} height={h} rx={8} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
|
||
<rect x={x + 16} y={y + 4} width={plate} height={12} rx={2} style={{ fill: "var(--blume-background)" }} />
|
||
<text x={x + 20} y={y + 13} fontSize={7} letterSpacing="0.14em" style={{ ...mono, fill: tint(fg, 40) }}>{label}</text>
|
||
</g>
|
||
))}
|
||
|
||
{EDGES.map(([id, d]) => (
|
||
<path key={id} d={d} fill="none" strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }}>
|
||
<title>{id}</title>
|
||
</path>
|
||
))}
|
||
|
||
{TOPICS.map((t) => {
|
||
const x = COL_X[t.col]!;
|
||
const y = ROW_Y[t.row]!;
|
||
const s = NODE_STYLE[t.kind];
|
||
const route = routes?.[`/${t.slug}`];
|
||
const tag = String(t.n).padStart(2, "0");
|
||
const body = (
|
||
<>
|
||
<title>{route ? `${tag} · ${t.name} — open topic` : `${tag} · ${t.name} — no pages yet`}</title>
|
||
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: "var(--blume-background)" }} />
|
||
<rect x={x} y={y} width={NODE_W} height={NODE_H} rx={6} style={{ fill: s.fill, stroke: s.stroke, strokeWidth: s.strokeWidth }} />
|
||
<rect x={x + 8} y={y + 6} width={24} height={12} rx={2} fill="transparent" style={{ stroke: tint(s.tag, 50), strokeWidth: 0.8 }} />
|
||
<text x={x + 20} y={y + 15} fontSize={7} textAnchor="middle" letterSpacing="0.08em" style={{ ...mono, fill: s.tag }}>{tag}</text>
|
||
<text x={x + NODE_W / 2} y={y + (t.sub ? 26 : 32)} fontSize={12} fontWeight={600} textAnchor="middle" style={{ ...sans, fill: fg }}>{t.name}</text>
|
||
{t.sub && <text x={x + NODE_W / 2} y={y + 40} fontSize={9} textAnchor="middle" style={{ ...mono, fill: muted }}>{t.sub}</text>}
|
||
</>
|
||
);
|
||
return route ? (
|
||
<a
|
||
key={t.n}
|
||
href={withBase(route)}
|
||
className="spine-node"
|
||
style={{ cursor: "pointer", textDecoration: "none" }}
|
||
onClick={(e) => { if (drag.current?.moved) e.preventDefault(); }}
|
||
>
|
||
{body}
|
||
</a>
|
||
) : (
|
||
<g key={t.n} style={{ opacity: routes ? 0.55 : 1 }} aria-disabled="true">{body}</g>
|
||
);
|
||
})}
|
||
|
||
<line x1={40} y1={600} x2={1432} y2={600} strokeWidth={0.8} style={{ stroke: tint(fg, 10) }} />
|
||
<text x={40} y={620} fontSize={8} letterSpacing="0.14em" style={{ ...mono, fill: muted }}>LEGEND</text>
|
||
<rect x={160} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.entry.fill, stroke: NODE_STYLE.entry.stroke, strokeWidth: 1 }} />
|
||
<text x={188} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>ENTRY — NO PREREQS</text>
|
||
<rect x={360} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1 }} />
|
||
<text x={388} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>TOPIC</text>
|
||
<rect x={480} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.focal.fill, stroke: NODE_STYLE.focal.stroke, strokeWidth: 1.2 }} />
|
||
<text x={508} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>FOCAL HUB</text>
|
||
<line x1={624} y1={614} x2={656} y2={614} strokeWidth={1} markerEnd="url(#spine-arrow)" style={{ stroke: muted }} />
|
||
<text x={668} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>BUILDS ON</text>
|
||
<rect x={792} y={608} width={20} height={12} rx={2} style={{ fill: tint(fg, 2), stroke: tint(fg, 10), strokeWidth: 0.8 }} />
|
||
<text x={820} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>PHASE ZONE</text>
|
||
<rect x={1000} y={608} width={20} height={12} rx={2} style={{ fill: NODE_STYLE.topic.fill, stroke: NODE_STYLE.topic.stroke, strokeWidth: 1, opacity: 0.55 }} />
|
||
<text x={1028} y={618} fontSize={8} letterSpacing="0.06em" style={{ ...mono, fill: muted }}>NO PAGES YET</text>
|
||
</svg>
|
||
<style>{`.spine-node:hover rect:nth-of-type(2), .spine-node:focus-visible rect:nth-of-type(2) { stroke: ${accent}; stroke-width: 1.6; } .spine-node:focus { outline: none; }`}</style>
|
||
</figure>
|
||
);
|
||
}
|