Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Generate TradingView-style dark-theme candlestick charts with RSI, MACD, Bollinger Bands, and EMA/SMA using mplfinance.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/base-export.js
1/* Chart Skill — Export Utilities v3 (project-based)2* Strategy: ECharts native getDataURL() + Canvas merge3*4* Functions exposed:5* downloadPNG() — merge all charts into one PNG and download6* copyToClipboard() — copy merged PNG to clipboard (fallback: download)7* saveToProject() — POST PNG to /save-chart and save into current project dir8*/910/* ── Toast notification ─────────────────────────────────── */11function showToast(msg, type = 'info') {12let t = document.getElementById('toast');13if (!t) {14t = document.createElement('div');15t.id = 'toast';16t.style.cssText = `17position:fixed;bottom:24px;right:24px;z-index:9999;18background:#1e2130;border:1px solid #2d3148;color:#e1e4ea;19padding:10px 18px;border-radius:8px;font-size:0.85rem;20opacity:0;transition:opacity 0.25s;pointer-events:none;21box-shadow:0 4px 20px rgba(0,0,0,0.4);22`;23document.body.appendChild(t);24}25t.style.borderColor = type === 'success' ? '#34d399' : type === 'error' ? '#ef4444' : '#2d3148';26t.style.color = type === 'success' ? '#34d399' : type === 'error' ? '#ef4444' : '#e1e4ea';27t.textContent = msg;28t.style.opacity = '1';29clearTimeout(t._hideTimer);30t._hideTimer = setTimeout(() => { t.style.opacity = '0'; }, 2500);31}3233/* ── Preview-safe API URL helpers ─────────────────────────── */34function getPreviewBasePath() {35const m = window.location.pathname.match(/^(\/preview\/[^/]+\/)/);36return m ? m[1] : '/';37}3839function apiUrl(endpoint) {40const base = getPreviewBasePath();41const ep = String(endpoint || '').replace(/^\/+/, '');42return `${base}${ep}`;43}4445/* ── Resolve current project from pathname ───────────────── */46function getCurrentProjectPath() {47// expected path: /preview/<id>/<project>/index.html48// or /<project>/index.html49let path = window.location.pathname;50path = path.replace(/^\/preview\/[^/]+\//, '/');51path = path.replace(/^\//, '');5253// remove filename54const parts = path.split('/').filter(Boolean);55if (!parts.length) return '';56if (parts[parts.length - 1].includes('.')) parts.pop();57return parts.join('/');58}5960/* ── Collect all ECharts instances from page ─────────────── */61function getAllChartInstances() {62if (window.CHART_INSTANCES && window.CHART_INSTANCES.length > 0) {63return window.CHART_INSTANCES.filter(i => i && !i.isDisposed());64}65if (!window.echarts) return [];66const containers = document.querySelectorAll('[_echarts_instance_]');67const instances = [];68containers.forEach(c => {69const inst = echarts.getInstanceByDom(c);70if (inst && !inst.isDisposed()) instances.push(inst);71});72return instances;73}7475/* ── Merge multiple chart canvases into one PNG DataURL ───── */76async function mergeChartsToDataURL() {77const instances = getAllChartInstances();78if (instances.length === 0) throw new Error('No ECharts instances found');7980instances.forEach(inst => { try { inst.resize(); } catch(e) {} });81await new Promise(r => setTimeout(r, 150));8283const images = instances.map(inst => ({84dataUrl: inst.getDataURL({85type: 'png',86pixelRatio: 2,87backgroundColor: '#1a1d27',88excludeComponents: []89}),90width: inst.getWidth(),91height: inst.getHeight()92}));9394// Always compose onto a new canvas so output consistently includes title95// (even when there's only one chart).96const layout = window.CHART_LAYOUT || 'vertical';97const PR = 2;98const PAD = 24 * PR;99100let canvasW, canvasH;101const cols = layout === 'grid' ? Math.min(2, images.length) : 1;102const rows = Math.ceil(images.length / cols);103104// Reserve explicit title area so both button-save and one-click screenshot are equivalent105const title = document.querySelector('h1')?.textContent || document.title || 'Chart';106const subtitle = document.querySelector('.subtitle')?.textContent || '';107const titleHeight = PAD * 2.2;108109if (layout === 'grid') {110canvasW = (images[0].width * PR * cols) + PAD * (cols + 1);111canvasH = titleHeight + (images[0].height * PR * rows) + PAD * (rows + 1);112} else {113canvasW = Math.max(...images.map(i => i.width)) * PR + PAD * 2;114canvasH = titleHeight + images.reduce((s, i) => s + i.height * PR, 0) + PAD * (images.length + 1);115}116117const canvas = document.createElement('canvas');118canvas.width = canvasW;119canvas.height = canvasH;120const ctx = canvas.getContext('2d');121ctx.fillStyle = '#0f1117';122ctx.fillRect(0, 0, canvasW, canvasH);123124ctx.fillStyle = '#f0f2f5';125ctx.font = `bold ${28}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;126ctx.fillText(title, PAD, PAD * 1.1);127if (subtitle) {128ctx.fillStyle = '#9aa0b4';129ctx.font = `normal ${16 * PR / 2}px -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif`;130ctx.fillText(subtitle, PAD, PAD * 1.7);131}132133const loadImg = (dataUrl) => new Promise((resolve, reject) => {134const img = new Image();135img.onload = () => resolve(img);136img.onerror = reject;137img.src = dataUrl;138});139140const imgs = await Promise.all(images.map(i => loadImg(i.dataUrl)));141142for (let idx = 0; idx < imgs.length; idx++) {143const img = imgs[idx];144const w = images[idx].width * PR;145const h = images[idx].height * PR;146let x, y;147148if (layout === 'grid') {149const col = idx % cols;150const row = Math.floor(idx / cols);151x = PAD + col * (w + PAD);152y = titleHeight + PAD + row * (h + PAD);153} else {154x = PAD;155y = titleHeight + images.slice(0, idx).reduce((s, i) => s + i.height * PR + PAD, 0);156}157ctx.drawImage(img, x, y, w, h);158}159160return canvas.toDataURL('image/png');161}162163/* ── Unified export helpers (single pipeline) ───────────── */164async function exportMergedPNG() {165const dataUrl = await mergeChartsToDataURL();166const blob = await (await fetch(dataUrl)).blob();167return { dataUrl, blob };168}169170async function blobToDataURL(blob) {171return await new Promise((resolve, reject) => {172const reader = new FileReader();173reader.onload = () => resolve(reader.result);174reader.onerror = reject;175reader.readAsDataURL(blob);176});177}178179/* ── Download PNG ───────────────────────────────────────── */180async function downloadPNG(btn) {181btn = btn || event?.currentTarget;182const orig = btn?.textContent;183if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }184try {185const { blob } = await exportMergedPNG();186const filename = (document.title || 'chart').replace(/[^a-zA-Z0-9\u4e00-\u9fff-_]/g, '_') + '_' +187new Date().toISOString().slice(0,10) + '.png';188189const url = URL.createObjectURL(blob);190const a = document.createElement('a');191a.href = url;192a.download = filename;193a.rel = 'noopener';194document.body.appendChild(a);195a.click();196document.body.removeChild(a);197198if (window.self !== window.top) {199window.open(url, '_blank', 'noopener');200}201202setTimeout(() => URL.revokeObjectURL(url), 2000);203204if (btn) { btn.textContent = '✅'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }205showToast('PNG ready (download/new tab)', 'success');206setTimeout(() => {207if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }208}, 2000);209} catch(e) {210console.error('[chart] download failed:', e);211if (btn) { btn.textContent = '❌'; btn.disabled = false; }212showToast('Export failed: ' + e.message, 'error');213setTimeout(() => { if (btn) { btn.textContent = orig; } }, 2000);214}215}216217/* ── Copy to Clipboard (same output as save/download) ───── */218async function copyToClipboard(btn) {219btn = btn || event?.currentTarget;220const orig = btn?.textContent;221if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }222try {223const { blob } = await exportMergedPNG();224225if (navigator.clipboard && typeof ClipboardItem !== 'undefined') {226await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);227if (btn) { btn.textContent = '✅'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }228showToast('Image copied to clipboard ✓', 'success');229} else {230// Fallback still uses the exact same merged PNG bytes231const url = URL.createObjectURL(blob);232window.open(url, '_blank', 'noopener');233setTimeout(() => URL.revokeObjectURL(url), 3000);234showToast('Clipboard N/A — opened same exported image in new tab', 'info');235}236} catch(e) {237console.warn('[chart] clipboard failed, fallback to download:', e.message);238try {239const { blob } = await exportMergedPNG();240const filename = (document.title || 'chart').replace(/[^a-zA-Z0-9\u4e00-\u9fff-_]/g, '_') + '_'+241new Date().toISOString().slice(0,10) + '.png';242const url = URL.createObjectURL(blob);243const a = document.createElement('a');244a.href = url; a.download = filename;245document.body.appendChild(a); a.click(); document.body.removeChild(a);246setTimeout(() => URL.revokeObjectURL(url), 2000);247showToast('Clipboard N/A — downloaded same exported image', 'info');248} catch(e2) {249showToast('Export failed: ' + e2.message, 'error');250}251} finally {252setTimeout(() => {253if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }254}, 2000);255}256}257258/* ── Save to Project ────────────────────────────────────── */259async function saveToProject(btn) {260btn = btn || event?.currentTarget;261const orig = btn?.textContent;262if (btn) { btn.textContent = '⏳ Saving...'; btn.disabled = true; }263try {264const project = getCurrentProjectPath();265if (!project) throw new Error('Cannot detect project folder from URL');266267const { blob } = await exportMergedPNG();268const dataUrl = await blobToDataURL(blob);269const filename = 'screenshot.png';270271const resp = await fetch(apiUrl('save-chart'), {272method: 'POST',273headers: { 'Content-Type': 'application/json' },274body: JSON.stringify({ dataUrl, filename, project })275});276277if (!resp.ok) throw new Error(`Server returned ${resp.status}`);278const result = await resp.json();279280if (btn) { btn.textContent = '✅ Saved!'; btn.style.borderColor = '#34d399'; btn.style.color = '#34d399'; }281showToast(`Saved: ${result.url}`, 'success');282} catch(e) {283console.warn('[chart] saveToProject failed:', e.message);284showToast('Save API not available — downloading PNG...', 'info');285await downloadPNG(null);286if (btn) { btn.textContent = '📥 Downloaded'; }287} finally {288setTimeout(() => {289if (btn) { btn.textContent = orig; btn.disabled = false; btn.style.borderColor = ''; btn.style.color = ''; }290}, 3000);291}292}293294/* ── Backward-compat aliases ────────────────────────────── */295window.saveToWorkspace = saveToProject;296window.savePage = function(btn) {297showToast('savePage removed in project-based mode', 'info');298};299300/* ── Auto-resize all ECharts on window resize ───────────── */301window.addEventListener('resize', () => {302if (!window.echarts) return;303getAllChartInstances().forEach(inst => {304try { inst.resize(); } catch(e) {}305});306});307