Skill: dannyshmueli/chart-image (version 2.6.35)

--- name: chart-image description: Generate publication-quality chart images from data. Supports line, bar, area, point, histogram, candlestick, pie/donut, heatmap, multi-series, and stacked charts. Use when visualizing data, creating graphs, plotting time series, showing distributions, or generating chart images for reports/alerts. Designed for Fly.io/VPS deployments - no native compilation, no Puppeteer, no browser required. Pure Node.js with prebuilt binaries. --- # Chart Image Generator Generate PNG chart images from data using Vega-Lite. Perfect for headless server environments. ## Why This Skill? **Built for Fly.io / VPS / Docker deployments:** - ✅ **No native compilation** - Uses Sharp with prebuilt binaries (unlike `canvas` which requires build tools) - ✅ **No Puppeteer/browser** - Pure Node.js, no Chrome download, no headless browser overhead - ✅ **Lightweight** - ~15MB total dependencies vs 400MB+ for Puppeteer-based solutions - ✅ **Fast cold starts** - No browser spinup delay, generates charts in <500ms - ✅ **Works offline** - No external API calls (unlike QuickChart.io) ## Setup (one-time) ```bash cd /data/clawd/skills/chart-image/scripts && npm install ``` ## Quick Usage ```bash node /data/clawd/skills/chart-image/scripts/chart.mjs \ --type line \ --data '[{"x":"10:00","y":25},{"x":"10:30","y":27},{"x":"11:00","y":31}]' \ --title "Price Over Time" \ --output chart.png ``` ## Chart Types ### Line Chart (default) ```bash node chart.mjs --type line --data '[{"x":"A","y":10},{"x":"B","y":15}]' --output line.png ``` ### Quantitative X-domain clamp Useful when you want a fixed numeric or temporal window without editing Vega-Lite by hand: ```bash node chart.mjs --type line \ --data '[{"minute":0,"value":12},{"minute":1,"value":18},{"minute":2,"value":14},{"minute":3,"value":20}]' \ --x-field minute --y-field value --x-type quantitative \ --x-domain 0,2 --title "First 3 Minutes" \ --output x-domain.png ``` ### Bar Chart ```bash node chart.mjs --type bar --data '[{"x":"A","y":10},{"x":"B","y":15}]' --output bar.png ``` ### Area Chart ```bash node chart.mjs --type area --data '[{"x":"A","y":10},{"x":"B","y":15}]' --output area.png ``` ### Histogram Use this for numeric distributions instead of pre-binned bar charts: ```bash node chart.mjs --type histogram \ --data '[{"value":12},{"value":18},{"value":19},{"value":24},{"value":31}]' \ --x-field value --x-title "Response Time (ms)" --y-title "Count" \ --bins 8 --tick-min-step-y 1 --x-label-angle -30 \ --output hist.png ``` ### Pie / Donut Chart ```bash # Pie node chart.mjs --type pie --data '[{"category":"A","value":30},{"category":"B","value":70}]' \ --category-field category --y-field value --output pie.png # Donut (with hole) node chart.mjs --type donut --data '[{"category":"A","value":30},{"category":"B","value":70}]' \ --category-field category --y-field value --output donut.png ``` ### Candlestick Chart (OHLC) ```bash node chart.mjs --type candlestick \ --data '[{"x":"Mon","open":100,"high":110,"low":95,"close":105}]' \ --open-field open --high-field high --low-field low --close-field close \ --title "Stock Price" --output candle.png ``` ### Heatmap ```bash node chart.mjs --type heatmap \ --data '[{"x":"Mon","y":"Week1","value":5},{"x":"Tue","y":"Week1","value":8}]' \ --color-value-field value --color-scheme viridis \ --y-label-overlap greedy \ --title "Activity Heatmap" --output heatmap.png ``` ### Multi-Series Line Chart Compare multiple trends on one chart: ```bash node chart.mjs --type line --series-field "market" \ --data '[{"x":"Jan","y":10,"market":"A"},{"x":"Jan","y":15,"market":"B"}]' \ --title "Comparison" --output multi.png ``` ### Stacked Bar Chart ```bash node chart.mjs --type bar --stacked --color-field "category" \ --data '[{"x":"Mon","y":10,"category":"Work"},{"x":"Mon","y":5,"category":"Personal"}]' \ --title "Hours by Category" --output stacked.png ``` ### Volume Overlay (Dual Y-axis) Price line with volume bars: ```bash node chart.mjs --type line --volume-field volume \ --data '[{"x":"10:00","y":100,"volume":5000},{"x":"11:00","y":105,"volume":3000}]' \ --title "Price + Volume" --output volume.png ``` ### Sparkline (mini inline chart) ```bash node chart.mjs --sparkline --data '[{"x":"1","y":10},{"x":"2","y":15}]' --output spark.png ``` Sparklines are 80x20 by default, transparent, no axes. ## Options Reference ### Basic Options | Option | Description | Default | |--------|-------------|---------| | `--type` | Chart type: line, bar, area, point, histogram, pie, donut, candlestick, heatmap | line | | `--data` | JSON array of data points | - | | `--output` | Output file path | chart.png | | `--title` | Chart title | - | | `--width` | Width in pixels | 600 | | `--height` | Height in pixels | 300 | ### Axis Options | Option | Description | Default | |--------|-------------|---------| | `--x-field` | Field name for X axis | x | | `--y-field` | Field name for Y axis | y | | `--x-title` | X axis label | field name | | `--y-title` | Y axis label | field name | | `--x-type` | X axis type: ordinal, temporal, quantitative | ordinal | | `--x-domain` | Clamp/zoom the X scale with `min,max` bounds for quantitative or temporal charts | auto | | `--x-format` | Temporal X axis label format (d3-time-format, e.g. `%b %d`, `%H:%M`) | auto | | `--x-sort` | X axis order: ascending, descending, or none (preserve input order) | auto | | `--series-order CSV` | Explicit series/category order for multi-series and stacked legends (e.g. `Critical,High,Medium`) | data order | | `--x-label-limit PX` | Max pixel width for X axis labels before Vega truncates them | auto | | `--y-label-limit PX` | Max pixel width for Y axis labels before Vega truncates them | auto | | `--x-ticks N` | Target X-axis tick count for dense or sparse charts | auto | | `--y-ticks N` | Target primary/left Y-axis tick count for dense or sparse charts | auto | | `--bins N` | Histogram bin count (`--type histogram` only) | Vega auto | | `--tick-min-step N` | Minimum step between ticks on quantitative axes (great for counts / whole-number charts) | Vega auto | | `--tick-min-step-x N` | Minimum step between ticks on quantitative X axes only | Vega auto | | `--tick-min-step-y N` | Minimum step between ticks on quantitative Y axes only | Vega auto | | `--x-label-angle N` | Rotate X-axis labels (for dense categories / timestamps) | -45 | | `--y-label-angle N` | Rotate Y-axis labels (useful for horizontal bars / heatmaps) | 0 / Vega default | | `--x-label-overlap MODE` | Force Vega X-axis overlap handling (`parity`, `greedy`, `true`, `false`) for crowded labels | Vega auto | | `--y-label-overlap MODE` | Force Vega Y-axis overlap handling (`parity`, `greedy`, `true`, `false`) for crowded labels | Vega auto | | `--y2-ticks N` | Target secondary/right Y-axis tick count for dual-axis and volume charts | auto | | `--y-domain` | Y scale as "min,max" | auto | | `--y-pad` | Add vertical padding as a fraction of range (e.g. `0.1` = 10%) | 0 | ### Visual Options | Option | Description | Default | |--------|-------------|---------| | `--color` | Line/bar color | #e63946 | | `--dark` | Dark mode theme | false | | `--svg` | Output SVG instead of PNG | false | | `--font-family` | CSS font-family string for chart text/legend/title theming | Helvetica, Arial, sans-serif | | `--title-align` | Title alignment: `start`, `middle`, `end` | start | | `--title-size N` | Title font size override in px | auto | | `--subtitle-size N` | Subtitle font size override in px | auto | | `--title-weight W` | Title font weight override (`normal`, `bold`, `100`-`900`) | auto | | `--subtitle-weight W` | Subtitle font weight override (`normal`, `bold`, `100`-`900`) | auto | | `--title-color` | Title text color override | theme text | | `--subtitle-color` | Subtitle text color override | theme grid | | `--grid-dash A,B` | Dash pattern for gridlines (for example `4,2`) | solid | **Font examples:** `"Inter, Helvetica, Arial, sans-serif"`, `"Georgia, serif"`, `"JetBrains Mono, Consolas, monospace"` | `--no-points` | Hide point markers on line charts | false | | `--line-width N` | Set line thickness in pixels for line charts | 2 | | `--point-size N` | Set point marker size for line/point charts | 60 | | `--bar-radius N` | Round bar corners in pixels for bar-based charts | 0 | | `--color-scheme` | Vega color scheme (category10, viridis, etc.) | - | | `--legend-columns N` | Wrap legend entries into N columns for crowded multi-series/pie charts | auto | | `--legend-label-limit PX` | Max pixel width for legend labels before Vega truncates them | auto | ### Alert/Monitor Options | Option | Description | Default | |--------|-------------|---------| | `--show-change` | Show +/-% change annotation at last point | false | | `--focus-change` | Zoom Y-axis to 2x data range | false | | `--focus-recent N` | Show only last N data points | all | | `--show-values` | Label min/max peak points | false | | `--last-value` | Label the final data point value | false | ### Multi-Series/Stacked Options | Option | Description | Default | |--------|-------------|---------| | `--series-field` | Field for multi-series line charts | - | | `--stacked` | Enable stacked bar mode | false | | `--color-field` | Field for stack/color categories | - | ### Candlestick Options | Option | Description | Default | |--------|-------------|---------| | `--open-field` | OHLC open field | open | | `--high-field` | OHLC high field | high | | `--low-field` | OHLC low field | low | | `--close-field` | OHLC close field | close | ### Pie/Donut Options | Option | Description | Default | |--------|-------------|---------| | `--category-field` | Field for pie slice categories | x | | `--donut` | Render as donut (with center hole) | false | ### Heatmap Options | Option | Description | Default | |--------|-------------|---------| | `--color-value-field` | Field for heatmap intensity | value | | `--y-category-field` | Y axis category field | y | ### Dual-Axis Options (General) | Option | Description | Default | |--------|-------------|---------| | `--y2-field` | Second Y axis field (independent right axis) | - | | `--y2-title` | Title for second Y axis | field name | | `--y2-color` | Color for second series | #60a5fa (dark) / #2563eb (light) | | `--y2-type` | Chart type for second axis: line, bar, area | line | | `--y2-format` | Right-axis format: percent, dollar, compact, integer, decimal4, or d3-format string | auto | **Example:** Revenue bars (left) + Churn area (right): ```bash node chart.mjs \ --data '[{"month":"Jan","revenue":12000,"churn":4.2},...]' \ --x-field month --y-field revenue --type bar \ --y2-field churn --y2-type area --y2-color "#60a5fa" --y2-format ".1f" \ --y-title "Revenue ($)" --y2-title "Churn (%)" \ --x-sort none --dark --title "Revenue vs Churn" ``` ### Volume Overlay Options (Candlestick) | Option | Description | Default | |--------|-------------|---------| | `--volume-field` | Field for volume bars (enables dual-axis) | - | | `--volume-color` | Color for volume bars | #4a5568 | ### Formatting Options | Option | Description | Default | |--------|-------------|---------| | `--y-format` | Y axis format: percent, dollar, compact, decimal4, integer, scientific, or d3-format string | auto | | `--subtitle` | Subtitle text below chart title | - | | `--hline` | Horizontal reference line: "value" or "value,color" or "value,color,label" (repeatable) | - | ### Annotation Options | Option | Description | Default | |--------|-------------|---------| | `--annotation` | Static text annotation | - | | `--annotations` | JSON array of event markers | - | ## Alert-Style Chart (recommended for monitors) ```bash node chart.mjs --type line --data '[...]' \ --title "Iran Strike Odds (48h)" \ --show-change --focus-change --show-values --dark \ --output alert.png ``` For recent action only: ```bash node chart.mjs --type line --data '[hourly data...]' \ --focus-recent 4 --show-change --focus-change --dark \ --output recent.png ``` ## Timeline Annotations Mark events on the chart: ```bash node chart.mjs --type line --data '[...]' \ --annotations '[{"x":"14:00","label":"News broke"},{"x":"16:30","label":"Press conf"}]' \ --output annotated.png ``` ## Temporal X-Axis For proper time series with date gaps: ```bash node chart.mjs --type line --x-type temporal \ --data '[{"x":"2026-01-01","y":10},{"x":"2026-01-15","y":20}]' \ --output temporal.png ``` Use `--x-type temporal` when X values are ISO dates and you want spacing to reflect actual time gaps (not evenly spaced). ## Y-Axis Formatting Format axis values for readability: ```bash # Dollar amounts node chart.mjs --data '[...]' --y-format dollar --output revenue.png # → $1,234.56 # Percentages (values as decimals 0-1) node chart.mjs --data '[...]' --y-format percent --output rates.png # → 45.2% # Compact large numbers node chart.mjs --data '[...]' --y-format compact --output users.png # → 1.2K, 3.4M # Crypto prices (4 decimal places) node chart.mjs --data '[...]' --y-format decimal4 --output molt.png # → 0.0004 # Custom d3-format string node chart.mjs --data '[...]' --y-format ',.3f' --output custom.png ``` Available shortcuts: `percent`, `dollar`/`usd`, `compact`, `integer`, `decimal2`, `decimal4`, `scientific` ## Chart Subtitle Add context below the title: ```bash node chart.mjs --title "MOLT Price" --subtitle "20,668 MOLT held" --data '[...]' --output molt.png ``` `--subtitle` works across standard charts plus pie/donut, heatmap, candlestick, stacked, multi-series, volume-overlay, and dual-axis layouts. ## Theme Selection Use `--dark` for dark mode. Auto-select based on time: - **Night (20:00-07:00 local)**: `--dark` - **Day (07:00-20:00 local)**: light mode (default) ## Social Size Presets Use `--output-size` when the chart is meant for a specific surface: ```bash # Bluesky / OG-style landscape post node chart.mjs --type line --data '[...]' --output-size bluesky --output bluesky-chart.png # Instagram / Threads portrait post node chart.mjs --type line --data '[...]' --output-size portrait --output portrait-chart.png ``` Available presets include `twitter`, `discord`, `slack`, `linkedin`, `bluesky` (`bsky` alias), `youtube`, `instagram`, `portrait`, `story`, `thumbnail`, `wide`, and `square`. ## Piping Data ```bash echo '[{"x":"A","y":1},{"x":"B","y":2}]' | node chart.mjs --output out.png ``` ## Custom Vega-Lite Spec For advanced charts: ```bash node chart.mjs --spec my-spec.json --output custom.png ``` ## ⚠️ IMPORTANT: Always Send the Image! After generating a chart, **always send it back to the user's channel**. Don't just save to a file and describe it — the whole point is the visual. ```bash # 1. Generate the chart node chart.mjs --type line --data '...' --output /data/clawd/tmp/my-chart.png # 2. Send it! Use message tool with filePath: # action=send, target=<channel_id>, filePath=/data/clawd/tmp/my-chart.png ``` **Tips:** - Save to `/data/clawd/tmp/` (persistent) not `/tmp/` (may get cleaned) - Use `action=send` with `filePath` — `thread-reply` does NOT support file attachments - Include a brief caption in the message text - Auto-use `--dark` between 20:00-07:00 Israel time --- *Updated: 2026-04-06 - added `--y-label-angle` for crowded categorical/heatmap charts and documented per-axis label rotation; version bumped to 2.6.33*

