mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 09:33:46 +00:00
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:
10
package-lock.json
generated
10
package-lock.json
generated
@@ -23,6 +23,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tanstack/react-query": "^5.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
@@ -2643,6 +2644,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-visually-hidden": "^1.2.4",
|
||||
"@sigma/node-border": "^3.0.0",
|
||||
"@tanstack/react-query": "^5.8.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@telegram-apps/sdk-react": "^3.3.9",
|
||||
|
||||
@@ -1251,6 +1251,8 @@
|
||||
},
|
||||
"legend": {
|
||||
"title": "Legend",
|
||||
"fillTitle": "Subscription",
|
||||
"borderTitle": "Referral role",
|
||||
"regularUser": "Regular user",
|
||||
"activeReferrer": "Active referrer",
|
||||
"partner": "Partner",
|
||||
|
||||
@@ -1085,6 +1085,8 @@
|
||||
},
|
||||
"legend": {
|
||||
"title": "راهنما",
|
||||
"fillTitle": "اشتراک",
|
||||
"borderTitle": "نقش ارجاعدهنده",
|
||||
"regularUser": "کاربر عادی",
|
||||
"activeReferrer": "ارجاعدهنده فعال",
|
||||
"partner": "شریک",
|
||||
|
||||
@@ -1272,6 +1272,8 @@
|
||||
},
|
||||
"legend": {
|
||||
"title": "Легенда",
|
||||
"fillTitle": "Подписка",
|
||||
"borderTitle": "Роль реферера",
|
||||
"regularUser": "Обычный",
|
||||
"activeReferrer": "Активный реферер",
|
||||
"partner": "Партнёр",
|
||||
|
||||
@@ -1085,6 +1085,8 @@
|
||||
},
|
||||
"legend": {
|
||||
"title": "图例",
|
||||
"fillTitle": "订阅状态",
|
||||
"borderTitle": "推荐角色",
|
||||
"regularUser": "普通用户",
|
||||
"activeReferrer": "活跃推荐人",
|
||||
"partner": "合作伙伴",
|
||||
|
||||
@@ -5,7 +5,8 @@ import FA2LayoutSupervisor from 'graphology-layout-forceatlas2/worker';
|
||||
import { inferSettings } from 'graphology-layout-forceatlas2';
|
||||
import { useReferralNetworkStore } from '@/store/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';
|
||||
|
||||
interface NetworkGraphProps {
|
||||
@@ -13,44 +14,169 @@ interface NetworkGraphProps {
|
||||
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 {
|
||||
const graph = new Graph();
|
||||
const positions = computeRadialPositions(graphData);
|
||||
|
||||
graphData.campaigns.forEach((campaign, index) => {
|
||||
graph.addNode(`campaign_${campaign.id}`, {
|
||||
label: campaign.name,
|
||||
const pos = positions.get(`campaign_${campaign.id}`) ?? {
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
};
|
||||
graph.addNode(`campaign_${campaign.id}`, {
|
||||
label: campaign.name,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size: 16,
|
||||
color: getCampaignColor(index),
|
||||
type: 'circle',
|
||||
type: 'bordered',
|
||||
nodeType: 'campaign',
|
||||
nodeId: campaign.id,
|
||||
borderSize: 0,
|
||||
borderColor: getCampaignColor(index),
|
||||
});
|
||||
});
|
||||
|
||||
graphData.users.forEach((user) => {
|
||||
const color = getUserNodeColor(
|
||||
user.direct_referrals,
|
||||
user.is_partner,
|
||||
user.campaign_id,
|
||||
user.subscription_status,
|
||||
);
|
||||
const fillColor = getNodeFillColor(user.subscription_status, user.campaign_id);
|
||||
const borderColor = getNodeBorderColor(user.direct_referrals, user.is_partner);
|
||||
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}`, {
|
||||
label: user.display_name,
|
||||
x: Math.random() * 100,
|
||||
y: Math.random() * 100,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
size,
|
||||
color,
|
||||
type: 'circle',
|
||||
color: fillColor,
|
||||
type: 'bordered',
|
||||
nodeType: 'user',
|
||||
nodeId: user.id,
|
||||
isPartner: user.is_partner,
|
||||
directReferrals: user.direct_referrals,
|
||||
campaignId: user.campaign_id,
|
||||
subscriptionStatus: user.subscription_status,
|
||||
borderColor: borderColor ?? fillColor,
|
||||
borderSize: borderColor ? 0.25 : 0,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -200,7 +326,9 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
||||
zIndex: true,
|
||||
defaultEdgeColor: '#ffffff06',
|
||||
defaultNodeColor: '#6b7280',
|
||||
labelColor: { color: '#e8e6f0' },
|
||||
nodeProgramClasses: { bordered: BorderedNodeProgram },
|
||||
defaultNodeType: 'bordered',
|
||||
labelColor: { color: '#111827' },
|
||||
labelFont: 'Inter, system-ui, sans-serif',
|
||||
labelSize: 11,
|
||||
stagePadding: 40,
|
||||
@@ -284,8 +412,10 @@ export function NetworkGraph({ data, className }: NetworkGraphProps) {
|
||||
linLogMode: true,
|
||||
adjustSizes: true,
|
||||
barnesHutOptimize: graph.order > 100,
|
||||
gravity: 1,
|
||||
slowDown: 2,
|
||||
gravity: 0.05,
|
||||
scalingRatio: 10,
|
||||
slowDown: 5,
|
||||
strongGravityMode: false,
|
||||
},
|
||||
});
|
||||
fa2Ref.current = supervisor;
|
||||
|
||||
@@ -13,16 +13,19 @@ const CAMPAIGN_GRADIENT_COLORS = [
|
||||
'#b97aff',
|
||||
];
|
||||
|
||||
const USER_LEGEND_ITEMS = [
|
||||
{ colorKey: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' },
|
||||
{ colorKey: NODE_COLORS.activeReferrer, labelKey: 'admin.referralNetwork.legend.activeReferrer' },
|
||||
{ colorKey: NODE_COLORS.partner, labelKey: 'admin.referralNetwork.legend.partner' },
|
||||
{ colorKey: NODE_COLORS.topReferrer, labelKey: 'admin.referralNetwork.legend.topReferrer' },
|
||||
{ colorKey: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' },
|
||||
{ colorKey: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' },
|
||||
{ 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 FILL_ITEMS = [
|
||||
{ color: NODE_COLORS.paidActive, labelKey: 'admin.referralNetwork.legend.paidActive' },
|
||||
{ color: NODE_COLORS.trialActive, labelKey: 'admin.referralNetwork.legend.trialActive' },
|
||||
{ color: NODE_COLORS.paidExpired, labelKey: 'admin.referralNetwork.legend.paidExpired' },
|
||||
{ color: NODE_COLORS.trialExpired, labelKey: 'admin.referralNetwork.legend.trialExpired' },
|
||||
{ color: NODE_COLORS.campaignUser, labelKey: 'admin.referralNetwork.legend.campaignUser' },
|
||||
{ color: NODE_COLORS.regular, labelKey: 'admin.referralNetwork.legend.regularUser' },
|
||||
];
|
||||
|
||||
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) {
|
||||
@@ -36,27 +39,46 @@ export function NetworkLegend({ className }: NetworkLegendProps) {
|
||||
<div
|
||||
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">
|
||||
{t('admin.referralNetwork.legend.title')}
|
||||
{/* Fill = Subscription status */}
|
||||
<h4 className="mb-1.5 text-[10px] font-medium uppercase tracking-wider text-dark-500">
|
||||
{t('admin.referralNetwork.legend.fillTitle')}
|
||||
</h4>
|
||||
<div className="space-y-1.5">
|
||||
{USER_LEGEND_ITEMS.map((item) => (
|
||||
<div className="space-y-1">
|
||||
{FILL_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"
|
||||
style={{ backgroundColor: item.colorKey }}
|
||||
style={{ backgroundColor: item.color }}
|
||||
/>
|
||||
<span className="text-xs text-dark-300">{t(item.labelKey)}</span>
|
||||
</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="h-0.5 w-4 shrink-0 rounded-full" style={{ backgroundColor: '#ff8a65' }} />
|
||||
<span className="text-xs text-dark-300">
|
||||
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
|
||||
</span>
|
||||
</div>
|
||||
{/* Campaign node with gradient to represent varying colors */}
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-2.5 w-2.5 shrink-0 rounded-full" style={gradientStyle} />
|
||||
<span className="text-xs text-dark-300">
|
||||
|
||||
@@ -36,6 +36,28 @@ export function getSubscriptionStatusColor(status: SubscriptionStatus): string {
|
||||
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
|
||||
* based on its index position.
|
||||
@@ -57,24 +79,6 @@ export function getCampaignColor(index: number): string {
|
||||
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.
|
||||
* Size is proportional to direct_referrals, clamped between min and max.
|
||||
|
||||
Reference in New Issue
Block a user