
Chart
- 1 installs
- 1 repo stars
- Updated July 29, 2026
- starchild-ai-agent/community-skills
This is a copy of chart by starchild-ai-agent - installs and ranking accrue to the original listing.
Helps with ai & agent building tasks during AI-assisted development.
About
chart is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.
- chart
- AI & Agent Building
- AI-coding skill
Chart by the numbers
- 1 all-time installs (skills.sh)
- Data as of Jul 30, 2026 (Skillselion catalog sync)
npx skills add https://github.com/starchild-ai-agent/community-skills --skill chartAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 1 |
| Last updated | July 29, 2026 |
| Repository | starchild-ai-agent/community-skills ↗ |
What it does
Helps with ai & agent building tasks during AI-assisted development.
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
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 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 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>