| |
| |
| |
| import { |
| App, |
| applyHostStyleVariables, |
| applyHostFonts, |
| applyDocumentTheme, |
| type McpUiHostContext, |
| } from "@modelcontextprotocol/ext-apps"; |
| import { Chart, registerables } from "chart.js"; |
| import "./global.css"; |
| import "./mcp-app.css"; |
| import type { Customer, SegmentSummary, MetricName } from "./types.ts"; |
| import { SEGMENT_COLORS, METRIC_LABELS } from "./types.ts"; |
|
|
| |
| Chart.register(...registerables); |
|
|
| const log = { |
| info: console.log.bind(console, "[APP]"), |
| error: console.error.bind(console, "[APP]"), |
| }; |
|
|
| |
| const mainEl = document.querySelector(".main") as HTMLElement; |
| const xAxisSelect = document.getElementById("x-axis") as HTMLSelectElement; |
| const yAxisSelect = document.getElementById("y-axis") as HTMLSelectElement; |
| const sizeMetricSelect = document.getElementById( |
| "size-metric", |
| ) as HTMLSelectElement; |
| const chartCanvas = document.getElementById( |
| "scatter-chart", |
| ) as HTMLCanvasElement; |
| const legendContainer = document.getElementById("legend")!; |
| const detailPanel = document.getElementById("detail-panel")!; |
|
|
| |
| interface AppState { |
| customers: Customer[]; |
| segments: SegmentSummary[]; |
| chart: Chart | null; |
| xAxis: MetricName; |
| yAxis: MetricName; |
| sizeMetric: string; |
| hiddenSegments: Set<string>; |
| selectedCustomer: Customer | null; |
| } |
|
|
| const state: AppState = { |
| customers: [], |
| segments: [], |
| chart: null, |
| xAxis: "annualRevenue", |
| yAxis: "engagementScore", |
| sizeMetric: "off", |
| hiddenSegments: new Set(), |
| selectedCustomer: null, |
| }; |
|
|
| |
| function formatValue(value: number, metric: MetricName): string { |
| switch (metric) { |
| case "annualRevenue": |
| if (value >= 1_000_000) { |
| return `$${(value / 1_000_000).toFixed(1)}M`; |
| } |
| return `$${(value / 1_000).toFixed(0)}K`; |
| case "employeeCount": |
| return value.toLocaleString(); |
| case "accountAge": |
| return `${value}mo`; |
| case "engagementScore": |
| return `${value}`; |
| case "supportTickets": |
| return `${value}`; |
| case "nps": |
| return value >= 0 ? `+${value}` : `${value}`; |
| default: |
| return `${value}`; |
| } |
| } |
|
|
| |
| function getMetricRange( |
| customers: Customer[], |
| metric: MetricName, |
| ): { min: number; max: number } { |
| const values = customers.map((c) => c[metric] as number); |
| return { |
| min: Math.min(...values), |
| max: Math.max(...values), |
| }; |
| } |
|
|
| |
| function normalizeToRadius(value: number, min: number, max: number): number { |
| if (max === min) return 12; |
| const normalized = (value - min) / (max - min); |
| return 6 + normalized * 24; |
| } |
|
|
| |
| function getFilteredCustomers(): Customer[] { |
| return state.customers.filter((c) => !state.hiddenSegments.has(c.segment)); |
| } |
|
|
| |
| function buildDatasets(): Chart["data"]["datasets"] { |
| const customers = getFilteredCustomers(); |
| const segments = [...new Set(customers.map((c) => c.segment))]; |
|
|
| |
| let sizeRange: { min: number; max: number } | null = null; |
| if (state.sizeMetric !== "off") { |
| sizeRange = getMetricRange(state.customers, state.sizeMetric as MetricName); |
| } |
|
|
| return segments.map((segment) => { |
| const segmentCustomers = customers.filter((c) => c.segment === segment); |
| const color = SEGMENT_COLORS[segment] || "#888888"; |
|
|
| return { |
| label: segment, |
| data: segmentCustomers.map((c) => ({ |
| x: c[state.xAxis] as number, |
| y: c[state.yAxis] as number, |
| r: |
| sizeRange && state.sizeMetric !== "off" |
| ? normalizeToRadius( |
| c[state.sizeMetric as MetricName] as number, |
| sizeRange.min, |
| sizeRange.max, |
| ) |
| : 8, |
| customer: c, |
| })), |
| backgroundColor: color + "aa", |
| borderColor: color, |
| borderWidth: 1, |
| hoverBackgroundColor: color, |
| hoverBorderColor: "#ffffff", |
| hoverBorderWidth: 2, |
| }; |
| }); |
| } |
|
|
| |
| let colorResolver: HTMLDivElement | null = null; |
|
|
| |
| function resolveColor(cssValue: string, fallback: string): string { |
| if (!cssValue) return fallback; |
| |
| if (!cssValue.includes("light-dark(")) return cssValue; |
| |
| if (!colorResolver) { |
| colorResolver = document.createElement("div"); |
| colorResolver.style.position = "absolute"; |
| colorResolver.style.visibility = "hidden"; |
| colorResolver.style.pointerEvents = "none"; |
| document.body.appendChild(colorResolver); |
| } |
| colorResolver.style.color = cssValue; |
| return getComputedStyle(colorResolver).color || fallback; |
| } |
|
|
| |
| function getChartColors(): { textColor: string; gridColor: string } { |
| const style = getComputedStyle(document.documentElement); |
| const rawTextColor = style.getPropertyValue("--color-text-secondary").trim(); |
| const rawGridColor = style.getPropertyValue("--color-border-primary").trim(); |
| return { |
| textColor: resolveColor(rawTextColor, "#6b7280"), |
| gridColor: resolveColor(rawGridColor, "#e5e7eb"), |
| }; |
| } |
|
|
| |
| function initChart(): Chart { |
| const { textColor, gridColor } = getChartColors(); |
|
|
| return new Chart(chartCanvas, { |
| type: "bubble", |
| data: { |
| datasets: buildDatasets(), |
| }, |
| options: { |
| responsive: true, |
| maintainAspectRatio: false, |
| animation: { |
| duration: 300, |
| }, |
| interaction: { |
| intersect: true, |
| mode: "nearest", |
| }, |
| plugins: { |
| legend: { |
| display: false, |
| }, |
| tooltip: { |
| enabled: true, |
| callbacks: { |
| label: (context) => { |
| const point = context.raw as { customer: Customer }; |
| const c = point.customer; |
| return [ |
| c.name, |
| `${METRIC_LABELS[state.xAxis]}: ${formatValue(c[state.xAxis] as number, state.xAxis)}`, |
| `${METRIC_LABELS[state.yAxis]}: ${formatValue(c[state.yAxis] as number, state.yAxis)}`, |
| ]; |
| }, |
| }, |
| }, |
| }, |
| scales: { |
| x: { |
| title: { |
| display: true, |
| text: METRIC_LABELS[state.xAxis], |
| color: textColor, |
| font: { size: 11, weight: "bold" }, |
| }, |
| ticks: { |
| color: textColor, |
| font: { size: 10 }, |
| callback: (value) => formatValue(value as number, state.xAxis), |
| }, |
| grid: { |
| color: gridColor, |
| }, |
| }, |
| y: { |
| title: { |
| display: true, |
| text: METRIC_LABELS[state.yAxis], |
| color: textColor, |
| font: { size: 11, weight: "bold" }, |
| }, |
| ticks: { |
| color: textColor, |
| font: { size: 10 }, |
| callback: (value) => formatValue(value as number, state.yAxis), |
| }, |
| grid: { |
| color: gridColor, |
| }, |
| }, |
| }, |
| onClick: (_event, elements) => { |
| if (elements.length > 0) { |
| const element = elements[0]; |
| const dataset = state.chart!.data.datasets[element.datasetIndex]; |
| const point = dataset.data[element.index] as unknown as { |
| customer: Customer; |
| }; |
| state.selectedCustomer = point.customer; |
| updateDetailPanel(point.customer); |
| } |
| }, |
| onHover: (_event, elements) => { |
| if (elements.length > 0 && !state.selectedCustomer) { |
| const element = elements[0]; |
| const dataset = state.chart!.data.datasets[element.datasetIndex]; |
| const point = dataset.data[element.index] as unknown as { |
| customer: Customer; |
| }; |
| updateDetailPanel(point.customer); |
| } else if (elements.length === 0 && !state.selectedCustomer) { |
| resetDetailPanel(); |
| } |
| }, |
| }, |
| }); |
| } |
|
|
| |
| function updateChart(): void { |
| if (!state.chart) return; |
|
|
| const { textColor, gridColor } = getChartColors(); |
|
|
| state.chart.data.datasets = buildDatasets(); |
|
|
| |
| const scales = state.chart.options.scales as { |
| x: { |
| title: { text: string; color: string }; |
| ticks: { color: string; callback: (value: number) => string }; |
| grid: { color: string }; |
| }; |
| y: { |
| title: { text: string; color: string }; |
| ticks: { color: string; callback: (value: number) => string }; |
| grid: { color: string }; |
| }; |
| }; |
|
|
| scales.x.title.text = METRIC_LABELS[state.xAxis]; |
| scales.y.title.text = METRIC_LABELS[state.yAxis]; |
| scales.x.title.color = textColor; |
| scales.y.title.color = textColor; |
| scales.x.ticks.color = textColor; |
| scales.y.ticks.color = textColor; |
| scales.x.ticks.callback = (value: number) => formatValue(value, state.xAxis); |
| scales.y.ticks.callback = (value: number) => formatValue(value, state.yAxis); |
| scales.x.grid.color = gridColor; |
| scales.y.grid.color = gridColor; |
|
|
| state.chart.update(); |
| } |
|
|
| |
| function renderLegend(): void { |
| |
| const counts = new Map<string, number>(); |
| for (const c of state.customers) { |
| counts.set(c.segment, (counts.get(c.segment) || 0) + 1); |
| } |
|
|
| legendContainer.innerHTML = state.segments |
| .map((seg) => { |
| const count = counts.get(seg.name) || 0; |
| const isHidden = state.hiddenSegments.has(seg.name); |
| return ` |
| <div class="legend-item ${isHidden ? "hidden" : ""}" data-segment="${seg.name}"> |
| <span class="legend-dot" style="background: ${seg.color}"></span> |
| <span class="legend-label">${seg.name}</span> |
| <span class="legend-count">(${count})</span> |
| </div> |
| `; |
| }) |
| .join(""); |
|
|
| |
| legendContainer.querySelectorAll(".legend-item").forEach((item) => { |
| item.addEventListener("click", () => { |
| const segment = item.getAttribute("data-segment")!; |
| if (state.hiddenSegments.has(segment)) { |
| state.hiddenSegments.delete(segment); |
| } else { |
| state.hiddenSegments.add(segment); |
| } |
| renderLegend(); |
| updateChart(); |
| }); |
| }); |
| } |
|
|
| |
| function updateDetailPanel(customer: Customer): void { |
| const segmentClass = customer.segment.toLowerCase().replace("-", "-"); |
| detailPanel.innerHTML = ` |
| <span class="detail-name">${customer.name}</span> |
| <span class="detail-segment ${segmentClass}">${customer.segment}</span> |
| <span class="detail-metric"><strong>${formatValue(customer.annualRevenue, "annualRevenue")}</strong> rev</span> |
| <span class="detail-metric"><strong>${customer.engagementScore}</strong> engagement</span> |
| <span class="detail-metric"><strong>${customer.nps >= 0 ? "+" : ""}${customer.nps}</strong> NPS</span> |
| `; |
| } |
|
|
| |
| function resetDetailPanel(): void { |
| detailPanel.innerHTML = |
| '<span class="detail-placeholder">Hover over a point to see details</span>'; |
| } |
|
|
| |
| const app = new App({ name: "Customer Segmentation", version: "1.0.0" }); |
|
|
| |
| async function fetchData(): Promise<void> { |
| try { |
| const result = await app.callServerTool({ |
| name: "get-customer-data", |
| arguments: {}, |
| }); |
|
|
| const text = result |
| .content!.filter( |
| (c): c is { type: "text"; text: string } => c.type === "text", |
| ) |
| .map((c) => c.text) |
| .join(""); |
| const data = JSON.parse(text) as { |
| customers: Customer[]; |
| segments: SegmentSummary[]; |
| }; |
|
|
| state.customers = data.customers; |
| state.segments = data.segments; |
|
|
| |
| if (!state.chart) { |
| state.chart = initChart(); |
| } else { |
| updateChart(); |
| } |
|
|
| renderLegend(); |
| log.info(`Loaded ${data.customers.length} customers`); |
| } catch (error) { |
| log.error("Failed to fetch data:", error); |
| } |
| } |
|
|
| |
| xAxisSelect.addEventListener("change", () => { |
| state.xAxis = xAxisSelect.value as MetricName; |
| updateChart(); |
| }); |
|
|
| yAxisSelect.addEventListener("change", () => { |
| state.yAxis = yAxisSelect.value as MetricName; |
| updateChart(); |
| }); |
|
|
| sizeMetricSelect.addEventListener("change", () => { |
| state.sizeMetric = sizeMetricSelect.value; |
| updateChart(); |
| }); |
|
|
| |
| document.addEventListener("click", (e) => { |
| if (!(e.target as HTMLElement).closest(".chart-section")) { |
| state.selectedCustomer = null; |
| resetDetailPanel(); |
| } |
| }); |
|
|
| |
| window |
| .matchMedia("(prefers-color-scheme: dark)") |
| .addEventListener("change", (e) => { |
| |
| if (!app.getHostContext()?.theme) { |
| applyDocumentTheme(e.matches ? "dark" : "light"); |
| if (state.chart) { |
| state.chart.destroy(); |
| state.chart = initChart(); |
| } |
| } |
| }); |
|
|
| |
| const systemDark = window.matchMedia("(prefers-color-scheme: dark)").matches; |
| applyDocumentTheme(systemDark ? "dark" : "light"); |
|
|
| |
| app.onerror = log.error; |
|
|
| |
| function handleHostContextChanged(ctx: McpUiHostContext) { |
| if (ctx.theme) { |
| applyDocumentTheme(ctx.theme); |
| } |
| if (ctx.styles?.variables) { |
| applyHostStyleVariables(ctx.styles.variables); |
| } |
| if (ctx.styles?.css?.fonts) { |
| applyHostFonts(ctx.styles.css.fonts); |
| } |
| if (ctx.safeAreaInsets) { |
| mainEl.style.paddingTop = `${ctx.safeAreaInsets.top}px`; |
| mainEl.style.paddingRight = `${ctx.safeAreaInsets.right}px`; |
| mainEl.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`; |
| mainEl.style.paddingLeft = `${ctx.safeAreaInsets.left}px`; |
| } |
| |
| if (state.chart && (ctx.theme || ctx.styles?.variables)) { |
| state.chart.destroy(); |
| state.chart = initChart(); |
| } |
| } |
|
|
| app.onhostcontextchanged = handleHostContextChanged; |
|
|
| app.connect().then(() => { |
| const ctx = app.getHostContext(); |
| if (ctx) { |
| handleHostContextChanged(ctx); |
| } |
| }); |
|
|
| |
| setTimeout(fetchData, 100); |
|
|