← back to Trademarks Copyright
src/components/Canvas.tsx
124 lines
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import ReactFlow, {
Background, Controls, MiniMap, addEdge, useEdgesState, useNodesState,
type Connection, type Edge, type Node, type NodeProps,
} from "reactflow";
import "reactflow/dist/style.css";
type ItemLite = {
id: number;
name: string;
kind: string;
status: string;
composite_score: number | null;
};
function ScoreHue(n: number | null): string {
if (n === null) return "#cbd5e1";
const hue = Math.round((n / 100) * 120);
return `hsl(${hue} 55% 45%)`;
}
function ItemNode({ data }: NodeProps<ItemLite>) {
return (
<div className="rounded-lg border-2 bg-white p-2 shadow-sm" style={{ borderColor: ScoreHue(data.composite_score), minWidth: 180 }}>
<div className="text-xs uppercase tracking-wide text-ink/50">{data.kind} · {data.status}</div>
<div className="font-semibold">{data.name}</div>
{data.composite_score !== null && (
<div className="mt-1 inline-block rounded px-1.5 py-0.5 text-xs font-semibold text-white"
style={{ backgroundColor: ScoreHue(data.composite_score) }}>
{Number(data.composite_score).toFixed(1)}
</div>
)}
</div>
);
}
const nodeTypes = { item: ItemNode };
export default function Canvas() {
const [nodes, setNodes, onNodesChange] = useNodesState([]);
const [edges, setEdges, onEdgesChange] = useEdgesState([]);
const [items, setItems] = useState<ItemLite[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const raw = sessionStorage.getItem("canvasIds");
const ids: number[] = raw ? JSON.parse(raw) : [];
fetch("/api/items")
.then((r) => r.json())
.then((j) => {
const all: ItemLite[] = (j.items || []).map((i: { id: number; name: string; kind: string; status: string; composite_score: number | null }) => ({
id: i.id, name: i.name, kind: i.kind, status: i.status,
composite_score: i.composite_score,
}));
const subset = ids.length ? all.filter((i) => ids.includes(i.id)) : all.slice(0, 8);
setItems(subset);
const cols = Math.ceil(Math.sqrt(subset.length));
const nn: Node[] = subset.map((it, i) => ({
id: String(it.id),
type: "item",
data: it,
position: { x: (i % cols) * 240, y: Math.floor(i / cols) * 140 },
}));
setNodes(nn);
})
.finally(() => setLoading(false));
}, [setNodes]);
const onConnect = useCallback(
(c: Connection) => setEdges((eds) => addEdge({ ...c, animated: true, style: { stroke: "#b38f4e" } }, eds)),
[setEdges]
);
const addMore = useMemo(
() => async () => {
const r = await fetch("/api/items");
const j = await r.json();
const existing = new Set(nodes.map((n) => n.id));
const all: ItemLite[] = j.items || [];
const extra = all.filter((i) => !existing.has(String(i.id))).slice(0, 6);
const startY = nodes.length ? Math.max(...nodes.map((n) => n.position.y)) + 180 : 0;
const cols = 4;
const toAdd: Node[] = extra.map((it, i) => ({
id: String(it.id),
type: "item",
data: it,
position: { x: (i % cols) * 240, y: startY + Math.floor(i / cols) * 140 },
}));
setNodes((nds) => [...nds, ...toAdd]);
setItems((its) => [...its, ...extra]);
},
[nodes, setNodes]
);
return (
<div className="h-[calc(100vh-220px)] w-full">
<div className="mb-3 flex items-center gap-3">
<div className="text-sm text-ink/60">
{loading ? "loading…" : `${items.length} nodes`} · drag to rearrange, connect to cluster
</div>
<button className="btn-ghost ml-auto" onClick={addMore}>+ Add more items</button>
</div>
<div className="card h-full">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
nodeTypes={nodeTypes}
fitView
>
<Background gap={24} />
<Controls />
<MiniMap pannable zoomable />
</ReactFlow>
</div>
</div>
);
}