diff --git a/package-lock.json b/package-lock.json index 625c0d9..a8bccf5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 6e3d6d6..28ce37d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/locales/en.json b/src/locales/en.json index 1d68520..c19ecca 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1251,6 +1251,8 @@ }, "legend": { "title": "Legend", + "fillTitle": "Subscription", + "borderTitle": "Referral role", "regularUser": "Regular user", "activeReferrer": "Active referrer", "partner": "Partner", diff --git a/src/locales/fa.json b/src/locales/fa.json index 87854ae..02580f0 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -1085,6 +1085,8 @@ }, "legend": { "title": "راهنما", + "fillTitle": "اشتراک", + "borderTitle": "نقش ارجاع‌دهنده", "regularUser": "کاربر عادی", "activeReferrer": "ارجاع‌دهنده فعال", "partner": "شریک", diff --git a/src/locales/ru.json b/src/locales/ru.json index 46967d8..cda1bfc 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -1272,6 +1272,8 @@ }, "legend": { "title": "Легенда", + "fillTitle": "Подписка", + "borderTitle": "Роль реферера", "regularUser": "Обычный", "activeReferrer": "Активный реферер", "partner": "Партнёр", diff --git a/src/locales/zh.json b/src/locales/zh.json index 068e449..3d7fd84 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -1085,6 +1085,8 @@ }, "legend": { "title": "图例", + "fillTitle": "订阅状态", + "borderTitle": "推荐角色", "regularUser": "普通用户", "activeReferrer": "活跃推荐人", "partner": "合作伙伴", diff --git a/src/pages/ReferralNetwork/components/NetworkGraph.tsx b/src/pages/ReferralNetwork/components/NetworkGraph.tsx index 0adce54..9d2477c 100644 --- a/src/pages/ReferralNetwork/components/NetworkGraph.tsx +++ b/src/pages/ReferralNetwork/components/NetworkGraph.tsx @@ -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 { + const positions = new Map(); + + // Build adjacency: referrer_id -> [user_ids] + const childrenOf = new Map(); + const userMap = new Map(); + 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(); + 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; diff --git a/src/pages/ReferralNetwork/components/NetworkLegend.tsx b/src/pages/ReferralNetwork/components/NetworkLegend.tsx index af622f8..a9f0ec0 100644 --- a/src/pages/ReferralNetwork/components/NetworkLegend.tsx +++ b/src/pages/ReferralNetwork/components/NetworkLegend.tsx @@ -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) {
-

- {t('admin.referralNetwork.legend.title')} + {/* Fill = Subscription status */} +

+ {t('admin.referralNetwork.legend.fillTitle')}

-
- {USER_LEGEND_ITEMS.map((item) => ( +
+ {FILL_ITEMS.map((item) => (
{t(item.labelKey)}
))} - {/* Partner → Campaign edge */} +
+ + {/* Border = Referral role */} +

+ {t('admin.referralNetwork.legend.borderTitle')} +

+
+ {BORDER_ITEMS.map((item) => ( +
+
+ {t(item.labelKey)} +
+ ))} +
+ + {/* Edges */} +
{t('admin.referralNetwork.legend.partnerCampaignEdge')}
- {/* Campaign node with gradient to represent varying colors */}
diff --git a/src/pages/ReferralNetwork/utils.ts b/src/pages/ReferralNetwork/utils.ts index 5af8ddf..47f0630 100644 --- a/src/pages/ReferralNetwork/utils.ts +++ b/src/pages/ReferralNetwork/utils.ts @@ -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.