---
title: "tryopendata/skills"
description: "Official Agent Skills for the OpenData platform"
source: https://github.com/tryopendata/skills
ref: main
license: MIT
licenseName: "MIT License"
canonical: https://skillsdocs.com/tryopendata/skills
base: https://github.com/tryopendata/skills/blob/main/
provenance: authored
chapters: 3
inlined: 3
withheld: 0
words: 7542
updated: 2026-07-25T12:51:36Z
generator: "Skills Docs"
---

> **tryopendata/skills** — every Agent Skill in this repository, inlined verbatim.
>
> Canonical HTML: https://skillsdocs.com/tryopendata/skills
> Per-skill Markdown: https://skillsdocs.com/tryopendata/skills/<skill>.md
> Machine manifest: https://skillsdocs.com/tryopendata/skills/.well-known/agent-skills/index.json
> JSON: https://skillsdocs.com/api/v1/books/tryopendata/skills
> Install: `npx skills add tryopendata/skills`
> Upstream: https://github.com/tryopendata/skills @ `main`
> Licence: MIT
>
> Content is mirrored from GitHub and © its authors, served unmodified. Takedown: https://github.com/DreambaseAI/skillsdocs/issues/new?labels=takedown&title=Takedown+request

# tryopendata/skills

Official Agent Skills for the OpenData platform

- **Skills:** 3
- **Inlined:** 3 (licence detected)
- **Words:** 7,542
- **Reading time:** 34 min
- **Stars:** 127

## Table of contents

