mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-30 10:27:49 +00:00
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:
@@ -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'
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user