feat: add fill+border dual-color nodes, radial layout, and dark labels

Node fill color now encodes subscription status (paid/trial active/expired),
while border color indicates referral role (partner/top/active referrer).
Added BFS-based radial initial positioning to spread referral branches outward.
Updated legend with two sections and adjusted FA2 gravity for less clustering.
This commit is contained in:
Fringg
2026-03-22 09:25:23 +03:00
parent b289ea9c23
commit b18f3fb211
9 changed files with 227 additions and 52 deletions

10
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.8.0", "@tanstack/react-query": "^5.8.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@telegram-apps/sdk-react": "^3.3.9", "@telegram-apps/sdk-react": "^3.3.9",
@@ -2643,6 +2644,15 @@
"win32" "win32"
] ]
}, },
"node_modules/@sigma/node-border": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@sigma/node-border/-/node-border-3.0.0.tgz",
"integrity": "sha512-mE3zUfjvJVuAMhSjiP/zdlkqe0OVTETxd04XHUwof01YqdzTk0OB4ACJIhWrwgsBXl7tTd9lPuKoroafLh8MtQ==",
"license": "MIT",
"peerDependencies": {
"sigma": ">=3.0.0-beta.17"
}
},
"node_modules/@standard-schema/spec": { "node_modules/@standard-schema/spec": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",

View File

@@ -31,6 +31,7 @@
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/react-visually-hidden": "^1.2.4", "@radix-ui/react-visually-hidden": "^1.2.4",
"@sigma/node-border": "^3.0.0",
"@tanstack/react-query": "^5.8.0", "@tanstack/react-query": "^5.8.0",
"@tanstack/react-table": "^8.21.3", "@tanstack/react-table": "^8.21.3",
"@telegram-apps/sdk-react": "^3.3.9", "@telegram-apps/sdk-react": "^3.3.9",

View File

@@ -1251,6 +1251,8 @@
}, },
"legend": { "legend": {
"title": "Legend", "title": "Legend",
"fillTitle": "Subscription",
"borderTitle": "Referral role",
"regularUser": "Regular user", "regularUser": "Regular user",
"activeReferrer": "Active referrer", "activeReferrer": "Active referrer",
"partner": "Partner", "partner": "Partner",

View File

@@ -1085,6 +1085,8 @@
}, },
"legend": { "legend": {
"title": "راهنما", "title": "راهنما",
"fillTitle": "اشتراک",
"borderTitle": "نقش ارجاع‌دهنده",
"regularUser": "کاربر عادی", "regularUser": "کاربر عادی",
"activeReferrer": "ارجاع‌دهنده فعال", "activeReferrer": "ارجاع‌دهنده فعال",
"partner": "شریک", "partner": "شریک",

View File

@@ -1272,6 +1272,8 @@
}, },
"legend": { "legend": {
"title": "Легенда", "title": "Легенда",
"fillTitle": "Подписка",
"borderTitle": "Роль реферера",
"regularUser": "Обычный", "regularUser": "Обычный",
"activeReferrer": "Активный реферер", "activeReferrer": "Активный реферер",
"partner": "Партнёр", "partner": "Партнёр",

View File

@@ -1085,6 +1085,8 @@
}, },
"legend": { "legend": {
"title": "图例", "title": "图例",
"fillTitle": "订阅状态",
"borderTitle": "推荐角色",
"regularUser": "普通用户", "regularUser": "普通用户",
"activeReferrer": "活跃推荐人", "activeReferrer": "活跃推荐人",
"partner": "合作伙伴", "partner": "合作伙伴",

View File

@@ -5,7 +5,8 @@ import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
import { inferSettings } from 'graphology-layout-forceatlas2'; 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 { createNodeBorderProgram } from '@sigma/node-border';
import { getNodeFillColor, getNodeBorderColor, getUserNodeSize, getCampaignColor } from '../utils';
import { setSigmaInstance } from '../sigmaGlobals'; import { setSigmaInstance } from '../sigmaGlobals';
interface NetworkGraphProps { interface NetworkGraphProps {
@@ -13,44 +14,169 @@ interface NetworkGraphProps {
className?: string; className?: string;
} }
/**
* Compute initial positions using radial layout based on referral depth.
* Campaign nodes at center, direct users in first ring, their referrals further out.
*/
function computeRadialPositions(
graphData: NetworkGraphData,
): Map<string, { x: number; y: number }> {
const positions = new Map<string, { x: number; y: number }>();
// Build adjacency: referrer_id -> [user_ids]
const childrenOf = new Map<number, number[]>();
const userMap = new Map<number, (typeof graphData.users)[0]>();
for (const user of graphData.users) {
userMap.set(user.id, user);
if (user.referrer_id !== null) {
const children = childrenOf.get(user.referrer_id) ?? [];
children.push(user.id);
childrenOf.set(user.referrer_id, children);
}
}
// Place campaign nodes at center
const campaignCount = graphData.campaigns.length;
graphData.campaigns.forEach((campaign, i) => {
const angle = (2 * Math.PI * i) / Math.max(campaignCount, 1);
positions.set(`campaign_${campaign.id}`, {
x: Math.cos(angle) * 5,
y: Math.sin(angle) * 5,
});
});
// BFS from campaign users outward
const placed = new Set<number>();
const queue: Array<{ userId: number; depth: number; parentAngle: number }> = [];
// Seed: campaign users at depth 1
const campaignUserIds = new Set(
graphData.users.filter((u) => u.campaign_id !== null).map((u) => u.id),
);
const campaignUsers = [...campaignUserIds];
campaignUsers.forEach((userId, i) => {
const angle = (2 * Math.PI * i) / Math.max(campaignUsers.length, 1);
const radius = 30 + Math.random() * 10;
positions.set(`user_${userId}`, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
placed.add(userId);
queue.push({ userId, depth: 1, parentAngle: angle });
});
// Also seed root referrers (users who refer others but have no referrer and no campaign)
for (const user of graphData.users) {
if (
!placed.has(user.id) &&
user.referrer_id === null &&
(childrenOf.get(user.id)?.length ?? 0) > 0
) {
const angle = Math.random() * 2 * Math.PI;
const radius = 20 + Math.random() * 10;
positions.set(`user_${user.id}`, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
placed.add(user.id);
queue.push({ userId: user.id, depth: 1, parentAngle: angle });
}
}
// BFS: place children at increasing radius
while (queue.length > 0) {
const { userId, depth, parentAngle } = queue.shift()!;
const children = childrenOf.get(userId) ?? [];
const childCount = children.length;
if (childCount === 0) continue;
const spreadAngle = Math.min(Math.PI / 2, (Math.PI / 3) * (1 / Math.max(depth, 1)));
children.forEach((childId, i) => {
if (placed.has(childId)) return;
const offset = childCount === 1 ? 0 : (i / (childCount - 1) - 0.5) * spreadAngle;
const angle = parentAngle + offset + (Math.random() - 0.5) * 0.1;
const radius = 30 + depth * 25 + Math.random() * 10;
positions.set(`user_${childId}`, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
placed.add(childId);
queue.push({ userId: childId, depth: depth + 1, parentAngle: angle });
});
}
// Place remaining unplaced users at the outer edge
for (const user of graphData.users) {
if (!placed.has(user.id)) {
const angle = Math.random() * 2 * Math.PI;
const radius = 60 + Math.random() * 40;
positions.set(`user_${user.id}`, {
x: Math.cos(angle) * radius,
y: Math.sin(angle) * radius,
});
}
}
return positions;
}
const BorderedNodeProgram = createNodeBorderProgram({
borders: [
{
size: { attribute: 'borderSize', defaultValue: 0 },
color: { attribute: 'borderColor' },
},
],
});
function buildFullGraph(graphData: NetworkGraphData): Graph { function buildFullGraph(graphData: NetworkGraphData): Graph {
const graph = new Graph(); const graph = new Graph();
const positions = computeRadialPositions(graphData);
graphData.campaigns.forEach((campaign, index) => { graphData.campaigns.forEach((campaign, index) => {
graph.addNode(`campaign_${campaign.id}`, { const pos = positions.get(`campaign_${campaign.id}`) ?? {
label: campaign.name,
x: Math.random() * 100, x: Math.random() * 100,
y: Math.random() * 100, y: Math.random() * 100,
};
graph.addNode(`campaign_${campaign.id}`, {
label: campaign.name,
x: pos.x,
y: pos.y,
size: 16, size: 16,
color: getCampaignColor(index), color: getCampaignColor(index),
type: 'circle', type: 'bordered',
nodeType: 'campaign', nodeType: 'campaign',
nodeId: campaign.id, nodeId: campaign.id,
borderSize: 0,
borderColor: getCampaignColor(index),
}); });
}); });
graphData.users.forEach((user) => { graphData.users.forEach((user) => {
const color = getUserNodeColor( const fillColor = getNodeFillColor(user.subscription_status, user.campaign_id);
user.direct_referrals, const borderColor = getNodeBorderColor(user.direct_referrals, user.is_partner);
user.is_partner,
user.campaign_id,
user.subscription_status,
);
const size = getUserNodeSize(user.direct_referrals); const size = getUserNodeSize(user.direct_referrals);
const pos = positions.get(`user_${user.id}`) ?? {
x: Math.random() * 100,
y: Math.random() * 100,
};
graph.addNode(`user_${user.id}`, { graph.addNode(`user_${user.id}`, {
label: user.display_name, label: user.display_name,
x: Math.random() * 100, x: pos.x,
y: Math.random() * 100, y: pos.y,
size, size,
color, color: fillColor,
type: 'circle', type: 'bordered',
nodeType: 'user', nodeType: 'user',
nodeId: user.id, nodeId: user.id,
isPartner: user.is_partner, isPartner: user.is_partner,
directReferrals: user.direct_referrals, directReferrals: user.direct_referrals,
campaignId: user.campaign_id, campaignId: user.campaign_id,
subscriptionStatus: user.subscription_status, subscriptionStatus: user.subscription_status,
borderColor: borderColor ?? fillColor,
borderSize: borderColor ? 0.25 : 0,
}); });
}); });
@@ -200,7 +326,9 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
zIndex: true, zIndex: true,
defaultEdgeColor: '#ffffff06', defaultEdgeColor: '#ffffff06',
defaultNodeColor: '#6b7280', defaultNodeColor: '#6b7280',
labelColor: { color: '#e8e6f0' }, nodeProgramClasses: { bordered: BorderedNodeProgram },
defaultNodeType: 'bordered',
labelColor: { color: '#111827' },
labelFont: 'Inter, system-ui, sans-serif', labelFont: 'Inter, system-ui, sans-serif',
labelSize: 11, labelSize: 11,
stagePadding: 40, stagePadding: 40,
@@ -284,8 +412,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
linLogMode: true, linLogMode: true,
adjustSizes: true, adjustSizes: true,
barnesHutOptimize: graph.order > 100, barnesHutOptimize: graph.order > 100,
gravity: 1, gravity: 0.05,
slowDown: 2, scalingRatio: 10,
slowDown: 5,
strongGravityMode: false,
}, },
}); });
fa2Ref.current = supervisor; fa2Ref.current = supervisor;

View File

@@ -13,16 +13,19 @@ const CAMPAIGN_GRADIENT_COLORS = [
'#b97aff', '#b97aff',
]; ];
const USER_LEGEND_ITEMS = [ const FILL_ITEMS = [
{ colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' }, { color: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' },
{ colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' }, { color: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' },
{ colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' }, { color: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' },
{ colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' }, { color: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' },
{ colorKey: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' }, { color: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' },
{ colorKey: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' }, { color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' },
{ colorKey: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' }, ];
{ colorKey: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' },
{ colorKey: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' }, const BORDER_ITEMS = [
{ color: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' },
{ color: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' },
{ color: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' },
]; ];
export function NetworkLegend({ className }: NetworkLegendProps) { export function NetworkLegend({ className }: NetworkLegendProps) {
@@ -36,27 +39,46 @@ export function NetworkLegend({ className }: NetworkLegendProps) {
<div <div
className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`} className={`rounded-xl border border-dark-700/50 bg-dark-900/80 p-3 backdrop-blur-md ${className ?? ''}`}
> >
<h4 className="mb-2 text-[10px] font-medium uppercase tracking-wider text-dark-500"> {/* Fill = Subscription status */}
{t('admin.referralNetwork.legend.title')} <h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.legend.fillTitle')}
</h4> </h4>
<div className="space-y-1.5"> <div className="space-y-1">
{USER_LEGEND_ITEMS.map((item) => ( {FILL_ITEMS.map((item) => (
<div key={item.labelKey} className="flex items-center gap-2"> <div key={item.labelKey} className="flex items-center gap-2">
<div <div
className="h-2.5 w-2.5 shrink-0 rounded-full" className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: item.colorKey }} style={{ backgroundColor: item.color }}
/> />
<span className="text-xs text-dark-300">{t(item.labelKey)}</span> <span className="text-xs text-dark-300">{t(item.labelKey)}</span>
</div> </div>
))} ))}
{/* Partner → Campaign edge */} </div>
{/* Border = Referral role */}
<h4 className="mb-1.5 mt-2.5 text-[10px] font-medium uppercase tracking-wider text-dark-500">
{t('admin.referralNetwork.legend.borderTitle')}
</h4>
<div className="space-y-1">
{BORDER_ITEMS.map((item) => (
<div key={item.labelKey} className="flex items-center gap-2">
<div
className="h-2.5 w-2.5 shrink-0 rounded-full border-2"
style={{ borderColor: item.color, backgroundColor: 'transparent' }}
/>
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
</div>
))}
</div>
{/* Edges */}
<div className="mt-2.5 space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} /> <div className="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} />
<span className="text-xs text-dark-300"> <span className="text-xs text-dark-300">
{t('admin.referralNetwork.legend.partnerCampaignEdge')} {t('admin.referralNetwork.legend.partnerCampaignEdge')}
</span> </span>
</div> </div>
{/* Campaign node with gradient to represent varying colors */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} /> <div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
<span className="text-xs text-dark-300"> <span className="text-xs text-dark-300">

View File

@@ -36,6 +36,28 @@ export function getSubscriptionStatusColor(status: SubscriptionStatus): string {
return SUBSCRIPTION_STATUS_COLOR[status]; return SUBSCRIPTION_STATUS_COLOR[status];
} }
/**
* Fill color = subscription status (primary concern for campaign evaluation).
*/
export function getNodeFillColor(
subscriptionStatus: SubscriptionStatus | null,
campaignId: number | null,
): string {
if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus];
if (campaignId !== null) return NODE_COLORS.campaignUser;
return NODE_COLORS.regular;
}
/**
* Border color = referral role. Returns null if no special role.
*/
export function getNodeBorderColor(directReferrals: number, isPartner: boolean): string | null {
if (isPartner) return NODE_COLORS.partner;
if (directReferrals >= 10) return NODE_COLORS.topReferrer;
if (directReferrals >= 1) return NODE_COLORS.activeReferrer;
return null;
}
/** /**
* Campaign node color palette. Each campaign gets a distinct color * Campaign node color palette. Each campaign gets a distinct color
* based on its index position. * based on its index position.
@@ -57,24 +79,6 @@ export function getCampaignColor(index: number): string {
return CAMPAIGN_COLORS[index % CAMPAIGN_COLORS.length]; return CAMPAIGN_COLORS[index % CAMPAIGN_COLORS.length];
} }
/**
* Determine the visual color for a user node.
* Priority: partner > top referrer > active referrer > subscription status > campaign > regular.
*/
export function getUserNodeColor(
directReferrals: number,
isPartner: boolean,
campaignId: number | null,
subscriptionStatus: SubscriptionStatus | null,
): string {
if (isPartner) return NODE_COLORS.partner;
if (directReferrals >= 10) return NODE_COLORS.topReferrer;
if (directReferrals >= 1) return NODE_COLORS.activeReferrer;
if (subscriptionStatus) return SUBSCRIPTION_STATUS_COLOR[subscriptionStatus];
if (campaignId !== null) return NODE_COLORS.campaignUser;
return NODE_COLORS.regular;
}
/** /**
* Determine the visual size for a user node. * Determine the visual size for a user node.
* Size is proportional to direct_referrals, clamped between min and max. * Size is proportional to direct_referrals, clamped between min and max.