| 1 | import React, { useState } from "react" |
| 2 | import { StreamNetworkFrame } from "semiotic" |
| 3 | |
| 4 | const groupColors = { |
| 5 | Engineering: "#6366f1", |
| 6 | Design: "#22c55e", |
| 7 | Product: "#f59e0b", |
| 8 | Marketing: "#ef4444", |
| 9 | } |
| 10 | |
| 11 | function NodeDetail({ node, edges }) { |
| 12 | if (!node) { |
| 13 | return <div style={{ color: "#8888a0", fontSize: "14px", padding: "16px 0" }}> |
| 14 | Click a node to see details. |
| 15 | </div> |
| 16 | } |
| 17 | |
| 18 | const raw = node.data || node |
| 19 | const nodeId = node.id |
| 20 | const label = raw.label || raw.id || nodeId |
| 21 | const group = raw.group |
| 22 | |
| 23 | const connectedEdges = edges.filter( |
| 24 | (e) => e.source === nodeId || e.target === nodeId || |
| 25 | (e.source && e.source.id === nodeId) || (e.target && e.target.id === nodeId) |
| 26 | ) |
| 27 | const connectedNames = connectedEdges.map((e) => { |
| 28 | const sourceId = typeof e.source === "string" ? e.source : e.source.id |
| 29 | const targetId = typeof e.target === "string" ? e.target : e.target.id |
| 30 | return sourceId === nodeId ? targetId : sourceId |
| 31 | }) |
| 32 | |
| 33 | return ( |
| 34 | <div> |
| 35 | <div style={{ fontSize: "18px", fontWeight: 700, marginBottom: "4px" }}>{label}</div> |
| 36 | <div style={{ |
| 37 | display: "inline-block", padding: "2px 8px", borderRadius: "999px", |
| 38 | fontSize: "11px", fontWeight: 600, color: "#fff", |
| 39 | background: groupColors[group] || "#6366f1", marginBottom: "12px", |
| 40 | }}> |
| 41 | {group} |
| 42 | </div> |
| 43 | <div style={{ fontSize: "13px", color: "#8888a0", marginBottom: "8px" }}> |
| 44 | {connectedNames.length} connection{connectedNames.length !== 1 ? "s" : ""} |
| 45 | </div> |
| 46 | <div style={{ fontSize: "13px" }}> |
| 47 | <div style={{ fontWeight: 600, marginBottom: "4px" }}>Connected to:</div> |
| 48 | <ul style={{ margin: 0, paddingLeft: "16px" }}> |
| 49 | {connectedNames.map((name) => <li key={name}>{name}</li>)} |
| 50 | </ul> |
| 51 | </div> |
| 52 | </div> |
| 53 | ) |
| 54 | } |
| 55 | |
| 56 | export default function NetworkExplorer({ nodes, edges, width = 500, height = 400 }) { |
| 57 | const [searchTerm, setSearchTerm] = useState("") |
| 58 | const [selectedNode, setSelectedNode] = useState(null) |
| 59 | const [hoveredNode, setHoveredNode] = useState(null) |
| 60 | |
| 61 | const searchLower = searchTerm.toLowerCase() |
| 62 | |
| 63 | return ( |
| 64 | <div style={{ display: "flex", gap: "24px" }}> |
| 65 | <div style={{ flex: 1, minWidth: 0 }}> |
| 66 | <input |
| 67 | type="text" |
| 68 | placeholder="Search nodes..." |
| 69 | value={searchTerm} |
| 70 | onChange={(e) => setSearchTerm(e.target.value)} |
| 71 | style={{ |
| 72 | width: "100%", padding: "8px 12px", marginBottom: "12px", |
| 73 | background: "var(--surface-2, #1a1a25)", |
| 74 | border: "1px solid var(--surface-3, #252530)", |
| 75 | borderRadius: "6px", color: "var(--text-primary, #f0f0f5)", |
| 76 | fontSize: "14px", outline: "none", |
| 77 | }} |
| 78 | /> |
| 79 | <StreamNetworkFrame |
| 80 | size={[width, height]} |
| 81 | nodes={nodes} |
| 82 | edges={edges} |
| 83 | networkType={{ type: "force", iterations: 500 }} |
| 84 | nodeSize={5} |
| 85 | sourceAccessor="source" |
| 86 | targetAccessor="target" |
| 87 | nodeIDAccessor="id" |
| 88 | nodeStyle={(d) => { |
| 89 | const raw = d.data || d |
| 90 | const label = (raw.label || raw.id || "").toLowerCase() |
| 91 | const group = raw.group |
| 92 | const isMatch = !searchTerm || label.includes(searchLower) |
| 93 | const isSelected = selectedNode && selectedNode.id === d.id |
| 94 | const isHovered = hoveredNode && hoveredNode.id === d.id |
| 95 | return { |
| 96 | fill: groupColors[group] || "#6366f1", |
| 97 | stroke: isSelected ? "#f0f0f5" : isHovered ? "#f0f0f5" : "none", |
| 98 | strokeWidth: isSelected ? 3 : isHovered ? 2 : 0, |
| 99 | opacity: isMatch ? 1 : 0.15, |
| 100 | cursor: "pointer", |
| 101 | } |
| 102 | }} |
| 103 | edgeStyle={(d) => { |
| 104 | const raw = d.data || d |
| 105 | const src = typeof raw.source === "string" ? raw.source : (raw.source && raw.source.id) || "" |
| 106 | const tgt = typeof raw.target === "string" ? raw.target : (raw.target && raw.target.id) || "" |
| 107 | const sourceMatch = !searchTerm || src.toLowerCase().includes(searchLower) |
| 108 | const targetMatch = !searchTerm || tgt.toLowerCase().includes(searchLower) |
| 109 | return { |
| 110 | stroke: "#8888a0", strokeWidth: raw.value || 1, |
| 111 | opacity: sourceMatch || targetMatch ? 0.4 : 0.05, |
| 112 | } |
| 113 | }} |
| 114 | customClickBehavior={(d) => setSelectedNode(d)} |
| 115 | customHoverBehavior={(d) => setHoveredNode(d || null)} |
| 116 | nodeLabel={(d) => { |
| 117 | const raw = d.data || d |
| 118 | return raw.label || raw.id || "" |
| 119 | }} |
| 120 | showLabels |
| 121 | margin={{ top: 10, bottom: 10, left: 10, right: 10 }} |
| 122 | /> |
| 123 | </div> |
| 124 | <div style={{ width: "240px", flexShrink: 0 }}> |
| 125 | <h3 style={{ margin: "0 0 12px", fontSize: "14px", fontWeight: 700 }}>Node Detail</h3> |
| 126 | <NodeDetail node={selectedNode} edges={edges} /> |
| 127 | </div> |
| 128 | </div> |
| 129 | ) |
| 130 | } |
| 131 | |
| 132 | // Usage: |
| 133 | // <NetworkExplorer |
| 134 | // nodes={[{ id: "A", label: "Alice", group: "Eng", connections: 3 }, ...]} |
| 135 | // edges={[{ source: "A", target: "B", weight: 2 }, ...]} |
| 136 | // width={500} height={400} |
| 137 | // /> |