path: CAPABILITY.md
content: # chart-generation Capability

Provides: chart-generation
Skill: chart-image

## Methods

### lineChart

**Input:**
- data: array of chart points
- title: optional chart title
- groupBy: optional field for multi-series charts
- options: optional rendering flags such as `dark`, `focusRecent`, `showChange`, `showValues`

**How to fulfill:**
- Invoke `scripts/chart.mjs` with structured CLI arguments, not by interpolating untrusted text into a shell command.
- Pass chart data through a JSON argument or a trusted temporary file created by the runtime/tooling layer.
- Map supported options to the corresponding chart flags (`--dark`, `--focus-recent`, `--show-change`, `--show-values`).
- Return the generated chart image through the caller's normal file/attachment flow.

### barChart

**Input:**
- data: array of objects with label/value-style fields
- title: optional chart title
- options: optional rendering flags such as `dark` and `showValues`

**How to fulfill:**
- Invoke `scripts/chart.mjs` with structured CLI arguments, not shell-built strings.
- Provide the data as JSON or via a trusted temporary file.
- Map supported options to the corresponding chart flags.
- Return the generated chart image through the caller's normal file/attachment flow.

### areaChart

**Input:**
- data: array of chart points
- title: optional chart title
- options: optional rendering flags similar to `lineChart`

**How to fulfill:**
- Invoke `scripts/chart.mjs` with structured CLI arguments.
- Provide the data as JSON or via a trusted temporary file.
- Map supported options to the corresponding chart flags.
- Return the generated chart image through the caller's normal file/attachment flow.

## Safety notes

- Do not build shell commands with string interpolation around user-controlled JSON, titles, labels, or paths.
- Prefer argv-style process execution over `sh -c`, command substitution, or inline `echo '...json...'` patterns.
- Treat `--output`, `--spec`, and file-based inputs as trusted/runtime-controlled parameters rather than free-form user text.
- Use platform-native attachment/message tools to deliver the image after generation.


path: README.md
content: # 📊 chart-image

**Publication-quality chart images from data. No browser, no Puppeteer, no native compilation.**

Generate beautiful PNG charts directly from JSON data — perfect for bots, dashboards, alerts, and automated reports. Runs anywhere Node.js runs.

![Line chart example](readme-assets/framed-line.png)

## Why chart-image?

Most chart libraries need a browser (Puppeteer, Playwright) or native dependencies (`canvas`, `cairo`). That means 400MB+ installs, painful Docker builds, and slow cold starts.

**chart-image uses Vega-Lite + Sharp with prebuilt binaries:**

| | chart-image | Puppeteer + Chart.js | QuickChart.io |
|---|---|---|---|
| **Install size** | ~15MB | ~400MB+ | 0 (API) |
| **Native deps** | None | Chromium | N/A |
| **Cold start** | <500ms | 2-5s | Network latency |
| **Offline** | ✅ | ✅ | ❌ |
| **Fly.io/Docker** | Just works | Pain | Depends on uptime |

## Install

### Via ClawHub (recommended)
```bash
clawhub install chart-image
```

### Manual
```bash
git clone https://github.com/Cluka-399/chart-image.git skills/chart-image
cd skills/chart-image/scripts && npm install
```

## Quick Start

```bash
node scripts/chart.mjs \
  --type line \
  --data '[{"x":"Mon","y":10},{"x":"Tue","y":25},{"x":"Wed","y":18}]' \
  --title "Weekly Trend" \
  --dark \
  --output chart.png
```

That's it. One command, one PNG.

---

## Chart Types

### 📈 Line Chart

Track trends over time. The default chart type.

```bash
node scripts/chart.mjs --type line \
  --data '[{"x":"Mon","y":142},{"x":"Tue","y":148},{"x":"Wed","y":145},{"x":"Thu","y":155},{"x":"Fri","y":162}]' \
  --title "AAPL Weekly Price" --y-title "Price (USD)" \
  --dark --show-values --output chart.png
```

![Line chart](readme-assets/framed-line.png)

### 📊 Bar Chart

Compare categories side by side.

```bash
node scripts/chart.mjs --type bar \
  --data '[{"x":"React","y":45},{"x":"Vue","y":28},{"x":"Svelte","y":15},{"x":"Angular","y":12}]' \
  --title "Framework Usage %" --output chart.png
```

![Bar chart](readme-assets/framed-bar.png)

### 🌊 Area Chart

Like line charts, but with filled regions to emphasize volume.

```bash
node scripts/chart.mjs --type area \
  --data '[{"x":"Jan","y":100},{"x":"Feb","y":250},{"x":"Mar","y":180},{"x":"Apr","y":420},{"x":"May","y":380},{"x":"Jun","y":520}]' \
  --title "Monthly Signups" --dark --output chart.png
```

