Files
2026-04-05 00:43:23 +05:30

611 lines
23 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}