#!/usr/bin/env node /** * chart.mjs - Generate chart images from data using Vega-Lite * * Usage: * node chart.mjs --type line --data '[{"x":"10:00","y":25},{"x":"10:30","y":31}]' --output chart.png * node chart.mjs --spec spec.json --output chart.png * echo '{"type":"line","data":[...]}' | node chart.mjs --output chart.png * * Options: * --type Chart type: line, bar, area, point (default: line) * --data JSON array of data points * --spec Path to full Vega-Lite spec JSON file * --output Output file path (default: chart.png) * --title Chart title * --width Chart width (default: 600) * --height Chart height (default: 300) * --x-field X axis field name (default: x) * --y-field Y axis field name (default: y) * --x-title X axis title * --y-title Y axis title * --color Line/bar color (default: #e63946) * --y-domain Y axis domain as "min,max" (e.g., "0,100") * --svg Output SVG instead of PNG */ import * as vega from 'vega'; import * as vegaLite from 'vega-lite'; import sharp from 'sharp'; import { writeFileSync, readFileSync } from 'fs'; // Show help function showHelp() { console.log(` chart.mjs - Generate chart images from data using Vega-Lite USAGE: node chart.mjs --type line --data '[{"x":"A","y":10}]' --output chart.png node chart.mjs --type bar --data '[...]' --title "Sales" --dark cat data.json | node chart.mjs --type area --output out.png CHART TYPES: line Line chart (default) bar Vertical bar chart area Area chart with fill point Scatter plot pie Pie chart (use --category-field, --y-field) donut Donut chart (pie with hole) candlestick OHLC candlestick (use --open/high/low/close-field) heatmap Heatmap grid (use --color-value-field) BASIC OPTIONS: --data JSON array of data points (or shorthand "Mon:10,Tue:15") --csv-file Path to CSV file (headers become field names) --csv Inline CSV string (headers + rows, newline-separated) --output Output file path (default: chart.png) --title Chart title --subtitle Chart subtitle --width Chart width in pixels (default: 600) --height Chart height in pixels (default: 300) --dark Dark mode (night-friendly colors) --svg Output SVG instead of PNG DATA FIELDS: --x-field X axis field name (default: x) --y-field Y axis field name (default: y) --x-title X axis label --y-title Y axis label --x-type X axis type: ordinal, temporal, quantitative STYLING: --color Primary color (default: #e63946) --y-domain Y axis range as "min,max" (e.g., "0,100") --y-format Y axis format: percent, dollar, compact, integer, decimal2 --hline Horizontal line: "value" or "value,color,label" --no-grid Remove gridlines for cleaner look --smooth Smooth/curved lines (monotone interpolation) --legend Legend position: top, bottom, left, right, none --output-size Platform preset: twitter, discord, slack, instagram, story, thumbnail, wide, square SORTING: --sort Sort bars by value: asc, desc (bar charts only) --bar-labels Show value labels on top of every bar (bar charts only) --horizontal Horizontal bar chart (categories on Y axis, values on X) --gradient Gradient fill for area charts (fades from color to background) --transparent Transparent background (useful for embedding) --bg-color COLOR Custom background color (hex, e.g. #f0f0f0) --y-scale TYPE Y axis scale type: linear (default), log, sqrt, symlog --zero-baseline Force Y axis to start at zero (aka --zero) --conditional-color Color by threshold: "value,belowColor,aboveColor" (default: red/green) DUAL AXIS: --y2-field Second Y axis field (independent right axis) --y2-title Title for second Y axis --y2-color Color for second series (default: blue) --y2-type Chart type for second axis: line, bar, area (default: line) ANNOTATIONS: --trend-line Add linear regression trend line (dashed) --show-change Show % change from first to last value --focus-change Zoom Y axis to highlight the change --focus-recent N Focus on last N data points --show-values Label min/max values on chart MULTI-SERIES: --series-field Field to split data into multiple lines --color-field Field for color encoding --stacked Stack bars/areas SPECIAL CHARTS: --sparkline Tiny 80x20 inline chart, no axes --category-field Category field for pie/donut --open/high/low/close-field Fields for candlestick --volume-field Volume overlay (dual Y axis) --color-value-field Value field for heatmap coloring --color-scheme Color scheme for heatmap (e.g., viridis) EXAMPLES: # Simple line chart node chart.mjs --data '[{"x":"Mon","y":10},{"x":"Tue","y":15}]' -o sales.png # Dark mode bar chart with title node chart.mjs --type bar --data '[...]' --title "Revenue" --dark -o rev.png # Time series with percentage Y axis node chart.mjs --data '[...]' --x-type temporal --y-format percent -o pct.png # Sparkline for inline use node chart.mjs --sparkline --data '[{"x":1,"y":10},{"x":2,"y":15}]' -o spark.png # CSV file input node chart.mjs --type bar --csv-file data.csv --x-field name --y-field value -o out.png # CSV via stdin (auto-detected) cat data.csv | node chart.mjs --type line --x-field date --y-field price -o out.png `); process.exit(0); } // Parse CLI args function parseArgs(args) { const opts = { type: 'line', output: 'chart.png', width: 600, height: 300, xField: 'x', yField: 'y', color: '#e63946', svg: false, showChange: false, sparkline: false, }; // Support shorthand: "Mon:10,Tue:25,Wed:18" → [{x:"Mon",y:10},...] function parseDataArg(str) { str = str.trim(); if (str.startsWith('[') || str.startsWith('{')) return JSON.parse(str); // shorthand: key:value,key:value,... return str.split(',').map(pair => { const [label, val] = pair.split(':'); const num = Number(val); return { x: label.trim(), y: isNaN(num) ? val.trim() : num }; }); } // parseCsv is defined at module level (below parseArgs) for (let i = 0; i < args.length; i++) { const arg = args[i]; const next = args[i + 1]; switch (arg) { case '--help': case '-h': showHelp(); break; case '--version': case '-v': console.log('chart.mjs v2.5.0'); process.exit(0); break; case '--type': opts.type = next; i++; break; case '--data': opts.data = parseDataArg(next); i++; break; case '--spec': opts.specFile = next; i++; break; case '--output': opts.output = next; i++; break; case '--title': opts.title = next; i++; break; case '--width': opts.width = parseInt(next); i++; break; case '--height': opts.height = parseInt(next); i++; break; case '--x-field': opts.xField = next; i++; break; case '--y-field': opts.yField = next; i++; break; case '--x-title': opts.xTitle = next; i++; break; case '--y-title': opts.yTitle = next; i++; break; case '--color': opts.color = next; i++; break; case '--y-domain': opts.yDomain = next.split(',').map(Number); i++; break; case '--svg': opts.svg = true; break; case '--show-change': opts.showChange = true; break; case '--annotation': opts.annotation = next; i++; break; case '--focus-change': opts.focusChange = true; break; case '--focus-recent': opts.focusRecent = parseInt(next) || 4; i++; break; case '--dark': opts.dark = true; break; case '--show-values': opts.showValues = true; break; case '--sparkline': opts.sparkline = true; break; case '--stacked': opts.stacked = true; break; case '--color-field': opts.colorField = next; i++; break; case '--series-field': opts.seriesField = next; i++; break; case '--open-field': opts.openField = next; i++; break; case '--high-field': opts.highField = next; i++; break; case '--low-field': opts.lowField = next; i++; break; case '--close-field': opts.closeField = next; i++; break; case '--donut': opts.donut = true; break; case '--category-field': opts.categoryField = next; i++; break; case '--volume-field': opts.volumeField = next; i++; break; case '--volume-color': opts.volumeColor = next; i++; break; case '--annotations': opts.annotations = JSON.parse(next); i++; break; case '--color-value-field': opts.colorValueField = next; i++; break; case '--y-category-field': opts.yCategoryField = next; i++; break; case '--color-scheme': opts.colorScheme = next; i++; break; case '--x-type': opts.xType = next; i++; break; // ordinal, temporal, quantitative case '--hline': { // Format: "value" or "value,color" or "value,color,label" const parts = next.split(','); const hline = { value: parseFloat(parts[0]) }; if (parts[1]) hline.color = parts[1]; if (parts[2]) hline.label = parts[2]; opts.hlines = opts.hlines || []; opts.hlines.push(hline); i++; break; } case '--y-format': opts.yFormat = next; i++; break; // percent, dollar, compact, or d3-format string case '--subtitle': opts.subtitle = next; i++; break; case '--no-grid': opts.noGrid = true; break; case '--legend': opts.legend = next; i++; break; // top, bottom, left, right, none case '--x-label-angle': opts.xLabelAngle = parseFloat(next); i++; break; // X axis label rotation angle case '--trend-line': opts.trendLine = true; break; // Linear regression trend line case '--watermark': opts.watermark = next; i++; break; // Watermark text overlay case '--smooth': opts.smooth = true; break; // Smooth/curved line interpolation (monotone) case '--output-size': opts.outputSize = next; i++; break; // Size preset: twitter, discord, slack, instagram, thumbnail case '--sort': opts.sort = next; i++; break; // Sort bars: asc, desc, none (default: none) case '--bar-labels': opts.barLabels = true; break; // Show value on every bar case '--gradient': opts.gradient = true; break; // Gradient fill for area charts (top-to-bottom fade) case '--y-scale': opts.yScale = next; i++; break; // Y axis scale: linear (default), log, sqrt, symlog case '--y2-field': opts.y2Field = next; i++; break; // Second Y axis field (dual-axis) case '--y2-title': opts.y2Title = next; i++; break; // Second Y axis title case '--y2-color': opts.y2Color = next; i++; break; // Second Y axis line color case '--y2-type': opts.y2Type = next; i++; break; // Second Y axis chart type: line, bar, area (default: line) case '--transparent': opts.transparent = true; break; // Transparent background case '--bg-color': opts.bgColor = next; i++; break; // Custom background color case '--csv': opts.data = parseCsv(next); i++; break; // Inline CSV string case '--csv-file': opts.data = parseCsv(readFileSync(next, 'utf8')); i++; break; // CSV file path case '--zero-baseline': case '--zero': opts.zeroBaseline = true; break; // Force Y axis to start at 0 case '--horizontal': opts.horizontal = true; break; // Horizontal bar chart (swap x/y axes) case '--conditional-color': { // Format: "threshold,belowColor,aboveColor" or "threshold" (uses red/green defaults) const ccParts = next.split(','); opts.conditionalColor = { threshold: parseFloat(ccParts[0]), below: ccParts[1] || '#e63946', above: ccParts[2] || '#2a9d8f' }; i++; break; } case '-o': opts.output = next; i++; break; // Shorthand for --output } } // Sparkline mode: tiny inline chart, no axes/labels if (opts.sparkline) { opts.width = opts.width === 600 ? 80 : opts.width; // Default 80 unless specified opts.height = opts.height === 300 ? 20 : opts.height; // Default 20 unless specified opts.noAxes = true; opts.title = null; // No title for sparklines } // Output size presets (apply only if width/height weren't explicitly set) if (opts.outputSize) { const sizePresets = { 'twitter': { w: 1200, h: 675 }, // Twitter/X card 16:9 'x': { w: 1200, h: 675 }, // Alias for twitter 'discord': { w: 800, h: 400 }, // Discord embed 'discord-embed': { w: 800, h: 400 }, // Alias 'slack': { w: 800, h: 450 }, // Slack unfurl 'instagram': { w: 1080, h: 1080 }, // Instagram square 'story': { w: 1080, h: 1920 }, // Instagram/TikTok story 9:16 'thumbnail': { w: 320, h: 180 }, // Small thumbnail 'wide': { w: 1200, h: 400 }, // Wide banner 'square': { w: 600, h: 600 }, // Square }; const preset = sizePresets[opts.outputSize.toLowerCase()]; if (preset) { // Only override if user didn't explicitly set width/height if (opts.width === 600) opts.width = preset.w; if (opts.height === 300) opts.height = preset.h; } else { console.error(`Unknown --output-size preset: ${opts.outputSize}. Available: ${Object.keys(sizePresets).join(', ')}`); } } return opts; } // Read from stdin if no data provided // Parse CSV string into array of objects (auto-detect numeric fields) // RFC 4180-compliant CSV parser — handles quoted fields with commas, newlines, escaped quotes function parseCsvLine(line) { const fields = []; let i = 0, field = '', inQuotes = false; while (i < line.length) { const ch = line[i]; if (inQuotes) { if (ch === '"' && line[i + 1] === '"') { field += '"'; i += 2; } else if (ch === '"') { inQuotes = false; i++; } else { field += ch; i++; } } else { if (ch === '"') { inQuotes = true; i++; } else if (ch === ',') { fields.push(field); field = ''; i++; } else { field += ch; i++; } } } fields.push(field); return fields; } function parseCsv(str) { // Strip UTF-8 BOM (common in Excel-exported CSVs) if (str.charCodeAt(0) === 0xFEFF) str = str.slice(1); // Handle TSV auto-detection: if first line has tabs but no commas, treat as TSV const firstLine = str.trim().split('\n')[0]; const sep = (firstLine.includes('\t') && !firstLine.includes(',')) ? '\t' : null; const lines = str.trim().split('\n').map(l => l.trim()).filter(Boolean); if (lines.length < 2) return []; const parseRow = sep ? (line) => line.split(sep).map(v => v.trim().replace(/^["']|["']$/g, '')) : parseCsvLine; const headers = parseRow(lines[0]).map(h => h.trim()); return lines.slice(1).map(line => { const vals = parseRow(line); const row = {}; headers.forEach((h, i) => { const raw = (vals[i] || '').trim(); const num = Number(raw); row[h] = (raw !== '' && !isNaN(num)) ? num : raw; }); return row; }); } async function readStdin() { return new Promise((resolve) => { let data = ''; process.stdin.setEncoding('utf8'); process.stdin.on('readable', () => { let chunk; while ((chunk = process.stdin.read()) !== null) { data += chunk; } }); process.stdin.on('end', () => resolve(data)); // Timeout for non-piped usage setTimeout(() => resolve(''), 100); }); } // Resolve y-format shorthand to d3-format string function resolveYFormat(fmt) { if (!fmt) return null; const shortcuts = { 'percent': '.1%', // 45.2% 'pct': '.1%', 'dollar': '$,.2f', // $1,234.56 'usd': '$,.2f', 'compact': '~s', // 1.2K, 3.4M 'integer': ',.0f', // 1,234 'decimal2': ',.2f', // 1,234.56 'decimal4': ',.4f', // 0.0003 'scientific': '.2e', // 3.71e-4 }; return shortcuts[fmt] || fmt; // Allow raw d3-format strings } // Build Vega-Lite spec from options function buildSpec(opts) { // Theme colors const theme = opts.dark ? { bg: opts.transparent ? 'transparent' : (opts.bgColor || '#1a1a2e'), text: '#e0e0e0', grid: opts.noGrid ? 'transparent' : '#333355', accent: opts.color || '#ff6b6b', positive: '#4ade80', negative: '#f87171', } : { bg: opts.transparent ? 'transparent' : (opts.bgColor || '#ffffff'), text: '#333333', grid: opts.noGrid ? 'transparent' : '#e0e0e0', accent: opts.color || '#e63946', positive: '#22c55e', negative: '#ef4444', }; const markConfig = { line: { type: 'line', point: true, color: theme.accent, strokeWidth: 2, ...(opts.smooth ? { interpolate: 'monotone' } : {}) }, bar: { type: 'bar', color: theme.accent }, area: { type: 'area', ...(opts.gradient ? { color: { x1: 1, y1: 1, x2: 1, y2: 0, gradient: 'linear', stops: [{ offset: 0, color: theme.bg }, { offset: 1, color: theme.accent }] } } : { color: theme.accent, opacity: 0.7 }), line: { color: theme.accent }, ...(opts.smooth ? { interpolate: 'monotone' } : {}) }, point: { type: 'point', color: theme.accent, size: 100 }, candlestick: null, // Handled separately as composite chart }; // Pie/donut chart if (opts.type === 'pie' || opts.type === 'donut' || opts.donut) { const catField = opts.categoryField || opts.xField || 'category'; const valField = opts.yField || 'value'; const innerRadius = (opts.type === 'donut' || opts.donut) ? Math.min(opts.width, opts.height) * 0.2 : 0; const pieSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 10, top: 10, bottom: 10 }, data: { values: opts.data }, mark: { type: 'arc', innerRadius: innerRadius, stroke: theme.bg, strokeWidth: 2 }, encoding: { theta: { field: valField, type: 'quantitative', stack: true }, color: { field: catField, type: 'nominal', title: opts.xTitle || catField, scale: { scheme: opts.colorScheme || (opts.dark ? 'category20' : 'category10') }, legend: { labelColor: theme.text, titleColor: theme.text } }, order: { field: valField, type: 'quantitative', sort: 'descending' } }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, view: { stroke: null } } }; if (opts.title) { pieSpec.title = { text: opts.title, anchor: 'middle', color: theme.text }; } // Add labels if showValues if (opts.showValues) { pieSpec.layer = [ { mark: { type: 'arc', innerRadius: innerRadius, stroke: theme.bg, strokeWidth: 2 } }, { mark: { type: 'text', radius: Math.min(opts.width, opts.height) * 0.35, fontSize: 12, fontWeight: 'bold' }, encoding: { text: { field: valField, type: 'quantitative' }, color: { value: theme.text } } } ]; // Move common encodings to top level when using layers pieSpec.encoding = { theta: { field: valField, type: 'quantitative', stack: true }, color: { field: catField, type: 'nominal', title: opts.xTitle || catField, scale: { scheme: opts.colorScheme || (opts.dark ? 'category20' : 'category10') }, legend: { labelColor: theme.text, titleColor: theme.text } }, order: { field: valField, type: 'quantitative', sort: 'descending' } }; delete pieSpec.mark; } return pieSpec; } // Heatmap chart (rect marks with color encoding) if (opts.type === 'heatmap') { // Expects data with x, y, and value fields // x = columns (e.g., day of week, hour) // y = rows (e.g., week number, category) // value = intensity (color) const xField = opts.xField || 'x'; const yField = opts.yCategoryField || opts.yField || 'y'; const valueField = opts.colorValueField || 'value'; // Choose color scheme based on data type and theme // Common schemes: blues, greens, reds, viridis, magma, inferno, plasma let colorScheme = opts.colorScheme || (opts.dark ? 'viridis' : 'blues'); const heatmapSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 10, top: 10, bottom: 10 }, data: { values: opts.data }, mark: { type: 'rect', stroke: theme.bg, strokeWidth: 1 }, encoding: { x: { field: xField, type: 'ordinal', title: opts.xTitle || xField, axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid } }, y: { field: yField, type: 'ordinal', title: opts.yTitle || yField, axis: { labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid } }, color: { field: valueField, type: 'quantitative', title: valueField, scale: { scheme: colorScheme }, legend: { labelColor: theme.text, titleColor: theme.text, gradientLength: 150 } } }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13 }, view: { stroke: null } } }; if (opts.title) { heatmapSpec.title = { text: opts.title, anchor: 'start', color: theme.text }; } // Add value labels if showValues if (opts.showValues) { heatmapSpec.layer = [ { mark: { type: 'rect', stroke: theme.bg, strokeWidth: 1 } }, { mark: { type: 'text', fontSize: 10, fontWeight: 'bold' }, encoding: { text: { field: valueField, type: 'quantitative' }, color: { condition: { test: `datum['${valueField}'] > 50`, // Light text on dark cells value: theme.bg === '#ffffff' ? '#ffffff' : '#1a1a2e' }, value: theme.text } } } ]; // Move encodings to top level for layers heatmapSpec.encoding = { x: heatmapSpec.encoding.x, y: heatmapSpec.encoding.y, color: heatmapSpec.encoding.color }; delete heatmapSpec.mark; } return heatmapSpec; } // Candlestick chart (OHLC) - composite of rule + bar marks if (opts.type === 'candlestick') { // Expects data with: x, open, high, low, close fields const openField = opts.openField || 'open'; const highField = opts.highField || 'high'; const lowField = opts.lowField || 'low'; const closeField = opts.closeField || 'close'; // Add computed "bullish" field to data const dataWithDirection = opts.data.map(d => ({ ...d, _bullish: d[closeField] >= d[openField] })); const candleSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 10, top: 10, bottom: 10 }, data: { values: dataWithDirection }, layer: [ // Wick (high-low line) { mark: { type: 'rule', strokeWidth: 1 }, encoding: { x: { field: opts.xField, type: 'ordinal', axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 } }, y: { field: lowField, type: 'quantitative', title: 'Price' }, y2: { field: highField }, color: { condition: { test: 'datum._bullish', value: theme.positive }, value: theme.negative } } }, // Body (open-close bar) { mark: { type: 'bar', width: { band: 0.6 } }, encoding: { x: { field: opts.xField, type: 'ordinal' }, y: { field: openField, type: 'quantitative' }, y2: { field: closeField }, color: { condition: { test: 'datum._bullish', value: theme.positive }, value: theme.negative } } } ], config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, gridColor: theme.grid, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, view: { stroke: null } } }; if (opts.title) { candleSpec.title = { text: opts.title, anchor: 'start', color: theme.text }; } if (opts.yDomain || opts.yScale || opts.zeroBaseline) { const scaleObj = { ...(opts.yDomain ? { domain: opts.yDomain } : {}), ...(opts.yScale ? { type: opts.yScale } : {}), ...(opts.zeroBaseline ? { zero: true } : {}) }; candleSpec.layer[0].encoding.y.scale = scaleObj; candleSpec.layer[1].encoding.y.scale = scaleObj; } return candleSpec; } // Stacked bar chart if (opts.stacked && opts.colorField) { const stackedSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 10, top: 10, bottom: 10 }, data: { values: opts.data }, mark: { type: 'bar' }, encoding: { x: { field: opts.xField, type: 'ordinal', title: opts.xTitle || opts.xField, axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 } }, y: { field: opts.yField, type: 'quantitative', title: opts.yTitle || opts.yField, stack: 'zero' }, color: { field: opts.colorField, type: 'nominal', title: opts.colorField, scale: { scheme: opts.colorScheme || 'category10' } } }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, gridColor: theme.grid, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, legend: { labelColor: theme.text, titleColor: theme.text }, view: { stroke: null } } }; if (opts.title) { stackedSpec.title = { text: opts.title, anchor: 'start', color: theme.text }; } return stackedSpec; } // Multi-series line chart if (opts.seriesField && opts.type === 'line') { const multiSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 10, top: 10, bottom: 10 }, data: { values: opts.data }, mark: { type: 'line', point: true, strokeWidth: 2, ...(opts.smooth ? { interpolate: 'monotone' } : {}) }, encoding: { x: { field: opts.xField, type: 'ordinal', title: opts.xTitle || opts.xField, axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 } }, y: { field: opts.yField, type: 'quantitative', title: opts.yTitle || opts.yField, scale: opts.yDomain ? { domain: opts.yDomain } : {} }, color: { field: opts.seriesField, type: 'nominal', title: opts.seriesField, scale: { scheme: opts.colorScheme || 'category10' } } }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, gridColor: theme.grid, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, legend: { labelColor: theme.text, titleColor: theme.text }, view: { stroke: null } } }; if (opts.title) { multiSpec.title = { text: opts.title, anchor: 'start', color: theme.text }; } return multiSpec; } // Volume overlay (dual-axis chart with price line + volume bars) if (opts.volumeField && opts.data) { const volumeColor = opts.volumeColor || (opts.dark ? '#4a5568' : '#cbd5e0'); // Apply focusRecent if specified let chartData = opts.data; if (opts.focusRecent && chartData.length > opts.focusRecent) { chartData = chartData.slice(-opts.focusRecent); } // Calculate Y domain for price if focusChange let priceYDomain = opts.yDomain; if (opts.focusChange && chartData.length >= 2) { const values = chartData.map(d => d[opts.yField]); const min = Math.min(...values); const max = Math.max(...values); const range = max - min; const padding = range * 0.5; priceYDomain = [Math.max(0, Math.floor(min - padding)), Math.ceil(max + padding)]; } // Calculate change annotation let changeText = opts.annotation || null; if (opts.showChange && chartData.length >= 2) { const first = chartData[0][opts.yField]; const last = chartData[chartData.length - 1][opts.yField]; const change = last - first; const sign = change >= 0 ? '+' : ''; changeText = `${sign}${change.toFixed(1)}%`; } const volumeSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 50, top: 10, bottom: 10 }, // Extra right padding for 2nd axis data: { values: chartData }, layer: [ // Volume bars (behind, on secondary y-axis) { mark: { type: 'bar', color: volumeColor, opacity: 0.4 }, encoding: { x: { field: opts.xField, type: 'ordinal', axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45, title: opts.xTitle || opts.xField } }, y: { field: opts.volumeField, type: 'quantitative', axis: { title: 'Volume', orient: 'right', titleColor: volumeColor, labelColor: volumeColor, gridColor: 'transparent' // No grid lines for volume }, scale: { zero: true } } } }, // Price line (front, on primary y-axis) { mark: { type: 'line', point: true, color: theme.accent, strokeWidth: 2 }, encoding: { x: { field: opts.xField, type: 'ordinal' }, y: { field: opts.yField, type: 'quantitative', axis: { title: opts.yTitle || opts.yField, orient: 'left', gridColor: theme.grid }, scale: priceYDomain ? { domain: priceYDomain } : {} } } } ], resolve: { scale: { y: 'independent' } // Key: independent Y scales for dual axis }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, view: { stroke: null } } }; // Add change annotation if requested if (changeText && chartData.length >= 1) { const lastPoint = chartData[chartData.length - 1]; const isNegative = changeText.startsWith('-'); volumeSpec.layer.push({ mark: { type: 'text', align: 'left', dx: 8, dy: -8, fontSize: 18, fontWeight: 'bold', color: isNegative ? theme.positive : theme.negative }, encoding: { x: { datum: lastPoint[opts.xField] }, y: { datum: lastPoint[opts.yField] }, text: { value: changeText } } }); } if (opts.title) { volumeSpec.title = { text: opts.title, anchor: 'start', color: theme.text }; } return volumeSpec; } // General dual-axis chart (--y2-field) if (opts.y2Field && opts.data) { const y2Color = opts.y2Color || (opts.dark ? '#60a5fa' : '#2563eb'); // Blue default const y2Mark = opts.y2Type || 'line'; // Apply focusRecent let chartData = opts.data; if (opts.focusRecent && chartData.length > opts.focusRecent) { chartData = chartData.slice(-opts.focusRecent); } const xAxisType = opts.xType || 'ordinal'; const yFormat = resolveYFormat(opts.yFormat); const dualSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 50, top: 10, bottom: 10 }, data: { values: chartData }, layer: [ // Primary Y axis (left) { mark: { type: opts.type || 'line', ...(opts.type === 'line' || !opts.type ? { point: true, strokeWidth: 2, ...(opts.smooth ? { interpolate: 'monotone' } : {}) } : {}), color: theme.accent }, encoding: { x: { field: opts.xField, type: xAxisType, title: opts.xTitle || opts.xField, axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 } }, y: { field: opts.yField, type: 'quantitative', axis: { title: opts.yTitle || opts.yField, orient: 'left', gridColor: theme.grid, ...(yFormat ? { format: yFormat } : {}) }, scale: { ...(opts.yDomain ? { domain: opts.yDomain } : {}), ...(opts.yScale ? { type: opts.yScale } : {}) } } } }, // Secondary Y axis (right) { mark: { type: y2Mark, ...(y2Mark === 'line' ? { point: true, strokeWidth: 2, ...(opts.smooth ? { interpolate: 'monotone' } : {}) } : { opacity: 0.5 }), color: y2Color }, encoding: { x: { field: opts.xField, type: xAxisType }, y: { field: opts.y2Field, type: 'quantitative', axis: { title: opts.y2Title || opts.y2Field, orient: 'right', titleColor: y2Color, labelColor: y2Color, gridColor: 'transparent' } } } } ], resolve: { scale: { y: 'independent' } }, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, view: { stroke: null } } }; if (opts.title) { dualSpec.title = { text: opts.title, anchor: 'start', color: theme.text, ...(opts.subtitle ? { subtitle: opts.subtitle, subtitleColor: theme.grid, subtitleFontSize: 12 } : {}) }; } return dualSpec; } // Calculate change if requested let changeText = opts.annotation || null; if (opts.showChange && opts.data && opts.data.length >= 2) { const first = opts.data[0][opts.yField]; const last = opts.data[opts.data.length - 1][opts.yField]; const change = last - first; const sign = change >= 0 ? '+' : ''; changeText = `${sign}${change.toFixed(1)}%`; } // Focus on recent data points only if (opts.focusRecent && opts.data && opts.data.length > opts.focusRecent) { opts.data = opts.data.slice(-opts.focusRecent); } // Focus Y-axis on change (2x scale of the data range) if (opts.focusChange && opts.data && opts.data.length >= 2) { const values = opts.data.map(d => d[opts.yField]); const min = Math.min(...values); const max = Math.max(...values); const range = max - min; const padding = range * 0.5; // 50% padding on each side = 2x total range const yMin = Math.max(0, Math.floor(min - padding)); const yMax = Math.ceil(max + padding); opts.yDomain = [yMin, yMax]; } // Base layer - the main chart const xAxisType = opts.xType || 'ordinal'; // ordinal (default), temporal, quantitative const yFormat = resolveYFormat(opts.yFormat); const mainLayer = { mark: markConfig[opts.type] || markConfig.line, encoding: { x: { field: opts.xField, type: xAxisType, title: opts.xTitle || opts.xField, axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 }, // Sort bar charts by value when --sort is specified ...(opts.sort && opts.type === 'bar' ? { sort: opts.sort === 'desc' ? { field: opts.yField, order: 'descending' } : opts.sort === 'asc' ? { field: opts.yField, order: 'ascending' } : null } : {}) }, y: { field: opts.yField, type: 'quantitative', title: opts.yTitle || opts.yField, ...(yFormat ? { axis: { format: yFormat } } : {}) } } }; if (opts.yDomain || opts.yScale || opts.zeroBaseline) { mainLayer.encoding.y.scale = { ...(opts.yDomain ? { domain: opts.yDomain } : {}), ...(opts.yScale ? { type: opts.yScale } : {}), ...(opts.zeroBaseline ? { zero: true } : {}) }; } // Conditional color encoding (--conditional-color "threshold,belowColor,aboveColor") if (opts.conditionalColor) { const cc = opts.conditionalColor; if (opts.type === 'bar' || opts.type === 'area' || opts.type === 'point') { // Direct color condition on marks mainLayer.encoding.color = { condition: { test: `datum['${opts.yField}'] >= ${cc.threshold}`, value: cc.above }, value: cc.below }; // Legend not needed for conditional coloring } else { // Line charts: neutral line + colored points mainLayer.mark = { type: 'line', strokeWidth: 2, color: opts.dark ? '#888' : '#999', ...(opts.smooth ? { interpolate: 'monotone' } : {}) }; delete mainLayer.encoding.color; } } // Horizontal bar chart: swap x and y encoding if (opts.horizontal && opts.type === 'bar') { const origX = mainLayer.encoding.x; const origY = mainLayer.encoding.y; mainLayer.encoding.x = { ...origY, axis: origY.axis || {} }; mainLayer.encoding.y = { field: origX.field, type: origX.type || 'ordinal', title: origX.title, axis: { labelAngle: 0 }, ...(opts.sort ? { sort: opts.sort === 'asc' ? { field: opts.yField, order: 'ascending' } : opts.sort === 'desc' ? { field: opts.yField, order: 'descending' } : null } : {}) }; } const layers = [mainLayer]; // Conditional color: add colored points layer for line charts if (opts.conditionalColor && (!opts.type || opts.type === 'line')) { const cc = opts.conditionalColor; layers.push({ mark: { type: 'point', size: 60, filled: true }, encoding: { x: { ...mainLayer.encoding.x }, y: { ...mainLayer.encoding.y }, color: { condition: { test: `datum['${opts.yField}'] >= ${cc.threshold}`, value: cc.above }, value: cc.below } } }); } // Add value labels on every bar (--bar-labels) if (opts.barLabels && opts.type === 'bar' && opts.data && opts.data.length > 0) { const yFormat2 = resolveYFormat(opts.yFormat); const fmtBarVal = (v) => { if (!yFormat2) return typeof v === 'number' ? (Number.isInteger(v) ? `${v}` : v.toFixed(1)) : `${v}`; if (yFormat2 === '.1%') return `${(v * 100).toFixed(1)}%`; if (yFormat2 === '$,.2f') return `$${v.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; if (yFormat2 === ',.4f') return v.toFixed(4); if (yFormat2 === '.2e') return v.toExponential(2); if (yFormat2 === '~s') return v >= 1e6 ? `${(v/1e6).toFixed(1)}M` : v >= 1e3 ? `${(v/1e3).toFixed(1)}K` : `${v}`; return typeof v === 'number' ? (Number.isInteger(v) ? `${v}` : v.toFixed(1)) : `${v}`; }; const isHoriz = opts.horizontal; layers.push({ mark: { type: 'text', align: isHoriz ? 'left' : 'center', ...(isHoriz ? { dx: 4 } : { dy: -8 }), fontSize: 11, fontWeight: 'bold', color: theme.text }, encoding: { ...(isHoriz ? { x: { field: opts.yField, type: 'quantitative' }, y: { field: opts.xField, type: xAxisType, ...(opts.sort ? { sort: opts.sort === 'desc' ? { field: opts.yField, order: 'descending' } : opts.sort === 'asc' ? { field: opts.yField, order: 'ascending' } : null } : {}) } } : { x: { field: opts.xField, type: xAxisType, ...(opts.sort && opts.type === 'bar' ? { sort: opts.sort === 'desc' ? { field: opts.yField, order: 'descending' } : opts.sort === 'asc' ? { field: opts.yField, order: 'ascending' } : null } : {}) }, y: { field: opts.yField, type: 'quantitative' } }), text: { field: opts.yField, type: 'quantitative', format: yFormat2 || '' } } }); } // Add change annotation on the last point if (changeText && opts.data && opts.data.length >= 1) { const lastPoint = opts.data[opts.data.length - 1]; const isNegative = changeText.startsWith('-'); layers.push({ mark: { type: 'text', align: 'left', dx: 8, dy: -8, fontSize: 18, fontWeight: 'bold', color: isNegative ? theme.positive : theme.negative // Green for negative (good), red for positive (bad for risk) }, encoding: { x: { datum: lastPoint[opts.xField] }, y: { datum: lastPoint[opts.yField] }, text: { value: changeText } } }); } // Add value labels at peak points (min and max) if (opts.showValues && opts.data && opts.data.length >= 2) { const values = opts.data.map(d => d[opts.yField]); const minVal = Math.min(...values); const maxVal = Math.max(...values); const minPoint = opts.data.find(d => d[opts.yField] === minVal); const maxPoint = opts.data.find(d => d[opts.yField] === maxVal); // Format value labels using y-format if available const fmtVal = (v) => { if (!yFormat) return `${v}`; // Simple formatting for common cases if (yFormat === '.1%') return `${(v * 100).toFixed(1)}%`; if (yFormat === '$,.2f') return `$${v.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`; if (yFormat === ',.4f') return v.toFixed(4); if (yFormat === '.2e') return v.toExponential(2); return `${v}`; }; // Max point label (above) if (maxPoint) { layers.push({ mark: { type: 'text', align: 'center', dy: -12, fontSize: 13, fontWeight: 'bold', color: theme.text }, encoding: { x: { datum: maxPoint[opts.xField] }, y: { datum: maxPoint[opts.yField] }, text: { value: fmtVal(maxVal) } } }); } // Min point label (below) - only if different from max if (minPoint && minVal !== maxVal) { layers.push({ mark: { type: 'text', align: 'center', dy: 16, fontSize: 13, fontWeight: 'bold', color: theme.text }, encoding: { x: { datum: minPoint[opts.xField] }, y: { datum: minPoint[opts.yField] }, text: { value: fmtVal(minVal) } } }); } } // Add timeline annotations (vertical lines with labels) // Format: --annotations '[{"x":"10:00","label":"Event 1"},{"x":"14:00","label":"Event 2"}]' if (opts.annotations && Array.isArray(opts.annotations) && opts.data) { const annotationColor = opts.dark ? '#fbbf24' : '#d97706'; // Amber color for visibility for (const ann of opts.annotations) { const xVal = ann.x || ann[opts.xField]; const label = ann.label || ann.text || ''; // Find Y value at this X point for positioning (or use middle of Y range) const dataPoint = opts.data.find(d => d[opts.xField] === xVal); const yValues = opts.data.map(d => d[opts.yField]); const yMin = Math.min(...yValues); const yMax = Math.max(...yValues); const yPos = dataPoint ? dataPoint[opts.yField] : (yMin + yMax) / 2; // Vertical rule line layers.push({ mark: { type: 'rule', color: annotationColor, strokeWidth: 2, strokeDash: [4, 4] // Dashed line }, encoding: { x: { datum: xVal } } }); // Label above the line if (label) { layers.push({ mark: { type: 'text', align: 'center', baseline: 'bottom', dy: -5, fontSize: 11, fontWeight: 'bold', color: annotationColor }, encoding: { x: { datum: xVal }, y: { value: 10 }, // Near top of chart text: { value: label } } }); } } } // Add linear regression trend line if (opts.trendLine && opts.data && opts.data.length >= 2) { // Simple linear regression: y = mx + b const n = opts.data.length; const xs = opts.data.map((_, i) => i); const ys = opts.data.map(d => d[opts.yField]); const sumX = xs.reduce((a, b) => a + b, 0); const sumY = ys.reduce((a, b) => a + b, 0); const sumXY = xs.reduce((acc, x, i) => acc + x * ys[i], 0); const sumX2 = xs.reduce((acc, x) => acc + x * x, 0); const m = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX); const b = (sumY - m * sumX) / n; const trendColor = opts.dark ? '#fbbf24' : '#d97706'; const firstPoint = opts.data[0]; const lastPoint = opts.data[opts.data.length - 1]; const y0 = b; const y1 = m * (n - 1) + b; // Add trend line as a layer with two-point dataset layers.push({ data: { values: [ { _tx: firstPoint[opts.xField], _ty: y0 }, { _tx: lastPoint[opts.xField], _ty: y1 } ] }, mark: { type: 'line', color: trendColor, strokeWidth: 2, strokeDash: [6, 4], opacity: 0.8 }, encoding: { x: { field: '_tx', type: xAxisType }, y: { field: '_ty', type: 'quantitative' } } }); } // Add horizontal reference lines (thresholds, targets, etc.) // Format: --hline "value" or --hline "value,color" or --hline "value,color,label" if (opts.hlines && opts.hlines.length > 0) { for (const hline of opts.hlines) { const lineColor = hline.color || (opts.dark ? '#fbbf24' : '#d97706'); // Amber default // Horizontal rule layers.push({ mark: { type: 'rule', color: lineColor, strokeWidth: 2, strokeDash: [6, 3] }, encoding: { y: { datum: hline.value } } }); // Label on the right side if (hline.label) { layers.push({ mark: { type: 'text', align: 'right', baseline: 'middle', dx: -5, fontSize: 11, fontWeight: 'bold', color: lineColor }, encoding: { x: { value: opts.width - 5 }, y: { datum: hline.value }, text: { value: hline.label } } }); } } } // Sparkline mode: minimal spec, no axes if (opts.sparkline || opts.noAxes) { const sparkSpec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: opts.sparkline ? 'transparent' : theme.bg, padding: 0, autosize: { type: 'fit', contains: 'padding' }, data: { values: opts.data }, mark: { type: 'line', color: theme.accent, strokeWidth: opts.sparkline ? 1.5 : 2 }, encoding: { x: { field: opts.xField, type: 'ordinal', axis: null // No axis }, y: { field: opts.yField, type: 'quantitative', axis: null, // No axis scale: opts.yDomain ? { domain: opts.yDomain } : { zero: false } } }, config: { view: { stroke: null } } }; return sparkSpec; } const spec = { $schema: 'https://vega.github.io/schema/vega-lite/v5.json', width: opts.width, height: opts.height, background: theme.bg, padding: { left: 10, right: 30, top: 10, bottom: 10 }, data: { values: opts.data }, layer: layers, config: { font: 'Helvetica, Arial, sans-serif', title: { fontSize: 16, fontWeight: 'bold', color: theme.text }, axis: { labelFontSize: 11, titleFontSize: 13, gridColor: theme.grid, labelColor: theme.text, titleColor: theme.text, domainColor: theme.grid }, view: { stroke: null }, legend: { labelColor: theme.text, titleColor: theme.text, ...(opts.legend === 'none' ? { disable: true } : {}), ...(opts.legend && opts.legend !== 'none' ? { orient: opts.legend } : {}) } } }; if (opts.title) { spec.title = { text: opts.title, anchor: 'start', color: theme.text, ...(opts.subtitle ? { subtitle: opts.subtitle, subtitleColor: theme.grid, subtitleFontSize: 12 } : {}) }; } return spec; } // Main async function main() { const opts = parseArgs(process.argv.slice(2)); let spec; if (opts.specFile) { // Load full spec from file spec = JSON.parse(readFileSync(opts.specFile, 'utf8')); } else if (opts.data) { // Build spec from options spec = buildSpec(opts); } else { // Try stdin const stdin = await readStdin(); if (stdin.trim()) { // Auto-detect CSV/TSV (has commas or tabs, no brackets, first line looks like headers) const trimmed = stdin.trim(); const looksLikeCsvOrTsv = !trimmed.startsWith('[') && !trimmed.startsWith('{') && trimmed.includes('\n') && (trimmed.includes(',') || trimmed.includes('\t')); if (looksLikeCsvOrTsv) { opts.data = parseCsv(trimmed); spec = buildSpec(opts); } else { const input = JSON.parse(stdin); if (input.$schema) { // Full spec via stdin spec = input; } else if (Array.isArray(input)) { // Just data array opts.data = input; spec = buildSpec(opts); } else if (input.data) { // Simplified format: {type, data, title, ...} Object.assign(opts, input); if (typeof opts.data === 'string') opts.data = JSON.parse(opts.data); spec = buildSpec(opts); } } } } if (!spec || !spec.data?.values?.length) { console.error('Error: No data provided. Use --data, --spec, or pipe JSON to stdin.'); process.exit(1); } // Compile Vega-Lite to Vega const vgSpec = vegaLite.compile(spec).spec; const view = new vega.View(vega.parse(vgSpec), { renderer: 'none' }); await view.initialize(); // Generate SVG const svg = await view.toSVG(); if (opts.svg || opts.output.endsWith('.svg')) { writeFileSync(opts.output, svg); console.log(`SVG saved to ${opts.output}`); } else { // Convert SVG to PNG using sharp let pngBuffer = await sharp(Buffer.from(svg)) .png() .toBuffer(); // Apply watermark if specified if (opts.watermark) { const meta = await sharp(pngBuffer).metadata(); const w = meta.width || opts.width; const h = meta.height || opts.height; const fontSize = Math.max(12, Math.round(h * 0.035)); const escaped = opts.watermark.replace(/&/g, '&').replace(//g, '>'); const wmSvg = ` ${escaped} `; pngBuffer = await sharp(pngBuffer) .composite([{ input: Buffer.from(wmSvg), top: 0, left: 0 }]) .png() .toBuffer(); } writeFileSync(opts.output, pngBuffer); console.log(`Chart saved to ${opts.output}`); } } main().catch(err => { console.error('Error:', err.message); process.exit(1); });