Charts
Native Excel chart (c:chartSpace) support: read, render, create, edit, and save charts, round-tripping through .xlsx. The chart definition's source of truth is the Excel-native OOXML model, so charts interoperate with Excel/WPS and need no proprietary migration.
Architecture
xl/charts/chartN.xml (c:chartSpace) ← source of truth, in the .xlsx
├─ crates/workbook parse + serialize + lossless round-trip
├─ crates/controller ChartManager (in Status; undo/redo via snapshot)
│ ├─ payloads CreateChart / UpdateChart / MoveChart / DeleteChart
│ └─ get_charts() → ChartInfo (live values re-read from source ranges)
├─ crates/wasms/server GetCharts RPC + payload dispatch
└─ packages/engine ChartLayer in Spreadsheet.svelte, rendered with ECharts- Source values are read live.
get_chartsresolves each series' reference (e.g.Sheet1!$B$2:$E$2) to current cell values, so editing data updates the chart. Category labels and number formats are read live the same way. The OOXMLnumCacheis only a fallback. - Editing rewrites the chart XML.
UpdateChartpatches the parsed model and regeneratesc:chartSpacefrom it. Everything the editor understands is typed; everything else — fills, fonts, gridlines, markers, 3-D settings, trendlines — is captured as verbatimUnparsedsubtrees (PreservedXml) and written back untouched, so an edit cannot silently restyle a chart authored in Excel. The chart's relationships and its siblingstyle1.xml/colors1.xmlparts ride along too. Adding a typed field means adding it to bothChartDataandbuild_chart_xml. - Anchored by stable
CellId. Charts shift with row/column insert/delete, like images. - Data ranges are held by
CellIdtoo (chart_manager/refs.rs). The engine's rule is that positions exist only at the edges — ids in the middle — and chart refs used to be the exception: they kept the file'sSheet1!$B$2:$E$2text and nothing rewrote it, so a row inserted above the data left the chart resolving a range of now-empty cells and rendering blank. AChartRangeis aSheetIdplus the same id-basedRangeformulas use, so a chart now shifts with rows and columns, widens when a row is inserted inside its range (as Excel does), and re-points when its sheet is renamed. The A1 text inChartDatais the fallback for a corner whose cell has been deleted. On save the part is regenerated only if the resolved A1 differs from what the chart came in with, so an undisturbed chart still round-trips byte for byte. - A chart can be bound to a block instead of a range.
ChartBlockSource(block id + field names) is stored on the controller'sChart, outside the OOXML model, andchart_manager::resolve_block_refsturns it into A1 ranges from the block's current place and schema. That resolution runs on everyget_chartsand every save, so the chart follows the block: records appended to it are plotted with nothing touching the chart, and a row inserted above the block cannot leave it pointing at the wrong cells. Fields are held by name — the identity#FIELD("qty")formulas use — so a moved column keeps the link and a renamed field breaks it. The.xlsxstill carries real A1 refs (Excel has no idea what a block is); the binding itself is persisted inlogisheets.xmlas<chartSource>and restored on load, so reopening in LogiSheets picks the chart back up as bound rather than frozen. - Rendering library: ECharts, bundled in
logisheets-engineas an external dependency + tree-shaken (echarts/core+use()), rendered as a DOM overlay (.chart-layer) positioned from the grid.
What works
| Capability | Status | Verified by |
|---|---|---|
Read & display charts from .xlsx | ✅ | user + tests |
| Chart follows data edits (live values) | ✅ | chart_reflects_live_data |
| Range follows row/col insert, delete, sheet rename | ✅ | plain_chart_follows_row_and_column_edits, tests/chart_range_tracking.rs |
| Chart bound to a block, follows it as it grows | ✅ | chart_bound_to_block_follows_it, block_bound_chart_survives_save |
| Select (click) | ✅ | user |
| Move (drag) | ✅ | move_chart_updates_anchor + user |
| Resize (corner handles) | ✅ | reuses MoveChart + user |
| Delete (Delete/Backspace) | ✅ | delete_chart_removes_it |
| Create from selection (toolbar 📊) | ✅ | create_chart_from_scratch + user |
| Save round-trips (incl. moved/created/deleted) | ✅ | tests |
| Lossless round-trip of chart part bytes | ✅ | chart_round_trips |
Chart types: all 16 classic c:*Chart groups | ✅ | round-trip tests per kind + new_kinds_do_not_leak_settings |
| Combo charts (a kind per series) | ✅ | combo_chart_keeps_every_group, create_a_combo_chart_and_keep_it_through_edits |
| Series colors match the workbook theme (scheme + RGB) | ✅ | chart_reflects_live_data |
| Category labels follow the source cells (formatted) | ✅ | chart_categories_and_formats_are_live |
| Data labels (value / category / series name / percent, position) | ✅ | update_chart_changes_every_setting + browser |
| Number formats on labels and the value axis | ✅ | same |
| Reconfigure type, title, legend, stacking, axis titles | ✅ | update_chart_changes_every_setting |
| Re-point the data (categories, per-series range, name, color) | ✅ | update_chart_repoints_series_and_keeps_colors |
Size-anchored charts (oneCellAnchor) draw at their real size | ✅ | browser |
| Insert picks the chart type; labels/series inferred from the selection | ✅ | browser |
| Editing keeps unmodeled styling (fonts, fills, gridlines, markers) | ✅ | rebuilding_keeps_unmodeled_styling, editing_a_chart_keeps_its_styling_and_satellite_parts |
| Editing keeps the chart's rels and style/colors parts | ✅ | same |
| Value-axis scale: min, max, unit, log base, reverse | ✅ | update_chart_sets_the_axis_scale, axis_scale_round_trips |
Undo/redo works for all chart edits (chart_edits_are_undoable). Charts live in Status, which the undo stack snapshots whole on every undoable action — which is why ChartManager uses persistent (imbl) collections: a snapshot is a refcount bump, not a deep copy of every chart's preserved XML. For the same reason charts_of_sheet hands out borrows rather than a cloned Vec.
Series fill colors are resolved from the OOXML: direct RGB passes through, and scheme colors (accent1..6, tx/bg/dk/lt, hyperlink) map to the workbook theme, so a loaded chart matches Excel's colors. Regenerating the XML on an edit writes those colors back, so a re-typed or re-pointed chart keeps its palette.
A selected chart shows a type picker and a ⚙ button; the ⚙ opens the chart editor (ChartSettings.svelte) — type, title, legend position, stacking, axis titles, value-axis scale (min / max / unit / log base / reverse), data labels, number format, and the data itself (category range, plus each series' name / range / color, with add and remove). Every control is one field of UpdateChart, and Engine.updateChart(id, patch) is the same thing programmatically.
Most UpdateChart fields patch (None keeps, "" clears). The axis scale is the exception: it replaces the scale wholesale, which is the only way to put a fixed bound back to automatic.
Where numbers get formatted. Data labels and category labels are rendered core-side with ssf-rs and shipped as strings (ChartSeriesInfo.formattedValues), so they match the sheet exactly. Axis ticks are the exception — the renderer picks the tick values, so the engine formats them with formatAxisNumber, a small subset renderer (separators, decimals, percent, currency affixes).
What's remaining
Fidelity gaps
- Fonts, fills, gridline styling and 3-D effects survive an edit but are not rendered — the ECharts layer draws its own defaults. They are preserved for Excel, not reproduced here.
- Color modifiers (
lumMod/lumOfftints on scheme colors) and per-data-point pie slice colors are not applied — base scheme/RGB colors are. numCacheis not written on save. Compliant readers recompute from the refs; a reader that only trusts the cache would show empty until recalculated.- Setting a series color explicitly replaces that series' whole
c:spPr, so a custom outline or effect on that series is dropped in exchange. Leaving colors alone keeps it byte-for-byte. - Series names are literals, not references: renaming the header cell does not rename the series.
- A secondary value axis is not modeled.
Coverage gaps
- All 16 classic
c:*Chartgroups are read, written and editable. Thecx:family (treemap, sunburst, histogram, pareto, box & whisker, waterfall, funnel, filled map) lives in a different part and is not. - A combo chart is modeled as one whose series disagree:
ChartSeriescarries an optionalseries_type, and the writer emits one plot group per distinct kind, the chart's own kind first. Only the flat category/value kinds combine (is_combinable); an override Excel could not honour folds back into the primary group rather than producing a file it would refuse. Series keep the orderc:ordergave them, not the order the groups were read in. - Per-group settings of a combo's secondary groups are not modeled — the file's settings describe the primary group, and the rest get defaults.
- The 3-D kinds render flat (
col3ddraws ascol, and so on): the depth round-trips to Excel but drawing it would mean pulling inecharts-gl. - Some kinds are rendered as the nearest thing ECharts core can draw, which is a deliberate trade rather than a bug: stock becomes a candlestick (an HLC chart has no open, so open is set to the close), a surface becomes a heatmap of the same grid, and the 3-D kinds become their flat forms. All still round-trip to Excel as their proper type.
- A stock chart's series are read positionally (4 = open/high/low/close, 3 = high/low/close); any other count falls back to plain lines. Volume variants, which put volume on a secondary axis, are not modeled.
- Of-pie honours
splitTypepos/val/percent;cust(a hand-assigned split) falls back to the automatic one, though thec:custSplitelement is preserved. - The modern "chartEx" family is not read at all: treemap, sunburst, histogram, Pareto, box & whisker, waterfall, funnel and filled map live in a separate part (
xl/charts/chartEx1.xml,cx:namespace, its own relationship type) that the reader does not pick up. - Chart sheets (a whole sheet that is one chart) are not supported.
- An unsupported chart is dropped on save.
parse_chartreturnsNone, the chart never entersChartManager, and the save path only emits anchors for charts it holds — so the drawing goes out without it. Verified by hand on a fixture whosec:barChartwas swapped forc:bubbleChartbefore bubble was supported. - 100% stacked (
percentStacked) is read as plainstacked, so editing such a chart degrades it to a normal stacked one. - Non-chart
graphicData(SmartArt, OLE embeds) does NOT round-trip — the drawinggraphicFramemodel is chart-only, so such objects are dropped on save. (Plain charts and text-box shapes are preserved.)
Minor UX
- The mouse wheel does not scroll the grid while the cursor is over a chart (the transparent drag-capture cover swallows it); could re-dispatch wheel to the canvas like the React overlay layers do.
Selecting a chart
Clicking a chart selects it, and the cells it plots are outlined on the grid in the colour of the series they feed — categories dashed, bubble sizes dotted — so you can see what a chart is reading without opening the editor. The ranges come from chart/source-ranges.ts, which parses the chart's own A1 references.
One thing it will not draw: a range on another sheet, which the editor lists instead. A range that has scrolled out of view is skipped too (isRangeVisible), since there is nothing to show.
Both the outlines and the chart frames are positioned with xForColStartUnclamped / yForRowStartUnclamped rather than the plain helpers. The plain ones only walk the rows and columns the grid has laid out, so they answer with the window's own edge for anything scrolled past — which flattens an overlay against the edge instead of letting it scroll away. The unclamped pair extrapolates at the size of the row/column nearest that edge, exact whenever the ones outside match it.
Inserting from a selection
chart/from-selection.ts turns the selected range into the chart's data references, the way Excel infers them: a leading column of text becomes the categories, a leading row of text becomes the series names, every other column is a series. The corner cell votes for neither edge — the label column is decided from the rows below it and the header row from the data columns to its right — so a table with row labels but no header row keeps its first row as data. A bubble chart reads differently: its first data column is the shared X and the rest pair up as (Y, size).
It takes cell lookups as a SelectionCells interface rather than the data service, so the inference is unit-tested without a workbook (from-selection.test.ts).
Key files
crates/workbook/src/ooxml/chart.rs— parse (parse_chart) + generate (build_chart_xml).crates/workbook/src/ooxml/drawing_part.rs—graphicFrameanchor model.crates/controller/src/chart_manager/—ChartManager+ executor, plusblock_source.rs(block binding → live A1 ranges) andrefs.rs(data ranges as cell ids, and the A1 ↔ id conversion at the edges).crates/controller/src/api/worksheet.rs—get_charts(live-value resolution and live block-range resolution).crates/workbook/src/logisheets.rs—<chartSource>, the persisted binding.packages/logician/src/tools/charts.ts— Watson's chart tools, includingchart__from_block, which states fields and lets the engine find them.packages/engine/src/lib/chart/— ECharts setup, model, renderer,ChartSettings.svelte(the editor),num-format.ts(axis ticks),from-info.ts(binding → model),from-selection.ts(insert inference) andsource-ranges.ts(the source outlines). The non-Svelte modules are the engine's tested seam.packages/engine/src/lib/components/Spreadsheet.svelte—ChartLayer, select/move/resize/delete,insertChart.src/components/toolbar/index.tsx— Insert Chart button + type menu.