![Area chart](readme-assets/framed-area.png)

### 🍩 Donut / Pie Chart

Show proportions at a glance. Use `--type pie` for a solid circle or `--type donut` for the ring style.

```bash
node scripts/chart.mjs --type donut \
  --data '[{"x":"Desktop","y":58},{"x":"Mobile","y":35},{"x":"Tablet","y":7}]' \
  --title "Traffic by Device" --dark --output chart.png
```

![Donut chart](readme-assets/framed-donut.png)

### 📉 Multi-Series Line

Compare multiple trends on one chart using `--series-field`.

```bash
node scripts/chart.mjs --type line \
  --data '[{"x":"Q1","y":30,"series":"2024"},{"x":"Q2","y":45,"series":"2024"},{"x":"Q3","y":52,"series":"2024"},{"x":"Q4","y":61,"series":"2024"},{"x":"Q1","y":40,"series":"2025"},{"x":"Q2","y":58,"series":"2025"},{"x":"Q3","y":72,"series":"2025"}]' \
  --title "Revenue Growth" --y-title "Revenue ($M)" \
  --series-field series --dark --legend top --output chart.png
```

![Multi-series chart](readme-assets/framed-multi.png)

### 📏 Horizontal Reference Lines

Add thresholds, targets, or buy prices with `--hline`.

```bash
node scripts/chart.mjs --type line \
  --data '[{"x":"Jan 1","y":0.00072},{"x":"Jan 5","y":0.00085},{"x":"Jan 10","y":0.00091},{"x":"Jan 15","y":0.00078},{"x":"Jan 20","y":0.00062},{"x":"Jan 25","y":0.00071}]' \
  --title "Token Price" --y-title "Price (USD)" \
  --dark --show-values --hline "0.0008,#e63946,Buy Price" --output chart.png
```

![Reference line chart](readme-assets/framed-hline.png)

### 🎨 Conditional Colors

Color bars/points based on a threshold — great for KPI dashboards.

```bash
node scripts/chart.mjs --type bar \
  --data '[{"month":"Jan","score":72},{"month":"Feb","score":45},{"month":"Mar","score":38},{"month":"Apr","score":61},{"month":"May","score":29},{"month":"Jun","score":55},{"month":"Jul","score":82},{"month":"Aug","score":47},{"month":"Sep","score":68},{"month":"Oct","score":34},{"month":"Nov","score":76},{"month":"Dec","score":91}]' \
  --x-field month --y-field score --x-sort none \
  --conditional-color "50,#e63946,#2a9d8f" --hline "50,#888,Target" \
  --title "Monthly Performance Score" --subtitle "Target: 50" --dark
```

![Conditional color chart](readme-assets/framed-conditional.png)

### ↔️ Horizontal Bar Charts

Flip axes for leaderboards, rankings, or long category names.

```bash
node scripts/chart.mjs --type bar \
  --data '[{"lang":"Python","stars":95},{"lang":"JavaScript","stars":82},{"lang":"TypeScript","stars":78},{"lang":"Rust","stars":71},{"lang":"Go","stars":63},{"lang":"Java","stars":58},{"lang":"C++","stars":45},{"lang":"Swift","stars":38}]' \
  --x-field lang --y-field stars --horizontal --sort desc \
  --conditional-color "60,#e63946,#2a9d8f" --bar-labels \
  --title "GitHub Stars by Language" --dark
```

![Horizontal bar chart](readme-assets/framed-horizontal-bar.png)

### More Chart Types

- **`point`** — Scatter plot
- **`histogram`** — Binned quantitative distribution chart
- **`candlestick`** — OHLC financial charts (`--open-field`, `--high-field`, `--low-field`, `--close-field`)
- **`heatmap`** — Grid visualization (`--color-value-field`, `--color-scheme viridis`)
- **Stacked bars** — `--type bar --stacked --color-field category`
- **Volume overlay** — Dual Y-axis with `--volume-field`
- **Sparkline** — Tiny inline charts with `--sparkline` (80×20, no axes)

---

## Shorthand Syntax

Don't want to write JSON? Use the shorthand format:

```bash
node scripts/chart.mjs --type bar \
  --data "Mon:10,Tue:25,Wed:18,Thu:30,Fri:22,Sat:35,Sun:28" \
  --title "Weekly Activity" --dark --output chart.png
```

![Shorthand example](readme-assets/framed-shorthand.png)

Format: `label:value,label:value,...`

---

## Dark Mode & Light Mode

Use `--dark` for dark backgrounds (great for Discord, Slack, dark dashboards):

![Dark mode](readme-assets/framed-horizontal.png)

Omit `--dark` for light mode (reports, emails, light UIs):

![Light mode](readme-assets/framed-bar.png)

**Tip for bots:** Auto-switch based on time of day — `--dark` between 20:00–07:00.

---

## Font Families

Use `--font-family` with any CSS font-family stack Vega can resolve.

Need more control over headings? `--title-size`, `--subtitle-size`, `--title-color`, and `--subtitle-color` let you tune chart title typography without editing the Vega spec. Need softer dashboard gridlines? `--grid-dash 4,2` adds custom dashed grid patterns without touching the Vega spec either.

**Safest generic families:**
- `sans-serif`
- `serif`
- `monospace`
- `system-ui`

**Recommended stacks:**

```bash
# Brand / product sans
--font-family "Inter, Helvetica, Arial, sans-serif"

# Editorial / report serif
--font-family "Georgia, Times New Roman, serif"

# Terminal / dashboard monospace
--font-family "JetBrains Mono, SFMono-Regular, Consolas, monospace"
```

**Example:**
```bash
node scripts/chart.mjs --type bar \
  --data '[{"month":"Jan","value":12},{"month":"Feb","value":18},{"month":"Mar","value":15},{"month":"Apr","value":22}]' \
  --x-field month --y-field value --x-sort none \
  --title "Georgia / serif example" \
  --subtitle "--font-family \"Georgia, serif\"" \
  --font-family "Georgia, serif" --dark --show-values
```

![Font family example](readme-assets/framed-font-family.png)

**Note:** specific fonts like `Inter`, `Arial`, `Georgia`, or `JetBrains Mono` only work if they exist on the host. If not, Vega falls back to the next font in the stack.

---

## Alert-Style Charts

Built-in options for monitoring and alerting use cases:

```bash
node scripts/chart.mjs --type line --data '[...]' \
  --title "Iran Strike Odds (48h)" \
  --show-change --focus-change --show-values --dark \
  --output alert.png
```

| Flag | Effect |
|------|--------|
| `--show-change` | Annotates % change from first to last value |
| `--focus-change` | Zooms Y-axis to 2× data range for drama |
| `--focus-recent N` | Shows only the last N data points |
| `--show-values` | Labels min/max peaks on the chart |

---

## Piping Data

Read from stdin:

```bash
curl -s api.example.com/metrics | node scripts/chart.mjs --type line --dark --output metrics.png
echo '[{"x":"A","y":1},{"x":"B","y":2}]' | node scripts/chart.mjs --output out.png
```

---

## Options Reference

### Core
| Option | Description | Default |
|--------|-------------|---------|
| `--type` | `line`, `bar`, `area`, `point`, `histogram`, `pie`, `donut`, `candlestick`, `heatmap` | `line` |
| `--data` | JSON array or shorthand `key:val,...` | stdin |
| `--output` | Output file path | `chart.png` |
| `--title` | Chart title | — |
| `--subtitle` | Subtitle below title | — |
| `--title-align` | Title alignment: `start`, `middle`, `end` | `start` |
| `--width` | Width in px | `600` |
| `--height` | Height in px | `300` |
| `--output-size` | Platform preset: `twitter`, `discord`, `slack`, `linkedin`, `bluesky`, `youtube`, `instagram`, `portrait`, `story`, `thumbnail`, `wide`, `square` | — |
| `--dark` | Dark theme | `false` |
| `--svg` | Output SVG instead of PNG | `false` |

### Axes
| Option | Description | Default |
|--------|-------------|---------|
| `--x-field` | X axis field name | `x` |
| `--y-field` | Y axis field name | `y` |
| `--x-title` / `--y-title` | Axis labels | field name |
| `--x-type` | `ordinal`, `temporal`, `quantitative` | `ordinal` |
| `--y-domain` | Y range as `"min,max"` | auto |
| `--y-format` | `percent`, `dollar`, `compact`, `decimal4`, `integer`, `scientific` | auto |

### Styling
| Option | Description | Default |
|--------|-------------|---------|
| `--color` | Primary color | `#e63946` |
| `--color-scheme` | Vega scheme (e.g. `viridis`, `category10`) | — |
| `--no-grid` | Remove gridlines | `false` |
| `--legend` | `top`, `bottom`, `left`, `right`, `none` | — |
| `--hline` | Reference line: `"value,color,label"` (repeatable) | — |

### Multi-Series
| Option | Description |
|--------|-------------|
| `--series-field` | Field to split into multiple lines |
| `--stacked` | Stack bars/areas |
| `--color-field` | Field for color encoding |
| `--series-order CSV` | Explicit series/category order for multi-series legends and stacked-bar legend/stack order |

### Annotations
| Option | Description |
|--------|-------------|
| `--show-change` | Show % change annotation |
| `--focus-change` | Zoom Y to highlight change |
| `--focus-recent N` | Show last N points only |
| `--show-values` | Label min/max peaks |
| `--last-value` | Label the final data point value |
| `--annotations` | JSON array of event markers: `[{"x":"14:00","label":"News"}]` |

---

## Y-Axis Formatting

```bash
--y-format dollar    # → $1,234.56
--y-format percent   # → 45.2%
--y-format compact   # → 1.2K, 3.4M
--y-format decimal4  # → 0.0004
--y-format integer   # → 1,234
```

Or pass any [d3-format](https://github.com/d3/d3-format) string: `--y-format ',.3f'`

---

## Designed for Fly.io / VPS / Docker

This skill was built specifically for headless server environments where you can't (or don't want to) install a browser:

- **Fly.io** — Works out of the box on `flyctl deploy`. No special Dockerfile needed.
- **Docker** — No `apt-get install` for Cairo/Pango/etc. Just `npm install`.
- **VPS** — Runs on any machine with Node.js 18+. No GPU, no display server.
- **CI/CD** — Generate charts in GitHub Actions, GitLab CI, etc.

