fix: referral network graph rendering and layout

- fix top offset breakpoint: sm:top-14 → lg:top-14 to match AppShell
  header visibility (mobile header is lg:hidden with h-16)
- add gravity to FA2 layout to prevent disconnected nodes from flying
  to extreme positions, making auto-rescale shrink everything to dots
- clamp outlier node positions after FA2 finishes (3σ threshold)
- add ResizeObserver to sync sigma canvas with container size changes
- call sigma.resize() before camera reset after FA2
- increase node sizes: user min 8→12, max 40→50, campaign 18→24
- reduce FA2 duration 5s→3s and slowDown 5→2 for tighter layout
This commit is contained in:
c0mrade
2026-03-19 10:24:47 +03:00
parent 7c0b8e571a
commit b787726d1b
3 changed files with 63 additions and 42 deletions

View File

@@ -31,9 +31,8 @@ export function ReferralNetwork() {
return (
<div
id="referral-network-container"
className="fixed inset-x-0 bottom-0 top-16 z-40 grid grid-rows-[auto_1fr] bg-[#0a0a0f] sm:top-14"
className="fixed inset-x-0 bottom-0 top-16 z-40 grid grid-rows-[auto_1fr] bg-[#0a0a0f] lg:top-14"
>
{/* Top bar */}
<div className="z-20 border-b border-dark-700/50 bg-dark-900/90 backdrop-blur-md">
<div className="flex items-center gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
<AdminBackButton />
@@ -45,9 +44,7 @@ export function ReferralNetwork() {
</div>
</div>
{/* Main content area — grid row takes all remaining space */}
<div className="relative overflow-hidden">
{/* Loading state */}
{isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="text-center">
@@ -57,43 +54,36 @@ export function ReferralNetwork() {
</div>
)}
{/* Error state */}
{isError && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p>
</div>
)}
{/* Empty state */}
{networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
</div>
)}
{/* Graph — fills entire content area */}
{networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && (
<>
<NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" />
{/* Bottom-left: stats overlay */}
<div className="absolute bottom-3 left-3 z-10 sm:bottom-4 sm:left-4">
<NetworkStats data={networkData} />
</div>
{/* Bottom-right: legend (hidden on mobile to avoid overlap) */}
<div className="absolute bottom-3 right-3 z-10 hidden sm:bottom-4 sm:right-4 sm:block">
<NetworkLegend />
</div>
{/* Bottom-center: controls */}
<div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
<NetworkControls />
</div>
</>
)}
{/* Right side panel */}
<div
className={`absolute right-0 top-0 z-30 flex h-full w-full flex-col border-l border-dark-700/50 bg-dark-900/95 backdrop-blur-md transition-transform duration-300 ease-in-out sm:w-[400px] ${
isPanelOpen ? 'translate-x-0' : 'translate-x-full'

View File

@@ -13,10 +13,6 @@ interface NetworkGraphProps {
className?: string;
}
/**
* Build the full graph from data (no filter logic).
* Stores filter-relevant attributes on nodes so reducers can check them.
*/
function buildFullGraph(graphData: NetworkGraphData): Graph {
const graph = new Graph();
@@ -25,7 +21,7 @@ function buildFullGraph(graphData: NetworkGraphData): Graph {
label: campaign.name,
x: Math.random() * 100,
y: Math.random() * 100,
size: 18,
size: 24,
color: getCampaignColor(index),
type: 'circle',
nodeType: 'campaign',
@@ -68,9 +64,6 @@ function buildFullGraph(graphData: NetworkGraphData): Graph {
return graph;
}
/**
* Compute set of node keys that should be hidden based on current filters.
*/
function computeHiddenNodes(graph: Graph, filters: NetworkFilters): Set<string> {
const hidden = new Set<string>();
const filterCampaignSet = new Set(filters.campaigns);
@@ -99,7 +92,43 @@ function computeHiddenNodes(graph: Graph, filters: NetworkFilters): Set<string>
return hidden;
}
const FA2_DURATION_MS = 5000;
const FA2_DURATION_MS = 3000;
function clampOutlierPositions(graph: Graph): void {
const xs: number[] = [];
const ys: number[] = [];
graph.forEachNode((_, attrs) => {
xs.push(attrs.x as number);
ys.push(attrs.y as number);
});
if (xs.length < 2) return;
const meanX = xs.reduce((a, b) => a + b, 0) / xs.length;
const meanY = ys.reduce((a, b) => a + b, 0) / ys.length;
const stdX = Math.sqrt(xs.reduce((sum, x) => sum + (x - meanX) ** 2, 0) / xs.length) || 1;
const stdY = Math.sqrt(ys.reduce((sum, y) => sum + (y - meanY) ** 2, 0) / ys.length) || 1;
const MAX_STD = 3;
const minX = meanX - MAX_STD * stdX;
const maxX = meanX + MAX_STD * stdX;
const minY = meanY - MAX_STD * stdY;
const maxY = meanY + MAX_STD * stdY;
graph.forEachNode((node, attrs) => {
const x = attrs.x as number;
const y = attrs.y as number;
const clampedX = Math.max(minX, Math.min(maxX, x));
const clampedY = Math.max(minY, Math.min(maxY, y));
if (clampedX !== x || clampedY !== y) {
graph.setNodeAttribute(node, 'x', clampedX);
graph.setNodeAttribute(node, 'y', clampedY);
}
});
}
export function NetworkGraph({ data, className }: NetworkGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
@@ -114,6 +143,7 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
const hoveredNodeId = useReferralNetworkStore((s) => s.hoveredNodeId);
const highlightedNodes = useReferralNetworkStore((s) => s.highlightedNodes);
const filters = useReferralNetworkStore((s) => s.filters);
const killFA2 = useCallback(() => {
if (fa2TimerRef.current !== null) {
clearTimeout(fa2TimerRef.current);
@@ -125,7 +155,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
}
}, []);
// Initialize sigma — only re-runs when data changes (NOT on filter changes)
useEffect(() => {
if (!containerRef.current || !data) return;
@@ -133,7 +162,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
let rafId: number;
let cancelled = false;
// Poll until container has real dimensions (browser needs a frame to compute layout)
function tryInit() {
if (cancelled || !container.isConnected) return;
if (container.offsetWidth === 0 || container.offsetHeight === 0) {
@@ -141,7 +169,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
return;
}
// Cleanup previous instance
killFA2();
if (sigmaRef.current) {
sigmaRef.current.kill();
@@ -151,7 +178,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
const graph = buildFullGraph(data);
graphRef.current = graph;
// Compute initial hidden set from current filters
const initialFilters = useReferralNetworkStore.getState().filters;
hiddenNodesRef.current = computeHiddenNodes(graph, initialFilters);
@@ -169,7 +195,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
nodeReducer: (node, attrs) => {
const res = { ...attrs };
// Filter visibility (read from pre-computed set)
if (hiddenNodesRef.current.has(node)) {
res.hidden = true;
return res;
@@ -179,7 +204,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
const hovered = store.hoveredNodeId;
const highlighted = store.highlightedNodes;
// Search highlighting
if (highlighted.size > 0) {
if (highlighted.has(node)) {
res.highlighted = true;
@@ -191,7 +215,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
}
}
// Hover highlighting
if (hovered) {
if (node === hovered) {
res.highlighted = true;
@@ -211,7 +234,6 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
edgeReducer: (edge, attrs) => {
const res = { ...attrs };
// Hide edges connected to filtered-out nodes
const [source, target] = graph.extremities(edge);
if (hiddenNodesRef.current.has(source) || hiddenNodesRef.current.has(target)) {
res.hidden = true;
@@ -240,12 +262,9 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
});
sigmaRef.current = sigma;
// Expose to module-level globals for search/controls
setSigmaInstance(sigma);
setGraphInstance(graph);
// Start ForceAtlas2 in a web worker (non-blocking)
if (graph.order > 0) {
const inferred = inferSettings(graph);
const supervisor = new FA2LayoutSupervisor(graph, {
@@ -254,13 +273,13 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
linLogMode: true,
adjustSizes: true,
barnesHutOptimize: graph.order > 100,
slowDown: 5,
gravity: 1,
slowDown: 2,
},
});
fa2Ref.current = supervisor;
supervisor.start();
// Kill after a fixed duration, then fit camera to all nodes
fa2TimerRef.current = setTimeout(() => {
if (fa2Ref.current === supervisor) {
supervisor.kill();
@@ -268,14 +287,17 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
}
fa2TimerRef.current = null;
// Fit camera to show all nodes after layout settles
if (graphRef.current) {
clampOutlierPositions(graphRef.current);
}
if (sigmaRef.current) {
sigmaRef.current.resize();
sigmaRef.current.getCamera().animatedReset({ duration: 400 });
}
}, FA2_DURATION_MS);
}
// Click handler
sigma.on('clickNode', ({ node }) => {
const attrs = graph.getNodeAttributes(node);
const nodeType = attrs.nodeType;
@@ -290,12 +312,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
}
});
// Click on stage deselects
sigma.on('clickStage', () => {
setSelectedNode(null);
});
// Hover handler
sigma.on('enterNode', ({ node }) => {
setHoveredNode(node);
if (containerRef.current) {
@@ -327,15 +347,26 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
};
}, [data, setSelectedNode, setHoveredNode, killFA2]);
// Recompute hidden nodes when filters change, then refresh sigma
// (no graph rebuild — positions and Sigma instance preserved)
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const observer = new ResizeObserver(() => {
if (sigmaRef.current) {
sigmaRef.current.resize();
}
});
observer.observe(container);
return () => observer.disconnect();
}, []);
useEffect(() => {
if (!graphRef.current || !sigmaRef.current) return;
hiddenNodesRef.current = computeHiddenNodes(graphRef.current, filters);
sigmaRef.current.refresh();
}, [filters]);
// Refresh sigma on hover/highlight changes
useEffect(() => {
if (sigmaRef.current) {
sigmaRef.current.refresh();

View File

@@ -46,8 +46,8 @@ export function getUserNodeColor(
* Size is proportional to direct_referrals, clamped between min and max.
*/
export function getUserNodeSize(directReferrals: number): number {
const MIN_SIZE = 8;
const MAX_SIZE = 40;
const MIN_SIZE = 12;
const MAX_SIZE = 50;
if (directReferrals === 0) return MIN_SIZE;
return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 5);
}