# Feature parity roadmap
A scope-and-sequencing doc for closing the legitimate feature gaps charted has
against Chart.js and D3, written as a test-driven plan. It is a roadmap, not the
implementation. Each feature lists the failing tests to write first (Red), the
minimal code to make them pass (Green), and what "done" means (Acceptance).
## Scope and non-goals
charted's niche is static, zero-dependency, publication-quality SVG/PNG from
Python, a CLI, SQL via DuckDB, or an LLM via MCP. Everything here has to fit
that niche: no browser runtime, no required third-party dependency in core, and
no break to the one-call API.
In scope, ordered by value:
1. Log and time scales (currently linear only)
2. A mixed chart type (bar + line on shared axes)
3. Curve interpolation for line and area (step, basis/cardinal spline)
4. Bubble and polar area chart types
5. A small annotation primitive (generalize the reference-line code into boxes, labels, line segments)
6. Opt-in interactivity in `to_html()` only (SVG `
` tooltips as the zero-JS baseline)
7. Continuous sequential/diverging color interpolation
Explicitly out of scope (rejected in the gap analysis, do not build): a plugin
architecture, Canvas/WebGL, default animations, full responsive resize, D3's
selection/data-binding model, force simulation, geo projections.
## How the codebase is laid out (the parts that change)
- `charted/charts/chart.py` is the base `Chart(Svg)`. It owns construction, the
`reproject` plumbing through `XAxis`/`YAxis`, `to_html()`, `to_config()`,
`describe()`, reference-line rendering (`_render_reference_lines`), and the
abstract `representation` property each chart subclass implements.
- `charted/charts/axes.py` holds `Axis`, `XAxis`, `YAxis`. The linear mapping
lives in the classmethod `Axis._reproject` and the tick math in
`calculate_axis_values` / `calculate_axis_dimensions`. This is the only place
value-to-pixel mapping happens, so scales hook in here.
- Chart subclasses (`line.py`, `area.py`, `scatter.py`, `pie.py`, `column.py`,
`bar.py`, etc.) each implement `representation`. Line/area delegate to
`charted/utils/line_renderer.py`.
- `charted/chart_config.py` holds the `*Config` dataclasses (`LineChartConfig`,
`ScatterChartConfig`, `PieChartConfig`, ...). New per-chart options get a
field here.
- `charted/themes/core.py` holds the `Theme` dataclass, `NAMED_PALETTES`, and
`resolve_palette`. `charted/utils/colors.py` holds color parsing/interpolation
primitives (`_parse_color_to_rgb`, `hex_to_rgb`, `rgb_to_hex`,
`calculate_contrast_ratio`).
- Surfaces that must stay in sync when a new chart type lands: the exports in
`charted/charts/__init__.py` (`__all__` and `_CHART_CLASSES`), `charted/__init__.py`,
and the MCP server maps `CHART_TYPE_MAP` / `CHART_DESCRIPTIONS` in
`mcp_server/tools.py`.
- Tests live under `tests/charts/` (per-chart), `tests/themes/`, `tests/utils/`,
`tests/accessibility/`, `tests/properties/` (hypothesis-style), `tests/visual/`
(SVG-structure regression), `tests/html/`, and `tests/cli/`. Shared fixtures
are in `tests/conftest.py`; assertion helpers in `tests/helpers/svg_assertions.py`.
---
## 1. Log and time scales
Highest value. Today the only mapping is the linear `Axis._reproject`
(`(value - min) / (max - min) * length`) and the tick generation in
`calculate_axis_values`. A scale is two things: a value-to-pixel transform and a
tick generator. The cleanest fit is a small `Scale` abstraction that `XAxis`/`YAxis`
delegate to, with `LinearScale` as the existing behavior, plus `LogScale` and
`TimeScale`.
**Red** (write first, in a new `tests/charts/test_scales.py`):
- `test_linear_scale_matches_current_reproject` — a `LinearScale(min=0, max=100)`
over length 400 maps 50 to 200.0; pins existing behavior before refactor.
- `test_log_scale_maps_decades` — a `LogScale(min=1, max=1000)` over length 300
places 1, 10, 100, 1000 at evenly spaced pixel positions (0, 100, 200, 300);
asserts the spacing between decade ticks is equal.
- `test_log_scale_rejects_nonpositive` — `LogScale(min=0, ...)` or data
containing <= 0 raises `ValueError` (log undefined at/below zero).
- `test_log_scale_ticks_are_powers` — generated ticks for [1, 1000] are
[1, 10, 100, 1000].
- `test_time_scale_maps_dates` — a `TimeScale` over
`[date(2024,1,1), date(2024,12,31)]` maps the midpoint date to ~length/2;
accepts `datetime`/`date` and ISO strings.
- `test_time_scale_nice_ticks` — a one-year span produces month-or-quarter
boundary ticks (clean dates, not arbitrary epochs).
- In `tests/charts/test_line.py` / `test_scatter.py`:
`test_line_chart_log_y_scale` and `test_scatter_log_x_scale` — passing
`y_scale="log"` (or `x_scale="log"`) produces a chart whose SVG renders and
whose y tick labels are decade values; `test_line_chart_time_x_axis` — passing
date-typed `x_data` with `x_scale="time"` renders without error and labels are
formatted dates.
- In `tests/properties/`: a hypothesis test that for any positive min/max and
value in range, `LogScale.reproject` returns a value within [0, length] and is
monotonic.
**Green:**
- Add `charted/charts/scales.py` with a `Scale` protocol/base exposing
`reproject(value) -> float`, `reverse(pixel) -> float`, and `ticks() -> list`.
Implement `LinearScale` (lift the current `_reproject` math), `LogScale`
(`log10`-based, validate positivity), `TimeScale` (normalize date/datetime/ISO
to epoch seconds, linear in that space, date-aware tick generation).
- In `axes.py`, have `XAxis`/`YAxis` hold a `scale` instance and delegate
`reproject` / `reverse` / tick values to it. Keep `LinearScale` the default so
existing behavior and all current tests stay green.
- Thread a `x_scale` / `y_scale` argument (string `"linear"|"log"|"time"` or a
`Scale` instance) through `Chart.__init__` and the relevant subclass
constructors (`LineChart`, `ScatterChart`, `AreaChart`, `ColumnChart`).
- Add `x_scale` / `y_scale` fields to the corresponding configs in
`chart_config.py`.
**Acceptance:**
- `LineChart`, `ScatterChart`, `AreaChart`, `ColumnChart` accept `x_scale` /
`y_scale`; default stays linear and is byte-for-byte unchanged (visual
regression baselines untouched).
- Log axis tick labels render as decade values; time axis labels render as
formatted dates; both respect the active theme's label color/font.
- `to_config()` round-trips the scale choice (serialized and replayable via
`from_config()`).
- `describe()` reports the scale type per axis.
- Positivity and date-parsing errors raise clear `ValueError`s with messages.
- No new core dependency; date handling uses stdlib `datetime`.
**Effort:** large. **Dependencies:** none; this is the foundation and should
land first because the mixed chart (feature 2) wants a shared axis built on it.
---
## 2. Mixed chart type (bar + line on shared axes)
Each chart class is currently a single representation. A mixed chart composes a
column/bar representation and a line representation against one shared
coordinate system (shared x, optionally a secondary y).
**Red** (`tests/charts/test_combo.py`):
- `test_combo_renders_bars_and_line` — `ComboChart` with one bar series and one
line series produces SVG containing both `` (or column path) and a line
``; both present.
- `test_combo_shares_x_axis` — bar centers and line points line up on the same x
tick positions for the same labels.
- `test_combo_secondary_y_axis` — when a series is assigned to a secondary y
axis, a second set of y tick labels renders on the right and that series is
scaled to its own range, not the primary range.
- `test_combo_legend_lists_all_series` — legend has one entry per series with the
correct per-series color.
- `test_combo_describe` — `describe()` reports `series_count == 2` and each
series' type.
- Sad path: `test_combo_requires_at_least_two_series` raises on a single series.
**Green:**
- Add `charted/charts/combo.py` with `ComboChart(Chart)`. Accept a list of
series each tagged with a type (`"bar"|"column"|"line"|"area"`) and an optional
`axis="primary"|"secondary"`. Reuse existing renderers: instantiate/borrow the
column and line representation logic rather than reimplementing.
- Add optional secondary-axis support in `axes.py` (a second `YAxis` with its own
`axis_dimension`) or a lightweight secondary `Scale`, rendered on the right.
- Add `ComboChartConfig` to `chart_config.py`.
- Register in `charts/__init__.py` (`__all__`, `_CHART_CLASSES`),
`charted/__init__.py`, and add `"combo"` to `CHART_TYPE_MAP` /
`CHART_DESCRIPTIONS` in `mcp_server/tools.py`.
**Acceptance:**
- A bar+line combo with shared x renders correctly; optional secondary y axis
scales its series independently and labels on the right.
- Theming, legend, axis titles, and accessibility contrast checks all apply.
- Exposed via Python API, CLI (`charted create --type combo ...` accepts it), and
MCP (`list_chart_types` includes it).
- `to_config()` / `from_config()` round-trip the per-series types and axis
assignment.
**Effort:** large. **Dependencies:** secondary axis benefits from feature 1's
`Scale` abstraction; build after scales.
---
## 3. Curve interpolation for line and area
Lines are polylines today (`LineRenderer` emits straight `L` segments). Add a
`curve=` option that changes how consecutive points are joined: `"linear"`
(default), `"step"` (before/after), and a smooth spline (`"basis"` or
`"cardinal"`). This is a path-generation change, not a data change.
**Red** (`tests/charts/test_curve_interpolation.py`):
- `test_linear_curve_is_default_polyline` — default output uses `L` commands
only; pins current behavior.
- `test_step_curve_emits_horizontal_then_vertical` — `curve="step"` produces a
path whose segments are axis-aligned (only `H`/`V` or `L` along one axis at a
time between points).
- `test_cardinal_curve_emits_cubic_beziers` — `curve="basis"`/`"cardinal"`
produces a path containing `C` (cubic Bezier) commands and still starts/ends at
the first/last data point.
- `test_curve_passes_through_endpoints` — for every curve type the path's first
and last coordinates equal the first and last data points (cardinal must
interpolate endpoints; basis may approximate, assert documented behavior).
- `test_area_curve_matches_line_curve` — `AreaChart(curve="cardinal")` fills
under the same smoothed boundary the line would draw.
- Sad path: `test_invalid_curve_raises` — unknown curve name raises `ValueError`.
- A hypothesis property in `tests/properties/`: a curved path emits the same
number of vertices as input points (no point dropped) for step and cardinal.
**Green:**
- Add a curve module (e.g. `charted/utils/curves.py`) with pure functions that
take a list of `(x, y)` points and return an SVG path `d` string:
`linear_path`, `step_path`, `cardinal_path`/`basis_path` (cardinal spline with a
default tension).
- In `charted/utils/line_renderer.py`, branch on the chart's `curve` attribute
when building the line path; reuse the same generated boundary for the area
fill in `area.py`.
- Add `curve: str = "linear"` to `LineChartConfig` (and an area equivalent) in
`chart_config.py`; thread it through `LineChart.__init__` / `AreaChart.__init__`.
**Acceptance:**
- `LineChart(..., curve="step"|"cardinal"|"basis")` and the area equivalent
render valid SVG; default `"linear"` output is unchanged (baselines intact).
- Markers and data labels still sit on the original data points regardless of
curve.
- `to_config()` round-trips `curve`.
**Effort:** medium. **Dependencies:** none; independent of scales. Can run in
parallel with feature 1.
---
## 4. Bubble and polar area chart types
Both are cheap given existing machinery. Bubble is a scatter where marker radius
encodes a third value. Polar area is a pie where every slice has the same angle
but radius encodes value.
**Red** (`tests/charts/test_bubble.py`, `tests/charts/test_polar_area.py`):
- `test_bubble_radius_encodes_third_dim` — `BubbleChart` with `sizes=[...]`
renders `` elements whose `r` is monotonic in the size value (largest
size -> largest radius).
- `test_bubble_radius_within_bounds` — all radii fall within a configured
`[min_radius, max_radius]` range.
- `test_bubble_reuses_scatter_positioning` — point centers match what a
`ScatterChart` with the same x/y would produce.
- `test_polar_area_equal_angles` — `PolarAreaChart` with N values produces N
slices each spanning 360/N degrees.
- `test_polar_area_radius_encodes_value` — slice radius is monotonic in value;
largest value -> outermost slice.
- Sad paths: negative sizes / negative polar values raise `ValueError`.
**Green:**
- `charted/charts/bubble.py`: `BubbleChart(ScatterChart)` adding a `sizes`
argument and a size-to-radius scale (`min_radius`/`max_radius`), overriding only
marker radius.
- `charted/charts/polar_area.py`: `PolarAreaChart(PieChart)` (or sharing pie's
arc-path helper) with equal angular slices and a value-to-radius mapping.
- Add `BubbleChartConfig` / `PolarAreaChartConfig` to `chart_config.py`.
- Register both in `charts/__init__.py`, `charted/__init__.py`, and
`CHART_TYPE_MAP` / `CHART_DESCRIPTIONS` in `mcp_server/tools.py`.
**Acceptance:**
- Bubble and polar area available via Python, CLI, and MCP; theming, legend, and
accessibility checks apply.
- `auto()` chart-type inference (`charted/utils/data_input.py`) optionally
recognizes a third numeric dimension as a bubble candidate (stretch).
- `to_config()` / `from_config()` round-trip both, including `sizes` and radius
bounds.
**Effort:** medium. **Dependencies:** bubble benefits from but does not require
feature 1 (size scale can be linear initially). Build after scatter/pie are
understood; independent of features 1-3.
---
## 5. Annotation primitive
Today annotations are limited to horizontal/vertical reference lines
(`Chart._render_reference_lines`, driven by `h_lines` / `v_lines`) plus scatter
quadrant labels. Generalize this into a small annotation layer: line segments,
boxes (shaded value ranges), and point/text labels, positioned in data
coordinates and reprojected through the axes.
**Red** (`tests/charts/test_annotations.py`):
- `test_line_annotation_renders_segment` — a `LineAnnotation((x0,y0),(x1,y1))`
draws a ``/`` between the reprojected data coordinates.
- `test_box_annotation_renders_rect` — a `BoxAnnotation(x_range, y_range)` draws a
shaded `` covering the reprojected data range.
- `test_label_annotation_renders_text` — a `LabelAnnotation((x,y), "text")`
renders `` at the reprojected point.
- `test_existing_h_lines_still_work` — `h_lines=[...]` / `v_lines=[...]` keep
producing the same dashed reference lines (back-compat pin).
- `test_h_lines_implemented_via_annotations` — internally the legacy reference
lines are expressed as annotations (refactor check, optional).
- `test_annotations_clipped_to_plot` — annotations render inside the plot area
group, not over the axes.
**Green:**
- Add `charted/charts/annotations.py` with small dataclasses:
`LineAnnotation`, `BoxAnnotation`, `LabelAnnotation`, each with a
`render(chart) -> Element` that reprojects its data coordinates via
`chart.x_axis.reproject` / `chart.y_axis.reproject`.
- Add an `annotations: list[Annotation]` argument to `Chart.__init__`; render them
in the same plot-area group as `_render_reference_lines`.
- Refactor `_render_reference_lines` to build `LineAnnotation`s from `h_lines` /
`v_lines` so there's one code path (keep the old kwargs as sugar).
**Acceptance:**
- Charts accept `annotations=[...]`; line/box/label types render in data
coordinates and respect theme colors.
- Legacy `h_lines` / `v_lines` behavior is byte-for-byte unchanged.
- `to_config()` round-trips annotations.
**Effort:** medium. **Dependencies:** annotations reproject through the axes, so
once feature 1 lands they automatically work on log/time axes; no hard ordering,
but landing after scales avoids reworking coordinate handling.
---
## 6. Opt-in interactivity in `to_html()` (SVG `` tooltips)
Keep file output (`to_svg()`, `save()`) inert. The zero-JS baseline is native
SVG `` elements: hovering a data element shows the browser's built-in
tooltip, no script. `to_html()` gains an opt-in flag to include them.
**Red** (`tests/html/test_tooltips.py`, plus `tests/html/test_formatter.py`):
- `test_to_svg_has_no_titles_by_default` — plain `to_svg()` output contains no
`` elements (file output stays inert).
- `test_to_html_tooltips_opt_in` — `to_html(tooltips=True)` injects ``
children inside data marks (``/``/path groups) with the
series/value text; `to_html()` without the flag does not.
- `test_tooltip_text_matches_data` — tooltip text for a point equals its label
and value (e.g. `"Feb: 59"`).
- `test_tooltips_no_javascript` — the emitted HTML contains no `