mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 20:51:49 +00:00
init
This commit is contained in:
610
thirdeye/dashboard/app/knowledge-base/NetworkMap.tsx
Normal file
610
thirdeye/dashboard/app/knowledge-base/NetworkMap.tsx
Normal file
@@ -0,0 +1,610 @@
|
||||
"use client";
|
||||
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import "./knowledge.css";
|
||||
import {
|
||||
fetchGroups,
|
||||
fetchAllSignals,
|
||||
Group,
|
||||
Signal,
|
||||
getSeverityColor,
|
||||
parseMetaList,
|
||||
} from "../lib/api";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface NodeDef {
|
||||
id: string;
|
||||
label: string;
|
||||
x: number; // 0..100 percentage
|
||||
y: number; // 0..100 percentage
|
||||
size: "core" | "group" | "signal";
|
||||
icon: string;
|
||||
color: string;
|
||||
tooltip: { title: string; lines: string[] };
|
||||
}
|
||||
|
||||
interface EdgeDef {
|
||||
from: string;
|
||||
to: string;
|
||||
dashed?: boolean;
|
||||
opacity?: number;
|
||||
}
|
||||
|
||||
// ─── Layout helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
const CORE = { x: 52, y: 48 };
|
||||
|
||||
/** Evenly distribute groups in a circle around the core */
|
||||
function groupPositions(count: number) {
|
||||
const positions: { x: number; y: number }[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const angle = (2 * Math.PI * i) / count - Math.PI / 2;
|
||||
const rx = 28; // ellipse horizontal radius (% units)
|
||||
const ry = 22; // vertical radius
|
||||
positions.push({
|
||||
x: CORE.x + rx * Math.cos(angle),
|
||||
y: CORE.y + ry * Math.sin(angle),
|
||||
});
|
||||
}
|
||||
return positions;
|
||||
}
|
||||
|
||||
/** Place signal satellites around their parent group node */
|
||||
function signalPositions(
|
||||
parentX: number,
|
||||
parentY: number,
|
||||
count: number,
|
||||
startAngle: number
|
||||
) {
|
||||
const r = 9;
|
||||
return Array.from({ length: count }, (_, i) => {
|
||||
const angle = startAngle + (2 * Math.PI * i) / count;
|
||||
return {
|
||||
x: parentX + r * Math.cos(angle),
|
||||
y: parentY + r * Math.sin(angle),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const SIGNAL_TYPE_ICONS: Record<string, string> = {
|
||||
security_concern: "security",
|
||||
architecture_decision: "architecture",
|
||||
tech_debt: "construction",
|
||||
consensus: "check_circle",
|
||||
blocker: "block",
|
||||
risk: "warning",
|
||||
sentiment_spike: "mood",
|
||||
knowledge_gap: "help",
|
||||
decision: "gavel",
|
||||
action_item: "task_alt",
|
||||
trend: "trending_up",
|
||||
default: "sensors",
|
||||
};
|
||||
|
||||
function getSignalTypeIcon(type: string): string {
|
||||
return SIGNAL_TYPE_ICONS[type] ?? SIGNAL_TYPE_ICONS.default;
|
||||
}
|
||||
|
||||
// ─── Node Components ──────────────────────────────────────────────────────────
|
||||
|
||||
function CoreNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer group"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-violet-500 rounded-full blur-xl opacity-40 group-hover:opacity-70 transition-opacity" />
|
||||
<div className="relative w-14 h-14 glass-card rounded-xl neon-border flex items-center justify-center rotate-45 group-hover:scale-110 transition-transform bg-[#19191d]/60">
|
||||
<span
|
||||
className="material-symbols-outlined text-violet-400 -rotate-45 block"
|
||||
style={{ fontVariationSettings: "'FILL' 1" }}
|
||||
>
|
||||
auto_awesome
|
||||
</span>
|
||||
</div>
|
||||
{hovered && (
|
||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-4 z-20 w-52 pointer-events-none">
|
||||
<div className="glass-card neon-border p-3 rounded-lg shadow-2xl bg-[#09090b]/90">
|
||||
<p className="text-[10px] font-mono-data text-violet-400 mb-1">NODE::CORE_AI</p>
|
||||
<p className="text-xs font-bold text-zinc-200">{node.tooltip.title}</p>
|
||||
<div className="h-[1px] bg-violet-500/20 my-2" />
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[9px] font-mono-data text-zinc-500">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer group"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="absolute inset-0 rounded-full blur-md opacity-30 group-hover:opacity-60 transition-opacity"
|
||||
style={{ backgroundColor: node.color }}
|
||||
/>
|
||||
<div
|
||||
className="relative w-11 h-11 glass-card rounded-full neon-border flex items-center justify-center group-hover:scale-110 transition-all bg-[#19191d]/60"
|
||||
>
|
||||
<span className="material-symbols-outlined text-lg" style={{ color: node.color }}>
|
||||
{node.icon}
|
||||
</span>
|
||||
</div>
|
||||
<p className="absolute -bottom-6 left-1/2 -translate-x-1/2 text-[8px] font-mono-data text-zinc-500 uppercase tracking-tighter group-hover:text-violet-400 whitespace-nowrap max-w-[80px] overflow-hidden text-ellipsis text-center">
|
||||
{node.label}
|
||||
</p>
|
||||
{hovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-3 z-20 w-52 pointer-events-none">
|
||||
<div className="glass-card neon-border p-3 rounded-lg shadow-2xl bg-[#09090b]/90">
|
||||
<p className="text-[10px] font-mono-data mb-1" style={{ color: node.color }}>
|
||||
NODE::GROUP
|
||||
</p>
|
||||
<p className="text-xs font-bold text-zinc-200 mb-2">{node.tooltip.title}</p>
|
||||
<div className="h-[1px] bg-violet-500/20 my-2" />
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[9px] font-mono-data text-zinc-400">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SignalNode({ node }: { node: NodeDef }) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
return (
|
||||
<div
|
||||
className="absolute pointer-events-auto cursor-pointer"
|
||||
style={{ left: `${node.x}%`, top: `${node.y}%`, transform: "translate(-50%,-50%)" }}
|
||||
onMouseEnter={() => setHovered(true)}
|
||||
onMouseLeave={() => setHovered(false)}
|
||||
>
|
||||
<div className="relative">
|
||||
<div
|
||||
className="relative w-7 h-7 glass-card rounded-full flex items-center justify-center hover:scale-125 transition-all"
|
||||
style={{ border: `1px solid ${node.color}40` }}
|
||||
>
|
||||
<span className="material-symbols-outlined" style={{ color: node.color, fontSize: "13px" }}>
|
||||
{node.icon}
|
||||
</span>
|
||||
</div>
|
||||
{hovered && (
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 z-20 w-44 pointer-events-none">
|
||||
<div className="glass-card p-2.5 rounded-lg shadow-2xl bg-[#09090b]/90" style={{ border: `1px solid ${node.color}30` }}>
|
||||
<p className="text-[9px] font-mono-data mb-0.5" style={{ color: node.color }}>
|
||||
SIGNAL::{node.tooltip.title.toUpperCase().replace(/ /g, "_")}
|
||||
</p>
|
||||
{node.tooltip.lines.map((l, i) => (
|
||||
<p key={i} className="text-[8px] font-mono-data text-zinc-500 leading-relaxed">{l}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Main Component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function NetworkMap() {
|
||||
const [nodes, setNodes] = useState<NodeDef[]>([]);
|
||||
const [edges, setEdges] = useState<EdgeDef[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [totalSignals, setTotalSignals] = useState(0);
|
||||
|
||||
// ── Pan state ──
|
||||
const [pan, setPan] = useState({ x: 0, y: 0 });
|
||||
const dragging = useRef(false);
|
||||
const dragStart = useRef({ x: 0, y: 0, panX: 0, panY: 0 });
|
||||
|
||||
const handleMouseDown = (e: React.MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest(".pointer-events-auto")) return;
|
||||
dragging.current = true;
|
||||
dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y };
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (!dragging.current) return;
|
||||
const dx = e.clientX - dragStart.current.x;
|
||||
const dy = e.clientY - dragStart.current.y;
|
||||
setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy });
|
||||
};
|
||||
|
||||
const handleMouseUp = () => { dragging.current = false; };
|
||||
|
||||
const resetPan = () => setPan({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
async function build() {
|
||||
try {
|
||||
const [groups, allGroupSignals] = await Promise.all([
|
||||
fetchGroups(),
|
||||
fetchAllSignals(),
|
||||
]);
|
||||
|
||||
const sigMap: Record<string, Signal[]> = {};
|
||||
allGroupSignals.forEach((g) => {
|
||||
sigMap[g.group_id] = g.signals;
|
||||
});
|
||||
const total = allGroupSignals.reduce((acc, g) => acc + g.signals.length, 0);
|
||||
setTotalSignals(total);
|
||||
|
||||
const newNodes: NodeDef[] = [];
|
||||
const newEdges: EdgeDef[] = [];
|
||||
|
||||
// Core node
|
||||
const coreId = "core_ai";
|
||||
newNodes.push({
|
||||
id: coreId,
|
||||
label: "ThirdEye AI",
|
||||
x: CORE.x,
|
||||
y: CORE.y,
|
||||
size: "core",
|
||||
icon: "auto_awesome",
|
||||
color: "#A78BFA",
|
||||
tooltip: {
|
||||
title: "ThirdEye Neural Engine",
|
||||
lines: [
|
||||
`GROUPS: ${groups.length}`,
|
||||
`SIGNALS: ${total}`,
|
||||
"STATUS: ACTIVE",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Group nodes
|
||||
const gPositions = groupPositions(Math.max(groups.length, 1));
|
||||
|
||||
const LENS_COLORS: Record<string, string> = {
|
||||
dev: "#00daf3",
|
||||
product: "#A78BFA",
|
||||
client: "#ffb300",
|
||||
community: "#10b981",
|
||||
meet: "#ff6f78",
|
||||
unknown: "#6b7280",
|
||||
};
|
||||
|
||||
groups.forEach((group, gi) => {
|
||||
const pos = gPositions[gi];
|
||||
const signals = sigMap[group.group_id] || [];
|
||||
const color = LENS_COLORS[group.lens] || LENS_COLORS.unknown;
|
||||
const groupId = `group_${group.group_id}`;
|
||||
|
||||
// Count signal types
|
||||
const typeCounts: Record<string, number> = {};
|
||||
signals.forEach((s) => {
|
||||
const t = s.metadata.type;
|
||||
typeCounts[t] = (typeCounts[t] || 0) + 1;
|
||||
});
|
||||
const topTypes = Object.entries(typeCounts)
|
||||
.sort(([, a], [, b]) => b - a)
|
||||
.slice(0, 3);
|
||||
|
||||
newNodes.push({
|
||||
id: groupId,
|
||||
label: group.group_name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: "group",
|
||||
icon: "group",
|
||||
color,
|
||||
tooltip: {
|
||||
title: group.group_name,
|
||||
lines: [
|
||||
`LENS: ${group.lens?.toUpperCase() || "UNKNOWN"}`,
|
||||
`SIGNALS: ${group.signal_count}`,
|
||||
...topTypes.map(([t, c]) => `${t.replace(/_/g, " ")}: ${c}`),
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
// Edge: core → group
|
||||
newEdges.push({ from: coreId, to: groupId, opacity: 0.8 });
|
||||
|
||||
// Signal satellite nodes (top 3 signal types)
|
||||
const angleSeed = (gi / Math.max(groups.length, 1)) * Math.PI * 2;
|
||||
const sPositions = signalPositions(pos.x, pos.y, topTypes.length, angleSeed);
|
||||
|
||||
topTypes.forEach(([type, count], si) => {
|
||||
const sigNodeId = `sig_${gi}_${si}`;
|
||||
const severity =
|
||||
signals.find((s) => s.metadata.type === type)?.metadata.severity || "low";
|
||||
newNodes.push({
|
||||
id: sigNodeId,
|
||||
label: type,
|
||||
x: sPositions[si].x,
|
||||
y: sPositions[si].y,
|
||||
size: "signal",
|
||||
icon: getSignalTypeIcon(type),
|
||||
color: getSeverityColor(severity),
|
||||
tooltip: {
|
||||
title: type.replace(/_/g, " "),
|
||||
lines: [`COUNT: ${count}`, `SEVERITY: ${severity.toUpperCase()}`],
|
||||
},
|
||||
});
|
||||
newEdges.push({ from: groupId, to: sigNodeId, dashed: true, opacity: 0.5 });
|
||||
});
|
||||
|
||||
// Cross-edges between adjacent groups (every even group connects to next)
|
||||
if (gi > 0 && gi % 2 === 0 && groups.length > 2) {
|
||||
newEdges.push({
|
||||
from: `group_${groups[gi - 1].group_id}`,
|
||||
to: groupId,
|
||||
dashed: true,
|
||||
opacity: 0.2,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// If no groups yet, show a placeholder set
|
||||
if (groups.length === 0) {
|
||||
["dev", "product", "client"].forEach((lens, i) => {
|
||||
const pos = gPositions[i] || { x: 30 + i * 20, y: 45 };
|
||||
const gId = `placeholder_${lens}`;
|
||||
newNodes.push({
|
||||
id: gId,
|
||||
label: `${lens}_group`,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: "group",
|
||||
icon: "group_off",
|
||||
color: "#3f3f46",
|
||||
tooltip: {
|
||||
title: `${lens.toUpperCase()} Group`,
|
||||
lines: ["No data yet", "Connect Telegram to populate"],
|
||||
},
|
||||
});
|
||||
newEdges.push({ from: coreId, to: gId, dashed: true, opacity: 0.15 });
|
||||
});
|
||||
}
|
||||
|
||||
setNodes(newNodes);
|
||||
setEdges(newEdges);
|
||||
} catch {
|
||||
// silently fail
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
build();
|
||||
}, []);
|
||||
|
||||
// Build a quick lookup for node positions
|
||||
const nodePos: Record<string, { x: number; y: number }> = {};
|
||||
nodes.forEach((n) => { nodePos[n.id] = { x: n.x, y: n.y }; });
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute inset-0 z-0 overflow-hidden"
|
||||
style={{ cursor: dragging.current ? "grabbing" : "grab" }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Pan hint */}
|
||||
{!loading && (pan.x !== 0 || pan.y !== 0) && (
|
||||
<button
|
||||
className="absolute top-3 left-1/2 -translate-x-1/2 z-30 flex items-center gap-1.5 px-3 py-1 rounded-full bg-black/60 border border-violet-500/30 text-[9px] font-mono-data text-violet-400 hover:text-white hover:bg-violet-900/40 transition-all pointer-events-auto"
|
||||
onClick={resetPan}
|
||||
>
|
||||
<span className="material-symbols-outlined text-[12px]">center_focus_weak</span>
|
||||
RESET VIEW
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pannable graph content */}
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{ transform: `translate(${pan.x}px, ${pan.y}px)`, willChange: "transform" }}
|
||||
>
|
||||
{/* SVG Edges */}
|
||||
<svg className="absolute inset-0 w-full h-full pointer-events-none" style={{ overflow: "visible" }}>
|
||||
<defs>
|
||||
{/* Strong glow – core edge packets */}
|
||||
<filter id="glow-strong" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="5" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
{/* Subtle glow – secondary edges */}
|
||||
<filter id="glow-subtle" x="-60%" y="-60%" width="220%" height="220%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="3" result="blur" />
|
||||
<feMerge>
|
||||
<feMergeNode in="blur" />
|
||||
<feMergeNode in="SourceGraphic" />
|
||||
</feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
{edges.map((edge, i) => {
|
||||
const from = nodePos[edge.from];
|
||||
const to = nodePos[edge.to];
|
||||
if (!from || !to) return null;
|
||||
|
||||
const isCoreEdge = !edge.dashed;
|
||||
// Speed: 0.7 – 1.3 s per edge, staggered so packets never sync
|
||||
const duration = 0.7 + (i * 0.18) % 0.6;
|
||||
const phase = (t: number) => -(i * t % Math.round(duration * 1000));
|
||||
|
||||
if (isCoreEdge) {
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Solid base track */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="rgba(255,255,255,0.12)"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
{/* Violet ambient trace */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
opacity="0.45"
|
||||
filter="url(#glow-subtle)"
|
||||
/>
|
||||
{/* Packet 1 – bright white, leading */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="14 220"
|
||||
opacity="1"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130)}ms` }}
|
||||
/>
|
||||
{/* Packet 2 – violet, ~1/3 behind */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="10 220"
|
||||
opacity="0.85"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130) - Math.round(duration * 333)}ms` }}
|
||||
/>
|
||||
{/* Packet 3 – cyan, ~2/3 behind */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#00daf3"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="7 220"
|
||||
opacity="0.7"
|
||||
filter="url(#glow-strong)"
|
||||
style={{ animation: `data-packet ${duration}s linear infinite`, animationDelay: `${phase(130) - Math.round(duration * 666)}ms` }}
|
||||
/>
|
||||
{/* Packet 4 – white micro dot, fills the gaps */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 220"
|
||||
opacity="0.5"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet ${duration * 0.8}s linear infinite`, animationDelay: `${phase(80) - Math.round(duration * 500)}ms` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Secondary / dashed edge (group→signal, cross-group) ──────────
|
||||
const dimDuration = 1.2 + (i % 4) * 0.2;
|
||||
const dimPhase = -(i * 60 % Math.round(dimDuration * 1000));
|
||||
return (
|
||||
<g key={i}>
|
||||
{/* Static dashed connector */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="rgba(167,139,250,0.35)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 8"
|
||||
opacity={edge.opacity ?? 0.6}
|
||||
/>
|
||||
{/* Traveling dot 1 */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#A78BFA"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="6 156"
|
||||
opacity="0.9"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet-dim ${dimDuration}s linear infinite`, animationDelay: `${dimPhase}ms` }}
|
||||
/>
|
||||
{/* Traveling dot 2 – offset by half cycle */}
|
||||
<line
|
||||
x1={`${from.x}%`} y1={`${from.y}%`}
|
||||
x2={`${to.x}%`} y2={`${to.y}%`}
|
||||
stroke="#ffffff"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray="4 156"
|
||||
opacity="0.6"
|
||||
filter="url(#glow-subtle)"
|
||||
style={{ animation: `data-packet-dim ${dimDuration}s linear infinite`, animationDelay: `${dimPhase - Math.round(dimDuration * 500)}ms` }}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Nodes */}
|
||||
{!loading && nodes.map((node) => {
|
||||
if (node.size === "core") return <CoreNode key={node.id} node={node} />;
|
||||
if (node.size === "group") return <GroupNode key={node.id} node={node} />;
|
||||
return <SignalNode key={node.id} node={node} />;
|
||||
})}
|
||||
|
||||
{/* Signal Count Watermark */}
|
||||
{!loading && totalSignals > 0 && (
|
||||
<div
|
||||
className="absolute bottom-6 left-1/2 -translate-x-1/2 pointer-events-none z-0"
|
||||
style={{ opacity: 0.07 }}
|
||||
>
|
||||
<p className="font-mono-data text-violet-300 text-[64px] font-bold tracking-tight select-none">
|
||||
{totalSignals.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading Overlay (outside pan transform) */}
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-20">
|
||||
<div className="text-center">
|
||||
<span className="material-symbols-outlined text-violet-400 text-4xl animate-spin block mb-2">
|
||||
autorenew
|
||||
</span>
|
||||
<p className="text-[10px] font-mono-data text-zinc-600 uppercase tracking-widest">
|
||||
Building knowledge graph...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Drag hint (shown once when loaded) */}
|
||||
{!loading && pan.x === 0 && pan.y === 0 && (
|
||||
<div className="absolute bottom-[140px] left-1/2 -translate-x-1/2 pointer-events-none z-10 opacity-30">
|
||||
<p className="text-[8px] font-mono-data text-zinc-500 uppercase tracking-widest flex items-center gap-1">
|
||||
<span className="material-symbols-outlined text-[11px]">drag_pan</span>
|
||||
drag to pan graph
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user