1. [openchart](https://skillsdocs.com/tryopendata/skills/openchart.md) — Generates OpenChart (https://github.com/tryopendata/openchart) chart, table, graph, sankey, tilemap, and geo map specs from data, and guides editorial design d…
2. [opendata-api](https://skillsdocs.com/tryopendata/skills/opendata-api.md) — Query the OpenData API for data research and analysis. Use when fetching dataset rows, filtering, sorting, aggregating, inspecting columns, composing cross-dat…
3. [svg-design](https://skillsdocs.com/tryopendata/skills/svg-design.md) — Generates and edits SVG logos, icons, and graphics. Use when creating SVG files, designing logos or icons, writing path data, optimizing SVGs, building icon sy…


## Front matter

_The repository README, verbatim except that relative links are resolved against https://github.com/tryopendata/skills/blob/main/._

# OpenData Skills

Agent skills for data, visualizations, and design. Built on the [Agent Skills spec](https://agentskills.io), distributed as a [Claude Code plugin marketplace](https://code.claude.com/docs/en/plugin-marketplaces).

| Plugin | What it does | Skills |
|--------|-------------|--------|
| **opendata** | Query and analyze public datasets via the OpenData API | `opendata-api` |
| **openchart** | Generate charts, tables, graphs, and sankey diagrams with [OpenChart](https://github.com/tryopendata/openchart) | `openchart` |
| **opendesign** | Create SVG logos, icons, and graphics | `svg-design` |

## Installation

### Claude Code

Add the marketplace, then install whichever plugins you need:

```
/plugin marketplace add tryopendata/skills
```

```
/plugin install opendata@opendata-skills
/plugin install openchart@opendata-skills
/plugin install opendesign@opendata-skills
```

Or from the CLI directly:

```bash
claude plugin marketplace add tryopendata/skills
claude plugin install opendata@opendata-skills
```

You can scope the install with `--scope project` (shared via version control) or `--scope local` (gitignored). Default is `user` (available everywhere).

### OpenAI Codex, Gemini CLI, Cursor, VS Code/Copilot, etc.

Each skill is a standalone [Agent Skills spec](https://agentskills.io/specification) directory with a `SKILL.md` file. To use them on other platforms, copy the skill directory you want into your platform's skills location (typically `.agents/skills/`):

```
plugins/<plugin>/skills/<skill-name>/   # copy this directory
```

For example, to add `svg-design` to a Codex project:

```bash
cp -r plugins/opendesign/skills/svg-design .agents/skills/svg-design
```

Check your platform's [Agent Skills docs](https://agentskills.io) for where it discovers skills.

> **Migrating from pre-1.0?** `svg-design` moved from the `openchart` plugin to the new `opendesign` plugin in v1.0.0.

---

## Contributing

### Structure

```
.claude-plugin/
  marketplace.json           # Claude Code marketplace registry
plugins/
  opendata/                  # OpenData plugin
    skills/
      opendata-api/
  openchart/                 # OpenChart plugin
    skills/
      openchart/
  opendesign/                # OpenDesign plugin
    skills/
      svg-design/
```

Each skill is a directory with a `SKILL.md` file:

```
skills/<name>/
├── SKILL.md        # Instructions with YAML frontmatter (name + description)
├── evals/          # Test cases for output quality verification
├── scripts/        # Bundled executable code
├── references/     # Supplementary docs loaded on-demand
└── assets/         # Templates, schemas, static resources
```

### Adding a Skill

1. Create a directory under `plugins/<plugin>/skills/` with a `SKILL.md` file
2. Follow the [Agent Skills specification](https://agentskills.io/specification) for frontmatter format
3. Add [evals](https://agentskills.io/skill-creation/evaluating-skills) to verify output quality
4. See [best practices](https://agentskills.io/skill-creation/best-practices) for writing effective skills

## License

MIT

---

<!-- chapter:begin slug=openchart position=1 -->

## 1. openchart

- **Source:** https://github.com/tryopendata/skills/blob/main/plugins/openchart/skills/openchart/SKILL.md
- **Raw:** https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/SKILL.md
- **Markdown:** https://skillsdocs.com/tryopendata/skills/openchart.md
- **Licence:** MIT — https://spdx.org/licenses/MIT.html

Bundled files (37), referenced from this skill's directory:
  - `references/animation.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/animation.md
  - `references/annotations.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/annotations.md
  - `references/area.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/area.md
  - `references/bar.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/bar.md
  - `references/chart-selection.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/chart-selection.md
  - `references/color-strategy.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/color-strategy.md
  - `references/d3/advanced-techniques.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/advanced-techniques.md
  - `references/d3/animation-transitions.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/animation-transitions.md
  - `references/d3/chart-hierarchy.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/chart-hierarchy.md
  - `references/d3/color-palettes.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/color-palettes.md
  - `references/d3/d3-core-patterns.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/d3-core-patterns.md
  - `references/d3/infographic-design.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/infographic-design.md
  - `references/d3/responsive-svg.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/responsive-svg.md
  - `references/d3/typography-labels.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/d3/typography-labels.md
  - `references/data-transforms.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/data-transforms.md
  - `references/design-review.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/design-review.md
  - `references/dot.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/dot.md
  - `references/editing.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/editing.md
  - `references/editorial-writing.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/editorial-writing.md
  - `references/encoding-channels.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/encoding-channels.md
  - `references/format-strings.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/format-strings.md
  - `references/gradients.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/gradients.md
  - `references/graph.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/graph.md
  - `references/line.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/openchart/skills/openchart/references/line.md
  - …and 13 more, listed in https://skillsdocs.com/api/v1/books/tryopendata/skills/skills/openchart

<!-- Verbatim upstream SKILL.md follows, YAML frontmatter included. -->

---
name: openchart
description: >
  Generates OpenChart (https://github.com/tryopendata/openchart) chart, table, graph, sankey,
  tilemap, and geo map specs from data, and guides editorial design decisions. Use when creating
  visualizations, building charts, rendering data tables, generating VizSpec JSON, creating network
  graphs, building sankey/flow diagrams, building US state tile grid maps, building choropleth or
  symbol maps from TopoJSON, answering questions about OpenChart types and encoding rules, or making
  design decisions about chart type selection, color strategy, typography, annotations, and
  editorial framing. Also covers custom D3.js infographics for cases beyond declarative specs.
---

# Data Visualization with OpenChart

<!-- Remove migration note after 2026-07 -->
> **Note:** SVG design capabilities (logos, icons, graphics) have moved to the `opendesign` plugin. Install with `/plugin install opendesign@opendata-skills`.

## Source of truth: load the types first

**Before authoring or modifying a spec, load the OpenChart type definitions.** They are the canonical source for field shapes, enums, and defaults. This skill complements the types — it does not replace them. When this skill and the types disagree, the types win.

Try these locations in order; stop at the first one that resolves:

1. **Installed package** (most common): `node_modules/@opendata-ai/openchart-core/dist/index.d.ts`. The full chart, table, graph, sankey, tilemap, and map spec surface is rolled into this single bundled `.d.ts`.
2. **Source repo** (if you're working in the openchart monorepo): `packages/core/src/types/spec.ts` and `packages/core/src/types/layout.ts`. JSDoc comments here are richer than the bundled `.d.ts`.
3. **Published CDN** (no local install, network available): `https://unpkg.com/@opendata-ai/openchart-core/dist/index.d.ts` (redirects to the latest published version).
4. **GitHub raw** (if unpkg is unreachable, or you want the richer source JSDoc): `https://raw.githubusercontent.com/tryopendata/openchart/main/packages/core/src/types/spec.ts`. For version-exact types, pin to a release tag instead of `main`: `https://raw.githubusercontent.com/tryopendata/openchart/core-v<version>/packages/core/src/types/spec.ts`, matching `<version>` to the installed `@opendata-ai/*` package version. Note `main` may document unpublished surface.
5. **Fallback:** if none of the above are reachable (no filesystem, no network fetch), use the type sketches in this skill and **flag the uncertainty** in your response so the user knows you authored without canonical types.

The names worth grepping for once you have a types file open: `ChartSpec`, `TableSpec`, `GraphSpec`, `SankeySpec`, `TileMapSpec`, `GeoMapSpec` (+ `GeoMapGeo`, `GeoMapPointsLayer`), `MarkType` (the 16-mark union), `Encoding`, `EncodingChannel`, `MarkDef`, `Chrome`, `Metric`, `EndpointLabelsConfig`, `Annotation` (union), `TextAnnotation`, `RangeAnnotation`, `RefLineAnnotation`, `LegendConfig`, `LabelSpec`, `SeriesStyle`, `AnimationSpec`, `ThemeConfig`, `A11yConfig`, `SeriesSearchConfig`, `YouDrawItConfig`.

## Rendering via MCP

If you have access to the OpenData MCP `openchart` tool, use it to render specs interactively -- it handles rendering, theming, and responsive layout. Two ways to call it:

- **Quick chart:** pass `chartType` (`bar`, `line`, `area`, `point`, `arc`, `lollipop`, `circle`, `rect`, `tick`) plus the field names (`x`, `y`, `color`). Good for the common x/y shapes.
- **Full control:** pass a `spec` object (the OpenChart JSON described in this skill) -- required for marks the quick `chartType` list doesn't cover (`range`, `waffle`, `calendar`, `parliament`, `beeswarm`, `text`, `rule`) and for any encoding/chrome/annotation options.

Feed data with `sql` (the tool re-runs your previous `query_sql` -- preferred, don't copy rows) or inline `data` rows (keep under 1000). The tool's own description carries version-pinned links to the full schema for constrained generation.

When the `openchart` tool is not available, output the spec as JSON for the user to render with `<Chart>` / `createChart()` (see [rendering reference](references/rendering.md)).

**Structured outputs (tool-use / constrained generation).** OpenChart ships a published JSON Schema and a generated `llms.txt`, both derived from the spec types so they never drift:

- **JSON Schema** for constraining an LLM tool call to a valid spec. Import from the core `./schema` subpath (not the type barrel): `@opendata-ai/openchart-core/schema` is the full VizSpec union (usable directly as an Anthropic tool `input_schema`); `@opendata-ai/openchart-core/schema/chart.schema.json` is the chart-only subset covering all 16 marks; `.../table.schema.json` is the table subset. Files also live at `packages/core/schema/*.schema.json` and on unpkg/GitHub raw for fetch-only agents.
- **`llms.txt`** at the repo root (`https://raw.githubusercontent.com/tryopendata/openchart/main/llms.txt`) is the compact narrative surface: install, core concept, the mark-encoding table, and validation notes. Prefer it as a quick primer when you can't load the full `.d.ts`.

**Validate before rendering.** `validateSpec(spec)` from `@opendata-ai/openchart-engine` returns `{ valid, errors, normalized }`. Each error carries a machine-readable `code`, the offending `path`, and a repair-friendly `suggestion`. Field references are checked against the columns in the provided `data`, and a misspelled field name gets a Levenshtein-based `Did you mean "..."?` clause pointing at the nearest real column -- use that clause to auto-repair typos in a generate-validate loop.

## What this skill carries that the types don't

The types tell you the shape of a valid spec. This skill carries the things types can't express:

- **Behavioral defaults that live in the normalizer/compiler**, not in the type signature. (E.g. bar and area both default to stacked when colored; grouped/overlap behavior requires explicit `stack: null`.)
- **Cross-field interactions and truth tables.** (E.g. how `legend.show`, `endpointLabels`, and the legacy end-of-line labels interact.)
- **Runtime semantics.** (E.g. legend toggle recompiles, rebalances the y-axis, locks the color scale, hides per-series UI, refuses to hide the last visible series.)
- **Editorial judgment.** (E.g. when to pick a bar vs a line, color strategy, annotation density, data-resolution budgets.)
- **Worked examples** for common stories.
- **Failure modes and gotchas** that you'd only learn after rendering and looking at the result.

If you find yourself restating a field shape this skill already documents, prefer the types — they're authoritative and won't drift.

**Core concept:** Write a VizSpec JSON object, render with `<Chart>` / `<DataTable>` / `<Graph>` / `<Sankey>` / `<TileMap>` / `<GeoMap>` (React/Vue/Svelte) or `createChart()` / `createTable()` / `createGraph()` / `createSankey()` / `createTileMap()` / `createGeoMap()` (vanilla JS). The engine validates, compiles, and renders. Specs are plain JSON, no imperative drawing. See https://github.com/tryopendata/openchart for the rendering engine.

**CSS is required.** OpenChart's stylesheet must be loaded for proper rendering (chrome, tables, tooltips, brand watermark). Framework imports handle this automatically, but CDN/standalone HTML needs an explicit `<link>`:

```html
<link rel="stylesheet" href="https://esm.sh/@opendata-ai/openchart-vanilla/styles.css">
```

See [rendering reference](references/rendering.md) for details.

## Chart Selection Decision Tree

```
Single KPI / KPI row         -> spec.metrics: Metric[] (label+value+delta, rendered between subtitle and chart)
Temporal x-axis column?      -> 1 series: line | 2-5 series: line + color | 6+: filter to top 5
Categorical + numeric?       -> Ranked list: bar (horizontal) | Periodic (Q1, Jan): bar (vertical) | 2-6 composition: arc
Two numeric columns?         -> point (optional size/color for 3rd/4th dims)
Categorical + series + num?  -> stacked bar (use color for series)
Distribution/spread?         -> circle (strip plot) | many observations per group: beeswarm
Change between two values?   -> range (dumbbell / arrow / floating bar per category)
Part-to-whole as counts?     -> waffle ("x of 100" unit grid)
Election / legislature seats?-> parliament (hemicycle) | half-donut result: arc + startAngle/endAngle
Daily value over a year?     -> calendar (GitHub-style heatmap)
Nodes + edges / network?     -> graph (force/radial/hierarchical layout)
Flow between stages?         -> sankey (source/target/value)
US state-level data?         -> tilemap (state codes + values, equal-weight grid)
Real geography / shapes?     -> map (TopoJSON choropleth; counties, countries, or points over a basemap)
Tabular data overview?       -> table (with sparklines, heatmaps, bars)
Scroll-driven narrative?     -> chart story (base spec + patch steps, see references/story.md)
Default                      -> bar
```

## Visualization Types

Each type has a detailed reference with full spec, encoding rules, and examples. Load the reference when you need the details.

| Mark / Type | Best for | Data model | Reference |
| --- | --- | --- | --- |
| `mark: "line"` | Trends over time | x: temporal/ordinal, y: quantitative | [references/line.md](references/line.md) |
| `mark: "area"` | Trends with volume emphasis | x: temporal/ordinal, y: quantitative | [references/area.md](references/area.md) |
| `mark: "bar"` | Rankings (horizontal) or periodic/categorical (vertical) | Orientation inferred from encoding (see below) | [references/bar.md](references/bar.md) |
| `mark: "arc"` | Part-to-whole (2-5 categories) | theta: quantitative, color: nominal/ordinal | [references/pie-donut.md](references/pie-donut.md) |
| `mark: "point"` | Correlation between two variables | x: quantitative, y: quantitative | [references/scatter.md](references/scatter.md) |
| `mark: "circle"` | Distribution, strip plots | x: quantitative, y: nominal/ordinal | [references/dot.md](references/dot.md) |
| `mark: "text"` | Text labels positioned by x/y | x: any, y: any, text: nominal | - |
| `mark: "rule"` | Reference lines (horizontal or vertical) | x or y: quantitative/temporal | - |
| `mark: "tick"` | Tick marks for distributions | x: quantitative, y: nominal/ordinal | - |
| `mark: "rect"` | Rectangles for heatmaps | x: ordinal/nominal, y: ordinal/nominal, color: quantitative | - |
| `mark: "lollipop"` | Ranked categorical values (dot on a stem) | x: quantitative, y: nominal/ordinal | [references/dot.md](references/dot.md) |
| `mark: "beeswarm"` | Distribution, one dot per observation | one axis quantitative, other optional nominal (lanes) | [references/dot.md](references/dot.md) |
| `mark: "range"` | Change between two values (dumbbell / arrow / floating bar) | category + start + end (x/x2 or y/y2) | - |
| `mark: "waffle"` | Part-to-whole as counts ("x of 100") | color: nominal, theta: quantitative share | [references/pie-donut.md](references/pie-donut.md) |
| `mark: "calendar"` | Daily value over weeks/years (GitHub heatmap) | x: temporal (daily), color: quantitative | - |
| `mark: "parliament"` | Election / legislature seats (hemicycle) | color: nominal (party), theta: quantitative seats | - |
| `type: "table"` | Data tables with visual features | columns + data rows | [references/table.md](references/table.md) |
| `type: "graph"` | Networks, relationships, hierarchies | nodes + edges | [references/graph.md](references/graph.md) |
| `type: "sankey"` | Flows between stages/processes | source + target + value | [references/sankey.md](references/sankey.md) |
| `type: "tilemap"` | US state-level data (equal-weight grid) | state codes + values | [references/tilemap.md](references/tilemap.md) |
| `type: "map"` | Real geography: choropleth + symbol maps | TopoJSON + join key + color value; optional lat/lon points | [references/map.md](references/map.md) |

**Bar orientation:** The engine infers orientation from encoding. `x: nominal/ordinal + y: quantitative` = vertical (column-style). `x: quantitative + y: nominal/ordinal` = horizontal bar. Override with `mark: { type: "bar", orient: "horizontal" | "vertical" }`.

**Arc variants:** `mark: "arc"` renders a pie chart by default. Add `innerRadius > 0` to get a donut: `mark: { type: "arc", innerRadius: 40 }`. For an election-style half-donut, restrict the sweep with `startAngle`/`endAngle` in radians (`mark: { type: "arc", innerRadius: 40, startAngle: -Math.PI/2, endAngle: Math.PI/2 }`); the engine resizes a partial sweep to fill the chart area so a half-donut isn't drawn at half size.

**New-mark behavior the types don't tell you:**

- **`range`** needs the second value channel: `x` + `x2` (horizontal, the common editorial form with `y` as the category) or `y` + `y2` (vertical). `style` picks the form: `"dumbbell"` (default, muted start dot + accent end dot + connector), `"arrow"` (arrowhead at the x2/y2 end, strongest "change over time" read), or `"bar"` (plain floating range bar). `colorByDirection: true` colors increases with the theme's positive color and decreases with negative; a field-based `encoding.color` wins over it. Use this mark for dumbbell/change plots rather than faking one with two overlaid point series.
- **`waffle`** takes `color` (the category) and a quantitative share via `theta` (the same part-to-whole channel arc uses; `y` is still accepted as a deprecated alias). `units` (default 100) sets total cells, `columns` (default 10) the grid width. Shares normalize to `units` via largest-remainder rounding so cells always sum exactly; a small nonzero share can round to 0 cells (there's no minimum-one-cell floor), but it still appears in the legend.
- **`calendar`** takes `x` (temporal, one row per day) and `color` (quantitative per-day value). Multi-year data stacks one band per year sharing a single color scale. Date math is UTC, so `"2024-01-15"` parses as UTC midnight. Days with no data render as empty achromatic cells, distinct from the scale minimum. `weekStart` ("monday" default / "sunday") sets the top row; `cellRadius` rounds the cells.
- **`parliament`** takes `color` (party) and seat count via `theta` (`y` is still accepted as a deprecated alias). Only `shape: "hemicycle"` ships (concentric semicircular arcs). Parties fill left-to-right in **data order**, so sort your rows by political spectrum yourself. `majorityLine` (default true) draws the threshold line and "N to win" label; `seatRadius` defaults to `"auto"`.
- **`beeswarm`** takes one quantitative positional channel (the value axis) plus an optional nominal channel for grouped lanes; `size` scales dot area. The cross axis is pure pixel-space with no scale, so tall stacks can overflow a short container -- cap the `size` range or give it vertical room.
- **`lollipop`** is a semantic alias for the dot/stem renderer (`x` quantitative, `y` category): a dot on a stem from the baseline. Negative values extend the stem left of the baseline.

**Collapsed mark types:** These mark aliases no longer exist as separate values. Use the canonical marks instead:

| Old mark | Use instead |
| --- | --- |
| `"column"` | `"bar"` (engine infers vertical from encoding) |
| `"pie"` | `"arc"` |
| `"donut"` | `{ type: "arc", innerRadius: 40 }` |
| `"scatter"` | `"point"` |
| `"dot"` | `"circle"` |

## Reference Routing

**Always load when generating a new chart spec:** [encoding-channels.md](references/encoding-channels.md), [format-strings.md](references/format-strings.md), [color-strategy.md](references/color-strategy.md)

| When the task involves... | Load |
| --- | --- |
| Dual-axis charts, independent y-scales | See Layer Composition section above -- no extra reference needed |
| Adding annotations, callouts, reference lines | [annotations.md](references/annotations.md) |
| onEdit callback, selection, inline editing | [editing.md](references/editing.md) |
| Responsive layout, mobile, breakpoint overrides | [responsive.md](references/responsive.md) |
| Theme customization (colors, fonts, spacing) | [theme.md](references/theme.md) |
| Data transforms (window, filter, aggregate, etc.) | [data-transforms.md](references/data-transforms.md) |
| Rendering setup (React, Vue, Svelte, vanilla) | [rendering.md](references/rendering.md) |
| Choosing chart type for a story | [chart-selection.md](references/chart-selection.md) |
| Writing titles, subtitles, annotation text | [editorial-writing.md](references/editorial-writing.md) |
| Font sizing, type hierarchy | [typography.md](references/typography.md) |
| Per-series visual overrides (dashed lines, opacity) | [series-styles.md](references/series-styles.md) |
| Gradient fills (linear, radial, per-mark) | [gradients.md](references/gradients.md) |
| Entrance animations, easing, stagger, reduced motion | [animation.md](references/animation.md) |
| Sankey diagram (flows between stages) | [sankey.md](references/sankey.md) |
| US state tile grid map | [tilemap.md](references/tilemap.md) |
| Geo map: choropleth, symbol/point layer, projections, TopoJSON joins | [map.md](references/map.md) |
| Scroll-driven chart story (scrollytelling) | [story.md](references/story.md) |
| Final design quality check | [design-review.md](references/design-review.md) |
| Checking rendered output for defects | [visual-qa.md](references/visual-qa.md) |

**Common reference bundles:**
- **New chart:** encoding-channels + format-strings + color-strategy + editorial-writing + (mark-specific ref)
- **Design polish:** design-review + visual-qa + editorial-writing
- **D3 infographic:** d3-core-patterns + infographic-design + (topic-specific D3 ref)

## Spec discriminant

- **Charts** use `mark` (16 marks: `"bar"`, `"line"`, `"area"`, `"point"`, `"circle"`, `"arc"`, `"text"`, `"rule"`, `"tick"`, `"rect"`, `"lollipop"`, `"beeswarm"`, `"range"`, `"waffle"`, `"calendar"`, `"parliament"`).
- **Tables, graphs, sankey, tilemap, geo maps** use `type` (`"table"` | `"graph"` | `"sankey"` | `"tilemap"` | `"map"`).

For the full top-level shape — every optional field, exact enum values, defaults — load `ChartSpec`, `TableSpec`, `GraphSpec`, `SankeySpec`, `TileMapSpec`, `GeoMapSpec` from `index.d.ts` (see "Source of truth" above).

**Behavior worth knowing that the types don't tell you:**

- `animation` is **off by default**. Set `true` for sensible per-mark entrance defaults; pass an `AnimationConfig` for per-phase control. See [animation.md](references/animation.md).
- `crosshair` is **off by default** and only renders on line/area charts.
- `endpointLabels` is **auto-on for ≥2-series line/area** and auto-suppresses the traditional legend in that case. See the "Endpoint Labels" section below for the full suppression truth table.
- `hiddenSeries` on the spec hides series on first render. The vanilla adapter also maintains a separate runtime hidden set populated by legend clicks; that triggers full engine recompile (y-axis rebalance, locked color scale, per-series UI hide). See "Legend Toggle (Runtime)" below.
- `display: 'sparkline'` strips chrome, axes, legend, watermark, animation, and crosshair for inline KPI-card use. Explicit per-field overrides still win (set `chrome.title` and you'll still get a title in sparkline mode).
- `seriesSearch` (`boolean | { placeholder }`) renders a typeahead "find your country" input over a categorical `color` encoding; selecting values highlights them (multi-select chips). Mutually exclusive with edit mode (search wins).
- `youDrawIt` (`{ from, prompt?, revealLabel?, comparisonLine? }`) is the NYT "draw your guess before the reveal" format. Line marks only, single-series only; mutually exclusive with edit mode and seriesSearch. The vanilla instance exposes `resetDrawing()` / `revealDrawing()` and an `onReveal(guess)` callback.
- `mark.fillPattern: 'auto'` layers a per-series SVG pattern (hatch, dots, crosshatch) over each fill so filled marks (bar/area/arc) stay distinguishable without color vision; `'none'` (default) is solid fills.
- Accessibility is automatic: openchart generates alt text, ARIA labels, and a hidden screen-reader data table. Override the alt text with top-level `description` (Vega-Lite sugar) or `a11y.description` (`a11y.description` wins); set `a11y.hidden: true` to `aria-hidden` a purely decorative chart. The engine also emits console warnings when adjacent series or text fall below WCAG contrast, naming the nearest passing color.

**Chrome elements** (`chrome.eyebrow` / `title` / `subtitle` / `source` / `byline` / `footer` / `brand`): each takes `string | ChromeText`. The `eyebrow` is a tracked, accent-tinted kicker above the title. The `brand` is a right-anchored block on the footer row paired with a small accent dot — setting it suppresses the default `tryOpenData.ai` watermark. All chrome text supports `\n` for explicit line breaks and auto-wraps at the container width. Exact field shape: see `Chrome` and `ChromeText` in `index.d.ts`.

**KPI metric row** (charts only): `metrics: Metric[]` at the top level (not inside `chrome`) renders a horizontal row of label+value cells between the subtitle and the chart area. Each cell can carry a delta and a secondary value. Auto-stripped in sparkline mode and at narrow/short containers, or when value text would overflow its cell. Exact field shape: see `Metric` in `index.d.ts`.

## Mark (Charts Only)

`mark` is either a string (one of the 16 marks: `"bar"`, `"line"`, `"area"`, `"point"`, `"circle"`, `"arc"`, `"text"`, `"rule"`, `"tick"`, `"rect"`, `"lollipop"`, `"beeswarm"`, `"range"`, `"waffle"`, `"calendar"`, `"parliament"`) or an object — see `MarkDef` in `index.d.ts` for the full field set (`type`, `point`, `interpolate`, `orient`, `innerRadius`, `startAngle`, `endAngle`, `fill`, `stroke`, `strokeWidth`, `opacity`, `fillPattern`, plus per-mark fields like `style`/`colorByDirection` for range, `units`/`columns` for waffle, `weekStart` for calendar, `shape`/`seatRadius`/`majorityLine` for parliament, etc.).

**Behavior the types don't tell you:**

- **Bar orientation is inferred from encoding types.** `x: nominal/ordinal/temporal + y: quantitative` = vertical column. `x: quantitative + y: nominal/ordinal` = horizontal bar. Override with `mark: { type: "bar", orient: "horizontal" | "vertical" }` only when the inference is wrong.
- **`mark: "arc"` is a pie by default.** Pass `innerRadius > 0` to get a donut.
- **`mark.fill` accepts a `GradientDef`** for linear or radial gradients. Gradients can also appear as conditional color values in `encoding.color`. See [gradients.md](references/gradients.md).
- **Default entrance animation depends on mark type** when `animation: true`. Bars clip-reveal from the baseline; lines draw progressively; areas draw + fade; arcs scale from center; points pop in with scale + fade; text/rules/ticks fade in. See [animation.md](references/animation.md) for per-mark defaults and per-phase overrides.

**Collapsed mark aliases (don't use these):**

| Old mark | Use instead |
| --- | --- |
| `"column"` | `"bar"` (engine infers vertical from encoding) |
| `"pie"` | `"arc"` |
| `"donut"` | `{ type: "arc", innerRadius: 40 }` |
| `"scatter"` | `"point"` |
| `"dot"` | `"circle"` |

**Examples:**
```json
{ "mark": { "type": "line", "point": true, "interpolate": "monotone" } }
{ "mark": { "type": "arc", "innerRadius": 40 } }
{ "mark": { "type": "bar", "fill": { "gradient": "linear", "stops": [{"offset": 0, "color": "#1b7fa3"}, {"offset": 1, "color": "#1b7fa3", "opacity": 0.4}] } } }
```

## Encoding Channels (Charts Only)

Charts map data to visuals via encoding channels: `x`, `y`, `color`, `size`, `detail`, `key`, `x2`, `y2`, `opacity`, `strokeDash`, `angle`, `text`, `tooltip`, `theta`, `facet`. Each channel is an `EncodingChannel` (`field`, `type`, `aggregate`, `axis`, `scale`, `bin`, `timeUnit`, `sort`, `format`, `title`, `stack`, `condition`, `value`). For the full shape and enum values: load `Encoding` and `EncodingChannel` from `index.d.ts`.

**Behavior the types don't tell you:**

- `field` + `type` is the minimum for any channel. `type` must be one of `"quantitative" | "temporal" | "nominal" | "ordinal"` — picking the wrong one is the most common spec authoring bug (see Spec Anti-Patterns below).
- `axis.format` is **d3-format with a literal-suffix extension** (e.g. `".1f%"`). The literal suffix is OpenChart's add-on; native d3 doesn't support it. See [format-strings.md](references/format-strings.md) and the Format Strings section below for the percent-form pitfall.
- `scale.nice: true` is the default for quantitative and temporal scales — it rounds the domain outward to clean tick values. On temporal scales this can shift the domain by years; set `nice: false` when you need precise control.
- `stack` defaults to stacked (`"zero"`) when colored for both bar and area; line = n/a. Set `stack: null` for grouped/overlap behavior. See the per-mark default table in [encoding-channels.md](references/encoding-channels.md).
- See [encoding-channels.md](references/encoding-channels.md) for conditional encoding examples and the full per-channel guide.

## Data Transforms (Charts Only)

Apply transforms (filter, bin, calculate, timeUnit, aggregate, fold, window) to data before encoding. Transforms run in array order. Filters support data-relative time references for temporal fields. See [data transforms reference](references/data-transforms.md).

## Layer Composition (Charts Only)

Overlay multiple marks in a single chart using `layer`. Each layer is a standalone spec with its own mark, data, and encoding.

```json
{
  "layer": [
    { "mark": "bar", "data": [...], "encoding": { ... } },
    { "mark": "line", "data": [...], "encoding": { ... } }
  ]
}
```

Layers share the same coordinate space. Use this for combo charts (bar + line), adding reference lines, or overlaying annotations.

### Dual-Axis Charts (Independent Y-Scales)

When two series have incompatible value ranges (e.g., revenue in millions vs. headcount in thousands), use `resolve: { scale: { y: "independent" } }` on the layer spec. Layer 0 gets the left y-axis; layer 1 gets the right y-axis. Both share the x-axis.

```json
{
  "resolve": { "scale": { "y": "independent" } },
  "layer": [
    {
      "mark": { "type": "bar", "opacity": 0.85 },
      "data": [
        { "year": "2022", "revenue": 8000000 },
        { "year": "2023", "revenue": -5000000 }
      ],
      "encoding": {
        "x": { "field": "year", "type": "ordinal" },
        "y": {
          "field": "revenue",
          "type": "quantitative",
          "axis": { "title": "Net Revenue ($)", "format": "~s", "labelColor": "#3E7CB1" }
        }
      },
      "labels": { "density": "none" }
    },
    {
      "mark": { "type": "line", "stroke": "#E07B39", "strokeWidth": 2.5, "point": true, "interpolate": "monotone" },
      "data": [
        { "year": "2022", "enrollment": 52800 },
        { "year": "2023", "enrollment": 51600 }
      ],
      "encoding": {
        "x": { "field": "year", "type": "ordinal" },
        "y": {
          "field": "enrollment",
          "type": "quantitative",
          "axis": { "title": "Enrollment", "format": "~s", "labelColor": "#E07B39" }
        }
      },
      "labels": { "density": "none" }
    }
  ]
}
```

**Dual-axis rules:**
- Max 2 layers -- there are only left and right y-axes.
- Both layers must have compatible x-field types (both `ordinal`, both `temporal`, etc.).
- Use `axis.labelColor` on each layer's y-encoding to color the axis labels to match the series -- this is the standard dual-axis pattern (Datawrapper, Highcharts style).
- Use `labels: { density: "none" }` on both layers to avoid label collisions between the two series.
- The engine zero-aligns both y-scales so zero sits at the same pixel height on both axes. Annotations target the primary (left) y-scale.
- Works with any combination: bar + line, bar + area, area + line.

## Legend Configuration (Charts Only)

`legend` accepts `position`, `show`, `columns`, `symbolLimit`, `maxRows`, `offset`, `exclude`. Full shape: `LegendConfig` in `index.d.ts`.

**Behavior:** Position is responsive by default — the engine picks `top` / `right` / `bottom` / `bottom-right` / `inline` based on container width. Set `position` to override. Use `show: false` when the legend is redundant (e.g. bar charts where the y-axis already labels each category). For multi-series line/area, leaving `show` unset triggers auto-suppression in favor of the endpoint chip column — see the truth table below.

## Endpoint Labels (Multi-Series Line/Area)

For multi-series line/area charts, the engine renders a column of **chip+swatch labels** at the trailing edge of each series (rounded pill with a colored bar swatch + label + last value). Auto-on for ≥2 series; off for single-series and non-line/area.

`endpointLabels` accepts `boolean | EndpointLabelsConfig` (`show`, `valueField`, `format`, `width`, `showMarker`, `showLeader`, `markerStyle`). Full shape: `EndpointLabelsConfig` in `index.d.ts`.

**Suppression truth table** (≥2-series line/area). The traditional legend, the endpoint column, and the legacy end-of-line labels are three knobs that interact. The implementation lives in `packages/engine/src/legend/suppression.ts` and is the single source of truth — if this table ever drifts, the engine wins.

| `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels |
|---|---|---|---|---|
| unset | unset | hidden (auto-suppressed) | shown (default) | hidden |
| `true` | unset | shown | shown | hidden |
| unset | `false` | shown (auto-suppress revoked) | hidden | hidden |
| `false` | `false` | hidden | hidden | shown (last-resort) |
| `true` | `false` | shown | hidden | hidden |
| `false` | `true` | hidden | shown | hidden |
| `true` | `true` | shown | shown | hidden |

Single-series charts: column hidden by default (nothing to identify).

**Common patterns:**
- Default multi-series: leave both unset -- you get the endpoint column, no traditional legend.
- "I want a top legend instead": `legend: { position: 'top' }, endpointLabels: false`.
- "Both legend and endpoint column": `legend: { show: true }` (endpointLabels stays auto-on).

## Legend Toggle (Runtime)

Clicking a legend entry hides/shows the corresponding series at runtime. This goes through engine recompile (not CSS hide), so:

- The y-axis **rebalances** to the remaining visible series.
- The color scale **stays locked** -- remaining lines keep their original palette colors (engine injects a stable `scale.domain` from the unfiltered data).
- **Per-series UI hides** with the line: endpoint chip, leader, dot annotation, and any text annotation anchored to that series.
- The **last visible series can't be hidden** (the toggle is a no-op).
- Range annotations and reference lines pass through unchanged (they anchor to constant axis values, not series).

Pass `onLegendToggle` to observe these clicks; you don't need to wire up `hiddenSeries` yourself for default behavior. Use `hiddenSeries` on the spec to start with specific series hidden on first render.

## Label Density (Charts Only)

`labels` accepts `density`, `format`, `prefix` (full shape: `LabelSpec` in `index.d.ts`). The choice that actually drives the chart is `density`:

| `density` | Behavior | Use when |
| --- | --- | --- |
| `"auto"` (default) | Show labels with collision detection | Most charts |
| `"all"` | Show every label, no collision detection | Few data points, precise values matter |
| `"endpoints"` | First and last per series only (legacy end-of-line labels on line/area) | Single-series line emphasizing start/end |
| `"none"` | No labels (tooltips + legend only) | Dense data, clean look |

For **multi-series line/area**, prefer `endpointLabels` (the chip+swatch column) over `density: "endpoints"` (the legacy fallback). The chip column wraps long names and resolves collisions; the legacy labels reserve a large right margin for long series names.

## Format Strings

Both `axis.format` and `labels.format` accept [d3-format](https://d3js.org/d3-format) strings plus a literal suffix extension. See [format strings reference](references/format-strings.md) for the full table.

| Format | Output example | Use case |
| --- | --- | --- |
| `".1f%"` | `12.5%` | Percentage (data already in %, not 0-1) |
| `"$,.0f"` | `$1,234` | Currency with commas |
| `"~s"` | `10k`, `1.5M` | SI suffix for large numbers |
| `",.0f"` | `132,979` | Comma-separated, no decimals |
| `".1%"` | `12.5%` (from 0.125) | d3 native percent (multiplies by 100) |

**Critical:** When data is already in percentage form (12.5 meaning 12.5%), use `".1f%"` not `".1%"`. The d3 `%` type multiplies by 100, so `12.5` becomes `1,250.0%`.

## Per-Series Styling (Charts Only)

Use `seriesStyles: Record<string, SeriesStyle>` to override visuals for individual series, keyed by the color-field value. Use this for "highlight one series, dim the rest" patterns or for a dashed reference series alongside primary data. Field shape: `SeriesStyle` in `index.d.ts`. Editorial guidance + examples: [series-styles.md](references/series-styles.md).

## Data Resolution

**Keep data arrays under 150 rows per series.** More data doesn't make a better chart - it makes a slower, harder-to-read one. Reduce resolution before building the spec, not after.

| Time span | Resolution | Typical rows | Example |
| --- | --- | --- | --- |
| < 1 year | Daily or weekly | 50-200 | Stock price last 6 months |
| 1-5 years | Monthly | 12-60 | Unemployment rate 2020-2025 |
| 5-25 years | Quarterly or annual | 20-100 | GDP since 2000 |
| 25-100+ years | Annual or decade | 25-100 | CO2 emissions since 1900 |

**How to reduce:** When querying APIs, aggregate before passing data to the spec:
- Use `group_by=year` with `aggregate=avg(value)` to go from monthly to annual
- Sample every Nth row for evenly-spaced data
- Filter to the time range that matters (don't chart 75 years when the story is about the last 10)

**Multi-series charts are multiplicative.** A 3-series line chart with 300 points per series = 900 data rows. Reduce each series to ~50-80 points for a clean result. For the same 25-year span, annual data (25 points x 3 series = 75 rows) reads better than monthly (300 x 3 = 900 rows).

**Why this matters beyond readability:** Large data arrays inflate the spec JSON, slow rendering, and generate massive accessibility tables in the DOM. A 900-row chart produces a ~19,000px tall screen-reader table that can break page layout if styles don't load correctly.

## First Draft Checklist

Run these checks before outputting a spec. These catch the issues that most often require iteration after rendering.

| Check | What to verify |
| --- | --- |
| **Data resolution is appropriate** | Check total rows in the data array. Over 150 per series? Aggregate to a coarser time grain or sample. A 25-year time series should use annual or quarterly data, not monthly. See Data Resolution table. |
| **Color encodes the story** | If one variable drives the narrative, color should reinforce it. Use the decision table in [color-strategy.md](references/color-strategy.md) to pick the right strategy and `theme.colors` array. Don't leave a scatter plot monochrome when a gradient would make the pattern obvious. |
| **Bar stacking is intentional** | If using `color` encoding on a bar chart, verify stacking mode. Default is stacked (`stack: "zero"`), which adds values together visually. For side-by-side comparison bars (e.g., 2018 vs 2022), set `stack: null` on the quantitative encoding. Stacked bars sum values visually, so a comparison chart will show bars extending to the sum of both values. |
| **Area stacking is intentional** | Area charts default to **stacked** (`stack: "zero"`) when colored, same as bars. For side-by-side/overlap comparison, set `stack: null` on the y-channel (or `"normalize"` for percentage stacking, `"center"` for streamgraph). |
| **Y-domain fits the data** | Domain ceiling should be ~5-10% above the highest data value. `[0, 55]` for data peaking at 48.8 wastes space. Use `[0, 52]`. **For bar/column charts with narrow data ranges** (e.g., values between 200 and 280), don't default the floor to 0 - it makes variations invisible. Set the domain floor near the minimum value. Exception: charts where zero is a meaningful baseline (percent change from 0, counts). |
| **Annotations clear of data AND each other** | The engine auto-resolves annotation-to-annotation collisions, but start with good separation for cleaner results. Prefer 0-2 text annotations; use reflines for additional callouts. On scatter/bubble, use 40-100px offsets into empty quadrants with connectors. When using 2+ text annotations, verify with `playwright-cli`. |
| **Subtitle is intentional about wrapping** | Unintentional wrapping with orphaned fragments looks broken. Abbreviate, restructure, or use `\n` for explicit line breaks. Use shorthand keys in the subtitle (e.g., `"LI = low-income"`) rather than spelling everything out. |
| **Endpoint labels won't eat the chart** | The default chip+swatch column (`endpointLabels`) wraps long names at `width` (default 96px) and resolves collisions, so it handles long series names well. Only the legacy fallback (`labels: { density: "endpoints" }` with no `endpointLabels` and no top legend) reserves a huge right margin for long names — if you've forced that path, either abbreviate series names or switch to `legend: { position: "top" }, endpointLabels: false`. |
| **Axis ticks show units** | Percentages should show `10%` not `10`. Use `format: ".0f%"` when data is already in percent form (e.g., 10 meaning 10%). Use `format: ".0%"` only when data is in decimal form (0.10 meaning 10%). Large numbers should use SI suffixes: `format: "~s"` turns 10000 into `10k` and 1000000 into `1M`. For currency: `format: "$~s"` gives `$10k`, `$1M`. See the Format Strings table above. |
| **Consistent color palette across related charts** | If multiple charts in the same article cover the same dimension (e.g., poverty), use the same color mapping (blue = low, red = high) so the reader builds a mental model. |

## Spec Anti-Patterns

| Mistake | Fix |
| --- | --- |
| Using `nominal` for numeric field | Use `quantitative` for numbers, `temporal` for dates |
| Using `ordinal` for temporal data | Use `temporal`; ordinal is for ordered categories |
| Too many data points (>150 per series) | Aggregate or sample before building the spec. See Data Resolution table above. Monthly data over 25 years = 300 rows per series, use annual instead |
| Forgetting encoding.color for multi-series | Line/bar with groups needs `color` channel |
| Bar chart for time series | Use line for temporal data; bar with vertical orientation for periodic categories |
| Using chart mark for network data | Use `type: "graph"` with nodes + edges |
| Using chart mark for US state data | Use `type: "tilemap"` with state code keys |
| Hand-rolling D3 for choropleths or symbol maps | Use `type: "map"` with TopoJSON from us-atlas/world-atlas. See [map.md](references/map.md) |
| Not specifying axis format for currency/pct | Add `axis: { format: "$,.0f" }` or `".1f%"` |
| Using `".1%"` when data is already in percent form | `".1%"` multiplies by 100 (d3 convention). If data is `12.5` meaning 12.5%, use `".1f%"` (literal suffix) |
| Axis format and label format inconsistent | Set both `axis.format` and `labels.format` to the same pattern so ticks and data labels match |
| Using `darkMode: "auto"` in class-based dark mode apps | `"auto"` checks `prefers-color-scheme` only. For class-based toggles (Astro, Next.js), observe DOM and map to `"force"`/`"off"`. See [rendering](references/rendering.md) |
| Temporal scale with `nice: true` (default) creating dead space | `nice` rounds the domain outward (e.g., `2010-01` becomes `2008`). Set `scale: { domain: ["2010-01", "2026-01"], nice: false }` for precise control |
| Using `type` instead of `mark` for charts | Charts use `mark: "line"` (not `type: "line"`). Only tables, graphs, sankey, tilemap, and geo maps use `type`. |

For design anti-patterns (titles, color, annotations), see [design review](references/design-review.md).

## Known Gotchas

Rendering and component behaviors that aren't obvious from the spec alone.

| Gotcha | Behavior | Fix |
| --- | --- | --- |
| Refline labels only support top/bottom | `labelAnchor` on refline annotations only accepts `"top"` or `"bottom"`. Left/right values are accepted in the type but have no visible effect on reflines (they do work on range annotations). | Set `label: ""` on the refline and add a separate `type: "text"` annotation positioned where you want a side label. |
| Endpoint chip labels wrap by width, not `\n` | The chip+swatch column wraps long series names at `endpointLabels.width` (default 96px). `\n` in the series name does not create a hard break. | Either shorten the series name in the data, or raise `width` if you have horizontal room. |
| Area defaults to stacked, not overlap | Multi-series area charts stack from a zero baseline by default when colored, same as bars. Setting `color` without `stack: null` on the y-channel produces a stacked composition, not overlapping semi-transparent fills. | For overlap/side-by-side, opt out with `encoding: { y: { ..., stack: null } }`. |
| `connector: 'drop-line'` only flips against the chart edge | The drop-line connector renders a vertical line through the data point's x and lays the label beside it. The auto-flip only checks against the chart area edge -- it does not avoid neighboring marks or other annotations. | Place the annotation away from cluttered regions; if collisions persist, switch to `connector: 'curve'` with a manual `offset`. |
| DataTable CSS overrides unreliable | Custom CSS targeting `.oc-table-wrapper td` may not apply due to CSS specificity. | Use the DataTable `style` prop for inline overrides: `<DataTable style={{ paddingLeft: 10 }} spec={...} />`. |
| Scatter plots auto-set `zero: false` | Unlike other chart types, scatter/point marks automatically set `scale.zero: false` on both axes if not explicitly configured. This means scatter domains fit tightly to data. | To include zero, explicitly set `scale: { zero: true }` on the relevant axis. Be aware that scatter and bar/line charts handle zero differently by default. |
| Constant colors require `mark.fill`, not encoding | `encoding.color: { value: "#hex" }` will error. The color encoding channel requires a `field` that maps to data. | Use `mark: { type: "bar", fill: "#1b7fa3" }` for constant colors across all marks. |
| Default gradient direction is top-to-bottom | A gradient with no explicit `x1/y1/x2/y2` defaults to vertical (top-to-bottom). On horizontal bars, the engine auto-orients this to left-to-right. On other marks, set coordinates explicitly. | For left-to-right: `x1:0, y1:0, x2:1, y2:0`. For top-to-bottom (default): omit coordinates or use `x1:0, y1:0, x2:0, y2:1`. |
| Layer scale mismatch | Second layer renders at wrong positions when layers have different value ranges. | For independent y-scales (e.g., revenue + enrollment), use `resolve: { scale: { y: "independent" } }` on the layer spec -- this renders both series correctly with left and right y-axes. For simple overlays where both series share the same scale, set an explicit `scale.domain` on both layers. Prefer `annotations` with `refline` over a full layer when you just need to add a threshold line. |

## Custom D3.js Infographics

When a visualization goes beyond what declarative specs can handle (creative metaphors, unusual layouts, treemaps, generative art), fall back to raw D3.js + SVG. Note: sankey, tilemap, and geo maps (choropleth + symbol) are first-class types with their own spec formats (see [references/sankey.md](references/sankey.md), [references/tilemap.md](references/tilemap.md), and [references/map.md](references/map.md)), and **scrollytelling is now first-class too** -- use the chart story API (base spec + patch steps) instead of hand-rolling D3 scroll effects (see [references/story.md](references/story.md)). Use the D3 reference only for heavily customized layouts. These references cover D3 implementation patterns:

| Topic | Reference |
| --- | --- |
| D3 selections, scales, axes, margin convention | [references/d3/d3-core-patterns.md](references/d3/d3-core-patterns.md) |
| Tufte principles, storytelling, publication design | [references/d3/infographic-design.md](references/d3/infographic-design.md) |
| Path morphing, generative art, creative coding | [references/d3/advanced-techniques.md](references/d3/advanced-techniques.md) |
| D3 transitions, scroll effects, easing, timing | [references/d3/animation-transitions.md](references/d3/animation-transitions.md) |
| Treemap, sunburst, sankey diagrams | [references/d3/chart-hierarchy.md](references/d3/chart-hierarchy.md) |
| D3 color APIs, programmatic palette generation, contrast checking | [references/d3/color-palettes.md](references/d3/color-palettes.md) |
| SVG text wrapping, collision detection, leader lines, ARIA | [references/d3/typography-labels.md](references/d3/typography-labels.md) |
| viewBox, ResizeObserver, responsive SVG patterns | [references/d3/responsive-svg.md](references/d3/responsive-svg.md) |

<!-- chapter:end slug=openchart -->

---

<!-- chapter:begin slug=opendata-api position=2 -->

## 2. opendata-api

- **Source:** https://github.com/tryopendata/skills/blob/main/plugins/opendata/skills/opendata-api/SKILL.md
- **Raw:** https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/SKILL.md
- **Markdown:** https://skillsdocs.com/tryopendata/skills/opendata-api.md
- **Licence:** MIT — https://spdx.org/licenses/MIT.html

Bundled files (10), referenced from this skill's directory:
  - `references/aggregation.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/aggregation.md
  - `references/column-introspection.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/column-introspection.md
  - `references/common-patterns.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/common-patterns.md
  - `references/composition.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/composition.md
  - `references/discover.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/discover.md
  - `references/filtering.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/filtering.md
  - `references/graph.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/graph.md
  - `references/output-formats.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/output-formats.md
  - `references/pagination-and-sort.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/pagination-and-sort.md
  - `references/sql-query.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendata/skills/opendata-api/references/sql-query.md

<!-- Verbatim upstream SKILL.md follows, YAML frontmatter included. -->

---
name: opendata-api
description: Query the OpenData API for data research and analysis. Use when fetching dataset rows, filtering, sorting, aggregating, inspecting columns, composing cross-dataset joins, exploring graph intelligence, or building data pipelines against OpenData endpoints.
---

# OpenData Query API

Query datasets stored as Parquet files through a REST API backed by DuckDB. The API returns JSON by default, with support for CSV, TSV, and XLSX exports.

**Base URL:** `https://api.tryopendata.ai` (production) or `http://localhost:8000` (local dev). Default to use production

## Authentication

All endpoints require authentication in production. Before making API calls, resolve a Bearer token using this sequence:

1. **Check env var**: If `OPENDATA_API_KEY` is set, use it.
2. **Check auth.json**: Read `~/.config/opendata/auth.json`. If it exists, extract the token:
   - `method: "api_key"` -> use the `api_key` field
   - `method: "clerk"` -> use the `access_token` field (check `expires_at` hasn't passed)
   - Legacy format (no `method` field, just `api_key`) -> use the `api_key` field
3. **Prompt to authenticate**: If neither source has a token:
   - Check if the `opendata` CLI is installed (`which opendata`)
   - If installed: run `opendata auth login` and let the user authenticate
   - If not installed: tell the user to install it (`brew install tryopendata/opendata/opendata` or `curl -fsSL https://raw.githubusercontent.com/tryopendata/opendata/main/scripts/install-cli.sh | bash`), then run `opendata auth login`
   - As a fallback, the user can set `OPENDATA_API_KEY` manually with a key from https://tryopendata.ai/settings/api-keys

Once resolved, pass the token via `Authorization: Bearer` header:

```bash
curl -H "Authorization: Bearer $TOKEN" \
  "https://api.tryopendata.ai/v1/datasets/fred/cpi?limit=5"
```

If you get a 401 during a session, re-run the resolution sequence (the token may have expired).

Local dev (`localhost:8000`) does not require auth when running the standalone opendata server (`make quickstart`). The backend server (`make dev-all`) requires auth for write endpoints but allows unauthenticated reads.

## Quick Start

**For analysis (aggregations, joins, window functions), use SQL:**

```bash
# Average CPI by year, most recent first
curl -X POST "https://api.tryopendata.ai/v1/datasets/fred/cpi/query" \
  -H "Authorization: Bearer ${OPENDATA_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT EXTRACT(YEAR FROM date) as year, AVG(value) as avg_cpi FROM data GROUP BY 1 ORDER BY 1 DESC LIMIT 10"}'
```

**Parameterized queries (avoids escaping issues):**

```bash
curl -X POST "https://api.tryopendata.ai/v1/datasets/owid/gdp/query" \
  -H "Authorization: Bearer ${OPENDATA_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"sql": "SELECT * FROM data WHERE country_name = ? AND year >= ? ORDER BY year", "params": ["United States", 2020]}'
```

**For simple row fetches (no aggregation), use the REST endpoint:**

```bash
# Get the 5 most recent CPI values
curl -H "Authorization: Bearer ${OPENDATA_API_KEY}" \
  "https://api.tryopendata.ai/v1/datasets/fred/cpi?limit=5&sort=-date"
```

**Do NOT append `/query` to GET requests.** `GET /v1/datasets/fred/cpi/query` will fail with a `SUBDATASET_NOT_FOUND` error because the API interprets `query` as a subdataset name. The `POST /query` endpoint is a separate SQL interface (see [sql-query.md](references/sql-query.md)).

All data endpoints live under `/v1/datasets/`.

## MCP Tools (Preferred When Available)

If you have access to OpenData MCP tools (`search_datasets`, `query_dataset`, `query_sql`), prefer them over direct API calls. The MCP tools handle auth, pagination, and response formatting automatically. **Use `query_sql` for analytical queries** (aggregations, joins, window functions) and `query_dataset` for simple row fetches. Fall back to the REST API below only when:
- MCP tools are not connected
- You need endpoints the MCP doesn't cover (graph intelligence, composition, activity feeds)
- You need raw HTTP control (custom headers, streaming, specific formats)

## Endpoints

### Data & Schema

| Method | Path                                               | Description                                                  |
| ------ | -------------------------------------------------- | ------------------------------------------------------------ |
| GET    | `/v1/datasets/{provider}/{dataset}`                | Query dataset rows (flat) or list subdatasets (hierarchical) |
| GET    | `/v1/datasets/{provider}/{dataset}/{subdataset}`   | Query subdataset rows                                        |
| GET    | `/v1/datasets/{provider}/{dataset}/columns`        | Column metadata and statistics                               |
| GET    | `/v1/datasets/{provider}/{dataset}/columns/{name}` | Single column detail with full value list                    |
| GET    | `/v1/datasets/{provider}/{dataset}/meta`           | Dataset metadata (schema, views, graph scores, merged enrichment) |
| GET    | `/v1/datasets/{provider}/{dataset}/views`          | List available views                                         |
| POST   | `/v1/datasets/{provider}/{dataset}/query`          | Execute SQL query (authenticated)                            |
| POST   | `/v1/query`                                        | Cross-dataset SQL query (join multiple datasets)             |

### Enrichment & Intelligence

| Method | Path                                               | Description                                                  |
| ------ | -------------------------------------------------- | ------------------------------------------------------------ |
| GET    | `/v1/datasets/{provider}/{dataset}/meta/enriched`  | AI-enriched metadata (descriptions, tags, methodology, coverage) |
| GET    | `/v1/datasets/{provider}/{dataset}/meta/view-suggestions` | AI-suggested views (timeseries, lookup, wide_to_long, pivot) |
| POST   | `/v1/datasets/{provider}/{dataset}/meta/view-suggestions/{id}/apply` | Apply a view suggestion (admin) |
| GET    | `/v1/datasets/{provider}/{dataset}/chart`           | Dataset chart data with auto-downsampling                    |
| GET    | `/v1/datasets/{provider}/{dataset}/activity`       | Recent activity events (ingestion, enrichment, schema changes) |
| GET    | `/v1/datasets/{provider}/{dataset}/related`        | Related datasets (semantic + join + graph signals)           |

### Composition (Cross-Dataset Joins)

| Method | Path                                               | Description                                                  |
| ------ | -------------------------------------------------- | ------------------------------------------------------------ |
| GET    | `/v1/datasets/{provider}/{dataset}/joinable`       | List joinable datasets for composition                       |
| POST   | `/v1/datasets/{provider}/{dataset}/compose/preview`| Preview a cross-dataset join (LEFT JOIN)                     |
| GET    | `/v1/datasets/{provider}/{dataset}/compose/download.csv` | Download a composed join as CSV (auth required)        |

### Search & Discovery

| Method | Path                                               | Description                                                  |
| ------ | -------------------------------------------------- | ------------------------------------------------------------ |
| GET    | `/v1/search`                                       | Search datasets (keyword/semantic/hybrid, graph-boosted)     |
| GET    | `/v1/search/suggest`                               | Autocomplete suggestions for search typeahead                |
| GET    | `/v1/discover`                                     | Search datasets with enriched metadata for LLM agents        |
| POST   | `/v1/discover/batch`                               | Batch discover across multiple queries with deduplication    |
| GET    | `/v1/categories/{slug}`                            | Browse datasets by category (supports graph sorting)         |

### Graph Intelligence

| Method | Path                                               | Description                                                  |
| ------ | -------------------------------------------------- | ------------------------------------------------------------ |
| GET    | `/v1/graph/datasets/{provider}/{dataset}/stats`    | Graph statistics for a dataset (importance, bridge, community) |
| GET    | `/v1/graph/datasets/{provider}/{dataset}/join-paths` | Multi-hop join paths from a dataset                        |
| GET    | `/v1/graph/datasets/{provider}/{dataset}/related`  | Graph-powered related datasets (structural + semantic)       |
| GET    | `/v1/graph/datasets/{provider}/{dataset}/neighbors`| Direct 1-hop connections (filterable by edge type)           |
| GET    | `/v1/graph/datasets/{provider}/{dataset}/schema-graph` | Schema-level subgraph for D3 visualization               |
| GET    | `/v1/graph/communities`                            | List communities with top datasets and dominant topics       |
| GET    | `/v1/graph/communities/{community_id}/datasets`    | List datasets in a community by importance                   |
| GET    | `/v1/graph/bridges`                                | Top bridge datasets by betweenness centrality                |
| GET    | `/v1/graph/subgraph`                               | Seeded subgraph for graph explorer                           |
| GET    | `/v1/graph/entities/{type}/{id}/datasets`          | Datasets referencing a specific entity                       |
| GET    | `/v1/graph/health`                                 | Graph health and sync status                                 |

## Subdatasets

Some datasets contain multiple tables (e.g., multi-sheet Excel workbooks, BLS series groups). For these:

- `GET /v1/datasets/{provider}/{dataset}` returns data for the default subdataset, or lists available subdatasets
- `GET /v1/datasets/{provider}/{dataset}/{subdataset}` queries a specific subdataset

If you get a `SUBDATASET_NOT_FOUND` error, the dataset likely has subdatasets. Check the error response's `suggestions` field - it includes a link to browse available subdatasets. Any unrecognized path segment after the dataset slug is interpreted as a subdataset name, which is why paths like `/query` or `/search` appended to a dataset path produce this error.

## Query Parameters

| Parameter         | Example                  | Description                                | Reference                                                   |
| ----------------- | ------------------------ | ------------------------------------------ | ----------------------------------------------------------- |
| `filter[col]`     | `filter[year]=2024`      | Filter rows by column value                | [filtering.md](references/filtering.md)                     |
| `filter[col][op]` | `filter[year][gte]=2020` | Filter with operator                       | [filtering.md](references/filtering.md)                     |
| `sort`            | `sort=-year`             | Sort by column (prefix `-` for desc)       | [pagination-and-sort.md](references/pagination-and-sort.md) |
| `limit`           | `limit=50`               | Max rows to return (1-1000, default 100)   | [pagination-and-sort.md](references/pagination-and-sort.md) |
| `offset`          | `offset=100`             | Skip N rows                                | [pagination-and-sort.md](references/pagination-and-sort.md) |
| `cursor`          | `cursor=...`             | Keyset pagination token                    | [pagination-and-sort.md](references/pagination-and-sort.md) |
| `fields`          | `fields=year,score`      | Column projection                          | [output-formats.md](references/output-formats.md)           |
| `format`          | `format=csv`             | Output format (json, csv, tsv, xlsx)       | [output-formats.md](references/output-formats.md)           |
| `aggregate`       | `aggregate=avg(score)`   | Aggregate functions                        | [aggregation.md](references/aggregation.md)                 |
| `group_by`        | `group_by=year`          | Group rows by column                       | [aggregation.md](references/aggregation.md)                 |
| `view`            | `view=enriched`          | Apply a named view (for SQL, prefer colon syntax: `FROM "bls/cpi-u:enriched"`) | [sql-query.md](references/sql-query.md) |
| `expand`          | `expand=area`            | Expand joined dimensions inline            |                                                             |
| `include_sources` | `include_sources=true`   | Show `_source_url`, `_source_page` columns |                                                             |
| `response_format` | `response_format=columnar` | Response shape: `objects` (default) or `columnar` (compact) | [output-formats.md](references/output-formats.md)           |
| `include_graph`   | `include_graph=true`     | Attach graph scores to `/meta` response    | [graph.md](references/graph.md)                             |
| `debug`           | `debug=true`             | Include generated SQL and query echo       |                                                             |

## Common Pitfalls

**Use `filter[col]=val`, not `?col=val`.** Bare column names as query params are silently ignored. The API returns a structured warning, but you still get unfiltered data back.

```bash
# Wrong - returns ALL rows, with a warning
curl '.../nces/naep?year=2024'

# Right
curl '.../nces/naep?filter[year]=2024'
```

**URL-encode brackets in curl.** Some shells interpret `[` and `]`. Use `%5B` / `%5D` or quote the URL.

```bash
curl 'https://api.tryopendata.ai/v1/datasets/nces/naep?filter%5Byear%5D=2024'
```

**Check `warnings` in the response.** Unknown parameters produce structured `QueryWarning` objects with `code`, `message`, and `param`. The `X-OpenData-Warnings` HTTP header also carries these for piped workflows.

**Use `?debug=true` to see generated SQL.** Returns a `debug` object with `debug.query` (echo of your parameters) and `debug.sql` (the DuckDB SQL that ran). Useful for verifying filters and sorts are applied correctly.

**`aggregate` and `nest_fields` are mutually exclusive.** You get a 400 error if you combine them. Aggregation produces flat summary rows; nesting produces grouped hierarchical data.

**If a SQL query returns an error**, check the error response body for details. Common causes: invalid column names (verify with `GET .../columns`), syntax issues, or timeout on very large datasets. For simple aggregations that don't need SQL features (window functions, CTEs, joins), the REST `aggregate` + `group_by` params are an alternative.

**Sorting on computed aggregation columns works.** When using `aggregate` + `group_by`, you can sort on the computed column names (e.g., `sort=-count_event_id` for `aggregate=count(event_id)`). Invalid sort fields return a 400 with `valid_values` showing available options.

**Always use `api.tryopendata.ai` for POST endpoints.** The frontend at `tryopendata.ai/api/` proxies GET requests only. POST requests to `tryopendata.ai/api/v1/query` return 405. Use `api.tryopendata.ai/v1/query` directly for SQL and cross-dataset queries.

**Set a User-Agent header in API requests.** Some CDN/WAF configurations may block requests with missing or generic user agents. Include a descriptive identifier:

```bash
curl -H "User-Agent: claude-code/opendata-skill" \
  -H "Authorization: Bearer ${OPENDATA_API_KEY}" \
  "https://api.tryopendata.ai/v1/datasets/fred/cpi?limit=5"
```

## SQL Query

The `POST /v1/datasets/{provider}/{dataset}/query` endpoint accepts raw SQL and executes it against the dataset. Requires authentication (API key or session). The dataset table is available as `data` or `"provider/dataset"`. SQL is validated against an allowlist (SELECT only, no DDL/DML/IO) and runs with resource limits (5s timeout, 10k rows, 512MB memory).

**Parameterized queries:** Use `?` placeholders with a `params` array to avoid string quoting issues:

```json
{
  "sql": "SELECT * FROM data WHERE country IN (?, ?) AND year >= ?",
  "params": ["United States", "Japan", 2020]
}
```

This eliminates the triple-nested escaping problem (SQL quotes inside JSON inside shell). See [sql-query.md](references/sql-query.md) for details.

## Composition (Cross-Dataset Joins)

The compose endpoints let you join two datasets and preview or download the result without writing SQL. Useful for enriching a dataset with columns from a related one (e.g., joining county-level education data with census demographics).

**Workflow:** Call `/joinable` to discover what can be joined, `/compose/preview` to check the result, then `/compose/download.csv` to export. See [composition.md](references/composition.md) for full details.

**Composite keys:** `source_column` and `join_column` accept arrays for multi-column joins. Both arrays must have the same length.

```bash
# 1. What can this dataset join with?
curl 'https://api.tryopendata.ai/v1/datasets/nces/naep/joinable'

# 2. Preview the join (anonymous: 100 rows, authenticated: 5000 rows)
curl -X POST 'https://api.tryopendata.ai/v1/datasets/nces/naep/compose/preview' \
  -H 'Content-Type: application/json' \
  -d '{"joins": [{"target": "census/saipe", "source_column": "jurisdiction_name", "join_column": "name"}]}'

# 2b. Composite key join (match on multiple columns)
curl -X POST 'https://api.tryopendata.ai/v1/datasets/nces/naep/compose/preview' \
  -H 'Content-Type: application/json' \
  -d '{"joins": [{"target": "census/saipe", "source_column": ["state", "year"], "join_column": ["name", "year"]}]}'

# 3. Download the full join as CSV (auth required)
curl -H "Authorization: Bearer ${OPENDATA_API_KEY}" \
  'https://api.tryopendata.ai/v1/datasets/nces/naep/compose/download.csv?target=census/saipe&source_column=jurisdiction_name&join_column=name' \
  -o composed.csv
```

## Search

The `GET /v1/search` endpoint supports three modes:

- **`keyword`**: Traditional full-text search with tsvector matching. Supports Google-style query syntax: quotes for phrases, `-` to exclude, `OR` for alternatives.
- **`semantic`**: Embedding-based similarity search for conceptual matching (e.g., "inflation data" finds CPI datasets).
- **`hybrid`** (default): Combines both using Reciprocal Rank Fusion (RRF). Best for most queries.

**Sort options:** `relevance` (default), `recency`, `name`, `popularity` (stars), `trending` (time-decayed activity), `queries`, `downloads`.

**Filters:** `provider`, `format`, `category`, `status` (defaults to "ready").

**Time ranges (for trending/queries/downloads sort):** `today`, `week`, `month`, `year`, `all_time`.

**Autocomplete:** `GET /v1/search/suggest?q=con` returns dataset names matching the prefix for typeahead.

All search results include graph intelligence fields (`importance`, `bridge_score`, `community_id`, `community_label`, `graph_available`). Graph scores contribute to search ranking via a multiplicative boost.

**View results:** Search may return dataset views alongside regular datasets. View results have `result_type: "view"`, a `view_name` field, and a `parent_ref` linking to the parent dataset. Query views using colon syntax: `FROM "provider/dataset:view_name"`.

## Enriched Metadata

The `GET /v1/datasets/{provider}/{dataset}/meta/enriched` endpoint returns AI-enriched metadata including:
- Provider and dataset-level descriptions (short, long, layman, technical)
- Subject tags, geographic/temporal granularity
- Column metadata (display names, descriptions, aliases, semantic types)
- Methodology (structured bullets or summary text)
- Known limitations
- Canonical questions
- Shape classification and KPI snapshot
- Metadata coverage score (8 quality checks across 3 tiers)
- YAML-declared joins with measured coverage percentages

## Chart Data

The `GET /v1/datasets/{provider}/{dataset}/chart` endpoint returns pre-aggregated chart data optimized for each dataset shape:

| Shape | Response key | Payload |
|-------|-------------|---------|
| `timeseries` | `series` | `{date, value}[]` with auto-downsampling when >500 points |
| `panel` | `panel` | Top-5 entities, each with `{date, value}[]` series |
| `categorical` | `buckets` | Top-20 `{label, count}[]` |
| `geo` | `regions` | `{region: value}` map using latest time period |

**Downsampling (timeseries only):** When raw data exceeds 500 points, the endpoint auto-buckets via `date_trunc` at the finest granularity that fits (week/month/quarter/year). Response includes `downsampled: true`, `granularity`, `aggregation` ("avg" or "count"), and `raw_count`. Returns 404 for tabular/text shapes.

## Activity Feed

The `GET /v1/datasets/{provider}/{dataset}/activity` endpoint returns recent system events (enrichment, ingestion, schema changes) in reverse chronological order. Accepts `?limit=` (1-50, default 20).

## Graph Intelligence

Datasets are connected in a knowledge graph (Neo4j). Graph algorithms (PageRank, betweenness centrality, Leiden community detection) produce scores that surface in search rankings, dataset metadata, and related datasets.

**On dataset metadata:** Pass `?include_graph=true` to `/meta` to get a `graph` block with importance, bridge_score, and community info.

**Dataset-specific graph endpoints** live under `/v1/graph/datasets/{provider}/{dataset}/`:
- `stats` - Graph-computed statistics (importance, bridge score, community, connection count)
- `join-paths` - Multi-hop join paths with configurable `max_hops` (1-3), `min_confidence`, and `limit`
- `related` - Blended structural + semantic related datasets
- `neighbors` - Direct 1-hop connections, filterable by `edge_types` (comma-separated, e.g., `SIMILAR_TO,BELONGS_TO`)
- `schema-graph` - Schema-level subgraph for D3 visualization with configurable `depth` (1-3)

**Global graph endpoints** live under `/v1/graph/`:
- `communities` - List communities with top datasets and dominant topics
- `communities/{id}/datasets` - Datasets in a community, sorted by importance
- `bridges` - Top bridge datasets by betweenness centrality
- `subgraph` - Seeded subgraph for graph explorer (accepts `seed_type`, `seed_id`, `depth`, `limit`). Dataset seeds use `provider/slug` format.
- `entities/{type}/{id}/datasets` - Datasets referencing a specific entity
- `health` - Graph connection status and sync info

All graph endpoints return 503 when Neo4j is unavailable. See [graph.md](references/graph.md) for details.

## Discovery

The `GET /v1/discover` endpoint returns datasets matching a natural language query, enriched with metadata tailored for LLM agents and programmatic integrations. Results include column schemas (with units, value ranges, display names), available views, canonical questions, methodology summaries, sample rows, and relevance scores. Unlike `/v1/search`, discover is authenticated and optimized for machine consumption rather than human browsing.

**Batch discover:** `POST /v1/discover/batch` accepts multiple queries in one call, deduplicates results, and returns per-query dataset references alongside the full metadata. See [discover.md](references/discover.md) for details.

## Reference Files

| File                                                                     | When to load                                               |
| ------------------------------------------------------------------------ | ---------------------------------------------------------- |
| [references/filtering.md](references/filtering.md)                       | Writing filter expressions, checking operator syntax       |
| [references/aggregation.md](references/aggregation.md)                   | Using group_by, aggregate functions, summary queries       |
| [references/pagination-and-sort.md](references/pagination-and-sort.md)   | Paginating large results, sorting, cursor-based pagination |
| [references/column-introspection.md](references/column-introspection.md) | Discovering schema, column types, value distributions      |
| [references/output-formats.md](references/output-formats.md)             | Exporting CSV/TSV/XLSX, field projection, system columns   |
| [references/common-patterns.md](references/common-patterns.md)           | Recipes for exploratory analysis and data research         |
| [references/sql-query.md](references/sql-query.md)                       | Raw SQL query endpoint, allowed functions, security model  |
| [references/discover.md](references/discover.md)                         | Using the discover endpoint, LLM agent integration, dataset discovery |
| [references/composition.md](references/composition.md)                   | Cross-dataset joins: joinable, preview, CSV download       |
| [references/graph.md](references/graph.md)                               | Graph intelligence: communities, importance, bridge scores |

<!-- chapter:end slug=opendata-api -->

---

<!-- chapter:begin slug=svg-design position=3 -->

## 3. svg-design

- **Source:** https://github.com/tryopendata/skills/blob/main/plugins/opendesign/skills/svg-design/SKILL.md
- **Raw:** https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/SKILL.md
- **Markdown:** https://skillsdocs.com/tryopendata/skills/svg-design.md
- **Licence:** MIT — https://spdx.org/licenses/MIT.html

Bundled files (9), referenced from this skill's directory:
  - `assets/preview.html` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/assets/preview.html
  - `references/accessibility-and-pitfalls.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/accessibility-and-pitfalls.md
  - `references/advanced-techniques.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/advanced-techniques.md
  - `references/animation.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/animation.md
  - `references/editing-workflow.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/editing-workflow.md
  - `references/icon-design.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/icon-design.md
  - `references/logo-techniques.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/logo-techniques.md
  - `references/optimization.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/optimization.md
  - `references/path-patterns.md` — https://raw.githubusercontent.com/tryopendata/skills/main/plugins/opendesign/skills/svg-design/references/path-patterns.md

<!-- Verbatim upstream SKILL.md follows, YAML frontmatter included. -->

---
name: svg-design
description: Generates and edits SVG logos, icons, and graphics. Use when creating SVG files, designing logos or icons, writing path data, optimizing SVGs, building icon systems, animating SVG elements, or modifying existing vector graphics. Covers path commands, shape primitives, styling, accessibility, gradients, masks, sprites, optimization, and animation (CSS keyframes, GPU acceleration, staggering, easing, SVG-specific techniques).
---

# SVG Creation and Editing

**Core principle:** SVGs are code. Write them by hand like you'd write any markup: clean, minimal, semantically meaningful. Every element and attribute should earn its place.

## Topic Routing

| Task | Load reference |
|------|---------------|
| Arc flag combinations, common path shapes | [references/path-patterns.md](references/path-patterns.md) |
| Logo design, typography, negative space | [references/logo-techniques.md](references/logo-techniques.md) |
| Icon design, grid systems, pixel alignment | [references/icon-design.md](references/icon-design.md) |
| Gradients, masks, clips, filters, transforms (design decisions) | [references/advanced-techniques.md](references/advanced-techniques.md) |
| Animation (CSS keyframes, stagger, GPU, easing, SVG-specific) | [references/animation.md](references/animation.md) |
| Optimization, sprites, SVGO config | [references/optimization.md](references/optimization.md) |
| Accessibility, browser pitfalls | [references/accessibility-and-pitfalls.md](references/accessibility-and-pitfalls.md) |
| Editing workflow, boolean operations, combining SVGs | [references/editing-workflow.md](references/editing-workflow.md) |

## SVG Skeleton

Always start from this structure:

```xml
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
  <!-- content -->
</svg>
```

Adjust `viewBox` to match the design canvas. Omit `width`/`height` attributes to let the SVG scale with its container (add them only when a fixed size is needed).

## Canvas Size Conventions

| Size | Use case | Notes |
|------|----------|-------|
| `0 0 16 16` | Micro icons, favicons | Heroicons micro, GitHub Octicons |
| `0 0 20 20` | Small UI icons, form elements | Heroicons mini |
| `0 0 24 24` | Standard icons (most common) | Lucide, Heroicons outline, Material |
| `0 0 32 32` | Medium icons, navigation | Phosphor uses 256x256 internally |
| `0 0 48 48` | Large display icons | App icons, illustrations |
| Custom | Logos, illustrations | Match the natural aspect ratio |

**Default to 24x24** unless there's a reason not to. It's the industry standard.

## Shape vs Path Decision

| Use shape primitive when... | Use `<path>` when... |
|---|---|
| The shape is a basic geometric form | The shape has curves, complex outlines |
| Readability matters (a circle should look like `<circle>`) | You need to minimize element count |
| You need to animate individual properties (r, cx, cy) | Combining multiple shapes into one element |
| The shape will be programmatically modified | Exporting from design tools (paths are universal) |

## Styling Defaults

Set these on the root `<svg>` element to avoid repetition on children:

| Attribute | Icon default | Logo default | Why |
|-----------|-------------|--------------|-----|
| `fill` | `none` | varies | Icons are typically stroked, logos are filled |
| `stroke` | `currentColor` | `none` or `currentColor` | Inherits text color from parent |
| `stroke-width` | `2` (on 24x24) | varies | Consistent weight across icons |
| `stroke-linecap` | `round` | `round` or `butt` | Rounded ends look cleaner at small sizes |
| `stroke-linejoin` | `round` | `round` or `miter` | Prevents sharp spikes at joins |

**`currentColor` is your friend.** It lets the SVG inherit whatever color the parent element has, making icons themeable with zero extra CSS.

## Stroke-Width Relative to ViewBox

`stroke-width` is in viewBox units, not pixels. Always set relative to your canvas dimensions:

| viewBox | Typical stroke-width | Visual result |
|---------|---------------------|---------------|
| 16x16 | 1.5 | ~9.4% of canvas |
| 24x24 | 2 | ~8.3% of canvas |
| 32x32 | 2-2.5 | ~6.3-7.8% of canvas |
| 48x48 | 3 | ~6.3% of canvas |
| 256x256 | 16 | ~6.3% of canvas |

## When to Convert Shapes to Paths

**Convert when:**
- Combining multiple shapes into a single compound path (boolean operations)
- You need the shape as part of a larger path composition
- Distributing to environments that only support `<path>`
- Optimizing for minimal DOM elements

**Keep as shapes when:**
- Code readability matters (a `<circle>` is self-documenting)
- You need to animate specific properties (animating `r` on a circle is cleaner than animating path data)
- The SVG will be programmatically modified

### Rounded rect-to-path template

The arc parameters here are error-prone to derive. Use this as a reference:

```xml
<!-- <rect x="2" y="2" width="20" height="20" rx="3" /> becomes: -->
<path d="M 5 2 h 14 a 3 3 0 0 1 3 3 v 14 a 3 3 0 0 1 -3 3 h -14 a 3 3 0 0 1 -3 -3 v -14 a 3 3 0 0 1 3 -3 Z" />
```

## fill-rule: evenodd vs nonzero

Use `evenodd` when you have compound shapes with holes. It's simpler because you don't need to worry about winding direction:

```xml
<!-- Donut using evenodd (direction doesn't matter) -->
<path fill-rule="evenodd" d="
  M 12 2 A 10 10 0 1 1 12 22 A 10 10 0 1 1 12 2 Z
  M 12 7 A 5 5 0 1 1 12 17 A 5 5 0 1 1 12 7 Z
" fill="black" />
```

With `nonzero` (default), the inner circle must wind in the opposite direction to create the hole.

## Logo Design Process

When creating logos (not icons), follow this process:

0. **Always clarify design direction before creating logos.** Use AskUserQuestion to present curated design direction choices before writing any SVG code. This step is mandatory for all logo projects. Even when the user provides some direction (like "modern" or "YC style"), those are vibes, not design briefs. A designer would still present options to narrow the direction before investing in 5-15 concepts.

    Present choices like a designer showing mood boards. Don't ask open-ended questions. Tailor options to the user's domain:

    ```
    AskUserQuestion({
      questions: [
        {
          question: "What visual personality fits your brand?",
          header: "Mood",
          multiSelect: false,
          options: [
            { label: "Bold & geometric", description: "Clean shapes, strong lines, confident. Think Stripe, Figma." },
            { label: "Organic & crafted", description: "Hand-drawn feel, natural curves, warmth. Think Mailchimp, Basecamp." },
            { label: "Minimal & typographic", description: "Wordmark-driven, restrained, sophisticated. Think Glossier, Everlane." },
            { label: "Playful & dynamic", description: "Energetic, colorful, movement. Think Slack, Discord." }
          ]
        },
        {
          question: "What should the logo emphasize?",
          header: "Focus",
          multiSelect: false,
          options: [
            { label: "What we do", description: "Visual metaphor tied to your product's core function" },
            { label: "How it feels", description: "Abstract mark that conveys emotion or energy" },
            { label: "Who we are", description: "Letterform or wordmark built from the brand name" }
          ]
        },
        {
          question: "Which of these logos in your space resonates most?",
          header: "Inspiration",
          multiSelect: false,
          options: [
            // IMPORTANT: populate these with 3-4 real, well-known brands
            // in or adjacent to the user's specific industry/domain.
            // Examples below are for an AI startup - replace entirely
            // for other domains.
            { label: "Linear", description: "Precise geometric icon, purple gradient, premium feel" },
            { label: "Stripe", description: "Simple bold wordmark, single accent color, confident restraint" },
            { label: "Notion", description: "Friendly icon with personality, approachable, slightly playful" },
            { label: "Vercel", description: "Abstract minimal triangle, stark black/white, developer-focused" }
          ]
        }
      ]
    })
    ```

    **Tailoring the questions to the domain is critical.** The mood options, focus options, and especially the inspiration logos must be specific to the user's industry. A coffee brand gets Blue Bottle, Stumptown, Intelligentsia, Counter Culture as inspiration options. A fintech startup gets Stripe, Plaid, Mercury, Ramp. A fitness app gets Peloton, Strava, Nike Run Club, Whoop. Pick brands the user will immediately recognize and have an opinion about. The inspiration question does the most work here because it anchors the entire aesthetic direction to something concrete.

    The only exception: skip if the user has specified both a concrete visual style AND specific imagery (e.g., "minimalist geometric logo using a mountain silhouette in navy blue").

1. **Explore multiple metaphors, not multiple layouts of one metaphor.** Conceptual diversity matters more than layout variations. Follow the full ideation process in [references/logo-techniques.md](references/logo-techniques.md), which covers domain-specific brainstorming, category diversity requirements, and cliche avoidance.
2. **Guarantee structural variety.** Every logo set must span multiple *categories* of approach, not just multiple metaphors within the same style. Include at least one from each column when presenting 5+ options: a typographic/wordmark approach, a symbolic icon, an abstract geometric mark, and a letterform-meets-metaphor hybrid. See the category diversity table in [references/logo-techniques.md](references/logo-techniques.md).
3. **Set up the preview immediately, then populate it progressively.** Don't design all logos first and then show them. The user should see results as they're created:
    1. Copy this skill's `assets/preview.html` to the project directory using `cp` with the absolute path from where this skill was loaded (do not read or modify the file).
    2. Design the first logo and write its SVG file.
    3. Write `variants.js` with just that first variant (format in [references/editing-workflow.md](references/editing-workflow.md)).
    4. Open the preview with `open preview.html` (macOS) or `xdg-open preview.html` (Linux). The user now sees the first logo while you keep working.
    5. For each subsequent logo: write the SVG file, then update `variants.js` to add it. The preview auto-reloads both every 3 seconds, so new logos appear in the browser as they're completed.

    This gives the user visual feedback within seconds of the first logo being ready, rather than waiting for all logos to be designed before seeing anything.
4. **For colored logos, always create a `-dark.svg` variant.** Dark navy edges (#1E3A5F) that look great on white disappear on dark backgrounds. Dark variants need lighter edges (#4B8BBE), lighter rings (#3B6B8A), and off-white centers (#E2E8F0).
5. **Plan your vertical budget before drawing.** On a 32x32 canvas with 3 stacked elements, you have ~30 usable units. Sketch the vertical distribution first (e.g., box=12, gap=2, layer=5, gap=2, layer=5) to avoid clipping at viewBox edges.

## Anti-Patterns

| Don't | Do instead |
|-------|-----------|
| Hardcode `width="24" height="24"` without `viewBox` | Use `viewBox` always; add width/height only if needed |
| Set `fill="none"` on a `<g>` group | Set fill on individual elements or the root `<svg>` |
| Use `px` units inside SVG | SVG coordinates are unitless; they map to viewBox |
| Include editor metadata (`<sodipodi:*>`, `<inkscape:*>`) | Strip all editor cruft |
| Use `<text>` for logo wordmarks in distributed SVGs | Convert text to paths for portability |
| Nest transforms three levels deep | Flatten transforms into path coordinates |
| Use `xlink:href` | Use `href` (xlink is deprecated) |
| Forget `xmlns` on standalone SVG files | Always include `xmlns="http://www.w3.org/2000/svg"` |
| Use decimal precision beyond 2-3 places for icons | Round to 2 decimals for icons, 3 max for complex art |

## Cross-References

- **Design aesthetics**: If the `ce` plugin is installed, `Skill(ce:design)` covers broader visual design decisions
- **Mermaid diagrams**: If the `ce` plugin is installed, `Skill(ce:visualizing-with-mermaid)` covers diagram-specific visualizations (not SVG)

<!-- chapter:end slug=svg-design -->