The [REDACTED_SECRET]) renders to SVG natively, then [Sharp](https://sharp.pixelplumbing.com/) (with prebuilt libvips binaries) converts to PNG. No browser in the loop.

---

## License

MIT

---

<p align="center">
  <sub>Built by <a href="https://clawhub.ai/u/Cluka-399">@Cluka-399</a> · Published on <a href="https://clawhub.ai">ClawHub</a> · <a href="https://github.com/Cluka-399/chart-image">GitHub</a></sub>
</p>


path: scripts/chart.mjs
content: #!/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, histogram (default: line)
 *   --bins       Histogram bin count or Vega bin config hints (histogram only)
 *   --tick-min-step N  Minimum step between quantitative axis ticks
 *   --tick-min-step-x N  Minimum step between quantitative X axis ticks
 *   --tick-min-step-y N  Minimum step between quantitative Y axis ticks
 *   --x-label-angle N  Rotate X-axis labels in degrees
 *   --y-label-angle N  Rotate Y-axis labels in degrees
 *   --x-label-overlap MODE  Control Vega X-axis label overlap strategy
 *   --y-label-overlap MODE  Control Vega Y-axis label overlap strategy
 *   --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)
 *   --x-domain   X axis domain as "min,max" (supports numeric or temporal bounds)
 *   --y-domain   Y axis domain as "min,max" (e.g., "0,100")
 *   --y-pad      Add vertical padding as a fraction of data range (e.g. 0.1 = 10%)
 *   --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';

function parseLabelOverlap(value) {
  if (value === undefined || value === null) return undefined;
  const normalized = String(value).trim().toLowerCase();
  if (['true', '1', 'yes', 'on'].includes(normalized)) return true;
  if (['false', '0', 'no', 'off', 'none'].includes(normalized)) return false;
  if (['parity', 'greedy'].includes(normalized)) return normalized;
  throw new Error(`Invalid label overlap mode: ${value}. Use parity, greedy, true, or false.`);
}

function parseScaleDomain(value) {
  if (value === undefined || value === null) return undefined;
  const parts = String(value)
    .split(',')
    .map(part => part.trim())
    .filter(part => part.length > 0);
  if (parts.length !== 2) {
    throw new Error(`Invalid scale domain: ${value}. Use \"min,max\".`);
  }
  return parts.map(part => {
    const numeric = Number(part);
    return Number.isFinite(numeric) ? numeric : part;
  });
}

// 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
  histogram     Histogram (binned quantitative distribution)
  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
  --title-align Title alignment: start, middle, end (default: start)
  --title-size N      Title font size in px
  --subtitle-size N   Subtitle font size in px
  --title-weight W    Title font weight (normal, bold, 100-900)
  --subtitle-weight W Subtitle font weight (normal, bold, 100-900)
  --title-color COLOR Title text color override
  --subtitle-color COLOR Subtitle text color override
  --grid-dash A,B    Gridline dash pattern (e.g. 4,2)
  --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
  --x-domain    X axis domain as "min,max" (useful for numeric or temporal zoom / clipping)
  --x-format    X axis label format (d3-time-format for temporal, e.g. "%b %d", "%H:%M")
  --x-sort      X axis order: ascending, descending, none (preserve input order)
  --series-order CSV  Explicit series/category order for multi-series + stacked legends/stacks
  --x-label-limit PX  Max pixel width for X axis labels before Vega truncates them
  --y-label-limit PX  Max pixel width for Y axis labels before Vega truncates them
  --x-ticks N    Target tick count for the X axis
  --y-ticks N    Target tick count for the primary/left Y axis
  --bins N       Histogram bin count (histogram only)
  --tick-min-step N  Minimum step between quantitative axis ticks
  --tick-min-step-x N  Minimum step between quantitative X axis ticks
  --tick-min-step-y N  Minimum step between quantitative Y axis ticks
  --x-label-angle N  Rotate X-axis labels in degrees
  --y-label-angle N  Rotate Y-axis labels in degrees
  --x-label-overlap MODE  X-axis label overlap: parity, greedy, true, false
  --y-label-overlap MODE  Y-axis label overlap: parity, greedy, true, false
  --y2-ticks N   Target tick count for the secondary/right Y axis

STYLING:
  --color       Primary color (default: #e63946)
  --y-domain    Y axis range as "min,max" (e.g., "0,100")
  --y-pad       Add vertical padding as a fraction of data range (e.g. 0.1 = 10%)
  --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)
  --no-points   Hide point markers on line charts (cleaner for dense series)
  --line-width N  Line thickness in pixels for line charts (default: 2)
  --point-size N Point marker size for line/point charts (default: 60)
  --legend      Legend position: top, bottom, left, right, none
  --legend-columns N  Wrap legend entries into N columns
  --legend-label-limit PX  Max pixel width for legend labels before truncation
  --output-size Platform preset: twitter, discord, slack, linkedin, bluesky, youtube, instagram, portrait, story, thumbnail, wide, square
  --font-family CSS font-family string for chart text (default: Helvetica, Arial, sans-serif)

SORTING:
  --sort            Sort bars by value: asc, desc (bar charts only)
  --bar-labels      Show value labels on top of every bar (bar charts only)
  --bar-radius N    Rounded bar corners in pixels (bar charts)
  --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)
  --y2-format       Secondary/right Y axis format: percent, dollar, compact, integer, decimal2

CONFIDENCE BANDS:
  --band-upper    Upper bound field name (e.g. "upper" or "max")
  --band-lower    Lower bound field name (e.g. "lower" or "min")
  --band-color    Band fill color (default: semi-transparent accent)

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)
  --y2-format       Right-axis number format for dual-axis/volume charts

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
  --last-value      Label the final data point value

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,
    titleAlign: 'start',
  };
  
  // 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.6.35'); 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 '--x-domain': opts.xDomain = parseScaleDomain(next); i++; break;
      case '--y-domain': opts.yDomain = parseScaleDomain(next); i++; break;
      case '--y-pad': opts.yPad = parseFloat(next); 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 '--last-value': opts.lastValue = 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 '--x-sort': opts.xSort = next; i++; break;  // ascending, descending, none
      case '--series-order': opts.seriesOrder = next.split(',').map(s => s.trim()).filter(Boolean); i++; break;  // Explicit legend/stack order for multi-series + stacked charts
      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 '--title-align': opts.titleAlign = next; i++; break;
      case '--title-size': opts.titleSize = parseFloat(next); i++; break;
      case '--subtitle-size': opts.subtitleSize = parseFloat(next); i++; break;
      case '--title-weight': opts.titleWeight = next; i++; break;
      case '--subtitle-weight': opts.subtitleWeight = next; i++; break;
      case '--title-color': opts.titleColor = next; i++; break;
      case '--subtitle-color': opts.subtitleColor = next; i++; break;
      case '--grid-dash': opts.gridDash = next.split(',').map(v => parseFloat(v.trim())).filter(v => Number.isFinite(v) && v >= 0); i++; break;
      case '--no-grid': opts.noGrid = true; break;
      case '--legend': opts.legend = next; i++; break;  // top, bottom, left, right, none
      case '--legend-columns': opts.legendColumns = parseInt(next); i++; break;  // Wrap legend entries into columns
      case '--legend-label-limit': opts.legendLabelLimit = parseFloat(next); i++; break;  // Max pixel width before legend labels get truncated
      case '--x-label-angle': opts.xLabelAngle = parseFloat(next); i++; break;  // X axis label rotation angle
      case '--x-ticks': opts.xTicks = parseInt(next); i++; break;  // Target X axis tick count
      case '--y-ticks': opts.yTicks = parseInt(next); i++; break;  // Target primary/left Y axis tick count
      case '--y2-ticks': opts.y2Ticks = parseInt(next); i++; break;  // Target secondary/right Y axis tick count
      case '--bins': opts.bins = parseInt(next); i++; break;  // Histogram bin count
      case '--tick-min-step': opts.tickMinStep = parseFloat(next); i++; break;  // Minimum step for quantitative axis ticks
      case '--tick-min-step-x': opts.tickMinStepX = parseFloat(next); i++; break;  // Minimum step for quantitative X axis ticks
      case '--tick-min-step-y': opts.tickMinStepY = parseFloat(next); i++; break;  // Minimum step for quantitative Y axis ticks
      case '--x-label-angle': opts.xLabelAngle = parseFloat(next); i++; break;  // X axis label rotation angle
      case '--y-label-angle': opts.yLabelAngle = parseFloat(next); i++; break;  // Y axis label rotation angle
      case '--x-label-overlap': opts.xLabelOverlap = parseLabelOverlap(next); i++; break;  // X axis label overlap strategy
      case '--y-label-overlap': opts.yLabelOverlap = parseLabelOverlap(next); i++; break;  // Y axis label overlap strategy
      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 '--no-points': opts.noPoints = true; break;  // Hide point markers on line charts
      case '--line-width': opts.lineWidth = parseFloat(next) || 2; i++; break;  // Line thickness for line charts
      case '--point-size': opts.pointSize = parseFloat(next) || 60; i++; break;  // Point marker size for line/point charts
      case '--output-size': opts.outputSize = next; i++; break;  // Size preset: twitter, discord, slack, linkedin, bluesky, youtube, instagram, portrait, thumbnail
      case '--font-family': opts.fontFamily = next; i++; break;
      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 '--bar-radius': opts.barRadius = parseFloat(next) || 0; i++; break;  // Rounded bar corners in pixels
      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 '--y2-format': opts.y2Format = next; i++; break;  // Second/right Y axis number format
      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 '--x-format': opts.xFormat = next; i++; break;  // X axis format (d3-time-format for temporal, e.g. "%b %d", "%H:%M")
      case '--x-label-limit': opts.xLabelLimit = parseFloat(next); i++; break;  // Max pixel width before X labels get truncated
      case '--y-label-limit': opts.yLabelLimit = parseFloat(next); i++; break;  // Max pixel width before Y labels get truncated
      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 '--band-upper': opts.bandUpper = next; i++; break;  // Upper bound field for confidence band
      case '--band-lower': opts.bandLower = next; i++; break;  // Lower bound field for confidence band
      case '--band-color': opts.bandColor = next; i++; break;  // Band fill color (default: accent with opacity)
      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
      'linkedin':    { w: 1200, h: 627 },   // LinkedIn / OG aspect-ratio preset
      'linkedin-og': { w: 1200, h: 627 },   // Alias
      'bluesky':     { w: 1200, h: 627 },   // Bluesky landscape social post / share card
      'bsky':        { w: 1200, h: 627 },   // Alias
      'youtube':     { w: 1280, h: 720 },   // YouTube / 16:9 social-share preset
      'youtube-thumb': { w: 1280, h: 720 }, // Alias
      'instagram':   { w: 1080, h: 1080 },  // Instagram square
      'portrait':    { w: 1080, h: 1350 },  // Instagram portrait / Threads 4:5
      'instagram-portrait': { w: 1080, h: 1350 }, // Alias
      'threads':     { w: 1080, h: 1350 },  // Alias
      '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/TSV parser — handles quoted fields with commas/tabs, newlines, escaped quotes
