fix: graph layout, node visibility and FA2 settings

- Switch to CSS Grid layout for reliable content area height
- Use inferSettings() for ForceAtlas2 auto-tuning based on graph structure
- Enable linLogMode and adjustSizes for better node clustering
- Fit camera after layout settles with animatedReset
- Increase node sizes for better visibility (MIN 8, MAX 40)
- Center controls at bottom, fix overlay positions
This commit is contained in:
Fringg
2026-03-19 09:18:11 +03:00
parent fd9a47ecda
commit 7c0b8e571a
3 changed files with 29 additions and 21 deletions

View File

@@ -31,10 +31,10 @@ 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 flex flex-col bg-[#0a0a0f] sm:top-14" className="fixed inset-x-0 bottom-0 top-16 z-40 grid grid-rows-[auto_1fr] bg-[#0a0a0f] sm:top-14"
> >
{/* Top bar */} {/* Top bar */}
<div className="relative z-20 shrink-0 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 />
<h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base"> <h1 className="shrink-0 text-sm font-bold text-dark-100 sm:text-base">
@@ -45,11 +45,11 @@ export function ReferralNetwork() {
</div> </div>
</div> </div>
{/* Main content area */} {/* Main content area — grid row takes all remaining space */}
<div className="relative min-h-0 flex-1"> <div className="relative overflow-hidden">
{/* Loading state */} {/* Loading state */}
{isLoading && ( {isLoading && (
<div className="absolute inset-0 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">
<div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" /> <div className="mx-auto mb-3 h-8 w-8 animate-spin rounded-full border-2 border-dark-600 border-t-accent-400" />
<p className="text-sm text-dark-400">{t('admin.referralNetwork.loading')}</p> <p className="text-sm text-dark-400">{t('admin.referralNetwork.loading')}</p>
@@ -59,35 +59,35 @@ export function ReferralNetwork() {
{/* Error state */} {/* Error state */}
{isError && ( {isError && (
<div className="absolute inset-0 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 */} {/* 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 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 */} {/* 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" /> <NetworkGraph data={networkData} className="absolute inset-0 h-full w-full" />
{/* Bottom-left: stats overlay */} {/* Bottom-left: stats overlay */}
<div className="absolute bottom-4 left-4 z-10"> <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) */} {/* Bottom-right: legend (hidden on mobile to avoid overlap) */}
<div className="absolute bottom-4 right-4 z-10 hidden 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-right on mobile, bottom-center on desktop: controls */} {/* Bottom-center: controls */}
<div className="absolute bottom-4 right-4 z-10 sm:left-1/2 sm:right-auto sm:-translate-x-1/2"> <div className="absolute bottom-3 left-1/2 z-10 -translate-x-1/2 sm:bottom-4">
<NetworkControls /> <NetworkControls />
</div> </div>
</> </>

View File

@@ -2,6 +2,7 @@ import { useEffect, useRef, useCallback } from 'react';
import Graph from 'graphology'; import Graph from 'graphology';
import Sigma from 'sigma'; import Sigma from 'sigma';
import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker'; import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
import { inferSettings } from 'graphology-layout-forceatlas2';
import { useReferralNetworkStore } from '@/store/referralNetwork'; import { useReferralNetworkStore } from '@/store/referralNetwork';
import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork'; import type { NetworkGraphData, NetworkFilters } from '@/types/referralNetwork';
import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils'; import { getUserNodeColor, getUserNodeSize, getCampaignColor } from '../utils';
@@ -246,24 +247,31 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
// Start ForceAtlas2 in a web worker (non-blocking) // Start ForceAtlas2 in a web worker (non-blocking)
if (graph.order > 0) { if (graph.order > 0) {
const inferred = inferSettings(graph);
const supervisor = new FA2LayoutSupervisor(graph, { const supervisor = new FA2LayoutSupervisor(graph, {
settings: { settings: {
gravity: 0.5, ...inferred,
scalingRatio: 10, linLogMode: true,
barnesHutOptimize: true, adjustSizes: true,
slowDown: 2, barnesHutOptimize: graph.order > 100,
slowDown: 5,
}, },
}); });
fa2Ref.current = supervisor; fa2Ref.current = supervisor;
supervisor.start(); supervisor.start();
// Kill after a fixed duration to free the web worker thread // 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();
fa2Ref.current = null; fa2Ref.current = null;
} }
fa2TimerRef.current = null; fa2TimerRef.current = null;
// Fit camera to show all nodes after layout settles
if (sigmaRef.current) {
sigmaRef.current.getCamera().animatedReset({ duration: 400 });
}
}, FA2_DURATION_MS); }, FA2_DURATION_MS);
} }

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 = 4; const MIN_SIZE = 8;
const MAX_SIZE = 30; const MAX_SIZE = 40;
if (directReferrals === 0) return MIN_SIZE; if (directReferrals === 0) return MIN_SIZE;
return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 4); return Math.min(MAX_SIZE, MIN_SIZE + Math.sqrt(directReferrals) * 5);
} }