feat(autovisualiser): Migrate the autovisualiser extension to MCP Apps (#7852)

Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
Andrew Harvard
2026-03-15 21:58:50 -04:00
committed by GitHub
parent ec7652dfd7
commit f697e8d7dd
13 changed files with 2701 additions and 2603 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,103 @@
/*
* MCP App Base — shared foundation styles for autovisualiser templates.
*
* All color, typography, and spacing values come from the 70+ host theme tokens.
* Fallback defaults (--color-background-primary: #fff etc.) are defined here
* for resilience, but are overridden at runtime by the bridge's applyTheme().
*/
/* ── Fallback tokens (overridden by host) ─────────────────────────── */
:root {
--color-background-primary: light-dark(#ffffff, #1a1d21);
--color-background-secondary: light-dark(#f4f6f7, #22252a);
--color-background-tertiary: light-dark(#e3e6ea, #2a2e35);
--color-text-primary: light-dark(#3f434b, #e0e0e0);
--color-text-secondary: light-dark(#878787, #a0a0a0);
--color-text-tertiary: light-dark(#a7b0b9, #666);
--color-border-primary: light-dark(#e3e6ea, #333);
--color-border-secondary: light-dark(#e3e6ea, #333);
--font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: monospace;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--font-text-sm-size: 0.875rem;
--font-text-md-size: 1rem;
--font-text-xs-size: 0.75rem;
--font-heading-sm-size: 1.125rem;
--font-heading-xs-size: 1rem;
--border-radius-sm: 4px;
--border-radius-md: 8px;
--border-radius-lg: 12px;
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
}
/* ── Reset ─────────────────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html, body {
overflow: hidden;
}
body {
font-family: var(--font-sans);
font-size: var(--font-text-sm-size);
font-weight: var(--font-weight-normal);
color: var(--color-text-primary);
background: linear-gradient(
light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2)),
light-dark(rgba(0, 0, 0, 0.02), rgba(0, 0, 0, 0.2))
),
var(--color-background-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* ── Fullscreen / PiP centering ────────────────────────────────────── */
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
/* ── Loading state ─────────────────────────────────────────────────── */
.av-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 200px;
color: var(--color-text-tertiary);
font-size: var(--font-text-sm-size);
letter-spacing: 0.01em;
}
.av-loading.hidden {
display: none;
}
/* ── Tooltip ───────────────────────────────────────────────────────── */
.av-tooltip {
position: absolute;
background: var(--color-background-inverse, rgba(0, 0, 0, 0.85));
color: var(--color-text-inverse, #fff);
padding: 6px 10px;
border-radius: var(--border-radius-sm);
font-size: var(--font-text-xs-size);
line-height: 1.4;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
z-index: 1000;
max-width: 280px;
}
.av-tooltip strong {
font-weight: var(--font-weight-semibold);
}
@@ -0,0 +1,262 @@
/**
* MCP App Bridge — shared protocol layer for autovisualiser templates.
*
* Provides:
* McpAppBridge.init(options) → bootstraps the MCP Apps lifecycle
*
* options:
* appName string, e.g. "autovisualiser-chart"
* onData function(data): called when tool-result or tool-input arrives
* onTheme function(): called after theme CSS vars are applied (optional)
* extractData function(msg): custom data extractor (optional, has sensible default)
*/
var McpAppBridge = (function () {
"use strict";
var _nextId = 1;
var _currentData = null;
var _onData = null;
var _onTheme = null;
var _extractData = null;
// ── JSON-RPC helpers ────────────────────────────────────────────────
function sendRequest(method, params) {
return new Promise(function (resolve, reject) {
var id = _nextId++;
function handler(event) {
if (event.data && event.data.id === id) {
window.removeEventListener("message", handler);
if (event.data.result) resolve(event.data.result);
else if (event.data.error) reject(event.data.error);
}
}
window.addEventListener("message", handler);
window.parent.postMessage(
{ jsonrpc: "2.0", id: id, method: method, params: params },
"*"
);
});
}
function sendNotification(method, params) {
window.parent.postMessage(
{ jsonrpc: "2.0", method: method, params: params },
"*"
);
}
// ── Size reporting ──────────────────────────────────────────────────
function reportSize() {
// In fullscreen/pip the host controls sizing — skip size reports
// to avoid a feedback loop when transitioning back to inline.
if (_displayMode === "fullscreen" || _displayMode === "pip") return;
var h = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
sendNotification("ui/notifications/size-changed", {
width: document.body.scrollWidth,
height: h,
});
}
// ── Theming ─────────────────────────────────────────────────────────
var _displayMode = "inline";
function applyTheme(hostContext) {
if (!hostContext) return;
// Clear resolved color cache — theme change means light-dark() flips.
_probeCache = {};
if (hostContext.theme)
document.documentElement.style.colorScheme = hostContext.theme;
if (hostContext.displayMode) {
_displayMode = hostContext.displayMode;
document.documentElement.setAttribute("data-display-mode", _displayMode);
}
if (hostContext.styles && hostContext.styles.variables) {
var vars = hostContext.styles.variables;
for (var key in vars) {
if (vars[key]) document.documentElement.style.setProperty(key, vars[key]);
}
}
if (hostContext.styles && hostContext.styles.css && hostContext.styles.css.fonts) {
if (!document.getElementById("mcp-host-fonts")) {
var style = document.createElement("style");
style.id = "mcp-host-fonts";
style.textContent = hostContext.styles.css.fonts;
document.head.appendChild(style);
}
}
if (_onTheme) _onTheme();
}
// ── Default data extractor ──────────────────────────────────────────
function defaultExtractData(msg) {
var sc = msg.params && msg.params.structuredContent;
if (sc) {
if (sc.data) return sc.data;
return sc;
}
var args = msg.params && msg.params.arguments;
if (args) {
if (args.data) return args.data;
return args;
}
return null;
}
// ── Read a computed CSS variable ────────────────────────────────────
// Host tokens may use light-dark(light, dark) syntax. getComputedStyle
// returns the raw string for custom properties, so we resolve it by
// reading the value through a real CSS property on a hidden probe element.
var _probe = null;
var _probeCache = {};
function resolveColor(raw) {
if (!raw) return raw;
// Fast path: no light-dark() wrapper
if (raw.indexOf("light-dark(") === -1) return raw;
var cached = _probeCache[raw];
if (cached) return cached;
if (!_probe) {
_probe = document.createElement("div");
_probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none;";
document.body.appendChild(_probe);
}
_probe.style.color = raw;
var resolved = getComputedStyle(_probe).color;
_probeCache[raw] = resolved;
return resolved;
}
function cssVar(name) {
var raw = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return resolveColor(raw);
}
// ── Public API ──────────────────────────────────────────────────────
function init(options) {
_onData = options.onData;
_onTheme = options.onTheme || null;
_extractData = options.extractData || defaultExtractData;
// Size observation
if (typeof ResizeObserver !== "undefined") {
new ResizeObserver(reportSize).observe(document.body);
}
window.addEventListener("resize", reportSize);
// Message listener
window.addEventListener("message", function (event) {
var msg = event.data;
if (!msg || msg.jsonrpc !== "2.0") return;
if (
msg.method === "ui/notifications/tool-result" ||
msg.method === "ui/notifications/tool-input"
) {
var data = _extractData(msg);
if (data) {
_currentData = data;
_onData(data);
}
}
if (msg.method === "ui/notifications/host-context-changed") {
applyTheme(msg.params);
}
if (msg.method === "ui/resource-teardown" && msg.id) {
window.parent.postMessage(
{ jsonrpc: "2.0", id: msg.id, result: {} },
"*"
);
}
});
// Handshake
var appIdentity = {
name: options.appName || "autovisualiser",
version: "1.0.0",
};
sendRequest("ui/initialize", {
protocolVersion: "2026-01-26",
appInfo: appIdentity,
clientInfo: appIdentity,
appCapabilities: {
availableDisplayModes: ["inline", "fullscreen"],
},
capabilities: {},
})
.then(function (result) {
applyTheme(result.hostContext || result);
sendNotification("ui/notifications/initialized", {});
reportSize();
})
.catch(function (err) {
console.warn("[" + (options.appName || "autovisualiser") + "] init failed:", err);
reportSize();
});
}
function positionTooltip(tooltipEl, event, offsetX, offsetY) {
var ox = offsetX || 10;
var oy = offsetY || -10;
var el = tooltipEl instanceof HTMLElement ? tooltipEl : tooltipEl.node();
if (!el) return;
var rect = el.getBoundingClientRect();
var w = rect.width || 150;
var h = rect.height || 40;
var x = event.pageX + ox;
var y = event.pageY + oy;
if (x + w > window.innerWidth - 8) x = event.pageX - w - ox;
if (y + h > window.innerHeight - 8) y = event.pageY - h - Math.abs(oy);
if (x < 8) x = 8;
if (y < 8) y = 8;
el.style.left = x + "px";
el.style.top = y + "px";
}
/**
* Display an error message in the page body.
* Hides the loading indicator and shows a styled error box.
*/
function showError(message) {
var loader = document.getElementById("loadingIndicator");
if (loader) loader.classList.add("hidden");
var el = document.createElement("div");
el.style.cssText =
"padding:24px;text-align:center;color:" +
(cssVar("--color-text-secondary") || "#878787") +
";font-size:14px;font-family:" +
(cssVar("--font-sans") || "sans-serif");
el.textContent = "Unable to render visualization: " + message;
document.body.appendChild(el);
}
return {
init: init,
cssVar: cssVar,
reportSize: reportSize,
positionTooltip: positionTooltip,
showError: showError,
get currentData() {
return _currentData;
},
get displayMode() {
return _displayMode;
},
};
})();
@@ -3,332 +3,300 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Chart</title>
<title>Chart</title>
<script>
{{CHART_MIN}}
</script>
<script>{{CHART_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
padding: 16px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
.chart-title {
font-size: var(--font-heading-md-size);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: center;
margin-bottom: 4px;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 2em;
font-weight: 300;
.chart-subtitle {
font-size: var(--font-text-md-size);
color: var(--color-text-secondary);
text-align: center;
margin-bottom: 16px;
}
.header p {
margin: 0;
font-size: 1em;
opacity: 0.9;
.chart-header:empty,
.chart-title:empty,
.chart-subtitle:empty {
display: none;
}
.chart-container {
padding: 30px;
.chart-area {
position: relative;
height: 500px;
height: 520px;
}
@media (max-width: 768px) {
.chart-container {
padding: 15px;
height: 400px;
}
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
padding: 24px;
}
:root[data-display-mode="fullscreen"] #chartRoot,
:root[data-display-mode="pip"] #chartRoot {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
}
:root[data-display-mode="fullscreen"] .chart-area,
:root[data-display-mode="pip"] .chart-area {
flex: 1;
height: auto;
min-height: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1 id="chartTitle">📊 Chart Visualization</h1>
<p id="chartSubtitle">Interactive data visualization</p>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div id="chartRoot" style="display:none;width:100%;">
<div class="chart-header">
<div class="chart-title" id="chartTitle"></div>
<div class="chart-subtitle" id="chartSubtitle"></div>
</div>
<div class="chart-container">
<div class="chart-area">
<canvas id="mainChart"></canvas>
</div>
</div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const chartData = {{CHART_DATA}};
let chart;
function getChartOptions(type) {
const baseOptions = {
(function () {
"use strict";
var chart = null;
// ── Palette ─────────────────────────────────────────────────
// Muted, purposeful colors that feel at home in light & dark.
// Each has a solid and a translucent variant.
var palette = [
{ solid: "#5c98f9", fill: "rgba(92,152,249,0.12)" },
{ solid: "#f97066", fill: "rgba(249,112,102,0.12)" },
{ solid: "#47c9a2", fill: "rgba(71,201,162,0.12)" },
{ solid: "#f0a03c", fill: "rgba(240,160,60,0.12)" },
{ solid: "#9b8afb", fill: "rgba(155,138,251,0.12)" },
{ solid: "#e87fa0", fill: "rgba(232,127,160,0.12)" },
{ solid: "#3cc0e0", fill: "rgba(60,192,224,0.12)" },
{ solid: "#8bba5a", fill: "rgba(139,186,90,0.12)" },
];
// ── Chart rendering ─────────────────────────────────────────
function getChartOptions(chartData, type) {
var cv = McpAppBridge.cssVar;
var isDark = document.documentElement.style.colorScheme === "dark";
var textColor = cv("--color-text-primary") || (isDark ? "#e0e0e0" : "#3f434b");
var textSecondary = cv("--color-text-secondary") || (isDark ? "#a0a0a0" : "#878787");
var gridColor = cv("--color-border-primary") || (isDark ? "#333" : "#e3e6ea");
var fontFamily = cv("--font-sans") || "sans-serif";
var base = {
responsive: true,
maintainAspectRatio: false,
interaction: {
intersect: false,
mode: 'index'
},
interaction: type === "scatter"
? { intersect: true, mode: "nearest" }
: { intersect: false, mode: "index" },
plugins: {
legend: {
position: 'top',
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
padding: 20,
font: {
size: 12
}
}
font: { size: 13, family: fontFamily },
color: textSecondary,
boxWidth: 10,
boxHeight: 10,
generateLabels: function (chart) {
var ds = chart.data.datasets;
return ds.map(function (d, i) {
var color = d.borderColor || d.backgroundColor;
return {
text: d.label,
fontColor: textSecondary,
fillStyle: color,
strokeStyle: color,
lineWidth: 0,
pointStyle: "circle",
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
};
});
},
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: 'white',
bodyColor: 'white',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 8,
backgroundColor: cv("--color-background-inverse") || "rgba(0,0,0,0.85)",
titleColor: cv("--color-text-inverse") || "#fff",
bodyColor: cv("--color-text-inverse") || "#fff",
borderColor: "transparent",
borderWidth: 0,
cornerRadius: 6,
padding: { top: 8, bottom: 8, left: 10, right: 10 },
titleFont: { size: 12, weight: "600", family: fontFamily },
bodyFont: { size: 12, family: fontFamily },
displayColors: true,
padding: 12
boxWidth: 8,
boxHeight: 8,
boxPadding: 4,
usePointStyle: true,
},
title: {
display: false // We handle title in HTML
}
title: { display: false },
},
animation: {
duration: 750,
easing: 'easeInOutQuart'
}
animation: { duration: 500, easing: "easeOutQuart" },
};
if (type === 'scatter') {
baseOptions.scales = {
x: {
type: 'linear',
position: 'bottom',
title: {
display: chartData.xAxisLabel ? true : false,
text: chartData.xAxisLabel || 'X Values',
font: {
size: 12
}
},
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
},
y: {
title: {
display: chartData.yAxisLabel ? true : false,
text: chartData.yAxisLabel || 'Y Values',
font: {
size: 12
}
},
grid: {
color: 'rgba(0, 0, 0, 0.1)'
}
}
var axisDefaults = {
grid: { color: gridColor, drawBorder: false, lineWidth: 1 },
ticks: {
color: textSecondary,
font: { size: 11, family: fontFamily },
padding: 8,
},
border: { display: false },
};
if (type === "scatter") {
base.scales = {
x: Object.assign({}, axisDefaults, {
type: "linear",
position: "bottom",
title: chartData.xAxisLabel
? { display: true, text: chartData.xAxisLabel, color: textSecondary, font: { size: 11, family: fontFamily } }
: { display: false },
}),
y: Object.assign({}, axisDefaults, {
title: chartData.yAxisLabel
? { display: true, text: chartData.yAxisLabel, color: textSecondary, font: { size: 11, family: fontFamily } }
: { display: false },
}),
};
} else {
baseOptions.scales = {
x: {
grid: {
color: 'rgba(0, 0, 0, 0.1)'
},
title: {
display: chartData.xAxisLabel ? true : false,
text: chartData.xAxisLabel,
font: {
size: 12
}
}
},
y: {
base.scales = {
x: Object.assign({}, axisDefaults, {
title: chartData.xAxisLabel
? { display: true, text: chartData.xAxisLabel, color: textSecondary, font: { size: 11, family: fontFamily } }
: { display: false },
}),
y: Object.assign({}, axisDefaults, {
beginAtZero: true,
grid: {
color: 'rgba(0, 0, 0, 0.1)'
},
title: {
display: chartData.yAxisLabel ? true : false,
text: chartData.yAxisLabel,
font: {
size: 12
}
}
}
title: chartData.yAxisLabel
? { display: true, text: chartData.yAxisLabel, color: textSecondary, font: { size: 11, family: fontFamily } }
: { display: false },
}),
};
}
// Apply any custom options from the data
if (chartData.options) {
return mergeOptions(baseOptions, chartData.options);
}
return baseOptions;
if (chartData.options) base = mergeDeep(base, chartData.options);
return base;
}
function mergeOptions(base, custom) {
// Simple deep merge for options
const merged = {...base};
for (const key in custom) {
if (typeof custom[key] === 'object' && !Array.isArray(custom[key]) && custom[key] !== null) {
merged[key] = mergeOptions(base[key] || {}, custom[key]);
function mergeDeep(target, source) {
var out = Object.assign({}, target);
for (var key in source) {
if (source[key] && typeof source[key] === "object" && !Array.isArray(source[key])) {
out[key] = mergeDeep(target[key] || {}, source[key]);
} else {
merged[key] = custom[key];
out[key] = source[key];
}
}
return merged;
return out;
}
// Default color palette
const defaultColors = [
'rgba(54, 162, 235, 0.8)', // Blue
'rgba(255, 99, 132, 0.8)', // Red
'rgba(75, 192, 192, 0.8)', // Teal
'rgba(255, 159, 64, 0.8)', // Orange
'rgba(153, 102, 255, 0.8)', // Purple
'rgba(255, 206, 86, 0.8)', // Yellow
'rgba(46, 204, 113, 0.8)', // Green
'rgba(231, 76, 60, 0.8)', // Darker Red
'rgba(52, 152, 219, 0.8)', // Light Blue
'rgba(155, 89, 182, 0.8)', // Violet
];
const defaultBorderColors = [
'rgba(54, 162, 235, 1)',
'rgba(255, 99, 132, 1)',
'rgba(75, 192, 192, 1)',
'rgba(255, 159, 64, 1)',
'rgba(153, 102, 255, 1)',
'rgba(255, 206, 86, 1)',
'rgba(46, 204, 113, 1)',
'rgba(231, 76, 60, 1)',
'rgba(52, 152, 219, 1)',
'rgba(155, 89, 182, 1)',
];
function processDatasets(datasets, chartType) {
return datasets.map((dataset, index) => {
const processed = {...dataset};
// Add default colors if not provided
if (!processed.backgroundColor) {
if (chartType === 'bar' && !dataset.label && datasets.length === 1) {
// For single bar dataset without label, use multiple colors
processed.backgroundColor = defaultColors;
processed.borderColor = defaultBorderColors;
} else {
processed.backgroundColor = defaultColors[index % defaultColors.length];
processed.borderColor = defaultBorderColors[index % defaultBorderColors.length];
}
function processDatasets(datasets, type) {
return datasets.map(function (ds, i) {
var p = palette[i % palette.length];
var out = Object.assign({}, ds);
if (!out.borderColor) out.borderColor = p.solid;
var lineColor = out.borderColor;
if (!out.backgroundColor) {
out.backgroundColor = type === "bar" ? lineColor : p.fill;
}
if (!processed.borderColor && processed.backgroundColor) {
processed.borderColor = processed.backgroundColor.replace('0.8', '1');
if (out.borderWidth === undefined) {
out.borderWidth = type === "line" ? 2 : 0;
}
// Set default border width
if (processed.borderWidth === undefined) {
processed.borderWidth = chartType === 'line' ? 2 : 1;
if (type === "line") {
if (out.tension === undefined) out.tension = 0.35;
out.fill = false;
out.pointStyle = "circle";
if (out.pointRadius === undefined) out.pointRadius = 3;
out.pointBackgroundColor = lineColor;
out.pointBorderColor = lineColor;
if (out.pointHitRadius === undefined) out.pointHitRadius = 20;
if (out.pointHoverRadius === undefined) out.pointHoverRadius = 5;
out.pointHoverBackgroundColor = lineColor;
}
// For line charts, add default tension if not specified
if (chartType === 'line' && processed.tension === undefined) {
processed.tension = 0.4;
if (type === "bar") {
if (out.borderRadius === undefined) out.borderRadius = 3;
if (out.maxBarThickness === undefined) out.maxBarThickness = 48;
}
// For line charts, set fill to false by default if not specified
if (chartType === 'line' && processed.fill === undefined) {
processed.fill = false;
if (type === "scatter") {
out.backgroundColor = lineColor;
out.pointStyle = "circle";
if (out.pointRadius === undefined) out.pointRadius = 4;
if (out.pointHoverRadius === undefined) out.pointHoverRadius = 6;
}
return processed;
return out;
});
}
function initChart() {
const ctx = document.getElementById('mainChart').getContext('2d');
// Set title and subtitle if provided
if (chartData.title) {
document.getElementById('chartTitle').textContent = chartData.title;
}
if (chartData.subtitle) {
document.getElementById('chartSubtitle').textContent = chartData.subtitle;
}
const chartType = chartData.type || 'line';
const processedDatasets = processDatasets(chartData.datasets, chartType);
const chartConfig = {
type: chartType,
function renderChart(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
document.getElementById("chartRoot").style.display = "";
if (chart) { chart.destroy(); chart = null; }
var titleEl = document.getElementById("chartTitle");
var subtitleEl = document.getElementById("chartSubtitle");
titleEl.textContent = data.title || "";
subtitleEl.textContent = data.subtitle || "";
var type = data.type || "line";
var ctx = document.getElementById("mainChart").getContext("2d");
chart = new Chart(ctx, {
type: type,
data: {
labels: chartData.labels || [],
datasets: processedDatasets
labels: data.labels || [],
datasets: processDatasets(data.datasets, type),
},
options: getChartOptions(chartType)
};
chart = new Chart(ctx, chartConfig);
options: getChartOptions(data, type),
});
setTimeout(McpAppBridge.reportSize, 80);
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change',
payload: {
height: contentHeight
}
}, '*');
}
// ── Bootstrap ───────────────────────────────────────────────
function safeRender(data) {
try { renderChart(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
// Initialize on load
window.onload = function() {
initChart();
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
McpAppBridge.init({
appName: "autovisualiser-chart",
onData: safeRender,
onTheme: function () {
if (McpAppBridge.currentData) safeRender(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
@@ -4,271 +4,154 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Chord Diagram</title>
<script>
{{D3_MIN}}
</script>
<script>{{D3_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
#chord {
border: 1px solid #ddd;
border-radius: 4px;
background: white;
display: block;
margin: 0 auto;
}
.group-arc {
body { padding: 16px; }
#chord { display: block; margin: 0 auto; }
.group-arc { cursor: pointer; }
.chord-ribbon {
cursor: pointer;
fill-opacity: 0.55;
stroke: none;
transition: fill-opacity 0.15s ease;
}
.chord {
cursor: pointer;
fill-opacity: 0.67;
stroke: #000;
stroke-width: 0.5px;
}
.chord:hover {
fill-opacity: 0.9;
}
.chord-ribbon:hover { fill-opacity: 0.8; }
.group-label {
font-size: 12px;
font-weight: bold;
font-size: var(--font-text-xs-size);
font-weight: var(--font-weight-medium);
text-anchor: middle;
pointer-events: none;
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
fill: var(--color-text-primary);
}
</style>
</head>
<body>
<div class="container">
<svg id="chord"></svg>
<div class="tooltip" id="tooltip"></div>
</div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<svg id="chord"></svg>
<div class="av-tooltip" id="tooltip"></div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const chordData = {{CHORD_DATA}};
(function () {
"use strict";
// Chord variables
let svg, chord, arc, ribbon, tooltip;
const width = 500;
const height = 500;
const outerRadius = Math.min(width, height) * 0.4;
const innerRadius = outerRadius - 25;
var svgEl, tooltipEl;
// Color scale
const colors = d3.scaleOrdinal(d3.schemeCategory10);
function getDimensions() {
if (McpAppBridge.displayMode === "fullscreen" || McpAppBridge.displayMode === "pip") {
var w = window.innerWidth - 32;
var h = window.innerHeight - 32;
var size = Math.min(w, h);
return { width: size, height: size };
}
return { width: 580, height: 580 };
}
var palette = [
"#5c98f9", "#f97066", "#47c9a2", "#f0a03c",
"#9b8afb", "#e87fa0", "#3cc0e0", "#8bba5a",
];
var colors = d3.scaleOrdinal(palette);
// Initialize SVG
function initializeSVG() {
svg = d3.select("#chord")
.attr("width", width)
.attr("height", height);
svg.selectAll("*").remove();
const g = svg.append("g")
.attr("transform", `translate(${width / 2},${height / 2})`);
chord = d3.chord()
.padAngle(0.05)
.sortSubgroups(d3.descending);
arc = d3.arc()
.innerRadius(innerRadius)
.outerRadius(outerRadius);
ribbon = d3.ribbon()
.radius(innerRadius);
tooltip = d3.select("#tooltip");
return g;
var dim = getDimensions();
svgEl = d3.select("#chord").attr("width", dim.width).attr("height", dim.height);
svgEl.selectAll("*").remove();
tooltipEl = d3.select("#tooltip");
return {
g: svgEl.append("g").attr("transform", "translate(" + dim.width / 2 + "," + dim.height / 2 + ")"),
outerRadius: Math.min(dim.width, dim.height) * 0.32,
};
}
// Draw the Chord diagram
function drawChord(g, data) {
const chords = chord(data.matrix);
// Draw the groups (arcs around the circle)
const group = g.append("g")
.selectAll("g")
.data(chords.groups)
.enter().append("g")
.attr("class", "group");
function drawChord(g, data, outerRadius) {
var innerRadius = outerRadius - 24;
var chordLayout = d3.chord().padAngle(0.04).sortSubgroups(d3.descending);
var arcGen = d3.arc().innerRadius(innerRadius).outerRadius(outerRadius);
var ribbonGen = d3.ribbon().radius(innerRadius);
var chords = chordLayout(data.matrix);
group.append("path")
.attr("class", "group-arc")
.style("fill", d => colors(d.index))
.style("stroke", d => d3.rgb(colors(d.index)).darker())
.attr("d", arc)
.on("mouseover", function(event, d) {
fadeChords(d.index, 0.1);
showGroupTooltip(event, d, data.labels);
var group = g.append("g").selectAll("g").data(chords.groups).enter().append("g");
group.append("path").attr("class", "group-arc")
.style("fill", function (d) { return colors(d.index); })
.style("stroke", function (d) { return d3.rgb(colors(d.index)).darker(0.3); })
.attr("d", arcGen)
.on("mouseover", function (event, d) {
fadeChords(g, d.index, 0.08);
tooltipEl.style("opacity", 1)
.html("<strong>" + data.labels[d.index] + "</strong><br/>Total: " + d.value.toLocaleString());
McpAppBridge.positionTooltip(tooltipEl, event);
})
.on("mouseout", function() {
fadeChords(null, 1);
hideTooltip();
.on("mouseout", function () {
fadeChords(g, null, 1);
tooltipEl.style("opacity", 0);
});
// Add group labels
group.append("text")
.attr("class", "group-label")
.each(d => { d.angle = (d.startAngle + d.endAngle) / 2; })
group.append("text").attr("class", "group-label")
.each(function (d) { d.angle = (d.startAngle + d.endAngle) / 2; })
.attr("dy", ".35em")
.attr("transform", d => `
rotate(${(d.angle * 180 / Math.PI - 90)})
translate(${outerRadius + 10})
${d.angle > Math.PI ? "rotate(180)" : ""}
`)
.style("text-anchor", d => d.angle > Math.PI ? "end" : null)
.text(d => data.labels[d.index]);
// Draw the chords (ribbons between groups)
g.append("g")
.selectAll("path")
.data(chords)
.enter().append("path")
.attr("class", "chord")
.style("fill", d => colors(d.source.index))
.attr("d", ribbon)
.on("mouseover", function(event, d) {
d3.select(this).style("fill-opacity", 0.9);
showChordTooltip(event, d, data.labels, data.matrix);
.attr("transform", function (d) {
return "rotate(" + (d.angle * 180 / Math.PI - 90) + ") translate(" + (outerRadius + 10) + ")" +
(d.angle > Math.PI ? " rotate(180)" : "");
})
.on("mouseout", function(event, d) {
d3.select(this).style("fill-opacity", 0.67);
hideTooltip();
.style("text-anchor", function (d) { return d.angle > Math.PI ? "end" : null; })
.text(function (d) { return data.labels[d.index]; });
g.append("g").selectAll("path").data(chords).enter().append("path")
.attr("class", "chord-ribbon")
.style("fill", function (d) { return colors(d.source.index); })
.attr("d", ribbonGen)
.on("mouseover", function (event, d) {
d3.select(this).style("fill-opacity", 0.8);
var s2t = data.matrix[d.source.index][d.target.index];
var t2s = data.matrix[d.target.index][d.source.index];
tooltipEl.style("opacity", 1)
.html(data.labels[d.source.index] + " → " + data.labels[d.target.index] + ": " + s2t.toLocaleString() +
"<br/>" + data.labels[d.target.index] + " → " + data.labels[d.source.index] + ": " + t2s.toLocaleString());
McpAppBridge.positionTooltip(tooltipEl, event);
})
.on("mouseout", function () {
d3.select(this).style("fill-opacity", 0.55);
tooltipEl.style("opacity", 0);
});
}
// Fade chords for highlighting
function fadeChords(focusIndex, opacity) {
svg.selectAll(".chord")
.style("opacity", d => {
if (focusIndex === null) return 1;
return d.source.index === focusIndex || d.target.index === focusIndex ? 1 : opacity;
});
svg.selectAll(".group-arc")
.style("opacity", d => {
if (focusIndex === null) return 1;
return d.index === focusIndex ? 1 : opacity;
});
function fadeChords(g, focusIndex, opacity) {
g.selectAll(".chord-ribbon").style("opacity", function (d) {
if (focusIndex === null) return 1;
return (d.source.index === focusIndex || d.target.index === focusIndex) ? 1 : opacity;
});
}
// Tooltip functions
function showGroupTooltip(event, d, labels) {
const totalOut = d.value;
const groupName = labels[d.index];
tooltip.style("opacity", 1)
.html(`
<strong>${groupName}</strong><br/>
Total Flow: ${totalOut.toLocaleString()}<br/>
Index: ${d.index}
`)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
function renderData(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
var init = initializeSVG();
drawChord(init.g, data, init.outerRadius);
setTimeout(McpAppBridge.reportSize, 80);
}
function showChordTooltip(event, d, labels, matrix) {
const sourceName = labels[d.source.index];
const targetName = labels[d.target.index];
const sourceToTarget = matrix[d.source.index][d.target.index];
const targetToSource = matrix[d.target.index][d.source.index];
tooltip.style("opacity", 1)
.html(`
<strong>Connection</strong><br/>
${sourceName}${targetName}: ${sourceToTarget.toLocaleString()}<br/>
${targetName}${sourceName}: ${targetToSource.toLocaleString()}<br/>
<strong>Total: ${(sourceToTarget + targetToSource).toLocaleString()}</strong>
`)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
function safeRender(data) {
try { renderData(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
function hideTooltip() {
tooltip.style("opacity", 0);
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
// Get the actual content height
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
// The message format must match what the MCP-UI client expects
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change', // lowercase as expected by MCP-UI client
payload: {
height: contentHeight
// width is optional, we're not setting it to allow responsive design
}
}, '*');
}
}
// Initialize and render on load
window.onload = function() {
const g = initializeSVG();
drawChord(g, chordData);
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
McpAppBridge.init({
appName: "autovisualiser-chord",
onData: safeRender,
onTheme: function () {
if (McpAppBridge.currentData) safeRender(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
</html>
@@ -3,407 +3,203 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Donut & Pie Charts</title>
<title>Donut &amp; Pie Charts</title>
<script>
{{CHART_MIN}}
</script>
<script>{{CHART_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
.container {
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
font-size: 1.8em;
font-weight: 300;
}
body { padding: 32px 16px; }
.charts-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 30px;
margin-bottom: 20px;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 16px;
}
.chart-wrapper {
background: #fafbfc;
border-radius: 8px;
padding: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.chart-title {
text-align: center;
font-size: 1.2em;
font-weight: 500;
color: #495057;
margin-bottom: 15px;
}
.chart-container {
position: relative;
height: 350px;
.chart-card {
display: flex;
justify-content: center;
flex-direction: column;
align-items: center;
}
.chart-card-title {
font-size: var(--font-heading-md-size);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
text-align: center;
margin-bottom: 12px;
}
.chart-card-title:empty { display: none; }
.chart-canvas-wrap {
position: relative;
width: 100%;
max-width: 280px;
height: 280px;
}
.chart-legend {
margin-top: 15px;
padding-top: 15px;
border-top: 1px solid #e9ecef;
margin-top: 12px;
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
font-size: 0.85em;
font-size: var(--font-text-sm-size);
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
.legend-item { display: flex; align-items: center; gap: 6px; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.legend-label { color: var(--color-text-secondary); }
.legend-value { color: var(--color-text-primary); font-weight: var(--font-weight-medium); }
@media (max-width: 640px) { .charts-grid { grid-template-columns: 1fr; } }
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
padding: 24px;
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 2px;
:root[data-display-mode="fullscreen"] .charts-grid,
:root[data-display-mode="pip"] .charts-grid {
height: 100%;
align-content: center;
}
.legend-label {
color: #6c757d;
}
.legend-value {
color: #495057;
font-weight: 500;
margin-left: 4px;
}
.stats-panel {
background: #f8f9fa;
padding: 20px;
margin-top: 30px;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
}
.stat-card {
background: white;
padding: 15px;
border-radius: 6px;
text-align: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
.stat-title {
font-size: 0.85em;
color: #6c757d;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.stat-value {
font-size: 1.5em;
font-weight: bold;
color: #007bff;
}
.stat-subtitle {
font-size: 0.9em;
color: #6c757d;
margin-top: 4px;
}
@media (max-width: 768px) {
.charts-grid {
grid-template-columns: 1fr;
}
:root[data-display-mode="fullscreen"] .chart-canvas-wrap,
:root[data-display-mode="pip"] .chart-canvas-wrap {
max-width: 520px;
height: 520px;
}
</style>
</head>
<body>
<div class="container">
<div class="charts-grid" id="chartsGrid">
<!-- Charts will be dynamically inserted here -->
</div>
<div class="stats-panel" id="statsPanel">
<!-- Stats will be dynamically inserted here -->
</div>
</div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div class="charts-grid" id="chartsGrid" style="width:100%;"></div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const chartsData = {{CHARTS_DATA}};
// Default color palette
const defaultColors = [
'rgba(255, 99, 132, 1)', // Red
'rgba(54, 162, 235, 1)', // Blue
'rgba(75, 192, 192, 1)', // Teal
'rgba(255, 159, 64, 1)', // Orange
'rgba(153, 102, 255, 1)', // Purple
'rgba(255, 206, 86, 1)', // Yellow
'rgba(46, 204, 113, 1)', // Green
'rgba(231, 76, 60, 1)', // Darker Red
'rgba(52, 152, 219, 1)', // Light Blue
'rgba(155, 89, 182, 1)', // Violet
'rgba(241, 196, 15, 1)', // Gold
'rgba(230, 126, 34, 1)', // Carrot
'rgba(149, 165, 166, 1)', // Gray
'rgba(26, 188, 156, 1)', // Turquoise
'rgba(52, 73, 94, 1)', // Dark Blue-Gray
(function () {
"use strict";
var charts = [];
var palette = [
"#5c98f9", "#f97066", "#47c9a2", "#f0a03c",
"#9b8afb", "#e87fa0", "#3cc0e0", "#8bba5a",
"#d4a05a", "#6bc4a6", "#c47ecf", "#7aa3d4",
];
function getColor(index, alpha = 1) {
const color = defaultColors[index % defaultColors.length];
return alpha === 1 ? color : color.replace('1)', `${alpha})`);
}
function processChartData(data) {
// Ensure we have an array of charts
const charts = Array.isArray(data) ? data : [data];
return charts.map(chart => {
// Process labels and data
const labels = chart.labels || chart.data.map((d, i) => d.label || `Item ${i + 1}`);
const values = chart.data.map(d => typeof d === 'object' ? d.value : d);
// Auto-assign colors if not provided
const backgroundColors = chart.backgroundColor ||
chart.data.map((d, i) => {
if (typeof d === 'object' && d.color) return d.color;
return getColor(i, 0.8);
});
const borderColors = chart.borderColor ||
backgroundColors.map(c => c.replace('0.8)', '1)'));
return {
title: chart.title || 'Chart',
type: chart.type || 'doughnut',
labels: labels,
values: values,
backgroundColors: backgroundColors,
borderColors: borderColors
};
function processCharts(data) {
var list = Array.isArray(data) ? data : [data];
return list.map(function (c) {
var items = c.values || c.data || [];
var labels = c.labels || items.map(function (d, i) { return (typeof d === "object" && d.label) ? d.label : "Item " + (i + 1); });
var values = items.map(function (d) { return typeof d === "object" ? d.value : d; });
var colors = values.map(function (_, i) { return palette[i % palette.length]; });
return { title: c.title || "", type: c.type || "doughnut", labels: labels, values: values, colors: colors };
});
}
function createChart(chartData, canvasId) {
const ctx = document.getElementById(canvasId).getContext('2d');
return new Chart(ctx, {
type: chartData.type,
data: {
labels: chartData.labels,
datasets: [{
data: chartData.values,
backgroundColor: chartData.backgroundColors,
borderColor: chartData.borderColors,
borderWidth: 2,
hoverOffset: 4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false // We'll create custom legend
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: 'white',
bodyColor: 'white',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
cornerRadius: 6,
padding: 10,
displayColors: true,
callbacks: {
label: function(context) {
const label = context.label || '';
const value = context.parsed;
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `${label}: ${value.toLocaleString()} (${percentage}%)`;
}
}
}
},
animation: {
animateRotate: true,
animateScale: false,
duration: 1000,
easing: 'easeInOutQuart'
},
cutout: chartData.type === 'doughnut' ? '50%' : '0%'
}
});
}
function createLegend(chartData, container) {
const legendHtml = chartData.labels.map((label, index) => {
const value = chartData.values[index];
const total = chartData.values.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return `
<div class="legend-item">
<div class="legend-color" style="background-color: ${chartData.backgroundColors[index]}"></div>
<span class="legend-label">${label}:</span>
<span class="legend-value">${value.toLocaleString()} (${percentage}%)</span>
</div>
`;
}).join('');
container.innerHTML = legendHtml;
}
function renderCharts() {
const processedData = processChartData(chartsData);
const chartsGrid = document.getElementById('chartsGrid');
// Clear existing content
chartsGrid.innerHTML = '';
// Create chart elements
processedData.forEach((chartData, index) => {
const canvasId = `chart-${index}`;
const legendId = `legend-${index}`;
const chartWrapper = document.createElement('div');
chartWrapper.className = 'chart-wrapper';
chartWrapper.innerHTML = `
<div class="chart-title">${chartData.title}</div>
<div class="chart-container">
<canvas id="${canvasId}"></canvas>
</div>
<div class="chart-legend" id="${legendId}"></div>
`;
chartsGrid.appendChild(chartWrapper);
// Create the chart
createChart(chartData, canvasId);
// Create the legend
createLegend(chartData, document.getElementById(legendId));
});
// Update statistics
updateStats(processedData);
}
function updateStats(processedData) {
const statsPanel = document.getElementById('statsPanel');
// Calculate overall statistics
let totalValue = 0;
let totalCategories = 0;
let maxValue = 0;
let minValue = Infinity;
processedData.forEach(chart => {
const chartTotal = chart.values.reduce((a, b) => a + b, 0);
const chartMax = Math.max(...chart.values);
const chartMin = Math.min(...chart.values);
totalValue += chartTotal;
totalCategories += chart.values.length;
maxValue = Math.max(maxValue, chartMax);
minValue = Math.min(minValue, chartMin);
});
const avgValue = totalCategories > 0 ? totalValue / totalCategories : 0;
statsPanel.innerHTML = `
<div class="stat-card">
<div class="stat-title">Total Charts</div>
<div class="stat-value">${processedData.length}</div>
</div>
<div class="stat-card">
<div class="stat-title">Total Value</div>
<div class="stat-value">${totalValue.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-title">Total Categories</div>
<div class="stat-value">${totalCategories}</div>
</div>
<div class="stat-card">
<div class="stat-title">Average Value</div>
<div class="stat-value">${avgValue.toFixed(0).toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-title">Max Value</div>
<div class="stat-value">${maxValue.toLocaleString()}</div>
</div>
<div class="stat-card">
<div class="stat-title">Min Value</div>
<div class="stat-value">${minValue.toLocaleString()}</div>
</div>
`;
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
// Get the actual content height
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change',
payload: {
height: contentHeight
}
}, '*');
}
}
// Initialize on load
window.onload = function() {
renderCharts();
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
function renderDonut(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
var grid = document.getElementById("chartsGrid");
grid.innerHTML = "";
charts.forEach(function (c) { c.destroy(); });
charts = [];
var cv = McpAppBridge.cssVar;
var fontFamily = cv("--font-sans") || "sans-serif";
var processed = processCharts(data);
processed.forEach(function (cd, idx) {
var card = document.createElement("div");
card.className = "chart-card";
var titleEl = document.createElement("div");
titleEl.className = "chart-card-title";
titleEl.textContent = cd.title;
card.appendChild(titleEl);
var wrap = document.createElement("div");
wrap.className = "chart-canvas-wrap";
var canvas = document.createElement("canvas");
canvas.id = "chart-" + idx;
wrap.appendChild(canvas);
card.appendChild(wrap);
var legendEl = document.createElement("div");
legendEl.className = "chart-legend";
var total = cd.values.reduce(function (a, b) { return a + b; }, 0);
cd.labels.forEach(function (label, i) {
var pct = total > 0 ? ((cd.values[i] / total) * 100).toFixed(1) : "0";
legendEl.innerHTML += '<div class="legend-item"><div class="legend-dot" style="background:' + cd.colors[i] + '"></div>' +
'<span class="legend-label">' + label + '</span>' +
'<span class="legend-value">' + pct + '%</span></div>';
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
card.appendChild(legendEl);
grid.appendChild(card);
var ctx = canvas.getContext("2d");
var c = new Chart(ctx, {
type: cd.type,
data: {
labels: cd.labels,
datasets: [{
data: cd.values,
backgroundColor: cd.colors,
borderColor: "transparent",
borderWidth: 0,
hoverOffset: 4,
}],
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: { display: false },
tooltip: {
backgroundColor: cv("--color-background-inverse") || "rgba(0,0,0,0.85)",
titleColor: cv("--color-text-inverse") || "#fff",
bodyColor: cv("--color-text-inverse") || "#fff",
borderWidth: 0,
cornerRadius: 6,
padding: { top: 8, bottom: 8, left: 10, right: 10 },
titleFont: { size: 12, weight: "600", family: fontFamily },
bodyFont: { size: 12, family: fontFamily },
callbacks: {
label: function (ctx) {
var val = ctx.parsed;
var t = ctx.dataset.data.reduce(function (a, b) { return a + b; }, 0);
return ctx.label + ": " + val.toLocaleString() + " (" + ((val / t) * 100).toFixed(1) + "%)";
},
},
},
},
cutout: cd.type === "doughnut" ? "55%" : "0%",
animation: { animateRotate: true, duration: 500, easing: "easeOutQuart" },
},
});
charts.push(c);
});
setTimeout(McpAppBridge.reportSize, 80);
}
function safeRender(data) {
try { renderDonut(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
McpAppBridge.init({
appName: "autovisualiser-donut",
onData: safeRender,
onTheme: function () {
if (McpAppBridge.currentData) safeRender(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
@@ -3,277 +3,232 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Interactive Map Visualization</title>
<title>Map</title>
<style>{{LEAFLET_CSS}}</style>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
{{LEAFLET_CSS}}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f7fa;
color: #333;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
body { padding: 0; }
.map-header {
padding: 12px 16px;
text-align: center;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 2.5em;
font-weight: 300;
.map-header:empty { display: none; }
.map-title {
font-size: var(--font-heading-md-size);
font-weight: var(--font-weight-semibold);
color: var(--color-text-primary);
margin-bottom: 4px;
}
.header p {
margin: 0;
font-size: 1.1em;
opacity: 0.9;
.map-subtitle {
font-size: var(--font-text-md-size);
color: var(--color-text-secondary);
}
#map {
.map-title:empty, .map-subtitle:empty { display: none; }
#map { width: 100%; height: 440px; }
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
padding: 0;
justify-content: stretch;
}
:root[data-display-mode="fullscreen"] #mapRoot,
:root[data-display-mode="pip"] #mapRoot {
display: flex;
flex-direction: column;
width: 100%;
height: 600px;
border-radius: 0 0 12px 12px;
height: 100%;
}
.custom-popup {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
:root[data-display-mode="fullscreen"] #map,
:root[data-display-mode="pip"] #map {
flex: 1;
height: auto;
}
.custom-popup h3 {
margin: 0 0 10px 0;
color: #333;
margin: 0 0 6px 0;
font-size: var(--font-text-sm-size);
font-weight: var(--font-weight-semibold);
}
.custom-popup p {
margin: 5px 0;
color: #666;
margin: 3px 0;
font-size: var(--font-text-xs-size);
color: var(--color-text-secondary);
}
.marker-cluster-small {
background-color: rgba(181, 226, 140, 0.6);
}
.marker-cluster-small div {
background-color: rgba(110, 204, 57, 0.6);
}
.marker-cluster-medium {
background-color: rgba(241, 211, 87, 0.6);
}
.marker-cluster-medium div {
background-color: rgba(240, 194, 12, 0.6);
}
.marker-cluster-large {
background-color: rgba(253, 156, 115, 0.6);
}
.marker-cluster-large div {
background-color: rgba(241, 128, 23, 0.6);
color: white;
text-align: center;
line-height: 40px;
font-weight: bold;
}
.marker-cluster {
border-radius: 50%;
}
/* MarkerCluster CSS */
.marker-cluster-small div,
.marker-cluster-medium div,
.marker-cluster-large div {
width: 30px;
height: 30px;
margin-left: 5px;
margin-top: 5px;
text-align: center;
border-radius: 15px;
font: 12px "Helvetica Neue", Arial, Helvetica, sans-serif;
.marker-cluster { border-radius: 50%; }
.marker-cluster-small { background-color: rgba(92,152,249,0.35); }
.marker-cluster-small div { background-color: rgba(92,152,249,0.7); }
.marker-cluster-medium { background-color: rgba(240,160,60,0.35); }
.marker-cluster-medium div { background-color: rgba(240,160,60,0.7); }
.marker-cluster-large { background-color: rgba(249,112,102,0.35); }
.marker-cluster-large div { background-color: rgba(249,112,102,0.7); }
.marker-cluster-small div, .marker-cluster-medium div, .marker-cluster-large div {
width: 30px; height: 30px; margin-left: 5px; margin-top: 5px;
text-align: center; border-radius: 15px;
font: 12px var(--font-sans, sans-serif);
line-height: 30px; color: #fff; font-weight: var(--font-weight-semibold);
}
</style>
</head>
<body>
<div class="container">
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div id="mapRoot" style="display:none;width:100%;">
<div class="map-header">
<div class="map-title" id="mapTitle"></div>
<div class="map-subtitle" id="mapSubtitle"></div>
</div>
<div id="map"></div>
</div>
<script>{{LEAFLET_JS}}</script>
<script>{{MARKERCLUSTER_JS}}</script>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
{{LEAFLET_JS}}
</script>
<script>
{{MARKERCLUSTER_JS}}
</script>
(function () {
"use strict";
var map, markerClusterGroup, savedBounds, tileLayer;
var markerPalette = ["#5c98f9", "#47c9a2", "#f0a03c", "#f97066", "#9b8afb"];
function getColorByValue(value, maxValue) {
var ratio = value / maxValue;
var idx = Math.min(Math.floor(ratio * markerPalette.length), markerPalette.length - 1);
return markerPalette[idx];
}
function initMap(mapData) {
if (map) { map.remove(); map = null; }
markerClusterGroup = null;
var lat = mapData.center ? mapData.center.lat : 39.8283;
var lng = mapData.center ? mapData.center.lng : -98.5795;
var zoom = mapData.zoom || 4;
map = L.map("map").setView([lat, lng], zoom);
var isDark = document.documentElement.style.colorScheme === "dark";
var tileUrl = isDark
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
tileLayer = L.tileLayer(tileUrl, {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 19,
subdomains: "abcd",
}).addTo(map);
<script>
// Data from the tool
const mapData = {{MAP_DATA}};
// Initialize map
let map;
let markerClusterGroup;
function initMap() {
// Determine initial center and zoom
let initialLat = mapData.center ? mapData.center.lat : 39.8283;
let initialLng = mapData.center ? mapData.center.lng : -98.5795;
let initialZoom = mapData.zoom || 4;
map = L.map('map').setView([initialLat, initialLng], initialZoom);
// Add tile layers
const osmLayer = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
});
const satelliteLayer = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
attribution: '© Esri, © OpenStreetMap contributors',
maxZoom: 18
});
const topoLayer = L.tileLayer('https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png', {
attribution: '© OpenTopoMap contributors',
maxZoom: 17
});
// Add default layer
osmLayer.addTo(map);
// Layer control
const baseLayers = {
"OpenStreetMap": osmLayer,
"Satellite": satelliteLayer,
"Topographic": topoLayer
};
L.control.layers(baseLayers).addTo(map);
// Initialize marker cluster group if clustering is enabled
if (mapData.clustering !== false) {
markerClusterGroup = L.markerClusterGroup({
chunkedLoading: true,
maxClusterRadius: mapData.clusterRadius || 50,
iconCreateFunction: function(cluster) {
const count = cluster.getChildCount();
let className = 'marker-cluster-small';
if (count > 10) {
className = 'marker-cluster-medium';
}
if (count > 100) {
className = 'marker-cluster-large';
}
return new L.DivIcon({
html: '<div><span>' + count + '</span></div>',
className: 'marker-cluster ' + className,
iconSize: new L.Point(40, 40)
});
}
iconCreateFunction: function (cluster) {
var count = cluster.getChildCount();
var cls = count > 100 ? "marker-cluster-large" : count > 10 ? "marker-cluster-medium" : "marker-cluster-small";
return new L.DivIcon({ html: "<div><span>" + count + "</span></div>", className: "marker-cluster " + cls, iconSize: new L.Point(40, 40) });
},
});
}
}
function renderMarkers() {
const markers = mapData.markers || [];
const maxValue = Math.max(...markers.map(m => m.value || 1), 1);
markers.forEach(point => {
// Create custom icon based on value
const value = point.value || 1;
const size = Math.max(10, Math.min(30, (value / maxValue) * 30));
const color = point.color || getColorByValue(value, maxValue);
const customIcon = L.divIcon({
className: 'custom-marker',
html: `<div style="
background-color: ${color};
width: ${size}px;
height: ${size}px;
border-radius: 50%;
border: 2px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: ${Math.max(8, size * 0.4)}px;
">${point.label || (value > 1000 ? Math.round(value/1000) + 'k' : value)}</div>`,
function renderMarkers(mapData) {
var markers = mapData.markers || [];
var maxValue = Math.max.apply(null, markers.map(function (m) { return m.value || 1; }).concat([1]));
markers.forEach(function (point) {
var value = point.value || 1;
var size = Math.max(10, Math.min(28, (value / maxValue) * 28));
var color = point.color || getColorByValue(value, maxValue);
var icon = L.divIcon({
className: "custom-marker",
html: '<div style="background:' + color + ';width:' + size + 'px;height:' + size + 'px;border-radius:50%;border:2px solid rgba(255,255,255,0.9);box-shadow:0 1px 4px rgba(0,0,0,0.25);"></div>',
iconSize: [size, size],
iconAnchor: [size/2, size/2]
iconAnchor: [size / 2, size / 2],
});
const marker = L.marker([point.lat, point.lng], {
icon: point.useDefaultIcon ? new L.Icon.Default() : customIcon
});
// Create popup content if provided
var marker = L.marker([point.lat, point.lng], { icon: point.useDefaultIcon ? new L.Icon.Default() : icon });
if (point.popup || point.name || point.description) {
const popupContent = point.popup || `
<div class="custom-popup">
${point.name ? `<h3>${point.name}</h3>` : ''}
${point.value !== undefined ? `<p><strong>Value:</strong> ${point.value.toLocaleString()}</p>` : ''}
<p><strong>Location:</strong> ${point.lat.toFixed(4)}, ${point.lng.toFixed(4)}</p>
${point.description ? `<p><strong>Description:</strong> ${point.description}</p>` : ''}
</div>
`;
marker.bindPopup(popupContent);
}
// Add to cluster group or directly to map
if (markerClusterGroup) {
markerClusterGroup.addLayer(marker);
} else {
marker.addTo(map);
var html = point.popup || '<div class="custom-popup">' +
(point.name ? "<h3>" + point.name + "</h3>" : "") +
(point.value !== undefined ? "<p><strong>Value:</strong> " + point.value.toLocaleString() + "</p>" : "") +
"<p>" + point.lat.toFixed(4) + ", " + point.lng.toFixed(4) + "</p>" +
(point.description ? "<p>" + point.description + "</p>" : "") +
"</div>";
marker.bindPopup(html);
}
if (markerClusterGroup) markerClusterGroup.addLayer(marker);
else marker.addTo(map);
});
// Add cluster group to map if using clustering
if (markerClusterGroup) {
map.addLayer(markerClusterGroup);
}
// Fit map to marker bounds if autoFit is enabled
if (markerClusterGroup) map.addLayer(markerClusterGroup);
if (mapData.autoFit !== false && markers.length > 0) {
const group = new L.featureGroup(markers.map(m => L.marker([m.lat, m.lng])));
map.fitBounds(group.getBounds().pad(0.1));
var group = new L.featureGroup(markers.map(function (m) { return L.marker([m.lat, m.lng]); }));
savedBounds = group.getBounds();
map.fitBounds(savedBounds.pad(0.05));
}
}
function getColorByValue(value, maxValue) {
const ratio = value / maxValue;
if (ratio > 0.8) return '#d73027';
if (ratio > 0.6) return '#f46d43';
if (ratio > 0.4) return '#fdae61';
if (ratio > 0.2) return '#74add1';
return '#4575b4';
function renderData(mapData) {
document.getElementById("loadingIndicator").classList.add("hidden");
document.getElementById("mapRoot").style.display = "";
document.getElementById("mapTitle").textContent = mapData.title || "";
document.getElementById("mapSubtitle").textContent = mapData.subtitle || "";
initMap(mapData);
renderMarkers(mapData);
setTimeout(McpAppBridge.reportSize, 80);
}
// Initialize everything when page loads
window.addEventListener('load', function() {
initMap();
renderMarkers();
function safeRender(data) {
try { renderData(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
McpAppBridge.init({
appName: "autovisualiser-map",
onData: safeRender,
onTheme: function () {
if (map) setTimeout(function () {
map.invalidateSize();
if (savedBounds) map.fitBounds(savedBounds.pad(0.05));
var isDark = document.documentElement.style.colorScheme === "dark";
var newUrl = isDark
? "https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
: "https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png";
if (tileLayer) {
tileLayer.setUrl(newUrl);
}
}, 100);
},
extractData: function (msg) {
var sc = msg.params && msg.params.structuredContent;
if (sc) {
if (sc.data) return sc.data;
if (sc.markers) return sc;
}
var args = msg.params && msg.params.arguments;
if (args) {
if (args.data) return args.data;
if (args.markers) return args;
}
return null;
},
});
})();
</script>
</body>
</html>
@@ -5,168 +5,159 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mermaid Diagram</title>
<script>
{{MERMAID_MIN}}
</script>
<script>{{MERMAID_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
body { padding: 16px; }
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
padding: 30px;
}
.header {
text-align: center;
margin-bottom: 30px;
}
.header h1 {
margin: 0 0 10px 0;
font-size: 2em;
font-weight: 300;
color: #333;
}
.mermaid {
#mermaidOutput {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
min-height: 120px;
}
/* Mermaid specific styles */
.mermaid .node {
fill: #667eea !important;
stroke: #4a5fc1 !important;
stroke-width: 2px !important;
#mermaidOutput svg {
max-width: 100%;
height: auto;
}
.mermaid .node text {
fill: white !important;
font-weight: 500 !important;
}
.mermaid .edgePath path {
stroke: #667eea !important;
stroke-width: 2px !important;
}
.mermaid .edgeLabel text {
fill: #333 !important;
.mermaid-error {
color: var(--color-text-danger, #f94b4b);
font-size: var(--font-text-sm-size);
font-family: var(--font-mono, monospace);
white-space: pre-wrap;
padding: 12px;
border-radius: var(--border-radius-md);
background: var(--color-background-secondary);
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Mermaid Diagram</h1>
</div>
<div class="mermaid">
{{MERMAID_CODE}}
</div>
</div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div id="mermaidOutput" style="display:none;width:100%;"></div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Initialize Mermaid
mermaid.initialize({
startOnLoad: true,
theme: 'default',
themeVariables: {
primaryColor: '#667eea',
primaryTextColor: '#fff',
primaryBorderColor: '#4a5fc1',
lineColor: '#667eea',
sectionBkgColor: '#f8f9fa',
altSectionBkgColor: '#e9ecef',
gridColor: '#e9ecef',
tertiaryColor: '#f8f9fa'
},
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis'
},
sequence: {
useMaxWidth: true,
htmlLabels: true,
diagramMarginX: 50,
diagramMarginY: 10,
actorMargin: 50,
width: 150,
height: 65,
boxMargin: 10,
boxTextMargin: 5,
noteMargin: 10,
messageMargin: 35,
mirrorActors: true,
bottomMarginAdj: 1,
useMaxWidth: true
},
gantt: {
useMaxWidth: true,
titleTopMargin: 25,
barHeight: 20,
barGap: 4,
topPadding: 50,
leftPadding: 75,
gridLineStartPadding: 35,
fontSize: 11,
fontFamily: '"Open Sans", sans-serif',
numberSectionStyles: 4,
axisFormat: '%Y-%m-%d'
}
});
(function () {
"use strict";
function reportContentSize() {
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
var mermaidReady = false;
// Send size change message to parent window (for MCP-UI iframe auto-resize)
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change',
payload: {
height: contentHeight
function initMermaid() {
if (mermaidReady) return;
mermaidReady = true;
var isDark = document.documentElement.style.colorScheme === "dark";
mermaid.initialize({
startOnLoad: false,
theme: isDark ? "dark" : "default",
themeVariables: isDark
? {
primaryColor: "#3d5a9e",
primaryTextColor: "#e0e0e0",
primaryBorderColor: "#4a6bb5",
lineColor: "#7cacff",
secondaryColor: "#3f434b",
tertiaryColor: "#474e57",
background: "#22252a",
mainBkg: "#3d5a9e",
nodeBorder: "#4a6bb5",
clusterBkg: "#2a2e35",
titleColor: "#e0e0e0",
edgeLabelBackground: "#2a2e35",
}
}, '*');
}
: {
primaryColor: "#5c98f9",
primaryTextColor: "#3f434b",
primaryBorderColor: "#4a82d9",
lineColor: "#5c98f9",
secondaryColor: "#f4f6f7",
tertiaryColor: "#e3e6ea",
background: "#ffffff",
mainBkg: "#5c98f9",
nodeBorder: "#4a82d9",
},
flowchart: { useMaxWidth: true, htmlLabels: true, curve: "basis" },
sequence: { useMaxWidth: true },
gantt: { useMaxWidth: true },
});
}
// Initialize on load
window.onload = function() {
// Report initial size
setTimeout(reportContentSize, 100);
// Normalise common diagram-type casing the LLM gets wrong.
// Mermaid is case-sensitive: "gitgraph" fails, "gitGraph" works, etc.
var diagramCasing = {
gitgraph: "gitGraph",
sequencediagram: "sequenceDiagram",
classdiagram: "classDiagram",
statediagram: "stateDiagram",
erdiagram: "erDiagram",
ganttchart: "gantt",
flowchart: "flowchart",
piechart: "pie",
mindmap: "mindmap",
timeline: "timeline",
};
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
function normaliseMermaidCode(code) {
// Fix the diagram type keyword on the first non-empty line
return code.replace(/^(\s*)(\S+)/, function (match, ws, keyword) {
var fixed = diagramCasing[keyword.toLowerCase()];
return fixed ? ws + fixed : match;
});
}
function renderMermaid(mermaidCode) {
document.getElementById("loadingIndicator").classList.add("hidden");
var output = document.getElementById("mermaidOutput");
output.style.display = "";
if (!mermaidCode || typeof mermaidCode !== "string") {
McpAppBridge.showError("No valid Mermaid code provided");
return;
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
initMermaid();
var code = normaliseMermaidCode(mermaidCode);
var uid = "mermaid-" + Date.now();
// Mermaid injects error SVGs into the DOM as side effects even
// when the promise rejects. Clean the output container first so
// stale artefacts from a previous render don't linger, and clean
// again in the catch path to remove any error SVGs mermaid added.
output.innerHTML = "";
mermaid.render(uid, code).then(function (result) {
output.innerHTML = result.svg;
setTimeout(McpAppBridge.reportSize, 80);
}).catch(function (err) {
// Remove any error artefacts mermaid injected (bomb-icon SVGs, etc.)
document.querySelectorAll('[id^="d' + uid + '"]').forEach(function (el) { el.remove(); });
output.innerHTML = '<div class="mermaid-error">' + (err.message || err) + "</div>";
setTimeout(McpAppBridge.reportSize, 80);
});
}
McpAppBridge.init({
appName: "autovisualiser-mermaid",
onData: renderMermaid,
extractData: function (msg) {
var sc = msg.params && msg.params.structuredContent;
if (sc) {
if (sc.mermaid_code) return sc.mermaid_code;
if (sc.data && sc.data.mermaid_code) return sc.data.mermaid_code;
}
var args = msg.params && msg.params.arguments;
if (args) {
if (args.mermaid_code) return args.mermaid_code;
if (args.data && args.data.mermaid_code) return args.data.mermaid_code;
}
return null;
},
});
})();
</script>
</body>
</html>
@@ -5,269 +5,148 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radar Chart</title>
<script>
{{CHART_MIN}}
</script>
<script>{{CHART_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
color: #333;
}
.container {
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 20px;
font-size: 1.8em;
font-weight: 300;
}
.chart-container {
body { padding: 32px 16px; }
.chart-area {
position: relative;
height: 600px;
width: 100%;
margin: 0 auto;
height: 580px;
display: flex;
justify-content: center;
align-items: center;
}
#radarChart {
max-width: 100%;
max-height: 100%;
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
:root[data-display-mode="fullscreen"] body,
:root[data-display-mode="pip"] body {
padding: 24px;
}
.stats-panel {
background: #f8f9fa;
padding: 20px;
margin-top: 20px;
border-radius: 8px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 15px;
}
.stat-item {
text-align: center;
padding: 10px;
background: white;
border-radius: 6px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.stat-value {
font-size: 1.5em;
font-weight: bold;
color: #007bff;
margin-bottom: 5px;
}
.stat-label {
font-size: 0.85em;
color: #6c757d;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.legend-panel {
background: #f8f9fa;
padding: 15px;
margin-top: 20px;
border-radius: 8px;
}
.legend-title {
font-weight: 600;
margin-bottom: 10px;
color: #495057;
text-align: center;
}
.legend-items {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 15px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
background: white;
border-radius: 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
}
.legend-color {
width: 12px;
height: 12px;
border-radius: 50%;
}
.legend-label {
font-size: 0.85em;
font-weight: 500;
:root[data-display-mode="fullscreen"] .chart-area,
:root[data-display-mode="pip"] .chart-area {
flex: 1;
height: auto;
width: 100%;
min-height: 0;
}
</style>
</head>
<body>
<div class="container">
<div class="chart-container">
<canvas id="radarChart"></canvas>
</div>
<div class="stats-panel" id="statsPanel">
<div class="stat-item">
<div class="stat-value" id="dimensions">0</div>
<div class="stat-label">Dimensions</div>
</div>
<div class="stat-item">
<div class="stat-value" id="datasets">0</div>
<div class="stat-label">Datasets</div>
</div>
<div class="stat-item">
<div class="stat-value" id="maxScore">0</div>
<div class="stat-label">Max Score</div>
</div>
<div class="stat-item">
<div class="stat-value" id="avgScore">0</div>
<div class="stat-label">Average</div>
</div>
</div>
<div class="legend-panel">
<div class="legend-title">Dataset Legend</div>
<div class="legend-items" id="legendItems">
<!-- Legend items will be populated by JavaScript -->
</div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div class="chart-area" id="chartContainer" style="display:none;width:100%;">
<canvas id="radarChart"></canvas>
</div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const radarData = {{RADAR_DATA}};
(function () {
"use strict";
let chart;
// Default color schemes for datasets
const defaultColors = [
'rgba(255, 99, 132, 1)', // Red
'rgba(54, 162, 235, 1)', // Blue
'rgba(75, 192, 192, 1)', // Teal
'rgba(255, 159, 64, 1)', // Orange
'rgba(153, 102, 255, 1)', // Purple
'rgba(255, 206, 86, 1)', // Yellow
'rgba(231, 76, 60, 1)', // Darker Red
'rgba(46, 204, 113, 1)', // Green
'rgba(52, 152, 219, 1)', // Light Blue
'rgba(155, 89, 182, 1)', // Violet
var chart = null;
var palette = [
{ solid: "#5c98f9", fill: "rgba(92,152,249,0.15)" },
{ solid: "#f97066", fill: "rgba(249,112,102,0.15)" },
{ solid: "#47c9a2", fill: "rgba(71,201,162,0.15)" },
{ solid: "#f0a03c", fill: "rgba(240,160,60,0.15)" },
{ solid: "#9b8afb", fill: "rgba(155,138,251,0.15)" },
{ solid: "#e87fa0", fill: "rgba(232,127,160,0.15)" },
{ solid: "#3cc0e0", fill: "rgba(60,192,224,0.15)" },
{ solid: "#8bba5a", fill: "rgba(139,186,90,0.15)" },
];
function getColor(index, alpha = 1) {
const color = defaultColors[index % defaultColors.length];
return alpha === 1 ? color : color.replace('1)', `${alpha})`);
}
function processData(data) {
// Ensure datasets have proper colors and styling
const processedData = {
labels: data.labels || [],
datasets: (data.datasets || []).map((dataset, index) => {
const borderColor = dataset.borderColor || getColor(index);
const backgroundColor = dataset.backgroundColor || getColor(index, 0.2);
return {
label: dataset.label || `Dataset ${index + 1}`,
data: dataset.data || [],
borderColor: borderColor,
backgroundColor: backgroundColor,
borderWidth: dataset.borderWidth || 2,
pointBackgroundColor: dataset.pointBackgroundColor || borderColor,
pointBorderColor: dataset.pointBorderColor || '#fff',
pointHoverBackgroundColor: dataset.pointHoverBackgroundColor || '#fff',
pointHoverBorderColor: dataset.pointHoverBorderColor || borderColor,
fill: dataset.fill !== undefined ? dataset.fill : true,
pointRadius: dataset.pointRadius !== undefined ? dataset.pointRadius : 4,
pointHoverRadius: dataset.pointHoverRadius !== undefined ? dataset.pointHoverRadius : 6
};
})
};
return processedData;
}
function initChart() {
const ctx = document.getElementById('radarChart').getContext('2d');
const data = processData(radarData);
// Determine scale max from data
let maxValue = 100;
if (data.datasets.length > 0) {
const allValues = data.datasets.flatMap(d => d.data);
const dataMax = Math.max(...allValues);
maxValue = Math.ceil(dataMax / 10) * 10; // Round up to nearest 10
}
function renderRadar(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
document.getElementById("chartContainer").style.display = "";
if (chart) { chart.destroy(); chart = null; }
var cv = McpAppBridge.cssVar;
var isDark = document.documentElement.style.colorScheme === "dark";
var textColor = cv("--color-text-primary") || (isDark ? "#e0e0e0" : "#3f434b");
var textSecondary = cv("--color-text-secondary") || (isDark ? "#a0a0a0" : "#878787");
var gridColor = cv("--color-border-primary") || (isDark ? "#333" : "#e3e6ea");
var fontFamily = cv("--font-sans") || "sans-serif";
var datasets = (data.datasets || []).map(function (ds, i) {
var p = palette[i % palette.length];
return {
label: ds.label || "Dataset " + (i + 1),
data: ds.data || [],
borderColor: ds.borderColor || p.solid,
backgroundColor: ds.backgroundColor || p.fill,
borderWidth: ds.borderWidth || 2,
pointStyle: "circle",
pointBackgroundColor: ds.pointBackgroundColor || p.solid,
pointBorderColor: ds.pointBorderColor || p.solid,
pointRadius: 3,
pointHoverRadius: 5,
pointHoverBackgroundColor: ds.pointHoverBackgroundColor || p.solid,
fill: ds.fill !== undefined ? ds.fill : true,
};
});
var allValues = datasets.reduce(function (acc, d) { return acc.concat(d.data); }, []);
var dataMax = allValues.length > 0 ? Math.max.apply(null, allValues) : 100;
var maxValue = Math.ceil(dataMax / 10) * 10;
var ctx = document.getElementById("radarChart").getContext("2d");
chart = new Chart(ctx, {
type: 'radar',
data: data,
type: "radar",
data: { labels: data.labels || [], datasets: datasets },
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'top',
position: "bottom",
labels: {
usePointStyle: true,
padding: 15,
font: {
size: 12
}
}
pointStyle: "circle",
padding: 20,
font: { size: 13, family: fontFamily },
color: textSecondary,
boxWidth: 10,
boxHeight: 10,
generateLabels: function (chart) {
var ds = chart.data.datasets;
return ds.map(function (d, i) {
var color = d.borderColor || d.backgroundColor;
return {
text: d.label,
fontColor: textSecondary,
fillStyle: color,
strokeStyle: color,
lineWidth: 0,
pointStyle: "circle",
hidden: !chart.isDatasetVisible(i),
datasetIndex: i,
};
});
},
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.8)',
titleColor: 'white',
bodyColor: 'white',
borderColor: 'rgba(255, 255, 255, 0.1)',
borderWidth: 1,
backgroundColor: cv("--color-background-inverse") || "rgba(0,0,0,0.85)",
titleColor: cv("--color-text-inverse") || "#fff",
bodyColor: cv("--color-text-inverse") || "#fff",
borderWidth: 0,
cornerRadius: 6,
padding: { top: 8, bottom: 8, left: 10, right: 10 },
titleFont: { size: 12, weight: "600", family: fontFamily },
bodyFont: { size: 12, family: fontFamily },
displayColors: true,
callbacks: {
label: function(context) {
const value = context.parsed.r;
const percentage = ((value / maxValue) * 100).toFixed(1);
return `${context.dataset.label}: ${value} (${percentage}%)`;
}
}
}
boxWidth: 8,
boxHeight: 8,
usePointStyle: true,
},
},
scales: {
r: {
@@ -276,127 +155,39 @@
min: 0,
ticks: {
stepSize: maxValue / 5,
font: {
size: 10
},
color: '#666'
},
grid: {
color: 'rgba(0, 0, 0, 0.1)'
},
angleLines: {
color: 'rgba(0, 0, 0, 0.1)'
font: { size: 10, family: fontFamily },
color: textSecondary,
backdropColor: "transparent",
},
grid: { color: gridColor },
angleLines: { color: gridColor },
pointLabels: {
font: {
size: 11,
weight: 'bold'
},
color: '#333'
}
}
font: { size: 11, weight: "500", family: fontFamily },
color: textColor,
},
},
},
animation: {
duration: 1000,
easing: 'easeInOutQuart'
},
interaction: {
intersect: false,
mode: 'point'
}
}
animation: { duration: 500, easing: "easeOutQuart" },
interaction: { intersect: false, mode: "point" },
},
});
updateStats();
updateLegend();
setTimeout(McpAppBridge.reportSize, 80);
}
function updateStats() {
const data = processData(radarData);
const dimensions = data.labels.length;
const datasets = data.datasets.length;
let allValues = [];
data.datasets.forEach(dataset => {
allValues = allValues.concat(dataset.data);
});
const maxScore = allValues.length > 0 ? Math.max(...allValues) : 0;
const avgScore = allValues.length > 0
? allValues.reduce((a, b) => a + b, 0) / allValues.length
: 0;
document.getElementById('dimensions').textContent = dimensions;
document.getElementById('datasets').textContent = datasets;
document.getElementById('maxScore').textContent = maxScore.toFixed(0);
document.getElementById('avgScore').textContent = avgScore.toFixed(1);
function safeRender(data) {
try { renderRadar(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
function updateLegend() {
const legendContainer = document.getElementById('legendItems');
const data = processData(radarData);
legendContainer.innerHTML = '';
data.datasets.forEach(dataset => {
const legendItem = document.createElement('div');
legendItem.className = 'legend-item';
const colorBox = document.createElement('div');
colorBox.className = 'legend-color';
colorBox.style.backgroundColor = dataset.borderColor;
const label = document.createElement('span');
label.className = 'legend-label';
label.textContent = dataset.label;
legendItem.appendChild(colorBox);
legendItem.appendChild(label);
legendContainer.appendChild(legendItem);
});
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
// Get the actual content height
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change',
payload: {
height: contentHeight
}
}, '*');
}
}
// Initialize and render on load
window.onload = function() {
initChart();
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
McpAppBridge.init({
appName: "autovisualiser-radar",
onData: safeRender,
onTheme: function () {
if (McpAppBridge.currentData) safeRender(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
@@ -5,279 +5,233 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Sankey Diagram</title>
<script>
{{D3_MIN}}
</script>
<script>
{{D3_SANKY}}
</script>
<script>{{D3_MIN}}</script>
<script>{{D3_SANKY}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
#sankey {
border: 1px solid #ddd;
border-radius: 4px;
background: white;
display: block;
margin: 0 auto;
}
body { padding: 16px; }
.node rect {
cursor: pointer;
stroke: #000;
stroke-width: 1px;
stroke-width: 0;
}
.node text {
pointer-events: none;
font-size: 12px;
font-weight: bold;
font-size: var(--font-text-xs-size);
font-weight: var(--font-weight-medium);
fill: var(--color-text-primary);
}
.link {
fill: none;
stroke-opacity: 0.5;
stroke-opacity: 0.35;
cursor: pointer;
transition: stroke-opacity 0.15s ease;
}
.link:hover {
stroke-opacity: 0.8;
}
.tooltip {
position: absolute;
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 8px;
border-radius: 4px;
font-size: 12px;
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
z-index: 1000;
stroke-opacity: 0.6;
}
</style>
</head>
<body>
<div class="container">
<svg id="sankey"></svg>
<div class="tooltip" id="tooltip"></div>
</div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<svg id="sankey" style="width:100%;"></svg>
<div class="av-tooltip" id="tooltip"></div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const sankeyData = {{SANKEY_DATA}};
(function () {
"use strict";
// Sankey variables
let svg, sankey, tooltip;
const width = 500;
const height = 400;
const margin = { top: 20, right: 20, bottom: 20, left: 20 };
var svg, sankey, tooltip;
var margin = { top: 16, right: 16, bottom: 16, left: 16 };
// Color schemes for different categories
const colorSchemes = {
source: "#ff6b6b",
income: "#4ecdc4",
input: "#45b7d1",
generation: "#f9ca24",
process: "#f0932b",
pool: "#6c5ce7",
distribution: "#a29bfe",
logistics: "#fd79a8",
landing: "#00b894",
page: "#00cec9",
storage: "#fdcb6e",
retail: "#e17055",
consumption: "#74b9ff",
expense: "#fd79a8",
conversion: "#00b894",
end: "#00b894",
savings: "#55a3ff",
loss: "#636e72",
exit: "#636e72",
reverse: "#ddd",
waste: "#636e72",
default: "#69b3a2"
};
function getWidth() {
return document.body.clientWidth - 32 || 500;
}
function getHeight() {
if (McpAppBridge.displayMode === "fullscreen" || McpAppBridge.displayMode === "pip") {
return window.innerHeight - 64;
}
return 420;
}
var palette = [
"#5c98f9", "#f97066", "#47c9a2", "#f0a03c",
"#9b8afb", "#e87fa0", "#3cc0e0", "#8bba5a",
];
function nodeColor(d) {
return palette[Math.abs(hashCode(d.name)) % palette.length];
}
function hashCode(str) {
var h = 0;
for (var i = 0; i < str.length; i++) {
h = ((h << 5) - h + str.charCodeAt(i)) | 0;
}
return h;
}
// Initialize SVG
function initializeSVG() {
svg = d3.select("#sankey")
.attr("width", width)
.attr("height", height);
var width = getWidth();
var height = getHeight();
svg = d3.select("#sankey").attr("width", width).attr("height", height);
svg.selectAll("*").remove();
sankey = d3.sankey()
.nodeWidth(20)
.nodePadding(10)
sankey = d3.sankey().nodeWidth(18).nodePadding(12)
.extent([[margin.left, margin.top], [width - margin.right, height - margin.bottom]]);
tooltip = d3.select("#tooltip");
}
// Draw the Sankey diagram
function prepareGraph(data) {
var nodes = data.nodes.map(function (d) { return Object.assign({}, d); });
var links = [];
var nameToIdx = new Map();
nodes.forEach(function (n, i) { nameToIdx.set(n.name, i); });
// Filter out links with unknown nodes or invalid values
data.links.forEach(function (link) {
if (nameToIdx.has(link.source) && nameToIdx.has(link.target) && link.value > 0) {
links.push({ source: link.source, target: link.target, value: link.value });
}
});
// Detect and break cycles by creating synthetic return nodes.
// d3-sankey doesn't support circular links so we redirect back-edges
// to a new "Target → Source" node to preserve the data visually.
var adj = new Map();
links.forEach(function (l) {
if (!adj.has(l.source)) adj.set(l.source, []);
adj.get(l.source).push(l.target);
});
var WHITE = 0, GRAY = 1, BLACK = 2;
var color = new Map();
nodes.forEach(function (n) { color.set(n.name, WHITE); });
var backEdges = new Set();
function dfs(u) {
color.set(u, GRAY);
(adj.get(u) || []).forEach(function (v) {
if (color.get(v) === GRAY) {
backEdges.add(u + "\0" + v);
} else if (color.get(v) === WHITE) {
dfs(v);
}
});
color.set(u, BLACK);
}
nodes.forEach(function (n) {
if (color.get(n.name) === WHITE) dfs(n.name);
});
if (backEdges.size > 0) {
var fixedLinks = [];
links.forEach(function (l) {
var key = l.source + "\0" + l.target;
if (backEdges.has(key)) {
var syntheticName = l.target + " ↩";
if (!nameToIdx.has(syntheticName)) {
nameToIdx.set(syntheticName, nodes.length);
nodes.push({ name: syntheticName, category: "cycle" });
}
fixedLinks.push({ source: l.source, target: syntheticName, value: l.value });
} else {
fixedLinks.push(l);
}
});
links = fixedLinks;
}
// Rebuild index map after possible new nodes
var idxMap = new Map();
nodes.forEach(function (n, i) { idxMap.set(n.name, i); });
var processedLinks = links.map(function (l) {
return { source: idxMap.get(l.source), target: idxMap.get(l.target), value: l.value };
});
return { nodes: nodes, links: processedLinks };
}
function drawSankey(data) {
// Create a map of node names to indices
const nodeMap = new Map();
data.nodes.forEach((node, index) => {
nodeMap.set(node.name, index);
var prepared = prepareGraph(data);
var graph = sankey({
nodes: prepared.nodes,
links: prepared.links,
});
// Process data for D3 Sankey - convert node names to indices in links
const processedLinks = data.links.map(link => ({
source: nodeMap.get(link.source),
target: nodeMap.get(link.target),
value: link.value
}));
const graph = sankey({
nodes: data.nodes.map(d => ({ ...d })),
links: processedLinks
});
// Draw links
svg.append("g")
.selectAll("path")
.data(graph.links)
.enter().append("path")
svg.append("g").selectAll("path").data(graph.links).enter().append("path")
.attr("class", "link")
.attr("d", d3.sankeyLinkHorizontal())
.attr("stroke", d => {
const sourceCategory = d.source.category;
return colorSchemes[sourceCategory] || colorSchemes.default;
.attr("stroke", function (d) { return nodeColor(d.source); })
.attr("stroke-width", function (d) { return Math.max(1, d.width); })
.on("mouseover", function (event, d) {
tooltip.style("opacity", 1)
.html("<strong>" + d.source.name + " → " + d.target.name + "</strong><br/>" + d.value.toLocaleString());
McpAppBridge.positionTooltip(tooltip, event);
})
.attr("stroke-width", d => Math.max(1, d.width))
.on("mouseover", function(event, d) {
showLinkTooltip(event, d);
})
.on("mouseout", hideTooltip);
.on("mouseout", function () { tooltip.style("opacity", 0); });
// Draw nodes
const node = svg.append("g")
.selectAll("g")
.data(graph.nodes)
.enter().append("g")
.attr("class", "node");
var node = svg.append("g").selectAll("g").data(graph.nodes).enter().append("g").attr("class", "node");
node.append("rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("height", d => d.y1 - d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("fill", d => colorSchemes[d.category] || colorSchemes.default)
.on("mouseover", function(event, d) {
showNodeTooltip(event, d);
.attr("x", function (d) { return d.x0; }).attr("y", function (d) { return d.y0; })
.attr("height", function (d) { return d.y1 - d.y0; }).attr("width", function (d) { return d.x1 - d.x0; })
.attr("fill", function (d) { return nodeColor(d); })
.attr("rx", 2)
.on("mouseover", function (event, d) {
var totalIn = d.targetLinks.reduce(function (s, l) { return s + l.value; }, 0);
var totalOut = d.sourceLinks.reduce(function (s, l) { return s + l.value; }, 0);
tooltip.style("opacity", 1)
.html("<strong>" + d.name + "</strong>" +
(totalIn ? "<br/>In: " + totalIn.toLocaleString() : "") +
(totalOut ? "<br/>Out: " + totalOut.toLocaleString() : ""));
McpAppBridge.positionTooltip(tooltip, event);
})
.on("mouseout", hideTooltip);
.on("mouseout", function () { tooltip.style("opacity", 0); });
// Add node labels
var w = getWidth();
node.append("text")
.attr("x", d => d.x0 < width / 2 ? d.x1 + 6 : d.x0 - 6)
.attr("y", d => (d.y1 + d.y0) / 2)
.attr("x", function (d) { return d.x0 < w / 2 ? d.x1 + 8 : d.x0 - 8; })
.attr("y", function (d) { return (d.y1 + d.y0) / 2; })
.attr("dy", "0.35em")
.attr("text-anchor", d => d.x0 < width / 2 ? "start" : "end")
.text(d => d.name)
.style("font-size", "12px")
.style("fill", "#333");
.attr("text-anchor", function (d) { return d.x0 < w / 2 ? "start" : "end"; })
.text(function (d) { return d.name; });
}
// Tooltip functions
function showNodeTooltip(event, d) {
const totalIn = d.targetLinks.reduce((sum, link) => sum + link.value, 0);
const totalOut = d.sourceLinks.reduce((sum, link) => sum + link.value, 0);
tooltip.style("opacity", 1)
.html(`
<strong>${d.name}</strong><br/>
Category: ${d.category || 'N/A'}<br/>
Total In: ${totalIn.toLocaleString()}<br/>
Total Out: ${totalOut.toLocaleString()}<br/>
Net: ${(totalIn - totalOut).toLocaleString()}
`)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
}
function showLinkTooltip(event, d) {
tooltip.style("opacity", 1)
.html(`
<strong>Flow</strong><br/>
From: ${d.source.name}<br/>
To: ${d.target.name}<br/>
Value: ${d.value.toLocaleString()}<br/>
Width: ${d.width.toFixed(1)}px
`)
.style("left", (event.pageX + 10) + "px")
.style("top", (event.pageY - 10) + "px");
}
function hideTooltip() {
tooltip.style("opacity", 0);
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
// Get the actual content height
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
// The message format must match what the MCP-UI client expects
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change', // lowercase as expected by MCP-UI client
payload: {
height: contentHeight
// width is optional, we're not setting it to allow responsive design
}
}, '*');
}
}
// Initialize and render on load
window.onload = function() {
function renderData(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
initializeSVG();
drawSankey(sankeyData);
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
try {
if (!data.nodes || !data.nodes.length || !data.links || !data.links.length) {
throw new Error("Sankey data must include at least one node and one link.");
}
drawSankey(data);
} catch (e) {
svg.append("text")
.attr("x", getWidth() / 2).attr("y", getHeight() / 2)
.attr("text-anchor", "middle")
.attr("fill", McpAppBridge.cssVar("--color-text-secondary") || "#878787")
.attr("font-size", "14px")
.text("Unable to render diagram: " + (e.message || e));
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
setTimeout(McpAppBridge.reportSize, 80);
}
McpAppBridge.init({
appName: "autovisualiser-sankey",
onData: renderData,
onTheme: function () {
if (McpAppBridge.currentData) renderData(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
</html>
@@ -3,319 +3,162 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Treemap Visualization</title>
<script>
{{D3_MIN}}
</script>
<title>Treemap</title>
<script>{{D3_MIN}}</script>
<style>{{MCP_APP_BASE_CSS}}</style>
<style>
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
margin: 0 auto;
}
h1 {
text-align: center;
color: #333;
margin-bottom: 30px;
}
.treemap-container {
padding: 0px;
}
body { padding: 16px; }
.treemap-rect {
stroke: white;
stroke: var(--color-background-primary, #fff);
stroke-width: 2px;
cursor: pointer;
transition: all 0.3s ease;
transition: filter 0.15s ease;
}
.treemap-rect:hover {
stroke-width: 3px;
stroke: #333;
filter: brightness(1.1);
filter: brightness(1.12);
}
.treemap-text {
font-size: 12px;
font-weight: 500;
fill: white;
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
font-size: 11px;
font-weight: var(--font-weight-medium);
fill: #fff;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
pointer-events: none;
text-anchor: middle;
dominant-baseline: middle;
}
.treemap-value {
font-size: 10px;
fill: rgba(255,255,255,0.8);
text-shadow: 1px 1px 2px rgba(0,0,0,0.7);
fill: rgba(255, 255, 255, 0.75);
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
pointer-events: none;
text-anchor: middle;
dominant-baseline: middle;
}
.group-label {
font-size: 14px;
font-weight: bold;
fill: #333;
pointer-events: none;
}
.tooltip {
position: absolute;
background: rgba(0,0,0,0.9);
color: white;
padding: 10px;
border-radius: 5px;
font-size: 12px;
pointer-events: none;
z-index: 1000;
opacity: 0;
transition: opacity 0.3s ease;
}
</style>
</head>
<body>
<div class="container">
<div class="treemap-container" id="treemap"></div>
</div>
<div class="tooltip" id="tooltip"></div>
<div class="av-loading" id="loadingIndicator">Waiting for data…</div>
<div id="treemap" style="width:100%;"></div>
<div class="av-tooltip" id="tooltip"></div>
<script>{{MCP_APP_BRIDGE}}</script>
<script>
// Data will be injected here
const treemapData = {{TREEMAP_DATA}};
let currentData = treemapData;
let currentRoot = treemapData;
let svg, treemap, root;
const width = 800;
const height = 500;
// Color scheme for different categories
const colorScale = d3.scaleOrdinal(d3.schemeTableau10);
(function () {
"use strict";
var svg, treemapLayout, root;
function getHeight() {
if (McpAppBridge.displayMode === "fullscreen" || McpAppBridge.displayMode === "pip") {
return window.innerHeight - 64;
}
return 460;
}
var palette = [
"#5c98f9", "#f97066", "#47c9a2", "#f0a03c",
"#9b8afb", "#e87fa0", "#3cc0e0", "#8bba5a",
];
var colorScale = d3.scaleOrdinal(palette);
function formatValue(v) {
if (v >= 1e6) return (v / 1e6).toFixed(1) + "M";
if (v >= 1e3) return (v / 1e3).toFixed(1) + "K";
return v.toLocaleString();
}
function getWidth() {
var container = document.getElementById("treemap");
return container.clientWidth || 800;
}
function initializeTreemap() {
const container = d3.select("#treemap");
var container = d3.select("#treemap");
container.selectAll("*").remove();
svg = container.append("svg")
.attr("width", width)
.attr("height", height)
.style("font", "10px sans-serif");
treemap = d3.treemap()
.size([width, height])
.padding(2)
.round(true);
var width = getWidth();
var h = getHeight();
svg = container.append("svg").attr("width", width).attr("height", h);
treemapLayout = d3.treemap().size([width, h]).padding(2).round(true);
}
function updateTreemap(data, isRoot = true) {
currentData = data;
if (isRoot) {
currentRoot = data;
}
// Create hierarchy and calculate layout
function updateTreemap(data) {
root = d3.hierarchy(data)
.sum(d => d.value || 0)
.sort((a, b) => b.value - a.value);
treemap(root);
// Clear previous content
.sum(function (d) { return d.value || 0; })
.sort(function (a, b) { return b.value - a.value; });
treemapLayout(root);
svg.selectAll("*").remove();
// Create leaf nodes
const leaf = svg.selectAll("g")
.data(root.leaves())
.join("g")
.attr("transform", d => `translate(${d.x0},${d.y0})`);
// Add rectangles
leaf.append("rect")
.attr("class", "treemap-rect")
.attr("fill", d => {
// Use category for color, or parent name if no category
const colorKey = d.data.category || d.parent.data.name || 'default';
return colorScale(colorKey);
var leaf = svg.selectAll("g").data(root.leaves()).join("g")
.attr("transform", function (d) { return "translate(" + d.x0 + "," + d.y0 + ")"; });
leaf.append("rect").attr("class", "treemap-rect")
.attr("fill", function (d) { return colorScale(d.data.category || (d.parent ? d.parent.data.name : "default")); })
.attr("width", function (d) { return d.x1 - d.x0; })
.attr("height", function (d) { return d.y1 - d.y0; })
.attr("rx", 3)
.on("mouseover", function (event, d) {
var pct = ((d.data.value / root.value) * 100).toFixed(1);
var tt = document.getElementById("tooltip");
tt.innerHTML = "<strong>" + d.data.name + "</strong><br/>" + formatValue(d.data.value) + " (" + pct + "%)" +
(d.data.category ? "<br/>" + d.data.category : "");
tt.style.opacity = 1;
McpAppBridge.positionTooltip(tt, event);
})
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0)
.on("mouseover", function(event, d) {
showTooltip(event, d);
.on("mousemove", function (event) {
McpAppBridge.positionTooltip(document.getElementById("tooltip"), event);
})
.on("mousemove", function(event, d) {
moveTooltip(event);
})
.on("mouseout", function() {
hideTooltip();
})
.on("click", function(event, d) {
// Navigate to parent if it has children
if (d.parent && d.parent.data.children) {
updateTreemap(d.parent.data, false);
.on("mouseout", function () { document.getElementById("tooltip").style.opacity = 0; });
leaf.append("text").attr("class", "treemap-text")
.attr("x", function (d) { return (d.x1 - d.x0) / 2; })
.attr("y", function (d) { return (d.y1 - d.y0) / 2 - 6; })
.text(function (d) { return d.data.name; })
.style("font-size", function (d) { return Math.min((d.x1 - d.x0) / 8, (d.y1 - d.y0) / 4, 14) + "px"; })
.each(function (d) {
var textLen = this.getComputedTextLength();
var avail = d.x1 - d.x0 - 8;
if (textLen > avail) {
var text = d.data.name;
var ratio = avail / textLen;
var maxLen = Math.floor(text.length * ratio) - 2;
d3.select(this).text(maxLen > 0 ? text.substring(0, maxLen) + "…" : "");
}
});
// Add text labels
leaf.append("text")
.attr("class", "treemap-text")
.attr("x", d => (d.x1 - d.x0) / 2)
.attr("y", d => (d.y1 - d.y0) / 2 - 5)
.text(d => d.data.name)
.style("font-size", d => Math.min((d.x1 - d.x0) / 8, (d.y1 - d.y0) / 4, 14) + "px")
.each(function(d) {
const textLength = this.getComputedTextLength();
const availableWidth = d.x1 - d.x0 - 4;
if (textLength > availableWidth) {
const text = d.data.name;
const ratio = availableWidth / textLength;
const maxLength = Math.floor(text.length * ratio) - 3;
if (maxLength > 0) {
d3.select(this).text(text.substring(0, maxLength) + "...");
} else {
d3.select(this).text("");
}
}
});
// Add value labels
leaf.append("text")
.attr("class", "treemap-value")
.attr("x", d => (d.x1 - d.x0) / 2)
.attr("y", d => (d.y1 - d.y0) / 2 + 10)
.text(d => formatValue(d.data.value))
.style("font-size", d => Math.min((d.x1 - d.x0) / 10, (d.y1 - d.y0) / 6, 12) + "px")
.style("display", d => {
// Hide value if rectangle is too small
const rectHeight = d.y1 - d.y0;
return rectHeight < 30 ? "none" : "block";
});
// Add group boundaries and labels for navigation (if data has children)
if (data.children) {
// Add group rectangles for visual hierarchy
const groups = svg.selectAll(".group-rect")
.data(root.children || [])
.join("rect")
.attr("class", "group-rect")
.attr("x", d => d.x0)
.attr("y", d => d.y0)
.attr("width", d => d.x1 - d.x0)
.attr("height", d => d.y1 - d.y0)
.attr("fill", "none")
.attr("stroke", "#333")
.attr("stroke-width", 2)
.attr("stroke-dasharray", "3,3")
.style("cursor", "pointer")
.style("pointer-events", "none");
// Add group labels
svg.selectAll(".group-label")
.data(root.children || [])
.join("text")
.attr("class", "group-label")
.attr("x", d => d.x0 + 5)
.attr("y", d => d.y0 + 15)
.text(d => d.data.name)
.style("display", d => {
// Hide label if group is too small
const groupWidth = d.x1 - d.x0;
return groupWidth < 50 ? "none" : "block";
});
}
leaf.append("text").attr("class", "treemap-value")
.attr("x", function (d) { return (d.x1 - d.x0) / 2; })
.attr("y", function (d) { return (d.y1 - d.y0) / 2 + 9; })
.text(function (d) { return formatValue(d.data.value); })
.style("font-size", function (d) { return Math.min((d.x1 - d.x0) / 10, (d.y1 - d.y0) / 6, 12) + "px"; })
.style("display", function (d) { return (d.y1 - d.y0) < 30 ? "none" : "block"; });
}
function showTooltip(event, d) {
const tooltip = document.getElementById('tooltip');
const percentage = ((d.data.value / root.value) * 100).toFixed(1);
tooltip.innerHTML = `
<strong>${d.data.name}</strong><br>
Value: ${formatValue(d.data.value)}<br>
${d.data.category ? `Category: ${d.data.category}<br>` : ''}
Percentage: ${percentage}%
${d.parent ? `<br>Parent: ${d.parent.data.name}` : ''}
`;
tooltip.style.opacity = 1;
moveTooltip(event);
}
function moveTooltip(event) {
const tooltip = document.getElementById('tooltip');
tooltip.style.left = (event.pageX + 10) + 'px';
tooltip.style.top = (event.pageY - 10) + 'px';
}
function hideTooltip() {
const tooltip = document.getElementById('tooltip');
tooltip.style.opacity = 0;
}
function formatValue(value) {
if (value >= 1000000) {
return (value / 1000000).toFixed(1) + 'M';
} else if (value >= 1000) {
return (value / 1000).toFixed(1) + 'K';
}
return value.toLocaleString();
}
// Function to measure and report content size for iframe auto-resizing
function reportContentSize() {
// Get the actual content height
const contentHeight = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.clientHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight
);
// Send size change message to parent window (for MCP-UI iframe auto-resize)
// The message format must match what the MCP-UI client expects
if (window.parent !== window) {
window.parent.postMessage({
type: 'ui-size-change', // lowercase as expected by MCP-UI client
payload: {
height: contentHeight
// width is optional, we're not setting it to allow responsive design
}
}, '*');
}
}
// Initialize and render on load
window.onload = function() {
function renderData(data) {
document.getElementById("loadingIndicator").classList.add("hidden");
initializeTreemap();
updateTreemap(treemapData);
// Report initial size
setTimeout(reportContentSize, 100);
// Watch for size changes using ResizeObserver if available
if (typeof ResizeObserver !== 'undefined') {
const resizeObserver = new ResizeObserver(() => {
reportContentSize();
});
resizeObserver.observe(document.body);
resizeObserver.observe(document.documentElement);
}
// Fallback: also report on window resize
window.addEventListener('resize', reportContentSize);
};
updateTreemap(data);
setTimeout(McpAppBridge.reportSize, 80);
}
function safeRender(data) {
try { renderData(data); }
catch (e) { McpAppBridge.showError(e.message || e); }
}
McpAppBridge.init({
appName: "autovisualiser-treemap",
onData: safeRender,
onTheme: function () {
if (McpAppBridge.currentData) safeRender(McpAppBridge.currentData);
},
});
})();
</script>
</body>
</html>
+1
View File
@@ -0,0 +1 @@
Goose <goose@block.xyz>
@@ -549,9 +549,27 @@ export default function McpAppRenderer({
[]
);
// Track when we *return* to inline from fullscreen/pip so we can briefly
// suppress stale size reports. The iframe body reflows from 100vh to natural
// height, which triggers a cascade of intermediate size-changed notifications
// that cause a visible "slow shrink" animation.
const inlineTransitionRef = useRef(false);
const wasInlineRef = useRef(isInline);
useEffect(() => {
const wasInline = wasInlineRef.current;
wasInlineRef.current = isInline;
// Only suppress when transitioning *back* to inline, not on initial mount.
if (!isInline || wasInline) return;
inlineTransitionRef.current = true;
const timer = setTimeout(() => {
inlineTransitionRef.current = false;
}, 300);
return () => clearTimeout(timer);
}, [isInline]);
const handleSizeChanged = useCallback(
({ height }: McpUiSizeChangedNotification['params']) => {
if (height !== undefined && height > 0 && isInline) {
if (height !== undefined && height > 0 && isInline && !inlineTransitionRef.current) {
setIframeHeight(height);
}
},