mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
feat(autovisualiser): Migrate the autovisualiser extension to MCP Apps (#7852)
Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
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 & 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: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> © <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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user