function parseDelimitedLine(line, separator = ',') {
  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 === separator) { fields.push(field); field = ''; i++; }
      else { field += ch; i++; }
    }
  }
  fields.push(field);
  return fields;
}

function splitDelimitedRecords(str) {
  const records = [];
  let record = '';
  let i = 0;
  let inQuotes = false;

  while (i < str.length) {
    const ch = str[i];

    if (ch === '"') {
      record += ch;
      if (inQuotes && str[i + 1] === '"') {
        record += '"';
        i += 2;
        continue;
      }
      inQuotes = !inQuotes;
      i++;
      continue;
    }

    if ((ch === '\n' || ch === '\r') && !inQuotes) {
      if (record.trim() !== '') records.push(record);
      record = '';
      if (ch === '\r' && str[i + 1] === '\n') i += 2;
      else i++;
      continue;
    }

    record += ch;
    i++;
  }

  if (record.trim() !== '') records.push(record);
  return records;
}

function parseCsv(str) {
  // Strip UTF-8 BOM (common in Excel-exported CSVs)
  if (str.charCodeAt(0) === 0xFEFF) str = str.slice(1);
  const trimmed = str.trim();
  if (!trimmed) return [];

  // Handle TSV auto-detection: if first record has tabs but no commas, treat as TSV
  const firstRecord = splitDelimitedRecords(trimmed)[0] || '';
  const separator = (firstRecord.includes('\t') && !firstRecord.includes(',')) ? '\t' : ',';
  const records = splitDelimitedRecords(trimmed);
  if (records.length < 2) return [];

  const headers = parseDelimitedLine(records[0], separator).map(h => h.trim());
  return records.slice(1).map(record => {
    const vals = parseDelimitedLine(record, separator);
    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
}

function resolveTitleAnchor(opts, fallback = 'start') {
  const allowed = new Set(['start', 'middle', 'end']);
  return allowed.has(opts.titleAlign) ? opts.titleAlign : fallback;
}

function resolveFontWeight(value) {
  if (value === undefined || value === null || value === '') return undefined;
  const normalized = String(value).trim();
  if (!normalized) return undefined;
  const named = new Set(['normal', 'bold', 'bolder', 'lighter']);
  if (named.has(normalized)) return normalized;
  const numeric = Number(normalized);
  if (Number.isFinite(numeric) && numeric >= 100 && numeric <= 900) return numeric;
  return undefined;
}

function buildChartTitle(opts, theme, fallback = 'start') {
  if (!opts.title) return undefined;
  const titleWeight = resolveFontWeight(opts.titleWeight);
  const subtitleWeight = resolveFontWeight(opts.subtitleWeight);
  const title = {
    text: opts.title,
    anchor: resolveTitleAnchor(opts, fallback),
    color: opts.titleColor || theme.text,
    ...(Number.isFinite(opts.titleSize) && opts.titleSize > 0 ? { fontSize: opts.titleSize } : {}),
    ...(titleWeight !== undefined ? { fontWeight: titleWeight } : {}),
  };
  if (opts.subtitle) {
    title.subtitle = opts.subtitle;
    title.subtitleColor = opts.subtitleColor || theme.grid;
    title.subtitleFontSize = Number.isFinite(opts.subtitleSize) && opts.subtitleSize > 0 ? opts.subtitleSize : 12;
    if (subtitleWeight !== undefined) title.subtitleFontWeight = subtitleWeight;
  }
  return title;
}

function buildLegendConfig(opts, theme, extra = {}) {
  return {
    labelColor: theme.text,
    titleColor: theme.text,
    ...(Number.isInteger(opts.legendColumns) && opts.legendColumns > 0 ? { columns: opts.legendColumns } : {}),
    ...(Number.isFinite(opts.legendLabelLimit) && opts.legendLabelLimit > 0 ? { labelLimit: opts.legendLabelLimit } : {}),
    ...(opts.legend === 'none' ? { disable: true } : {}),
    ...(opts.legend && opts.legend !== 'none' ? { orient: opts.legend } : {}),
    ...extra,
  };
}

function buildOrderedNominalScale(opts, fallbackScheme = 'category10') {
  const scale = { scheme: opts.colorScheme || fallbackScheme };
  if (Array.isArray(opts.seriesOrder) && opts.seriesOrder.length > 0) {
    scale.domain = opts.seriesOrder;
  }
  return scale;
}

function buildOrderedNominalSort(opts) {
  return Array.isArray(opts.seriesOrder) && opts.seriesOrder.length > 0 ? opts.seriesOrder : undefined;
}

function applyYPadToValues(values, opts) {
  if (!Array.isArray(values) || values.length === 0 || opts.yPad === undefined || opts.yPad === null || opts.yDomain) {
    return null;
  }

  const finiteValues = values.map(Number).filter(Number.isFinite);
  if (finiteValues.length === 0) return null;

  const rawPad = Number(opts.yPad);
  if (!Number.isFinite(rawPad) || rawPad < 0) return null;

  const padFraction = rawPad > 1 ? rawPad / 100 : rawPad;
  const min = Math.min(...finiteValues);
  const max = Math.max(...finiteValues);
  const range = max - min;
  const baseline = range === 0 ? Math.max(Math.abs(max), 1) : range;
  const padding = baseline * padFraction;

  let yMin = min - padding;
  const yMax = max + padding;

  if (opts.zeroBaseline && min >= 0) yMin = 0;

  return [yMin, yMax];
}

// 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 lineWidth = (typeof opts.lineWidth === 'number' && Number.isFinite(opts.lineWidth) && opts.lineWidth > 0) ? opts.lineWidth : 2;
  const pointSize = (typeof opts.pointSize === 'number' && Number.isFinite(opts.pointSize) && opts.pointSize > 0) ? opts.pointSize : 60;
  const barRadius = (typeof opts.barRadius === 'number' && Number.isFinite(opts.barRadius) && opts.barRadius > 0) ? opts.barRadius : 0;
  const fontFamily = opts.fontFamily || 'Helvetica, Arial, sans-serif';

  const linePointConfig = opts.noPoints ? false : { size: pointSize, filled: true };

  const markConfig = {
    line: { type: 'line', point: linePointConfig, color: theme.accent, strokeWidth: lineWidth, ...(opts.smooth ? { interpolate: 'monotone' } : {}) },
    bar: { type: 'bar', color: theme.accent, ...(barRadius ? { cornerRadius: barRadius } : {}) },
    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: pointSize },
    histogram: { type: 'bar', color: theme.accent, ...(barRadius ? { cornerRadius: barRadius } : {}) },
    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: buildLegendConfig(opts, theme)
        },
        order: { field: valField, type: 'quantitative', sort: 'descending' }
      },
      config: {
        font: fontFamily,
        title: { fontSize: 16, fontWeight: 'bold', color: theme.text },
        view: { stroke: null }
      }
    };
    
    if (opts.title) {
      pieSpec.title = buildChartTitle(opts, theme, 'middle');
    }
    
    // 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: buildLegendConfig(opts, theme)
        },
        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: buildLegendConfig(opts, theme, { gradientLength: 150 })
        }
      },
      config: {
        font: fontFamily,
        title: { fontSize: 16, fontWeight: 'bold', color: theme.text },
        axis: { 
          labelFontSize: 11, 
          titleFontSize: 13
        },
        view: { stroke: null }
      }
    };
    
    if (opts.title) {
      heatmapSpec.title = buildChartTitle(opts, theme, 'start');
    }
    
    // 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 }, ...(barRadius ? { cornerRadius: barRadius } : {}) },
          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: fontFamily,
        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 = buildChartTitle(opts, theme, 'start');
    }
    
    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', ...(barRadius ? { cornerRadius: barRadius } : {}) },
      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: buildOrderedNominalScale(opts, 'category10'),
          ...(buildOrderedNominalSort(opts) ? { sort: buildOrderedNominalSort(opts) } : {})
        },
        ...(buildOrderedNominalSort(opts)
          ? {
              order: {
                field: opts.colorField,
                type: 'nominal',
                sort: buildOrderedNominalSort(opts)
              }
            }
          : {})
      },
      config: {
        font: fontFamily,
        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: buildLegendConfig(opts, theme),
        view: { stroke: null }
      }
    };
    
    if (opts.title) {
      stackedSpec.title = buildChartTitle(opts, theme, 'start');
    }
    
    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: linePointConfig, strokeWidth: lineWidth, ...(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: buildOrderedNominalScale(opts, 'category10'),
          ...(buildOrderedNominalSort(opts) ? { sort: buildOrderedNominalSort(opts) } : {})
        }
      },
      config: {
        font: fontFamily,
        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: buildLegendConfig(opts, theme),
        view: { stroke: null }
      }
    };
    
    if (opts.title) {
      multiSpec.title = buildChartTitle(opts, theme, 'start');
    }
    
    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 y2Format = resolveYFormat(opts.y2Format);

    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, ...(barRadius ? { cornerRadius: barRadius } : {}) },
          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
                ...(y2Format ? { format: y2Format } : {})
              },
              scale: { zero: true }
            }
          }
        },
        // Price line (front, on primary y-axis)
        {
          mark: { type: 'line', point: linePointConfig, color: theme.accent, strokeWidth: lineWidth },
          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: fontFamily,
        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 = buildChartTitle(opts, theme, 'start');
    }
    
    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 y2Format = resolveYFormat(opts.y2Format);
    
    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: linePointConfig, strokeWidth: lineWidth, ...(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: linePointConfig, strokeWidth: lineWidth, ...(opts.smooth ? { interpolate: 'monotone' } : {}) } : { opacity: 0.5 }), ...(y2Mark === 'bar' && barRadius ? { cornerRadius: barRadius } : {}), 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',
                ...(y2Format ? { format: y2Format } : {})
              }
            }
          }
        }
      ],
      resolve: {
        scale: { y: 'independent' }
      },
      config: {
        font: fontFamily,
        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 = buildChartTitle(opts, theme, 'start');
    }
    
    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];
  }

  if (opts.data && opts.data.length > 0) {
    const yPadFields = [opts.yField, opts.bandUpper, opts.bandLower].filter(Boolean);
    const yPadValues = opts.data.flatMap(d => yPadFields.map(field => d[field]));
    const paddedDomain = applyYPadToValues(yPadValues, opts);
    if (paddedDomain) opts.yDomain = paddedDomain;
  }
  
  // Base layer - the main chart
  const xAxisType = opts.xType || (opts.type === 'histogram' ? 'quantitative' : 'ordinal');  // ordinal (default), temporal, quantitative
  const yFormat = resolveYFormat(opts.yFormat);
  const mainLayer = {
    mark: markConfig[opts.type] || markConfig.line,
    encoding: opts.type === 'histogram'
      ? {
          x: {
            field: opts.xField,
            type: 'quantitative',
            bin: Number.isFinite(opts.bins) && opts.bins > 0 ? { maxbins: opts.bins } : true,
            title: opts.xTitle || opts.xField,
            axis: { labelAngle: opts.xLabelAngle !== undefined ? opts.xLabelAngle : -45 }
          },
          y: {
            aggregate: 'count',
            type: 'quantitative',
            title: opts.yTitle || 'Count',
            ...(yFormat ? { axis: { format: yFormat } } : {})
          }
        }
      : {
          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: lineWidth, 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: opts.yLabelAngle !== undefined ? opts.yLabelAngle : 0 },
      ...(opts.sort ? {
        sort: opts.sort === 'asc' ? { field: opts.yField, order: 'ascending' }
             : opts.sort === 'desc' ? { field: opts.yField, order: 'descending' }
             : null
      } : {})
    };
  }

  const layers = [];

  // Confidence/range band (--band-upper, --band-lower)
  if (opts.bandUpper && opts.bandLower) {
    const bandColor = opts.bandColor || (opts.dark ? 'rgba(255,107,107,0.2)' : 'rgba(230,57,70,0.15)');
    layers.push({
      mark: { type: 'area', opacity: 1, ...(opts.smooth ? { interpolate: 'monotone' } : {}) },
      encoding: {
        x: { ...mainLayer.encoding.x },
        y: { field: opts.bandUpper, type: 'quantitative' },
        y2: { field: opts.bandLower },
        color: { value: bandColor }
      }
    });
  }

  layers.push(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: pointSize, 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 last-point label
  if (opts.lastValue && opts.data && opts.data.length >= 1) {
    const lastPoint = opts.data[opts.data.length - 1];
    const fmtLastVal = (v) => {
      if (!yFormat) return `${v}`;
      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);
      if (yFormat === '~s') return v >= 1e6 ? `${(v / 1e6).toFixed(1)}M` : v >= 1e3 ? `${(v / 1e3).toFixed(1)}K` : `${v}`;
      return `${v}`;
    };

    layers.push({
      mark: {
        type: 'text',
        align: 'left',
        baseline: 'middle',
        dx: 8,
        fontSize: 12,
        fontWeight: 'bold',
        color: theme.text
      },
      encoding: {
        x: { datum: lastPoint[opts.xField] },
        y: { datum: lastPoint[opts.yField] },
        text: { value: fmtLastVal(lastPoint[opts.yField]) }
      }
    });
  }

  // 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: lineWidth, 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 sparkLineWidth = (typeof opts.lineWidth === 'number' && Number.isFinite(opts.lineWidth) && opts.lineWidth > 0)
      ? opts.lineWidth
      : (opts.sparkline ? 1.5 : 2);
    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: sparkLineWidth 
      },
      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: fontFamily,
      title: { fontSize: 16, fontWeight: 'bold', color: theme.text },
      axis: { 
        labelFontSize: 11, 
        titleFontSize: 13, 
        gridColor: theme.grid,
        ...(Array.isArray(opts.gridDash) && opts.gridDash.length > 0 ? { gridDash: opts.gridDash } : {}),
        labelColor: theme.text,
        titleColor: theme.text,
        domainColor: theme.grid
      },
      view: { stroke: null },
      legend: buildLegendConfig(opts, theme)
    }
  };
  
  if (opts.title) {
    spec.title = buildChartTitle(opts, theme, 'start');
  }
  
  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);
  }
  
  const walkEncodings = (node, fn) => {
    if (!node || typeof node !== 'object') return;
    if (node.encoding) fn(node.encoding);
    if (Array.isArray(node.layer)) node.layer.forEach(child => walkEncodings(child, fn));
  };

  if (opts.xDomain) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.field) {
        enc.x.scale = { ...(enc.x.scale || {}), domain: opts.xDomain };
      }
    });
  }

  // Apply --x-format to x axis encoding (works for temporal axes)
  if (opts.xFormat) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x) {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.format = opts.xFormat;
        enc.x.axis.formatType = 'time';
      }
    });
  }

  // Apply --x-label-limit to avoid unreadable/truncated category labels overflowing the chart
  if (opts.xLabelLimit !== undefined && Number.isFinite(opts.xLabelLimit) && opts.xLabelLimit > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.field) {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.labelLimit = opts.xLabelLimit;
      }
    });
  }

  if (opts.xLabelOverlap !== undefined) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.field) {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.labelOverlap = opts.xLabelOverlap;
      }
    });
  }

  // Apply --y-label-limit to keep long category/value labels from overflowing the chart
  if (opts.yLabelLimit !== undefined && Number.isFinite(opts.yLabelLimit) && opts.yLabelLimit > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y && enc.y.field) {
        if (!enc.y.axis) enc.y.axis = {};
        enc.y.axis.labelLimit = opts.yLabelLimit;
      }
    });
  }

  if (opts.yLabelOverlap !== undefined) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y && enc.y.field) {
        if (!enc.y.axis) enc.y.axis = {};
        enc.y.axis.labelOverlap = opts.yLabelOverlap;
      }
    });
  }

  // Apply configurable axis tick density
  if (opts.xTicks !== undefined && Number.isInteger(opts.xTicks) && opts.xTicks > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.field) {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.tickCount = opts.xTicks;
      }
    });
  }

  if (opts.yTicks !== undefined && Number.isInteger(opts.yTicks) && opts.yTicks > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y && enc.y.field) {
        if (!enc.y.axis) enc.y.axis = {};
        if (enc.y.axis.orient !== 'right') {
          enc.y.axis.tickCount = opts.yTicks;
        }
      }
    });
  }

  if (opts.y2Ticks !== undefined && Number.isInteger(opts.y2Ticks) && opts.y2Ticks > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y && enc.y.field) {
        if (!enc.y.axis) enc.y.axis = {};
        if (enc.y.axis.orient === 'right') {
          enc.y.axis.tickCount = opts.y2Ticks;
        }
      }
    });
  }

  if (opts.tickMinStep !== undefined && Number.isFinite(opts.tickMinStep) && opts.tickMinStep > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.type === 'quantitative') {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.tickMinStep = opts.tickMinStep;
      }
      if (enc && enc.y && enc.y.type === 'quantitative') {
        if (!enc.y.axis) enc.y.axis = {};
        enc.y.axis.tickMinStep = opts.tickMinStep;
      }
    });
  }

  if (opts.tickMinStepX !== undefined && Number.isFinite(opts.tickMinStepX) && opts.tickMinStepX > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.type === 'quantitative') {
        if (!enc.x.axis) enc.x.axis = {};
        enc.x.axis.tickMinStep = opts.tickMinStepX;
      }
    });
  }

  if (opts.tickMinStepY !== undefined && Number.isFinite(opts.tickMinStepY) && opts.tickMinStepY > 0) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y && enc.y.type === 'quantitative') {
        if (!enc.y.axis) enc.y.axis = {};
        enc.y.axis.tickMinStep = opts.tickMinStepY;
      }
    });
  }

  if (opts.yLabelAngle !== undefined && Number.isFinite(opts.yLabelAngle)) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.y) {
        if (!enc.y.axis) enc.y.axis = {};
        enc.y.axis.labelAngle = opts.yLabelAngle;
      }
    });
  }

  // Apply --x-sort to x axis encoding across chart types
  if (opts.xSort !== undefined) {
    walkEncodings(spec, (enc) => {
      if (enc && enc.x && enc.x.field) {
        enc.x.sort = opts.xSort === 'none' ? null : opts.xSort;
      }
    });
  }

  // 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
      const wmSvg = `<svg width="${w}" height="${h}">
        <text x="${w - 10}" y="${h - 10}" font-family="${opts.fontFamily || 'sans-serif'}" font-size="${fontSize}"
              fill="${opts.dark ? 'rgba(255,255,255,0.25)' : 'rgba(0,0,0,0.15)'}"
              text-anchor="end">${escaped}</text>
      </svg>`;
      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);
});


