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 ( return (
<div <div
id="referral-network-container" 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="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"> <div className="flex items-center gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
<AdminBackButton /> <AdminBackButton />
@@ -45,9 +44,7 @@ export function ReferralNetwork() {
</div> </div>
</div> </div>
{/* Main content area — grid row takes all remaining space */}
<div className="relative overflow-hidden"> <div className="relative overflow-hidden">
{/* Loading state */}
{isLoading && ( {isLoading && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="text-center"> <div className="text-center">
@@ -57,43 +54,36 @@ export function ReferralNetwork() {
</div> </div>
)} )}
{/* Error state */}
{isError && ( {isError && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p> <p className="text-sm text-error-400">{t('admin.referralNetwork.error')}</p>
</div> </div>
)} )}
{/* Empty state */}
{networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && ( {networkData && networkData.users.length === 0 && networkData.campaigns.length === 0 && (
<div className="absolute inset-0 z-10 flex items-center justify-center"> <div className="absolute inset-0 z-10 flex items-center justify-center">
<p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p> <p className="text-sm text-dark-500">{t('admin.referralNetwork.empty')}</p>
</div> </div>
)} )}
{/* Graph — fills entire content area */}
{networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && ( {networkData && (networkData.users.length > 0 || networkData.campaigns.length > 0) && (
<> <>
<NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" /> <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"> <div className="absolute bottom-3 left-3 z-10 sm:bottom-4 sm:left-4">
<NetworkStats data={networkData} /> <NetworkStats data={networkData} />
</div> </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"> <div className="absolute bottom-3 right-3 z-10 hidden sm:bottom-4 sm:right-4 sm:block">
<NetworkLegend /> <NetworkLegend />
</div> </div>
{/* Bottom-center: controls */}
<div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4"> <div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
<NetworkControls /> <NetworkControls />
</div> </div>
</> </>
)} )}
{/* Right side panel */}
<div <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] ${ 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' isPanelOpen ? 'translate-x-0' : 'translate-x-full'

View File

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

View File

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