Demo MCP App for customer data visualization featuring: - Interactive bubble chart (Chart.js) with configurable X/Y axes - 250 customers across 4 segments (Enterprise, Mid-Market, SMB, Startup) - 6 metrics: Revenue, Employees, Account Age, Engagement, Tickets, NPS - Optional bubble sizing by a third metric - Clickable legend for segment filtering - Detail panel on hover/click - Light/dark theme support 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
231 lines
5.8 KiB
TypeScript
231 lines
5.8 KiB
TypeScript
import type { Customer, SegmentSummary, SegmentName } from "./types.ts";
|
|
import { SEGMENT_COLORS, SEGMENTS } from "./types.ts";
|
|
|
|
// Company name generation
|
|
const PREFIXES = [
|
|
"Apex",
|
|
"Nova",
|
|
"Prime",
|
|
"Vertex",
|
|
"Atlas",
|
|
"Quantum",
|
|
"Summit",
|
|
"Nexus",
|
|
"Titan",
|
|
"Pinnacle",
|
|
"Zenith",
|
|
"Vanguard",
|
|
"Horizon",
|
|
"Stellar",
|
|
"Onyx",
|
|
"Cobalt",
|
|
"Vector",
|
|
"Pulse",
|
|
"Forge",
|
|
"Spark",
|
|
];
|
|
|
|
const CORES = [
|
|
"Tech",
|
|
"Data",
|
|
"Cloud",
|
|
"Logic",
|
|
"Sync",
|
|
"Flow",
|
|
"Core",
|
|
"Net",
|
|
"Soft",
|
|
"Wave",
|
|
"Link",
|
|
"Mind",
|
|
"Byte",
|
|
"Grid",
|
|
"Hub",
|
|
];
|
|
|
|
const SUFFIXES = [
|
|
"Corp",
|
|
"Inc",
|
|
"Solutions",
|
|
"Systems",
|
|
"Labs",
|
|
"Group",
|
|
"Industries",
|
|
"Dynamics",
|
|
"Partners",
|
|
"Ventures",
|
|
"Global",
|
|
"Digital",
|
|
];
|
|
|
|
// Cluster centers for each segment
|
|
interface ClusterCenter {
|
|
annualRevenue: { min: number; max: number };
|
|
employeeCount: { min: number; max: number };
|
|
accountAge: { min: number; max: number };
|
|
engagementScore: { min: number; max: number };
|
|
supportTickets: { min: number; max: number };
|
|
nps: { min: number; max: number };
|
|
}
|
|
|
|
const CLUSTER_CENTERS: Record<SegmentName, ClusterCenter> = {
|
|
Enterprise: {
|
|
annualRevenue: { min: 2_000_000, max: 10_000_000 },
|
|
employeeCount: { min: 500, max: 5000 },
|
|
accountAge: { min: 60, max: 120 },
|
|
engagementScore: { min: 70, max: 95 },
|
|
supportTickets: { min: 5, max: 20 },
|
|
nps: { min: 40, max: 80 },
|
|
},
|
|
"Mid-Market": {
|
|
annualRevenue: { min: 500_000, max: 2_000_000 },
|
|
employeeCount: { min: 100, max: 500 },
|
|
accountAge: { min: 36, max: 84 },
|
|
engagementScore: { min: 60, max: 85 },
|
|
supportTickets: { min: 10, max: 30 },
|
|
nps: { min: 20, max: 60 },
|
|
},
|
|
SMB: {
|
|
annualRevenue: { min: 50_000, max: 500_000 },
|
|
employeeCount: { min: 10, max: 100 },
|
|
accountAge: { min: 12, max: 48 },
|
|
engagementScore: { min: 40, max: 70 },
|
|
supportTickets: { min: 15, max: 40 },
|
|
nps: { min: 0, max: 40 },
|
|
},
|
|
Startup: {
|
|
annualRevenue: { min: 10_000, max: 200_000 },
|
|
employeeCount: { min: 1, max: 50 },
|
|
accountAge: { min: 1, max: 24 },
|
|
engagementScore: { min: 50, max: 90 },
|
|
supportTickets: { min: 5, max: 25 },
|
|
nps: { min: 10, max: 70 },
|
|
},
|
|
};
|
|
|
|
// Segment distribution weights
|
|
const SEGMENT_WEIGHTS: Record<SegmentName, number> = {
|
|
Enterprise: 0.15,
|
|
"Mid-Market": 0.25,
|
|
SMB: 0.35,
|
|
Startup: 0.25,
|
|
};
|
|
|
|
// Box-Muller transform for Gaussian random numbers
|
|
function gaussianRandom(mean: number, stdDev: number): number {
|
|
const u1 = Math.random();
|
|
const u2 = Math.random();
|
|
const z0 = Math.sqrt(-2.0 * Math.log(u1)) * Math.cos(2.0 * Math.PI * u2);
|
|
return z0 * stdDev + mean;
|
|
}
|
|
|
|
// Generate a value within range with Gaussian distribution centered in range
|
|
function generateClusteredValue(min: number, max: number): number {
|
|
const mean = (min + max) / 2;
|
|
const stdDev = (max - min) / 4; // 95% of values within range
|
|
const value = gaussianRandom(mean, stdDev);
|
|
return Math.max(min * 0.8, Math.min(max * 1.2, value)); // Allow slight overflow
|
|
}
|
|
|
|
// Generate unique company name
|
|
function generateCompanyName(usedNames: Set<string>): string {
|
|
let attempts = 0;
|
|
while (attempts < 100) {
|
|
const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
|
|
const core = CORES[Math.floor(Math.random() * CORES.length)];
|
|
const suffix = SUFFIXES[Math.floor(Math.random() * SUFFIXES.length)];
|
|
const name = `${prefix} ${core} ${suffix}`;
|
|
if (!usedNames.has(name)) {
|
|
usedNames.add(name);
|
|
return name;
|
|
}
|
|
attempts++;
|
|
}
|
|
// Fallback: add a number
|
|
const prefix = PREFIXES[Math.floor(Math.random() * PREFIXES.length)];
|
|
const core = CORES[Math.floor(Math.random() * CORES.length)];
|
|
const num = Math.floor(Math.random() * 1000);
|
|
return `${prefix} ${core} ${num}`;
|
|
}
|
|
|
|
// Select segment based on weights
|
|
function selectSegment(): SegmentName {
|
|
const rand = Math.random();
|
|
let cumulative = 0;
|
|
for (const segment of SEGMENTS) {
|
|
cumulative += SEGMENT_WEIGHTS[segment];
|
|
if (rand < cumulative) {
|
|
return segment;
|
|
}
|
|
}
|
|
return "SMB";
|
|
}
|
|
|
|
// Generate a single customer
|
|
function generateCustomer(id: number, usedNames: Set<string>): Customer {
|
|
const segment = selectSegment();
|
|
const center = CLUSTER_CENTERS[segment];
|
|
|
|
return {
|
|
id: `cust-${id.toString().padStart(4, "0")}`,
|
|
name: generateCompanyName(usedNames),
|
|
segment,
|
|
annualRevenue: Math.round(
|
|
generateClusteredValue(
|
|
center.annualRevenue.min,
|
|
center.annualRevenue.max,
|
|
),
|
|
),
|
|
employeeCount: Math.round(
|
|
generateClusteredValue(
|
|
center.employeeCount.min,
|
|
center.employeeCount.max,
|
|
),
|
|
),
|
|
accountAge: Math.round(
|
|
generateClusteredValue(center.accountAge.min, center.accountAge.max),
|
|
),
|
|
engagementScore: Math.round(
|
|
generateClusteredValue(
|
|
center.engagementScore.min,
|
|
center.engagementScore.max,
|
|
),
|
|
),
|
|
supportTickets: Math.round(
|
|
generateClusteredValue(
|
|
center.supportTickets.min,
|
|
center.supportTickets.max,
|
|
),
|
|
),
|
|
nps: Math.round(generateClusteredValue(center.nps.min, center.nps.max)),
|
|
};
|
|
}
|
|
|
|
// Generate all customers
|
|
export function generateCustomers(count: number = 250): Customer[] {
|
|
const usedNames = new Set<string>();
|
|
const customers: Customer[] = [];
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
customers.push(generateCustomer(i + 1, usedNames));
|
|
}
|
|
|
|
return customers;
|
|
}
|
|
|
|
// Generate segment summaries from customers
|
|
export function generateSegmentSummaries(
|
|
customers: Customer[],
|
|
): SegmentSummary[] {
|
|
const counts = new Map<string, number>();
|
|
|
|
for (const customer of customers) {
|
|
counts.set(customer.segment, (counts.get(customer.segment) || 0) + 1);
|
|
}
|
|
|
|
return SEGMENTS.map((segment) => ({
|
|
name: segment,
|
|
count: counts.get(segment) || 0,
|
|
color: SEGMENT_COLORS[segment],
|
|
}));
|
|
}
|