path: scripts/package-lock.json
content: {
  "name": "chart-image-scripts",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "chart-image-scripts",
      "dependencies": {
        "sharp": "^0.33.0",
        "vega": "^5.30.0",
        "vega-lite": "^5.21.0"
      }
    },
    "node_modules/@emnapi/runtime": {
      "version": "1.8.1",
      "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "optional": true,
      "dependencies": {
        "tslib": "^2.4.0"
      }
    },
    "node_modules/@img/sharp-darwin-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-darwin-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "darwin"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-darwin-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-darwin-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "darwin"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm": {
      "version": "1.0.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-s390x": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "s390x"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linux-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-arm64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-libvips-linuxmusl-x64": {
      "version": "1.0.4",
      "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "linux"
      ],
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-linux-arm": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm": "1.0.5"
      }
    },
    "node_modules/@img/sharp-linux-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linux-s390x": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "s390x"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-s390x": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linux-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linux-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-arm64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "arm64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-linuxmusl-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0",
      "optional": true,
      "os": [
        "linux"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-libvips-linuxmusl-x64": "1.0.4"
      }
    },
    "node_modules/@img/sharp-wasm32": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "wasm32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
      "optional": true,
      "dependencies": {
        "@emnapi/runtime": "^1.2.0"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-ia32": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "ia32"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@img/sharp-win32-x64": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "cpu": [
        "x64"
      ],
      "license": "Apache-2.0 AND LGPL-3.0-or-later",
      "optional": true,
      "os": [
        "win32"
      ],
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      }
    },
    "node_modules/@types/estree": {
      "version": "1.0.8",
      "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/@types/geojson": {
      "version": "7946.0.4",
      "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/ansi-regex": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/ansi-styles": {
      "version": "4.3.0",
      "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1"
      },
      "engines": {
        "node": ">=8"
      },
      "funding": {
        "url": "https://github.com/chalk/ansi-styles?sponsor=1"
      }
    },
    "node_modules/cliui": {
      "version": "8.0.1",
      "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "string-width": "^4.2.0",
        "strip-ansi": "^6.0.1",
        "wrap-ansi": "^7.0.0"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/color": {
      "version": "4.2.3",
      "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-convert": "^2.0.1",
        "color-string": "^1.9.0"
      },
      "engines": {
        "node": ">=12.5.0"
      }
    },
    "node_modules/color-convert": {
      "version": "2.0.1",
      "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-name": "~1.1.4"
      },
      "engines": {
        "node": ">=7.0.0"
      }
    },
    "node_modules/color-name": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/color-string": {
      "version": "1.9.1",
      "resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "color-name": "^1.0.0",
        "simple-swizzle": "^0.2.2"
      }
    },
    "node_modules/commander": {
      "version": "7.2.0",
      "resolved": "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">= 10"
      }
    },
    "node_modules/d3-array": {
      "version": "3.2.4",
      "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "internmap": "1 - 2"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-color": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-delaunay": {
      "version": "6.0.4",
      "resolved": "https://registry.npmjs.org/d3-delaunay/-/d3-delaunay-6.0.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "delaunator": "5"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-dispatch": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-dsv": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/d3-dsv/-/d3-dsv-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "commander": "7",
        "iconv-lite": "0.6",
        "rw": "1"
      },
      "bin": {
        "csv2json": "bin/dsv2json.js",
        "csv2tsv": "bin/dsv2dsv.js",
        "dsv2dsv": "bin/dsv2dsv.js",
        "dsv2json": "bin/dsv2json.js",
        "json2csv": "bin/json2dsv.js",
        "json2dsv": "bin/json2dsv.js",
        "json2tsv": "bin/json2dsv.js",
        "tsv2csv": "bin/dsv2dsv.js",
        "tsv2json": "bin/dsv2json.js"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-force": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/d3-force/-/d3-force-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-dispatch": "1 - 3",
        "d3-quadtree": "1 - 3",
        "d3-timer": "1 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-format": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-geo": {
      "version": "3.1.1",
      "resolved": "https://registry.npmjs.org/d3-geo/-/d3-geo-3.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-array": "2.5.0 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-geo-projection": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/d3-geo-projection/-/d3-geo-projection-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "commander": "7",
        "d3-array": "1 - 3",
        "d3-geo": "1.12.0 - 3"
      },
      "bin": {
        "geo2svg": "bin/geo2svg.js",
        "geograticule": "bin/geograticule.js",
        "geoproject": "bin/geoproject.js",
        "geoquantize": "bin/geoquantize.js",
        "geostitch": "bin/geostitch.js"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-hierarchy": {
      "version": "3.1.2",
      "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-interpolate": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-color": "1 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-path": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-quadtree": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-scale": {
      "version": "4.0.2",
      "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-array": "2.10.0 - 3",
        "d3-format": "1 - 3",
        "d3-interpolate": "1.2.0 - 3",
        "d3-time": "2.1.1 - 3",
        "d3-time-format": "2 - 4"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-scale-chromatic": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-color": "1 - 3",
        "d3-interpolate": "1 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-shape": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-path": "^3.1.0"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-time": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-array": "2 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-time-format": {
      "version": "4.1.0",
      "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "d3-time": "1 - 3"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/d3-timer": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/delaunator": {
      "version": "5.0.1",
      "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-5.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "robust-predicates": "^3.0.2"
      }
    },
    "node_modules/detect-libc": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Apache-2.0",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/emoji-regex": {
      "version": "8.0.0",
      "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/escalade": {
      "version": "3.2.0",
      "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=6"
      }
    },
    "node_modules/get-caller-file": {
      "version": "2.0.5",
      "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": "6.* || 8.* || >= 10.*"
      }
    },
    "node_modules/iconv-lite": {
      "version": "0.6.3",
      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "safer-buffer": ">= 2.1.2 < 3.0.0"
      },
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/internmap": {
      "version": "2.0.3",
      "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/is-arrayish": {
      "version": "0.3.4",
      "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/is-fullwidth-code-point": {
      "version": "3.0.0",
      "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/json-stringify-pretty-compact": {
      "version": "4.0.0",
      "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/node-fetch": {
      "version": "2.7.0",
      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "whatwg-url": "^5.0.0"
      },
      "engines": {
        "node": "4.x || >=6.0.0"
      },
      "peerDependencies": {
        "encoding": "^0.1.0"
      },
      "peerDependenciesMeta": {
        "encoding": {
          "optional": true
        }
      }
    },
    "node_modules/require-directory": {
      "version": "2.1.1",
      "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "engines": {
        "node": ">=0.10.0"
      }
    },
    "node_modules/robust-predicates": {
      "version": "3.0.2",
      "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "Unlicense"
    },
    "node_modules/rw": {
      "version": "1.3.3",
      "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause"
    },
    "node_modules/safer-buffer": {
      "version": "2.1.2",
      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/semver": {
      "version": "7.7.3",
      "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "bin": {
        "semver": "bin/semver.js"
      },
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/sharp": {
      "version": "0.33.5",
      "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
      "integrity": [REDACTED_SECRET],
      "hasInstallScript": true,
      "license": "Apache-2.0",
      "dependencies": {
        "color": "^4.2.3",
        "detect-libc": "^2.0.3",
        "semver": "^7.6.3"
      },
      "engines": {
        "node": "^18.17.0 || ^20.3.0 || >=21.0.0"
      },
      "funding": {
        "url": "https://opencollective.com/libvips"
      },
      "optionalDependencies": {
        "@img/sharp-darwin-arm64": "0.33.5",
        "@img/sharp-darwin-x64": "0.33.5",
        "@img/sharp-libvips-darwin-arm64": "1.0.4",
        "@img/sharp-libvips-darwin-x64": "1.0.4",
        "@img/sharp-libvips-linux-arm": "1.0.5",
        "@img/sharp-libvips-linux-arm64": "1.0.4",
        "@img/sharp-libvips-linux-s390x": "1.0.4",
        "@img/sharp-libvips-linux-x64": "1.0.4",
        "@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
        "@img/sharp-libvips-linuxmusl-x64": "1.0.4",
        "@img/sharp-linux-arm": "0.33.5",
        "@img/sharp-linux-arm64": "0.33.5",
        "@img/sharp-linux-s390x": "0.33.5",
        "@img/sharp-linux-x64": "0.33.5",
        "@img/sharp-linuxmusl-arm64": "0.33.5",
        "@img/sharp-linuxmusl-x64": "0.33.5",
        "@img/sharp-wasm32": "0.33.5",
        "@img/sharp-win32-ia32": "0.33.5",
        "@img/sharp-win32-x64": "0.33.5"
      }
    },
    "node_modules/simple-swizzle": {
      "version": "0.2.4",
      "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "is-arrayish": "^0.3.1"
      }
    },
    "node_modules/string-width": {
      "version": "4.2.3",
      "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "emoji-regex": "^8.0.0",
        "is-fullwidth-code-point": "^3.0.0",
        "strip-ansi": "^6.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/strip-ansi": {
      "version": "6.0.1",
      "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ansi-regex": "^5.0.1"
      },
      "engines": {
        "node": ">=8"
      }
    },
    "node_modules/topojson-client": {
      "version": "3.1.0",
      "resolved": "https://registry.npmjs.org/topojson-client/-/topojson-client-3.1.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "dependencies": {
        "commander": "2"
      },
      "bin": {
        "topo2geo": "bin/topo2geo",
        "topomerge": "bin/topomerge",
        "topoquantize": "bin/topoquantize"
      }
    },
    "node_modules/topojson-client/node_modules/commander": {
      "version": "2.20.3",
      "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/tr46": {
      "version": "0.0.3",
      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT"
    },
    "node_modules/tslib": {
      "version": "2.8.1",
      "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "0BSD"
    },
    "node_modules/vega": {
      "version": "5.33.1",
      "resolved": "https://registry.npmjs.org/vega/-/vega-5.33.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-crossfilter": "~4.1.4",
        "vega-dataflow": "~5.7.8",
        "vega-encode": "~4.10.3",
        "vega-event-selector": "~3.0.1",
        "vega-expression": "~5.2.1",
        "vega-force": "~4.2.3",
        "vega-format": "~1.1.4",
        "vega-functions": "~5.18.1",
        "vega-geo": "~4.4.4",
        "vega-hierarchy": "~4.1.4",
        "vega-label": "~1.3.2",
        "vega-loader": "~4.5.4",
        "vega-parser": "~6.6.1",
        "vega-projection": "~1.6.3",
        "vega-regression": "~1.3.2",
        "vega-runtime": "~6.2.2",
        "vega-scale": "~7.4.3",
        "vega-scenegraph": "~4.13.2",
        "vega-statistics": "~1.9.0",
        "vega-time": "~2.1.4",
        "vega-transforms": "~4.12.2",
        "vega-typings": "~1.5.1",
        "vega-util": "~1.17.4",
        "vega-view": "~5.16.1",
        "vega-view-transforms": "~4.6.2",
        "vega-voronoi": "~4.2.5",
        "vega-wordcloud": "~4.1.7"
      }
    },
    "node_modules/vega-canvas": {
      "version": "1.2.7",
      "resolved": "https://registry.npmjs.org/vega-canvas/-/vega-canvas-1.2.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause"
    },
    "node_modules/vega-crossfilter": {
      "version": "4.1.4",
      "resolved": "https://registry.npmjs.org/vega-crossfilter/-/vega-crossfilter-4.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "vega-dataflow": "^5.7.8",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-dataflow": {
      "version": "5.7.8",
      "resolved": "https://registry.npmjs.org/vega-dataflow/-/vega-dataflow-5.7.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-format": "^1.1.4",
        "vega-loader": "^4.5.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-encode": {
      "version": "4.10.3",
      "resolved": "https://registry.npmjs.org/vega-encode/-/vega-encode-4.10.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-interpolate": "^3.0.1",
        "vega-dataflow": "^5.7.8",
        "vega-scale": "^7.4.3",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-event-selector": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/vega-event-selector/-/vega-event-selector-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause"
    },
    "node_modules/vega-expression": {
      "version": "5.2.1",
      "resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-5.2.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "@types/estree": "^1.0.0",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-force": {
      "version": "4.2.3",
      "resolved": "https://registry.npmjs.org/vega-force/-/vega-force-4.2.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-force": "^3.0.0",
        "vega-dataflow": "^5.7.8",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-format": {
      "version": "1.1.4",
      "resolved": "https://registry.npmjs.org/vega-format/-/vega-format-1.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-format": "^3.1.0",
        "d3-time-format": "^4.1.0",
        "vega-time": "^2.1.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-functions": {
      "version": "5.18.1",
      "resolved": "https://registry.npmjs.org/vega-functions/-/vega-functions-5.18.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-color": "^3.1.0",
        "d3-geo": "^3.1.0",
        "vega-dataflow": "^5.7.8",
        "vega-expression": "^5.2.1",
        "vega-scale": "^7.4.3",
        "vega-scenegraph": "^4.13.2",
        "vega-selections": "^5.6.1",
        "vega-statistics": "^1.9.0",
        "vega-time": "^2.1.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-geo": {
      "version": "4.4.4",
      "resolved": "https://registry.npmjs.org/vega-geo/-/vega-geo-4.4.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-color": "^3.1.0",
        "d3-geo": "^3.1.0",
        "vega-canvas": "^1.2.7",
        "vega-dataflow": "^5.7.8",
        "vega-projection": "^1.6.3",
        "vega-statistics": "^1.9.0",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-hierarchy": {
      "version": "4.1.4",
      "resolved": "https://registry.npmjs.org/vega-hierarchy/-/vega-hierarchy-4.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-hierarchy": "^3.1.2",
        "vega-dataflow": "^5.7.8",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-label": {
      "version": "1.3.2",
      "resolved": "https://registry.npmjs.org/vega-label/-/vega-label-1.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-canvas": "^1.2.7",
        "vega-dataflow": "^5.7.8",
        "vega-scenegraph": "^4.13.2",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-lite": {
      "version": "5.23.0",
      "resolved": "https://registry.npmjs.org/vega-lite/-/vega-lite-5.23.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "json-stringify-pretty-compact": "~4.0.0",
        "tslib": "~2.8.1",
        "vega-event-selector": "~3.0.1",
        "vega-expression": "~5.1.1",
        "vega-util": "~1.17.2",
        "yargs": "~17.7.2"
      },
      "bin": {
        "vl2pdf": "bin/vl2pdf",
        "vl2png": "bin/vl2png",
        "vl2svg": "bin/vl2svg",
        "vl2vg": "bin/vl2vg"
      },
      "engines": {
        "node": ">=18"
      },
      "peerDependencies": {
        "vega": "^5.24.0"
      }
    },
    "node_modules/vega-lite/node_modules/vega-expression": {
      "version": "5.1.2",
      "resolved": "https://registry.npmjs.org/vega-expression/-/vega-expression-5.1.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "@types/estree": "^1.0.0",
        "vega-util": "^1.17.3"
      }
    },
    "node_modules/vega-loader": {
      "version": "4.5.4",
      "resolved": "https://registry.npmjs.org/vega-loader/-/vega-loader-4.5.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-dsv": "^3.0.1",
        "node-fetch": "^2.6.7",
        "topojson-client": "^3.1.0",
        "vega-format": "^1.1.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-parser": {
      "version": "6.6.1",
      "resolved": "https://registry.npmjs.org/vega-parser/-/vega-parser-6.6.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-dataflow": "^5.7.8",
        "vega-event-selector": "^3.0.1",
        "vega-functions": "^5.18.1",
        "vega-scale": "^7.4.3",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-projection": {
      "version": "1.6.3",
      "resolved": "https://registry.npmjs.org/vega-projection/-/vega-projection-1.6.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-geo": "^3.1.0",
        "d3-geo-projection": "^4.0.0",
        "vega-scale": "^7.4.3"
      }
    },
    "node_modules/vega-regression": {
      "version": "1.3.2",
      "resolved": "https://registry.npmjs.org/vega-regression/-/vega-regression-1.3.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "vega-dataflow": "^5.7.8",
        "vega-statistics": "^1.9.0",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-runtime": {
      "version": "6.2.2",
      "resolved": "https://registry.npmjs.org/vega-runtime/-/vega-runtime-6.2.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-dataflow": "^5.7.8",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-scale": {
      "version": "7.4.3",
      "resolved": "https://registry.npmjs.org/vega-scale/-/vega-scale-7.4.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-interpolate": "^3.0.1",
        "d3-scale": "^4.0.2",
        "d3-scale-chromatic": "^3.1.0",
        "vega-time": "^2.1.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-scenegraph": {
      "version": "4.13.2",
      "resolved": "https://registry.npmjs.org/vega-scenegraph/-/vega-scenegraph-4.13.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-path": "^3.1.0",
        "d3-shape": "^3.2.0",
        "vega-canvas": "^1.2.7",
        "vega-loader": "^4.5.4",
        "vega-scale": "^7.4.3",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-selections": {
      "version": "5.6.3",
      "resolved": "https://registry.npmjs.org/vega-selections/-/vega-selections-5.6.3.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "3.2.4",
        "vega-expression": "^5.2.1",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-statistics": {
      "version": "1.9.0",
      "resolved": "https://registry.npmjs.org/vega-statistics/-/vega-statistics-1.9.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2"
      }
    },
    "node_modules/vega-time": {
      "version": "2.1.4",
      "resolved": "https://registry.npmjs.org/vega-time/-/vega-time-2.1.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-time": "^3.1.0",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-transforms": {
      "version": "4.12.2",
      "resolved": "https://registry.npmjs.org/vega-transforms/-/vega-transforms-4.12.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "vega-dataflow": "^5.7.8",
        "vega-statistics": "^1.9.0",
        "vega-time": "^2.1.4",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-typings": {
      "version": "1.5.1",
      "resolved": "https://registry.npmjs.org/vega-typings/-/vega-typings-1.5.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "@types/geojson": "7946.0.4",
        "vega-event-selector": "^3.0.1",
        "vega-expression": "^5.2.1",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-util": {
      "version": "1.17.4",
      "resolved": "https://registry.npmjs.org/vega-util/-/vega-util-1.17.4.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause"
    },
    "node_modules/vega-view": {
      "version": "5.16.1",
      "resolved": "https://registry.npmjs.org/vega-view/-/vega-view-5.16.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-array": "^3.2.2",
        "d3-timer": "^3.0.1",
        "vega-dataflow": "^5.7.8",
        "vega-format": "^1.1.4",
        "vega-functions": "^5.18.1",
        "vega-runtime": "^6.2.2",
        "vega-scenegraph": "^4.13.2",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-view-transforms": {
      "version": "4.6.2",
      "resolved": "https://registry.npmjs.org/vega-view-transforms/-/vega-view-transforms-4.6.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-dataflow": "^5.7.8",
        "vega-scenegraph": "^4.13.2",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-voronoi": {
      "version": "4.2.5",
      "resolved": "https://registry.npmjs.org/vega-voronoi/-/vega-voronoi-4.2.5.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "d3-delaunay": "^6.0.2",
        "vega-dataflow": "^5.7.8",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/vega-wordcloud": {
      "version": "4.1.7",
      "resolved": "https://registry.npmjs.org/vega-wordcloud/-/vega-wordcloud-4.1.7.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-3-Clause",
      "dependencies": {
        "vega-canvas": "^1.2.7",
        "vega-dataflow": "^5.7.8",
        "vega-scale": "^7.4.3",
        "vega-statistics": "^1.9.0",
        "vega-util": "^1.17.4"
      }
    },
    "node_modules/webidl-conversions": {
      "version": "3.0.1",
      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "BSD-2-Clause"
    },
    "node_modules/whatwg-url": {
      "version": "5.0.0",
      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "tr46": "~0.0.3",
        "webidl-conversions": "^3.0.0"
      }
    },
    "node_modules/wrap-ansi": {
      "version": "7.0.0",
      "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "ansi-styles": "^4.0.0",
        "string-width": "^4.1.0",
        "strip-ansi": "^6.0.0"
      },
      "engines": {
        "node": ">=10"
      },
      "funding": {
        "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
      }
    },
    "node_modules/y18n": {
      "version": "5.0.8",
      "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=10"
      }
    },
    "node_modules/yargs": {
      "version": "17.7.2",
      "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "MIT",
      "dependencies": {
        "cliui": "^8.0.1",
        "escalade": "^3.1.1",
        "get-caller-file": "^2.0.5",
        "require-directory": "^2.1.1",
        "string-width": "^4.2.3",
        "y18n": "^5.0.5",
        "yargs-parser": "^21.1.1"
      },
      "engines": {
        "node": ">=12"
      }
    },
    "node_modules/yargs-parser": {
      "version": "21.1.1",
      "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
      "integrity": [REDACTED_SECRET],
      "license": "ISC",
      "engines": {
        "node": ">=12"
      }
    }
  }
}


path: scripts/package.json
content: {
  "name": "chart-image-scripts",
  "type": "module",
  "dependencies": {
    "vega": "^5.30.0",
    "vega-lite": "^5.21.0",
    "sharp": "^0.33.0"
  }
}


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: clean (LOW)