
Chart
- 3.7k installs
- 18 repo stars
- Updated July 27, 2026
- starchild-ai-agent/official-skills
Interactive ECharts web page with specified data, template, and PNG export capability.
About
Chart is an interactive web charting skill that generates ECharts-based visualization pages with project-based organization. Developers use it to create time-series trends, category comparisons, OHLCV price analysis, and multi-dimensional dashboards. The skill supports 11 templates (line, bar, pie, candlestick, scatter, dashboard, radar, heatmap, dual-axis, multi-panel, waterfall) stored in dedicated output folders. Each chart project includes an HTML page, reproducible Python generation script, data snapshot, and optional PNG screenshot. Workflows involve picking a template, creating a project folder, building the chart with `build_chart()`, serving via `chart_server.py`, and exporting images via toolbar buttons (Download PNG, Copy Image, Save Image). Data embeds directly in HTML to avoid CORS issues. 11 chart templates: line, bar, candlestick, scatter, dashboard, dual-axis, multi-panel, waterfall, pie, radar, heatmap. Project-based storage: each chart in dedicated folder with index.html, generate.py, data.json, screenshot.png, README.
- 11 chart templates: line, bar, candlestick, scatter, dashboard, dual-axis, multi-panel, waterfall, pie, radar, heatmap
- Project-based storage: each chart in dedicated folder with index.html, generate.py, data.json, screenshot.png, README
- Toolbar exports: Download PNG, Copy Image to clipboard, Save Image to project folder
- ECharts CDN rendering with native getDataURL canvas merge for reliable PNG output
- Synced tooltips and zoom across multi-panel charts; floating-base waterfall for P&L attribution
Chart by the numbers
- 3,686 all-time installs (skills.sh)
- +81 installs in the week ending Jul 28, 2026 (Skillselion tracking)
- Ranked #131 of 2,277 Frontend Development skills by installs in the Skillselion catalog
- Security screen: MEDIUM risk (skills.sh audit)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
chart capabilities & compatibility
- Capabilities
- line chart · bar chart · candlestick chart · scatter chart · dashboard · png export · clipboard copy · multi panel sync
- Use cases
- data analysis
- Platforms
- macOS · Windows · Linux
- Runs
- Runs locally
- Pricing
- Free
What chart says it does
Interactive web charts: line, bar, candle, scatter, with HTML and screenshot output.
npx skills add https://github.com/starchild-ai-agent/official-skills --skill chartAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 3.7k |
|---|---|
| repo stars | ★ 18 |
| Security audit | 2 / 3 scanners passed |
| Last updated | July 27, 2026 |
| Repository | starchild-ai-agent/official-skills ↗ |
What it does
Generate interactive web charts (line, bar, candlestick, scatter) with PNG export for data visualization and business intelligence.
Who is it for?
Data analysis, BI dashboards, price charting, financial reporting, time-series comparisons, correlation analysis.
Skip if: Real-time streaming dashboards, high-frequency trading visualization, 3D geospatial rendering.
When should I use this skill?
User requests visual chart for data exploration, dashboard creation, or report generation.
What you get
Reproducible, shareable chart projects with interactive web pages, PNG exports, and version-controlled generation scripts.
- index.html (interactive chart page)
- generate.py (reproducible script)
- data.json (data snapshot)
By the numbers
- 11 chart templates available
- Project structure: 4 files per chart (index.html, generate.py, data.json, screenshot.png)
- Toolbar includes 3 export buttons
Files
Chart — Project-Based Interactive Charting
Generate interactive chart pages with Apache ECharts. Each chart lives in a dedicated project folder under output/chart-html/, making it easy to reuse and iterate.
When to Use
Any time the user wants a visual chart: price charts, comparisons, dashboards, business analytics, etc.
Architecture
- ECharts (CDN) for rendering
- ECharts native export (`getDataURL`) + canvas merge for reliable PNG output
- Project-based storage: one folder per chart project
- No gallery mode: all artifacts stay in the project folder
Project Structure (Required)
Each chart project should follow:
output/chart-html/
<project-name>/
index.html # chart page
generate.py # generation script (for reproducibility)
README.md # title / description / data source notes
data.json # data snapshot
screenshot.png # saved imageExample folder name: btc-90d-20260401
Workflow
Step 1: Pick template or custom layout
Available templates:
| Template | Best for |
|---|---|
line.html | Time-series trends, multi-series comparisons |
bar.html | Category comparisons, rankings |
pie.html | Composition / share breakdown |
candlestick.html | OHLCV price charts |
scatter.html | Correlation, distribution |
dashboard.html | KPI cards + 2×2 multi-chart grid |
radar.html | Multi-dimension scoring |
heatmap.html | Matrix / calendar intensity |
dual-axis.html | Two series with very different scales (e.g. market cap vs stablecoin supply) — left and right Y axes, each with its own label color |
multi-panel.html | Stacked panels sharing one X axis (e.g. price + volume + RSI) — single ECharts instance, tooltip/zoom synced across all panels |
waterfall.html | Incremental contribution breakdown (e.g. P&L attribution, budget variance) — positive/negative bars stacked on a floating base |
Step 2: Create project folder
Use create_project(name, description, data_sources) from scripts/build_chart.py.
Step 3: Build and save chart page
Use either:
build_chart(template_name, ...)build_chart_custom(...)
Then save as index.html in the project folder:
save_chart(html, project_dir=project_dir)
Step 4: Save reproducible assets
Also save:
save_generate_script(script_content, project_dir)→generate.pysave_data(data, project_dir)→data.json- project README is created by
create_project(...)
Step 5: Serve preview
Use project-root serving (recommended):
preview_serve(
title="Chart Preview",
dir="skills/chart/scripts",
command="python3 chart_server.py /data/workspace/output/chart-html 7860",
port=7860
)Then open: /preview/<id>/<project-name>/index.html
Important behavior in v3.0.1:
chart_server.pynow rewrites preview-prefixed static paths internally (/preview/<id>/...→/...) before filesystem lookup.- This guarantees the preview iframe resolves the real project
index.htmlinstead of falling back to root directory listing. - Keep project pages under
output/chart-html/<project>/index.html(do not serveoutput/chart-htmldirectly as a static preview withoutchart_server.py).
Step 6: Export image
Two modes: 1. User wants web page + image: click "💾 Save Image" in page toolbar, saves to current project as screenshot.png 2. User wants image only: call screenshot_chart(project_dir) (Playwright) and send screenshot.png directly
Toolbar Requirements
Every chart page must include these buttons:
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>Do not include gallery entry.
Key Files
| File | Purpose |
|---|---|
skills/chart/scripts/base-styles.css | Base dark theme CSS |
skills/chart/scripts/base-export.js | Export helpers: download/copy/save-to-project |
skills/chart/scripts/build_chart.py | Project creation, HTML build, data/script save, screenshot |
skills/chart/scripts/chart_server.py | Static server + /save-chart API |
skills/chart/templates/*.html | Reusable chart templates |
output/chart-html/<project>/* | All generated chart artifacts |
Notes
- Embed data directly in HTML (
const DATA = ...) to avoid iframe CORS issues. - For multi-chart pages, register all chart instances in
window.CHART_INSTANCES. - Use meaningful project names (
topic-range-date) for easy lookup.
/* Chart Skill — Export Utilities v3 (project-based)
* Strategy: ECharts native getDataURL() + Canvas merge
*
* Functions exposed:
* downloadPNG() — merge all charts into one PNG and download
* copyToClipboard() — copy merged PNG to clipboard (fallback: download)
* saveToProject() — POST PNG to /save-chart and save into current project dir
*/
/* ── Toast notification ─────────────────────────────────── */
function showToast(msg, type = 'info') {
let t = document.getElementById('toast');
if (!t) {
t = document.createElement('div');
t.id = 'toast';
t.style.cssText = `
position:fixed;bottom:24px;right:24px;z-index:9999;
background:#1e2130;border:1px solid #2d3148;color:#e1e4ea;
padding:10px 18px;border-radius:8px;font-size:0.85rem;
opacity:0;transition:opacity 0.25s;pointer-events:none;
box-shadow:0 4px 20px rgba(0,0,0,0.4);
`;
document.body.appendChild(t);
}
t.style.borderColor = type === 'success' ? '#34d399' : type === 'error' ? '#ef4444' : '#2d3148';
t.style.color = type === 'success' ? '#34d399' : type === 'error' ? '#ef4444' : '#e1e4ea';
t.textContent = msg;
t.style.opacity = '1';
clearTimeout(t._hideTimer);
t._hideTimer = setTimeout(() => { t.style.opacity = '0'; }, 2500);
}
/* ── Preview-safe API URL helpers ─────────────────────────── */
function getPreviewBasePath() {
const m = window.location.pathname.match(/^(\/preview\/[^/]+\/)/);
return m ? m[1] : '/';
}
function apiUrl(endpoint) {
const base = getPreviewBasePath();
const ep = String(endpoint || '').replace(/^\/+/, '');
return `${base}${ep}`;
}
/* ── Resolve current project from pathname ───────────────── */
function getCurrentProjectPath() {
// expected path: /preview/<id>/<project>/index.html
// or /<project>/index.html
let path = window.location.pathname;
path = path.replace(/^\/preview\/[^/]+\//, '/');
path = path.replace(/^\//, '');
// remove filename
const parts = path.split('/').filter(Boolean);
if (!parts.length) return '';
if (parts[parts.length - 1].includes('.')) parts.pop();
return parts.join('/');
}
/* ── Collect all ECharts instances from page ─────────────── */
function getAllChartInstances() {
if (window.CHART_INSTANCES && window.CHART_INSTANCES.length > 0) {
return window.CHART_INSTANCES.filter(i => i && !i.isDisposed());
}
if (!window.echarts) return [];
const containers = document.querySelectorAll('[_echarts_instance_]');
const instances = [];
containers.forEach(c => {
const inst = echarts.getInstanceByDom(c);
if (inst && !inst.isDisposed()) instances.push(inst);
});
return instances;
}
/* ── Merge multiple chart canvases into one PNG DataURL ───── */
async function mergeChartsToDataURL() {
const instances = getAllChartInstances();
if (instances.length === 0) throw new Error('No ECharts instances found');
instances.forEach(inst => { try { inst.resize(); } catch(e) {} });
await new Promise(r => setTimeout(r, 150));
const images = instances.map(inst => ({
dataUrl: inst.getDataURL({
type: 'png',
pixelRatio: 2,
backgroundColor: '#1a1d27',
excludeComponents: []
}),
width: inst.getWidth(),
height: inst.getHeight()
}));
// Always compose onto a new canvas so output consistently includes title
// (even when there's only one chart).
const layout = window.CHART_LAYOUT || 'vertical';
const PR = 2;
const PAD = 24 * PR;
let canvasW, canvasH;
const cols = layout === 'grid' ? Math.min(2, images.length) : 1;
const rows = Math.ceil(images.length / cols);
// Reserve explicit title area so both button-save and one-click screenshot are equivalent
const title = document.querySelector('h1')?.textContent || document.title || 'Chart';
const subtitle = document.querySelector('.subtitle')?.textContent || '';
const titleHeight = PAD * 2.2;
if (layout === 'grid') {
canvasW = (images[0].width * PR * cols) + PAD * (cols + 1);
canvasH = titleHeight + (images[0].height * PR * rows) + PAD * (rows + 1);
} else {
canvasW = Math.max(...images.map(i => i.width)) * PR + PAD * 2;
canvasH = titleHeight + images.reduce((s, i) => s + i.height * PR, 0) + PAD * (images.length + 1);
}
const canvas = document.createElement('canvas');
canvas.width = canvasW;
canvas.height = canvasH;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#0f1117';
ctx.fillRect(0, 0, canvasW, canvasH);
ctx.fillStyle = '#f0f2f5';
ctx.font = `bold ${28}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
ctx.fillText(title, PAD, PAD * 1.1);
if (subtitle) {
ctx.fillStyle = '#9aa0b4';
ctx.font = `normal ${16 * PR / 2}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;
ctx.fillText(subtitle, PAD, PAD * 1.7);
}
const loadImg = (dataUrl) => new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = dataUrl;
});
const imgs = await Promise.all(images.map(i => loadImg(i.dataUrl)));
for (let idx = 0; idx < imgs.length; idx++) {
const img = imgs[idx];
const w = images[idx].width * PR;
const h = images[idx].height * PR;
let x, y;
if (layout === 'grid') {
const col = idx % cols;
const row = Math.floor(idx / cols);
x = PAD + col * (w + PAD);
y = titleHeight + PAD + row * (h + PAD);
} else {
x = PAD;
y = titleHeight + images.slice(0, idx).reduce((s, i) => s + i.height * PR + PAD, 0);
}
ctx.drawImage(img, x, y, w, h);
}
return canvas.toDataURL('image/png');
}
/* ── Unified export helpers (single pipeline) ───────────── */
async function exportMergedPNG() {
const dataUrl = await mergeChartsToDataURL();
const blob = await (await fetch(dataUrl)).blob();
return { dataUrl, blob };
}
async function blobToDataURL(blob) {
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
/* ── Download PNG ───────────────────────────────────────── */
async function downloadPNG(btn) {
btn = btn || event?.currentTarget;
const orig = btn?.textContent;
if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }
try {
const { blob } = await exportMergedPNG();
const filename = (document.title || 'chart').replace(/[^a-zA-Z0-9\u4e00-\u9fff-_]/g, '_') + '_' +
new Date().toISOString().slice(0,10) + '.png';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
if (window.self !== window.top) {
window.open(url, '_blank', 'noopener');
}
setTimeout(() => URL.revokeObjectURL(url), 2000);
if (btn) { btn.textContent = '✅'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }
showToast('PNG ready (download/new tab)', 'success');
setTimeout(() => {
if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }
}, 2000);
} catch(e) {
console.error('[chart] download failed:', e);
if (btn) { btn.textContent = '❌'; btn.disabled = false; }
showToast('Export failed: ' + e.message, 'error');
setTimeout(() => { if (btn) { btn.textContent = orig; } }, 2000);
}
}
/* ── Copy to Clipboard (same output as save/download) ───── */
async function copyToClipboard(btn) {
btn = btn || event?.currentTarget;
const orig = btn?.textContent;
if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }
try {
const { blob } = await exportMergedPNG();
if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
if (btn) { btn.textContent = '✅'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }
showToast('Image copied to clipboard ✓', 'success');
} else {
// Fallback still uses the exact same merged PNG bytes
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 3000);
showToast('Clipboard N/A — opened same exported image in new tab', 'info');
}
} catch(e) {
console.warn('[chart] clipboard failed, fallback to download:', e.message);
try {
const { blob } = await exportMergedPNG();
const filename = (document.title || 'chart').replace(/[^a-zA-Z0-9\u4e00-\u9fff-_]/g, '_') + '_'+
new Date().toISOString().slice(0,10) + '.png';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = filename;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 2000);
showToast('Clipboard N/A — downloaded same exported image', 'info');
} catch(e2) {
showToast('Export failed: ' + e2.message, 'error');
}
} finally {
setTimeout(() => {
if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }
}, 2000);
}
}
/* ── Save to Project ────────────────────────────────────── */
async function saveToProject(btn) {
btn = btn || event?.currentTarget;
const orig = btn?.textContent;
if (btn) { btn.textContent = '⏳ Saving...'; btn.disabled = true; }
try {
const project = getCurrentProjectPath();
if (!project) throw new Error('Cannot detect project folder from URL');
const { blob } = await exportMergedPNG();
const dataUrl = await blobToDataURL(blob);
const filename = 'screenshot.png';
const resp = await fetch(apiUrl('save-chart'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ dataUrl, filename, project })
});
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
const result = await resp.json();
if (btn) { btn.textContent = '✅ Saved!'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }
showToast(`Saved: ${result.url}`, 'success');
} catch(e) {
console.warn('[chart] saveToProject failed:', e.message);
showToast('Save API not available — downloading PNG...', 'info');
await downloadPNG(null);
if (btn) { btn.textContent = '📥 Downloaded'; }
} finally {
setTimeout(() => {
if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }
}, 3000);
}
}
/* ── Backward-compat aliases ────────────────────────────── */
window.saveToWorkspace = saveToProject;
window.savePage = function(btn) {
showToast('savePage removed in project-based mode', 'info');
};
/* ── Auto-resize all ECharts on window resize ───────────── */
window.addEventListener('resize', () => {
if (!window.echarts) return;
getAllChartInstances().forEach(inst => {
try { inst.resize(); } catch(e) {}
});
});
/* Chart Skill — Base Dark Theme */
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0f1117;
color: #e1e4ea;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
padding: 20px;
min-height: 100vh;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 0 4px;
}
.toolbar h1 {
font-size: 1.3rem;
font-weight: 600;
color: #f0f2f5;
}
.toolbar .subtitle {
font-size: 0.85rem;
color: #6b7280;
margin-top: 2px;
}
.actions {
display: flex;
gap: 8px;
}
.actions button {
background: #1e2130;
border: 1px solid #2d3148;
color: #c8cdd6;
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.82rem;
transition: all 0.2s;
}
.actions button:hover {
background: #282d42;
border-color: #5d8fff;
color: #fff;
}
.actions button:active {
transform: scale(0.97);
}
.actions .btn-link {
background: #1e2130;
border: 1px solid #2d3148;
color: #c8cdd6;
padding: 6px 14px;
border-radius: 6px;
font-size: 0.82rem;
text-decoration: none;
display: inline-flex;
align-items: center;
}
.actions .btn-link:hover {
background: #282d42;
border-color: #5d8fff;
color: #fff;
}
.actions button.success {
background: #1a3a2a;
border-color: #34d399;
color: #34d399;
}
#chart-area {
background: #1a1d27;
border-radius: 12px;
padding: 20px;
border: 1px solid #2d3148;
}
/* Grid layouts for multi-chart */
.grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.grid-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 20px; }
.grid-2x2 { display: grid; grid-template-columns: 1fr 1fr; grid-template-rows: auto auto; gap: 20px; }
.full-width { grid-column: 1 / -1; }
.chart-box {
background: #12141c;
border-radius: 8px;
padding: 12px;
border: 1px solid #1e2236;
}
.chart-box .chart-title {
font-size: 0.85rem;
color: #9ca3af;
margin-bottom: 8px;
padding-left: 4px;
}
/* Summary cards (KPIs) */
.kpi-row {
display: flex;
gap: 16px;
margin-bottom: 20px;
}
.kpi-card {
flex: 1;
background: #1a1d27;
border-radius: 10px;
padding: 16px 20px;
border: 1px solid #2d3148;
}
.kpi-card .label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.kpi-card .value {
font-size: 1.5rem;
font-weight: 700;
color: #f0f2f5;
margin-top: 4px;
}
.kpi-card .change {
font-size: 0.8rem;
margin-top: 2px;
}
.kpi-card .change.up { color: #34d399; }
.kpi-card .change.down { color: #ef4444; }
/* Responsive */
@media (max-width: 768px) {
.grid-2, .grid-3, .grid-2x2 { grid-template-columns: 1fr; }
.kpi-row { flex-direction: column; }
}
/* Toast notification */
.toast {
position: fixed;
bottom: 24px;
right: 24px;
background: #1e2130;
color: #e1e4ea;
padding: 10px 20px;
border-radius: 8px;
border: 1px solid #2d3148;
font-size: 0.85rem;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s;
z-index: 1000;
pointer-events: none;
}
.toast.show {
opacity: 1;
transform: translateY(0);
}
#!/usr/bin/env python3
"""
Chart builder v3: project-based chart generation.
Each chart project lives in output/chart-html/<project-name>/
index.html — the chart page
generate.py — the generation script (optional, for reproducibility)
README.md — title, description, data sources
data.json — raw data snapshot
screenshot.png — exported PNG (via Playwright or button)
Usage:
from skills.chart.scripts.build_chart import (
create_project, build_chart, build_chart_custom, save_chart, screenshot_chart
)
# Create project directory
project_dir = create_project('btc-gold-90d')
# Build HTML
html = build_chart_custom(title='BTC vs Gold', ...)
save_chart(html, project_dir=project_dir)
# Optional: screenshot for direct image delivery
screenshot_chart(project_dir)
"""
import os
import json
from datetime import datetime
from pathlib import Path
SKILL_DIR = os.path.join(os.path.dirname(__file__), '..')
SCRIPTS_DIR = os.path.join(SKILL_DIR, 'scripts')
TEMPLATES_DIR = os.path.join(SKILL_DIR, 'templates')
CHART_HTML_DIR = os.path.join('/data/workspace', 'output', 'chart-html')
def _read(path):
with open(path, 'r') as f:
return f.read()
def get_base_css():
return _read(os.path.join(SCRIPTS_DIR, 'base-styles.css'))
def get_base_js():
return _read(os.path.join(SCRIPTS_DIR, 'base-export.js'))
def create_project(name, description='', data_sources=None):
"""Create a new chart project directory.
Args:
name: Project folder name (e.g. 'btc-gold-90d-20250701')
description: What this chart shows
data_sources: list of data source strings
Returns:
Absolute path to the project directory
"""
project_dir = os.path.join(CHART_HTML_DIR, name)
os.makedirs(project_dir, exist_ok=True)
# Write README.md
readme = f"# {name}\n\n"
readme += f"**Created:** {datetime.now().strftime('%Y-%m-%d %H:%M')}\n\n"
if description:
readme += f"{description}\n\n"
if data_sources:
readme += "**Data Sources:**\n"
for src in data_sources:
readme += f"- {src}\n"
readme_path = os.path.join(project_dir, 'README.md')
if not os.path.exists(readme_path):
with open(readme_path, 'w') as f:
f.write(readme)
return project_dir
def build_chart(template_name, title='Chart', subtitle='', replacements=None):
"""Build chart from a template file.
Args:
template_name: template filename without .html extension (e.g. 'line', 'bar')
title: chart page title
subtitle: subtitle text
replacements: dict of additional {{KEY}} → value replacements
Returns:
Complete HTML string
"""
tpl_path = os.path.join(TEMPLATES_DIR, f'{template_name}.html')
html = _read(tpl_path)
css = get_base_css()
js = get_base_js()
html = html.replace('{{BASE_STYLES}}', css)
html = html.replace('{{BASE_EXPORT_JS}}', js)
html = html.replace('{{TITLE}}', title)
html = html.replace('{{SUBTITLE}}', subtitle)
if replacements:
for k, v in replacements.items():
html = html.replace(f'{{{{{k}}}}}', v)
return html
def build_chart_custom(title='Chart', subtitle='', body_html='', chart_js='',
extra_css='', kpi_html='', layout='vertical'):
"""Build a fully custom chart page without using a template.
IMPORTANT: chart_js MUST:
1. Set window.CHART_INSTANCES = []; at the start
2. Push each echarts instance: CHART_INSTANCES.push(chartVar);
3. Optionally set window.CHART_LAYOUT = 'grid'; for 2-column export layout
Args:
title: page title
subtitle: subtitle text below title
body_html: HTML for chart containers (inside #chart-area)
chart_js: JavaScript for chart initialization (must register CHART_INSTANCES)
extra_css: additional CSS
kpi_html: optional KPI cards HTML (placed above chart-area, not exported)
layout: 'vertical' (default) or 'grid' — how charts are merged in export PNG
Returns:
Complete HTML string
"""
css = get_base_css()
js = get_base_js()
layout_js = f"window.CHART_LAYOUT = '{layout}';" if layout != 'vertical' else ''
return f'''<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{title}</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
{css}
{extra_css}
</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{title}</h1>
<div class="subtitle">{subtitle}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
{kpi_html}
<div id="chart-area">
{body_html}
</div>
<script>
{js}
{layout_js}
</script>
<script>
{chart_js}
</script>
</body>
</html>'''
def save_chart(html, filename='index.html', project_dir=None, output_dir=None):
"""Save HTML to project directory or a legacy output directory.
Args:
html: HTML string
filename: filename (default 'index.html')
project_dir: project directory path (preferred)
output_dir: legacy fallback directory
Returns:
The file path written
"""
if project_dir:
target_dir = project_dir
elif output_dir:
target_dir = output_dir
else:
target_dir = CHART_HTML_DIR
os.makedirs(target_dir, exist_ok=True)
path = os.path.join(target_dir, filename)
with open(path, 'w') as f:
f.write(html)
return path
def save_data(data, project_dir, filename='data.json'):
"""Save data snapshot to project directory.
Args:
data: dict or list to serialize as JSON
project_dir: project directory path
filename: filename (default 'data.json')
Returns:
The file path written
"""
os.makedirs(project_dir, exist_ok=True)
path = os.path.join(project_dir, filename)
with open(path, 'w') as f:
json.dump(data, f, ensure_ascii=False, indent=2)
return path
def save_generate_script(script_content, project_dir):
"""Save the generation script for reproducibility.
Args:
script_content: Python script as string
project_dir: project directory path
Returns:
The file path written
"""
os.makedirs(project_dir, exist_ok=True)
path = os.path.join(project_dir, 'generate.py')
with open(path, 'w') as f:
f.write(script_content)
return path
def screenshot_chart(project_dir, filename='screenshot.png', width=1280, height=720):
"""Generate PNG via the same merge pipeline as the "Save Image" button.
This ensures one-click screenshot output is visually equivalent to saveToProject()
in the browser (title included, merged multi-chart layout consistent).
Args:
project_dir: project directory containing index.html
filename: output PNG filename
width: viewport width
height: viewport height
Returns:
The screenshot file path, or None if failed
"""
try:
from playwright.sync_api import sync_playwright
except ImportError:
print("[chart] Playwright not available, skipping screenshot")
return None
html_path = os.path.join(project_dir, 'index.html')
if not os.path.exists(html_path):
print(f"[chart] No index.html found in {project_dir}")
return None
out_path = os.path.join(project_dir, filename)
file_url = f"file://{os.path.abspath(html_path)}"
try:
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={'width': width, 'height': height})
page.goto(file_url)
page.wait_for_timeout(2200) # let ECharts render
# Reuse front-end merge/export logic for fidelity with save button.
data_url = page.evaluate(
"""
async () => {
if (typeof mergeChartsToDataURL === 'function') {
return await mergeChartsToDataURL();
}
throw new Error('mergeChartsToDataURL is not available on page');
}
"""
)
if not data_url or ',' not in data_url:
raise RuntimeError('Invalid data URL returned from page export pipeline')
b64 = data_url.split(',', 1)[1]
import base64
png_bytes = base64.b64decode(b64)
with open(out_path, 'wb') as f:
f.write(png_bytes)
browser.close()
print(f"[chart] Screenshot saved: {out_path}")
return out_path
except Exception as e:
print(f"[chart] Screenshot failed: {e}")
return None
if __name__ == '__main__':
# Quick test
proj = create_project('test-chart')
html = build_chart('line', title='Test Chart', subtitle='Testing build v3')
path = save_chart(html, project_dir=proj)
print(f'Built: {path}')
#!/usr/bin/env python3
"""
Chart Skill — project-based static server + save APIs
Usage:
python3 chart_server.py [serve_dir] [port]
Defaults:
serve_dir = /data/workspace/output/chart-html
port = 7860
Endpoints:
- POST /save-chart : Save PNG to current project directory as screenshot.png (or filename)
- GET / : Static files from serve_dir
Notes:
- Gallery and library APIs are intentionally removed.
- Each chart should live in: output/chart-html/<project>/index.html
"""
import sys
import os
import json
import base64
import re
from pathlib import Path
from urllib.parse import urlsplit
from http.server import HTTPServer, SimpleHTTPRequestHandler
WORKSPACE = Path("/data/workspace")
DEFAULT_SERVE_DIR = WORKSPACE / "output" / "chart-html"
DEFAULT_SERVE_DIR.mkdir(parents=True, exist_ok=True)
def _safe_filename(name: str, ext: str) -> str:
name = re.sub(r"[^\w\u4e00-\u9fff.\-]", "_", name or "")
if not name:
name = f"screenshot{ext}"
if not name.endswith(ext):
name += ext
return name
class ChartHandler(SimpleHTTPRequestHandler):
def __init__(self, *args, serve_dir=None, **kwargs):
self._serve_dir = Path(serve_dir or DEFAULT_SERVE_DIR)
super().__init__(*args, directory=str(self._serve_dir), **kwargs)
def log_message(self, format, *args):
pass
def _normalized_path(self):
# Preview proxy may forward as /preview/{id}/<endpoint>
p = self.path.split('?', 1)[0]
m = re.match(r"^/preview/[^/]+/(.*)$", p)
if m:
p = '/' + m.group(1)
return p
def _translate_preview_path(self, raw_path: str) -> str:
"""Map /preview/<id>/<project>/... to /<project>/... before static lookup."""
# keep query string when present
split = urlsplit(raw_path)
p = split.path
m = re.match(r"^/preview/[^/]+/(.*)$", p)
if m:
mapped = '/' + m.group(1)
else:
mapped = p
if split.query:
mapped = f"{mapped}?{split.query}"
return mapped
def translate_path(self, path):
# SimpleHTTPRequestHandler resolves filesystem paths via this method.
# We normalize preview-prefixed URLs first so static files map correctly.
return super().translate_path(self._translate_preview_path(path))
def do_OPTIONS(self):
self.send_response(200)
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
self.send_header("Access-Control-Allow-Headers", "Content-Type")
self.end_headers()
def do_GET(self):
return super().do_GET()
def do_POST(self):
p = self._normalized_path()
if p in ("/save-chart", "/save-chart/"):
return self._handle_save_chart()
self.send_error(404, "Not found")
def _read_json_body(self):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length)
return json.loads(raw)
def _send_json(self, data, status=200):
body = json.dumps(data, ensure_ascii=False)
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body.encode("utf-8"))))
self.send_header("Access-Control-Allow-Origin", "*")
self.end_headers()
self.wfile.write(body.encode("utf-8"))
def _resolve_project_dir(self, project: str):
# project like "btc-90d-20260401" or "btc-90d-20260401/subdir"
project = (project or "").strip().strip('/')
if not project:
return None
target = (self._serve_dir / project).resolve()
# Prevent path traversal
try:
target.relative_to(self._serve_dir.resolve())
except ValueError:
return None
if not target.exists() or not target.is_dir():
return None
return target
def _handle_save_chart(self):
try:
data = self._read_json_body()
data_url = data.get("dataUrl", "")
filename = _safe_filename(data.get("filename", "screenshot"), ".png")
project = data.get("project", "")
project_dir = self._resolve_project_dir(project)
if project_dir is None:
return self._send_json({"ok": False, "error": "Invalid or missing project"}, status=400)
if "," in data_url:
_, b64 = data_url.split(",", 1)
else:
b64 = data_url
png_bytes = base64.b64decode(b64)
out_path = project_dir / filename
out_path.write_bytes(png_bytes)
rel_path = out_path.relative_to(self._serve_dir).as_posix()
self._send_json({"ok": True, "filename": filename, "path": str(out_path), "url": f"/{rel_path}"})
except Exception as e:
self._send_json({"ok": False, "error": str(e)}, status=500)
def run(serve_dir, port):
os.chdir(serve_dir)
server = HTTPServer(("127.0.0.1", port), lambda *a, **k: ChartHandler(*a, serve_dir=serve_dir, **k))
print(f"[chart-server] serving {serve_dir} on port {port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
serve_dir = sys.argv[1] if len(sys.argv) > 1 else str(DEFAULT_SERVE_DIR)
port = int(sys.argv[2]) if len(sys.argv) > 2 else 7860
Path(serve_dir).mkdir(parents=True, exist_ok=True)
run(serve_dir, port)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bar Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:450px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA ===
const categories = ['Product A','Product B','Product C','Product D','Product E'];
const series = [
{ name: 'Q1', data: [120,200,150,80,70] },
{ name: 'Q2', data: [150,230,180,100,90] },
{ name: 'Q3', data: [180,260,200,120,110] },
];
// === CHART ===
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
legend: { top: 8 },
grid: { top: 50, right: 30, bottom: 30, left: 60 },
xAxis: { type: 'category', data: categories },
yAxis: { type: 'value' },
series: series.map(s => ({
name: s.name,
type: 'bar',
data: s.data,
barMaxWidth: 40,
emphasis: { focus: 'series' },
itemStyle: { borderRadius: [4, 4, 0, 0] },
})),
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Candlestick Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="kline-chart" style="width:100%;height:500px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA: [date, open, close, low, high, volume] ===
const rawData = [
['2025-01-02', 94500, 96200, 93800, 96800, 12500],
['2025-01-03', 96200, 95100, 94200, 97000, 11200],
['2025-01-04', 95100, 97800, 94800, 98200, 15300],
// ... replace with real data
];
const dates = rawData.map(d => d[0]);
const ohlc = rawData.map(d => [d[1], d[2], d[3], d[4]]); // open, close, low, high
const volumes = rawData.map(d => d[5]);
const isUp = rawData.map(d => d[2] >= d[1]);
// Simple MA calculation
function calcMA(data, period) {
const result = [];
for (let i = 0; i < data.length; i++) {
if (i < period - 1) { result.push('-'); continue; }
let sum = 0;
for (let j = 0; j < period; j++) sum += data[i - j][1]; // close price
result.push((sum / period).toFixed(2));
}
return result;
}
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('kline-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: { data: ['K', 'MA7', 'MA25'], top: 8 },
grid: [
{ top: 50, left: 60, right: 30, height: '60%' },
{ left: 60, right: 30, top: '76%', height: '16%' },
],
xAxis: [
{ type: 'category', data: dates, gridIndex: 0, boundaryGap: true,
axisLine: { lineStyle: { color: '#2d3148' } } },
{ type: 'category', data: dates, gridIndex: 1, boundaryGap: true,
axisLine: { lineStyle: { color: '#2d3148' } } },
],
yAxis: [
{ scale: true, gridIndex: 0, splitLine: { lineStyle: { color: '#1e2236' } } },
{ scale: true, gridIndex: 1, splitLine: { show: false },
axisLabel: { show: false }, axisTick: { show: false } },
],
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1], start: 0, end: 100 },
{ type: 'slider', xAxisIndex: [0, 1], height: 20, bottom: 5 },
],
series: [
{
name: 'K', type: 'candlestick', data: ohlc,
itemStyle: {
color: '#26a69a', color0: '#ef5350',
borderColor: '#26a69a', borderColor0: '#ef5350',
},
},
{ name: 'MA7', type: 'line', data: calcMA(ohlc, 7), smooth: true,
lineStyle: { width: 1.5 }, symbol: 'none' },
{ name: 'MA25', type: 'line', data: calcMA(ohlc, 25), smooth: true,
lineStyle: { width: 1.5 }, symbol: 'none' },
{
name: 'Volume', type: 'bar', xAxisIndex: 1, yAxisIndex: 1,
data: volumes.map((v, i) => ({
value: v,
itemStyle: { color: isUp[i] ? 'rgba(38,166,154,0.5)' : 'rgba(239,83,80,0.5)' },
})),
},
],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<!-- KPI Cards (optional, not included in chart export) -->
<div class="kpi-row">
<div class="kpi-card">
<div class="label">Total Revenue</div>
<div class="value">$1.24M</div>
<div class="change up">↑ 12.5% vs last month</div>
</div>
<div class="kpi-card">
<div class="label">Active Users</div>
<div class="value">8,432</div>
<div class="change up">↑ 5.2%</div>
</div>
<div class="kpi-card">
<div class="label">Conversion Rate</div>
<div class="value">3.8%</div>
<div class="change down">↓ 0.3%</div>
</div>
<div class="kpi-card">
<div class="label">Avg Order Value</div>
<div class="value">$147</div>
<div class="change up">↑ 8.1%</div>
</div>
</div>
<div id="chart-area">
<div class="grid-2">
<div class="chart-box">
<div class="chart-title">Revenue Trend</div>
<div id="chart-line" style="width:100%;height:300px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">Category Sales</div>
<div id="chart-bar" style="width:100%;height:300px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">Traffic Sources</div>
<div id="chart-pie" style="width:100%;height:300px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">Price vs Volume</div>
<div id="chart-scatter" style="width:100%;height:300px;"></div>
</div>
</div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// ── Register all chart instances (REQUIRED for export) ──
window.CHART_INSTANCES = [];
window.CHART_LAYOUT = 'grid'; // 'vertical' | 'grid'
const darkOpt = { backgroundColor: 'transparent' };
// Line
const c1 = echarts.init(document.getElementById('chart-line'), 'dark');
c1.setOption({
...darkOpt,
tooltip: { trigger: 'axis' },
grid: { top: 20, right: 20, bottom: 30, left: 50 },
xAxis: { type: 'category', data: ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'] },
yAxis: { type: 'value' },
series: [{ type: 'line', data: [82,93,90,93,129,133,132,140,150,160,170,180], smooth: true, areaStyle: { opacity: 0.1 } }],
});
CHART_INSTANCES.push(c1);
// Bar
const c2 = echarts.init(document.getElementById('chart-bar'), 'dark');
c2.setOption({
...darkOpt,
tooltip: { trigger: 'axis', axisPointer: { type: 'shadow' } },
grid: { top: 20, right: 20, bottom: 30, left: 50 },
xAxis: { type: 'value' },
yAxis: { type: 'category', data: ['Electronics','Clothing','Food','Books','Sports'] },
series: [{ type: 'bar', data: [320,280,210,190,150], itemStyle: { borderRadius: [0,4,4,0] } }],
});
CHART_INSTANCES.push(c2);
// Pie
const c3 = echarts.init(document.getElementById('chart-pie'), 'dark');
c3.setOption({
...darkOpt,
tooltip: { trigger: 'item' },
series: [{
type: 'pie', radius: ['35%','65%'], center: ['50%','55%'],
data: [
{ name: 'Organic', value: 40 }, { name: 'Paid', value: 25 },
{ name: 'Social', value: 20 }, { name: 'Direct', value: 15 },
],
itemStyle: { borderRadius: 5, borderColor: '#12141c', borderWidth: 2 },
label: { color: '#c8cdd6' },
}],
});
CHART_INSTANCES.push(c3);
// Scatter
const c4 = echarts.init(document.getElementById('chart-scatter'), 'dark');
const scatterData = Array.from({length: 50}, () => [
Math.round(Math.random() * 200 + 20),
Math.round(Math.random() * 1000),
Math.round(Math.random() * 50 + 5),
]);
c4.setOption({
...darkOpt,
tooltip: { formatter: p => `Price: $${p.data[0]}<br>Volume: ${p.data[1]}<br>Size: ${p.data[2]}` },
grid: { top: 20, right: 20, bottom: 30, left: 50 },
xAxis: { type: 'value', name: 'Price' },
yAxis: { type: 'value', name: 'Volume' },
series: [{
type: 'scatter', data: scatterData,
symbolSize: d => d[2] / 3,
itemStyle: { opacity: 0.7 },
}],
});
CHART_INSTANCES.push(c4);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dual Axis Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:480px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA ===
// Left axis: large-scale values (e.g. total market cap in trillions)
// Right axis: small-scale values (e.g. stablecoin supply in billions)
const categories = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const leftSeries = {
name: 'Total Market Cap ($T)',
data: [1.8, 1.6, 1.9, 2.1, 2.4, 2.2, 2.6, 2.8, 3.0, 2.9, 3.2, 3.5],
};
const rightSeries = {
name: 'Stablecoin Supply ($B)',
data: [130, 128, 132, 135, 138, 140, 143, 145, 148, 150, 153, 156],
};
// === CHART ===
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: { top: 8 },
grid: { top: 50, right: 80, bottom: 50, left: 70 },
xAxis: {
type: 'category',
data: categories,
boundaryGap: false,
axisLine: { lineStyle: { color: '#444' } },
},
yAxis: [
{
type: 'value',
name: leftSeries.name,
nameLocation: 'middle',
nameGap: 55,
nameTextStyle: { color: '#5470c6', fontSize: 12 },
axisLabel: { color: '#5470c6', formatter: v => `$${v}T` },
splitLine: { lineStyle: { color: '#2a2d3a' } },
},
{
type: 'value',
name: rightSeries.name,
nameLocation: 'middle',
nameGap: 60,
nameTextStyle: { color: '#91cc75', fontSize: 12 },
axisLabel: { color: '#91cc75', formatter: v => `$${v}B` },
splitLine: { show: false },
},
],
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20, bottom: 5 }],
series: [
{
name: leftSeries.name,
type: 'line',
yAxisIndex: 0,
data: leftSeries.data,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#5470c6' },
itemStyle: { color: '#5470c6' },
areaStyle: { opacity: 0.08, color: '#5470c6' },
},
{
name: rightSeries.name,
type: 'line',
yAxisIndex: 1,
data: rightSeries.data,
smooth: true,
symbol: 'circle',
symbolSize: 6,
lineStyle: { width: 2.5, color: '#91cc75' },
itemStyle: { color: '#91cc75' },
areaStyle: { opacity: 0.08, color: '#91cc75' },
},
],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Heatmap</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:450px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
const hours = ['12a','1a','2a','3a','4a','5a','6a','7a','8a','9a','10a','11a',
'12p','1p','2p','3p','4p','5p','6p','7p','8p','9p','10p','11p'];
const days = ['Sun','Mon','Tue','Wed','Thu','Fri','Sat'];
// [dayIndex, hourIndex, value]
const data = [];
for (let d = 0; d < 7; d++) {
for (let h = 0; h < 24; h++) {
data.push([h, d, Math.round(Math.random() * 100)]);
}
}
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
position: 'top',
formatter: p => `${days[p.data[1]]} ${hours[p.data[0]]}: ${p.data[2]}`,
},
grid: { top: 20, right: 60, bottom: 40, left: 60 },
xAxis: { type: 'category', data: hours, splitArea: { show: true } },
yAxis: { type: 'category', data: days, splitArea: { show: true } },
visualMap: {
min: 0, max: 100, calculable: true, orient: 'vertical', right: 10, top: 'center',
inRange: { color: ['#0f1117', '#1a3a5c', '#2c7be5', '#5d8fff', '#a5c8ff'] },
textStyle: { color: '#9ca3af' },
},
series: [{
type: 'heatmap', data: data,
label: { show: false },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } },
}],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Line Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>
{{BASE_STYLES}}
</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:450px;"></div>
</div>
<script>
{{BASE_EXPORT_JS}}
</script>
<script>
// === DATA (replace with real data) ===
const categories = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const series = [
{ name: 'Revenue', data: [820,932,901,934,1290,1330,1320,1400,1500,1600,1700,1800] },
{ name: 'Expenses', data: [600,700,680,720,850,900,880,950,1000,1050,1100,1150] },
];
// === CHART ===
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'axis' },
legend: { top: 8 },
grid: { top: 50, right: 30, bottom: 40, left: 60 },
xAxis: { type: 'category', data: categories, boundaryGap: false },
yAxis: { type: 'value' },
dataZoom: [{ type: 'inside' }, { type: 'slider', height: 20, bottom: 5 }],
series: series.map(s => ({
name: s.name,
type: 'line',
data: s.data,
smooth: true,
symbol: 'circle',
symbolSize: 6,
areaStyle: { opacity: 0.08 },
emphasis: { focus: 'series' },
})),
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Multi Panel Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<!--
Multi-panel layout: all panels share ONE ECharts instance.
This enables tooltip sync / dataZoom linkage across panels automatically.
Panel height ratios: price 55% | volume 25% | indicator 20%
-->
<div id="chart-area">
<div id="main-chart" style="width:100%;height:600px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA ===
const dates = ['2024-01','2024-02','2024-03','2024-04','2024-05','2024-06',
'2024-07','2024-08','2024-09','2024-10','2024-11','2024-12'];
// [open, close, low, high]
const ohlc = [
[42000,43500,41000,44000],[43500,41000,40000,44500],[41000,52000,40500,53000],
[52000,60000,51000,62000],[60000,57000,55000,61000],[57000,62000,56000,63500],
[62000,65000,61000,66000],[65000,58000,57000,67000],[58000,61000,57500,62000],
[61000,68000,60000,69000],[68000,72000,67000,73000],[72000,95000,71000,96000],
];
const volume = [12000,15000,25000,30000,18000,22000,19000,35000,21000,28000,32000,55000];
// RSI (14) — mock values
const rsi = [52,44,68,72,58,65,67,48,55,63,70,78];
// Derived MA
function ma(data, n) {
return data.map((_, i) => i < n - 1 ? null : data.slice(i - n + 1, i + 1).reduce((s, v) => s + v, 0) / n);
}
const closes = ohlc.map(d => d[1]);
const ma7 = ma(closes, 3); // 3-period as proxy for short-window demo
const ma25 = ma(closes, 6);
// === CHART (single instance, 3 grids) ===
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
animation: false,
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross', link: [{ xAxisIndex: 'all' }] },
},
axisPointer: { link: [{ xAxisIndex: 'all' }] },
dataZoom: [
{ type: 'inside', xAxisIndex: [0, 1, 2] },
{ type: 'slider', xAxisIndex: [0, 1, 2], height: 18, bottom: 4 },
],
grid: [
{ top: 40, left: 70, right: 30, height: '52%' }, // price
{ top: '62%', left: 70, right: 30, height: '16%' }, // volume
{ top: '82%', left: 70, right: 30, height: '10%' }, // RSI
],
xAxis: [
{ type: 'category', data: dates, gridIndex: 0, axisLabel: { show: false }, boundaryGap: false },
{ type: 'category', data: dates, gridIndex: 1, axisLabel: { show: false }, boundaryGap: false },
{ type: 'category', data: dates, gridIndex: 2, boundaryGap: false },
],
yAxis: [
{ type: 'value', gridIndex: 0, scale: true, splitLine: { lineStyle: { color: '#2a2d3a' } }, axisLabel: { formatter: v => `$${(v/1000).toFixed(0)}k` } },
{ type: 'value', gridIndex: 1, splitLine: { show: false }, axisLabel: { formatter: v => `${(v/1000).toFixed(0)}k` } },
{ type: 'value', gridIndex: 2, min: 0, max: 100, splitLine: { lineStyle: { color: '#2a2d3a', type: 'dashed' } },
axisLabel: { formatter: v => `${v}` },
splitArea: {
show: true,
areaStyle: { color: ['rgba(255,80,80,0.04)', 'rgba(80,255,80,0.04)'] },
},
},
],
series: [
// Candlestick
{
name: 'BTC', type: 'candlestick', xAxisIndex: 0, yAxisIndex: 0,
data: ohlc,
itemStyle: {
color: '#26a69a', color0: '#ef5350',
borderColor: '#26a69a', borderColor0: '#ef5350',
},
},
// MA7
{ name: 'MA7', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: ma7,
smooth: true, symbol: 'none', lineStyle: { width: 1.5, color: '#f6c768' } },
// MA25
{ name: 'MA25', type: 'line', xAxisIndex: 0, yAxisIndex: 0, data: ma25,
smooth: true, symbol: 'none', lineStyle: { width: 1.5, color: '#ee6666' } },
// Volume
{
name: 'Volume', type: 'bar', xAxisIndex: 1, yAxisIndex: 1, data: volume,
itemStyle: {
color: p => ohlc[p.dataIndex][1] >= ohlc[p.dataIndex][0] ? '#26a69a' : '#ef5350',
opacity: 0.7,
},
},
// RSI
{
name: 'RSI(14)', type: 'line', xAxisIndex: 2, yAxisIndex: 2, data: rsi,
smooth: true, symbol: 'none',
lineStyle: { width: 1.5, color: '#7b68ee' },
markLine: {
silent: true,
lineStyle: { color: '#555', type: 'dashed' },
data: [{ yAxis: 70 }, { yAxis: 30 }],
},
},
],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Pie Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div class="grid-2">
<div class="chart-box">
<div class="chart-title">Distribution</div>
<div id="pie-chart" style="width:100%;height:400px;"></div>
</div>
<div class="chart-box">
<div class="chart-title">Breakdown (Donut)</div>
<div id="donut-chart" style="width:100%;height:400px;"></div>
</div>
</div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA ===
const data = [
{ name: 'Category A', value: 335 },
{ name: 'Category B', value: 234 },
{ name: 'Category C', value: 154 },
{ name: 'Category D', value: 135 },
{ name: 'Category E', value: 108 },
];
// === PIE ===
window.CHART_INSTANCES = [];
const pie = echarts.init(document.getElementById('pie-chart'), 'dark');
pie.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
type: 'pie', radius: '65%', center: ['50%','55%'],
data: data,
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } },
label: { color: '#c8cdd6' },
}],
});
CHART_INSTANCES.push(pie);
// === DONUT ===
const donut = echarts.init(document.getElementById('donut-chart'), 'dark');
donut.setOption({
backgroundColor: 'transparent',
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [{
type: 'pie', radius: ['40%','70%'], center: ['50%','55%'],
data: data,
emphasis: { label: { show: true, fontSize: 14, fontWeight: 'bold' } },
label: { show: true, formatter: '{b}\n{d}%', color: '#c8cdd6' },
itemStyle: { borderRadius: 6, borderColor: '#12141c', borderWidth: 2 },
}],
});
CHART_INSTANCES.push(donut);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Radar Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:500px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
const indicators = [
{ name: 'Performance', max: 100 },
{ name: 'Reliability', max: 100 },
{ name: 'Usability', max: 100 },
{ name: 'Security', max: 100 },
{ name: 'Scalability', max: 100 },
{ name: 'Cost', max: 100 },
];
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: {},
legend: { top: 8, data: ['Product A', 'Product B'] },
radar: {
indicator: indicators,
shape: 'circle',
splitArea: { areaStyle: { color: ['transparent'] } },
splitLine: { lineStyle: { color: '#2d3148' } },
axisLine: { lineStyle: { color: '#2d3148' } },
},
series: [{
type: 'radar',
data: [
{ name: 'Product A', value: [85, 90, 78, 95, 80, 70], areaStyle: { opacity: 0.15 } },
{ name: 'Product B', value: [70, 75, 92, 80, 88, 85], areaStyle: { opacity: 0.15 } },
],
}],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Scatter Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:500px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA: [x, y, size, category] ===
const data = {
'Group A': Array.from({length:30}, () => [Math.random()*100, Math.random()*100, Math.random()*40+5]),
'Group B': Array.from({length:30}, () => [Math.random()*100, Math.random()*100, Math.random()*40+5]),
};
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: { formatter: p => `${p.seriesName}<br>X: ${p.data[0].toFixed(1)}, Y: ${p.data[1].toFixed(1)}` },
legend: { top: 8 },
grid: { top: 50, right: 30, bottom: 40, left: 60 },
xAxis: { type: 'value', name: 'X Axis' },
yAxis: { type: 'value', name: 'Y Axis' },
series: Object.entries(data).map(([name, vals]) => ({
name, type: 'scatter', data: vals,
symbolSize: d => d[2] / 3,
emphasis: { focus: 'series' },
itemStyle: { opacity: 0.75 },
})),
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Waterfall Chart</title>
<script src="https://cdn.jsdelivr.net/npm/echarts@5/dist/echarts.min.js"></script>
<style>{{BASE_STYLES}}</style>
</head>
<body>
<div class="toolbar">
<div>
<h1>{{TITLE}}</h1>
<div class="subtitle">{{SUBTITLE}}</div>
</div>
<div class="actions">
<button onclick="downloadPNG(this)">📥 Download PNG</button>
<button onclick="copyToClipboard(this)">📋 Copy Image</button>
<button onclick="saveToProject(this)">💾 Save Image</button>
</div>
</div>
<div id="chart-area">
<div id="main-chart" style="width:100%;height:460px;"></div>
</div>
<script>{{BASE_EXPORT_JS}}</script>
<script>
// === DATA ===
// type: 'start' | 'end' | 'increase' | 'decrease'
const items = [
{ name: 'Starting Balance', value: 1000, type: 'start' },
{ name: 'Trading Profit', value: 320, type: 'increase' },
{ name: 'Funding Income', value: 85, type: 'increase' },
{ name: 'Withdrawal', value: -200, type: 'decrease' },
{ name: 'Gas Fees', value: -45, type: 'decrease' },
{ name: 'Slippage Loss', value: -30, type: 'decrease' },
{ name: 'Airdrop', value: 120, type: 'increase' },
{ name: 'Ending Balance', value: null, type: 'end' }, // auto-calculated
];
// === BUILD WATERFALL DATA ===
const COLOR_START = '#5470c6';
const COLOR_INCREASE = '#26a69a';
const COLOR_DECREASE = '#ef5350';
const COLOR_END = '#fac858';
let runningTotal = 0;
const categories = [];
const baseData = []; // invisible base bar (offset)
const barData = []; // actual bar value
items.forEach(item => {
categories.push(item.name);
if (item.type === 'start') {
runningTotal = item.value;
baseData.push(0);
barData.push({ value: item.value, itemStyle: { color: COLOR_START } });
} else if (item.type === 'end') {
const endVal = runningTotal;
baseData.push(0);
barData.push({ value: endVal, itemStyle: { color: COLOR_END } });
} else {
const change = item.value;
if (change > 0) {
baseData.push(runningTotal);
barData.push({ value: change, itemStyle: { color: COLOR_INCREASE } });
runningTotal += change;
} else {
runningTotal += change; // decrease first
baseData.push(runningTotal);
barData.push({ value: -change, itemStyle: { color: COLOR_DECREASE } });
}
}
});
// === CHART ===
window.CHART_INSTANCES = [];
const chart = echarts.init(document.getElementById('main-chart'), 'dark');
chart.setOption({
backgroundColor: 'transparent',
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: params => {
// Show only the actual bar (series index 1)
const bar = params.find(p => p.seriesIndex === 1);
if (!bar) return '';
const item = items[bar.dataIndex];
const isDecrease = item.type === 'decrease';
const sign = isDecrease ? '-' : (item.type === 'start' || item.type === 'end') ? '' : '+';
const val = isDecrease ? -item.value : (item.type === 'end' ? bar.value : item.value);
return `<b>${bar.name}</b><br/>${sign}${val.toLocaleString()}`;
},
},
legend: {
top: 8,
data: [
{ name: 'Start/End', icon: 'rect', itemStyle: { color: COLOR_START } },
{ name: 'Increase', icon: 'rect', itemStyle: { color: COLOR_INCREASE } },
{ name: 'Decrease', icon: 'rect', itemStyle: { color: COLOR_DECREASE } },
],
},
grid: { top: 50, right: 30, bottom: 40, left: 80 },
xAxis: {
type: 'category',
data: categories,
axisLabel: { rotate: 20, color: '#c8cdd6', fontSize: 11 },
},
yAxis: {
type: 'value',
splitLine: { lineStyle: { color: '#2a2d3a' } },
},
series: [
// Invisible base (offset)
{
name: '_base',
type: 'bar',
stack: 'waterfall',
silent: true,
itemStyle: { color: 'transparent', borderColor: 'transparent' },
data: baseData,
},
// Actual bar
{
name: 'Change',
type: 'bar',
stack: 'waterfall',
data: barData,
barMaxWidth: 50,
itemStyle: { borderRadius: [4, 4, 0, 0] },
label: {
show: true,
position: 'top',
color: '#c8cdd6',
fontSize: 11,
formatter: p => {
const item = items[p.dataIndex];
if (item.type === 'start' || item.type === 'end') return p.value.toLocaleString();
return (item.value > 0 ? '+' : '') + item.value.toLocaleString();
},
},
},
],
});
CHART_INSTANCES.push(chart);
</script>
</body>
</html>
Related skills
Forks & variants (1)
Chart has 1 known copy in the catalog totaling 1 installs. They canonicalize to this original listing.
- starchild-ai-agent - 1 installs
How it compares
Pick chart for agent-driven one-off ECharts HTML artifacts; use a dashboard framework when you need authenticated multi-user BI infrastructure.
FAQ
How do I create a new chart project?
Call create_project(name, description, data_sources) from scripts/build_chart.py, then use build_chart(template_name, ...) and save_chart(html, project_dir).
Can I export the chart as an image?
Yes. Click 'Download PNG' toolbar button in the page, or call screenshot_chart(project_dir) with Playwright to save PNG directly.
Which template should I use for time-series data?
Use line.html for trends and multi-series comparisons, or dual-axis.html if series have very different scales.
Is Chart safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.