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:
Log and time scales (currently linear only)
A mixed chart type (bar + line on shared axes)
Curve interpolation for line and area (step, basis/cardinal spline)
Bubble and polar area chart types
A small annotation primitive (generalize the reference-line code into boxes, labels, line segments)
Opt-in interactivity in
to_html()only (SVG<title>tooltips as the zero-JS baseline)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.pyis the baseChart(Svg). It owns construction, thereprojectplumbing throughXAxis/YAxis,to_html(),to_config(),describe(), reference-line rendering (_render_reference_lines), and the abstractrepresentationproperty each chart subclass implements.charted/charts/axes.pyholdsAxis,XAxis,YAxis. The linear mapping lives in the classmethodAxis._reprojectand the tick math incalculate_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 implementrepresentation. Line/area delegate tocharted/utils/line_renderer.py.charted/chart_config.pyholds the*Configdataclasses (LineChartConfig,ScatterChartConfig,PieChartConfig, …). New per-chart options get a field here.charted/themes/core.pyholds theThemedataclass,NAMED_PALETTES, andresolve_palette.charted/utils/colors.pyholds 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 mapsCHART_TYPE_MAP/CHART_DESCRIPTIONSinmcp_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/, andtests/cli/. Shared fixtures are intests/conftest.py; assertion helpers intests/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— aLinearScale(min=0, max=100)over length 400 maps 50 to 200.0; pins existing behavior before refactor.test_log_scale_maps_decades— aLogScale(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 raisesValueError(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— aTimeScaleover[date(2024,1,1), date(2024,12,31)]maps the midpoint date to ~length/2; acceptsdatetime/dateand 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_scaleandtest_scatter_log_x_scale— passingy_scale="log"(orx_scale="log") produces a chart whose SVG renders and whose y tick labels are decade values;test_line_chart_time_x_axis— passing date-typedx_datawithx_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.reprojectreturns a value within [0, length] and is monotonic.
Green:
Add
charted/charts/scales.pywith aScaleprotocol/base exposingreproject(value) -> float,reverse(pixel) -> float, andticks() -> list. ImplementLinearScale(lift the current_reprojectmath),LogScale(log10-based, validate positivity),TimeScale(normalize date/datetime/ISO to epoch seconds, linear in that space, date-aware tick generation).In
axes.py, haveXAxis/YAxishold ascaleinstance and delegatereproject/reverse/ tick values to it. KeepLinearScalethe default so existing behavior and all current tests stay green.Thread a
x_scale/y_scaleargument (string"linear"|"log"|"time"or aScaleinstance) throughChart.__init__and the relevant subclass constructors (LineChart,ScatterChart,AreaChart,ColumnChart).Add
x_scale/y_scalefields to the corresponding configs inchart_config.py.
Acceptance:
LineChart,ScatterChart,AreaChart,ColumnChartacceptx_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 viafrom_config()).describe()reports the scale type per axis.Positivity and date-parsing errors raise clear
ValueErrors 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.
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 usesLcommands only; pins current behavior.test_step_curve_emits_horizontal_then_vertical—curve="step"produces a path whose segments are axis-aligned (onlyH/VorLalong one axis at a time between points).test_cardinal_curve_emits_cubic_beziers—curve="basis"/"cardinal"produces a path containingC(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 raisesValueError.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 pathdstring:linear_path,step_path,cardinal_path/basis_path(cardinal spline with a default tension).In
charted/utils/line_renderer.py, branch on the chart’scurveattribute when building the line path; reuse the same generated boundary for the area fill inarea.py.Add
curve: str = "linear"toLineChartConfig(and an area equivalent) inchart_config.py; thread it throughLineChart.__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-tripscurve.
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—BubbleChartwithsizes=[...]renders<circle>elements whoseris 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 aScatterChartwith the same x/y would produce.test_polar_area_equal_angles—PolarAreaChartwith 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 asizesargument 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/PolarAreaChartConfigtochart_config.py.Register both in
charts/__init__.py,charted/__init__.py, andCHART_TYPE_MAP/CHART_DESCRIPTIONSinmcp_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, includingsizesand 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— aLineAnnotation((x0,y0),(x1,y1))draws a<path>/<line>between the reprojected data coordinates.test_box_annotation_renders_rect— aBoxAnnotation(x_range, y_range)draws a shaded<rect>covering the reprojected data range.test_label_annotation_renders_text— aLabelAnnotation((x,y), "text")renders<text>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.pywith small dataclasses:LineAnnotation,BoxAnnotation,LabelAnnotation, each with arender(chart) -> Elementthat reprojects its data coordinates viachart.x_axis.reproject/chart.y_axis.reproject.Add an
annotations: list[Annotation]argument toChart.__init__; render them in the same plot-area group as_render_reference_lines.Refactor
_render_reference_linesto buildLineAnnotations fromh_lines/v_linesso 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_linesbehavior 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 <title> tooltips)¶
Keep file output (to_svg(), save()) inert. The zero-JS baseline is native
SVG <title> 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— plainto_svg()output contains no<title>elements (file output stays inert).test_to_html_tooltips_opt_in—to_html(tooltips=True)injects<title>children inside data marks (<rect>/<circle>/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<script>tag (zero-JS guarantee).Accessibility:
test_tooltip_titles_are_accessibleintests/accessibility/—<title>provides an accessible name for the mark.
Green:
Add a
Titleelement tocharted/html/element.py(renders<title>...</title>).Give chart representations an opt-in hook to attach a
<title>child to each data mark carrying"<series>: <value>"(or label/value), gated so it only fires when requested.Add a
tooltips: bool = Falseparameter toChart.to_html()(and thread an internal flag the representation reads). Keepto_svg()/save()output free of titles.Update
generate_html_wrapperincharted/utils/rendering.pyif the SVG needs regenerating with titles for the HTML path.
Acceptance:
to_html(tooltips=True)produces hoverable native tooltips with no JavaScript; defaultto_html()and all file output stay inert and unchanged.Tooltip text is correct per data point and accessible.
Effort: medium. Dependencies: none functionally, but tooltip text reads nicer once scales/time formatting exist; can build independently.
7. Continuous sequential/diverging color interpolation¶
charted already ships discrete palettes (viridis, inferno, ocean, …). Expose continuous interpolation so a value in [0,1] (or a domain) maps to a color along a gradient. Most useful for heatmaps and bubble fills.
Red (tests/themes/test_color_interpolation.py, plus tests/properties/):
test_interpolate_sequential_endpoints—interpolate("viridis", 0.0)equals the palette’s first color andinterpolate("viridis", 1.0)equals the last.test_interpolate_midpoint_between_stops—interpolate(["#000000","#ffffff"], 0.5)returns mid-gray (#7f7f7f-ish within tolerance).test_diverging_scale_center— a diverging scale built from (low, mid, high) returnsmidat the domain center and the endpoints at the extremes.test_interpolate_clamps_out_of_range— t < 0 clamps to the first color, t > 1 to the last.A hypothesis property: for any t in [0,1] the result is a valid hex color parseable by
_parse_color_to_rgb.Integration
tests/charts/test_heatmap.py::test_heatmap_continuous_color— a heatmap built with a continuous scale colors cells by value along the gradient (min value -> first stop, max -> last stop).
Green:
Add interpolation helpers to
charted/utils/colors.py(reuse_parse_color_to_rgb/rgb_to_hex):interpolate_color(a, b, t)andinterpolate_palette(palette_or_list, t); add a smallColorScale/diverging_scale(low, mid, high, domain)inthemes/core.pynext toresolve_palette.Let
HeatmapChart(and optionallyBubbleChart) accept a continuous color scale instead of only discrete buckets.
Acceptance:
interpolate_palette("viridis", t)returns smooth intermediate colors; diverging scales center correctly.Heatmap can color by a continuous scale; existing discrete behavior unchanged.
Output still passes the WCAG-AA contrast check where contrast applies.
Effort: small-to-medium. Dependencies: pairs naturally with feature 4 (bubble fills) and improves heatmaps; independent of scales.
Suggested sequence¶
Scales (feature 1) first; it is the foundation the mixed chart and annotation reprojection lean on.
Curve interpolation (3) and color interpolation (7) in parallel; both are self-contained and low-risk.
Bubble + polar area (4), then mixed chart (2) once scales and a secondary axis exist.
Annotations (5) after scales so they reproject through any axis type.
Tooltips (6) any time; it only touches the HTML output path.
Every feature keeps the default output byte-for-byte unchanged so the visual
regression baselines in tests/visual/ stay valid, and every new chart type must
be wired into the Python exports, the CLI, and the MCP CHART_TYPE_MAP before it
counts as done.