feat(mark): add an array mark for gridded and multi-dimensional data - #9926
Open
mattijn wants to merge 28 commits into
Open
feat(mark): add an array mark for gridded and multi-dimensional data#9926mattijn wants to merge 28 commits into
mattijn wants to merge 28 commits into
Conversation
Implements #9389: a mark that renders 2D raster/grid data (e.g. {width, height, values}) via Vega's image mark + heatmap transform, driven through the postEncodingTransform hook (same mechanism geoshape uses for projections). Registers `array` across the Mark union, config, channel support, and the mark-compiler registry, plus the two knock-on fixes tsc surfaced (a non-exhaustive switch in legend/properties.ts, and missing 'image'/'aspect' entries in VgEncodeChannel). Verified end-to-end against a live Vega render (not just tsc/vitest): - postEncodingTransform runs on post-encode scenegraph items, not the raw datum, so the heatmap transform needs an explicit field: 'datum' to reach the original tuple. - Heatmap's `opacity` is a transform parameter, not an encode channel - `{value: 1}` silently zeroes alpha; only emit opacity/color together as bare values, and only when a color encoding exists (so encoding-less specs keep Heatmap's own opacity-gradient default). - Color encoding uses `datum.$value / datum.$max` against a fixed [0, 1] domain (the pattern validated in the upstream issue) rather than a data-driven domain, since VL's automatic null-filter (isValid/isFinite) silently drops any datum whose color field is a nested array rather than a scalar. `array: {invalid: null}` in the default mark config additionally suppresses that filter. Not yet implemented: axis/extent support (issue variants 3, 5, 6) - needs the compiler to derive x/y scales from the grid's own width/height/extent, which nothing here does yet. The mark always renders at x:0,y:0 filling the full view. Reusable demo-spec generator for the vega test page is at scratchpad/gen-array-variants.test.ts (see session notes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds #9389 variants 3, 5, and 6: when an array-mark spec field-encodes both a position channel and its range partner (x+x2, y+y2), position and size the image from their *scaled* extent instead of always filling the view. This reuses VL's ordinary x/y scale and axis machinery unchanged (domain from the encoded fields, "nice"/ "zero" fully user-configurable via the normal scale API) - the mark compiler only needed a custom (not rectPosition/rangePosition, which are built for band scales and natural image sizing, neither of which apply here) x/width and y/height signal pair per axis, since Vega's image mark has no native x2/y2 rendering support to derive width from. Falls back to the existing fill-the-view behavior when x/x2 (or y/y2) aren't both encoded, so variants 1/2/4 are unaffected. Verified end-to-end against live Vega renders for all three variants, including facet + shared geographic axes + independent per-facet color scale together (variant 6). Found and worked around a sharp, previously-undocumented footgun in the process: Heatmap.js's toCanvas (vega-geo) reads x1/x2/y1/y2 directly off the grid datum for its own unrelated, undocumented pixel-crop feature. Naming axis-extent fields exactly x1/x2/y1/y2 (as in the original issue's own early exploration) collides with that lookup and silently corrupts the raster with negative-index reads - this is exactly what the issue author worked around with a trailing underscore (x1_/x2_/...), which the demo specs now follow. Worth flagging upstream in vega (undocumented + no bounds safety), but out of scope for vega-lite itself. Full test/ suite (3440 tests) passes; tsc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Makes real-domain color the default whenever minField/maxField are present, replacing the always-normalize-by-$max behavior for those specs (not an opt-in flag - just supply the fields and it applies). Problem: the array mark's color-encoded field is a whole raster (an array of per-pixel values), so VL's ordinary field-extent domain machinery can't compute a scale domain from it (an array coerces to NaN under isFinite/aggregate min/max - the same failure mode already worked around for the null-filter). The fix so far was to always normalize color via datum.$value / datum.$max against a fixed [0, 1] domain - safe, but it means the legend never reflects real data values, and shared vs. independent color resolve across facets can never visibly differ, since the domain is a hardcoded literal instead of anything data-driven. Fix: two new MarkDef properties, minField/maxField (mark.ts, in a new ArrayConfig interface), naming per-datum scalar fields holding each grid's true min/max (precomputed by the user, since deriving them from the raster array at compile time hits the same array-field problem). When both are set: - domain.ts unions them into the color scale's domain via the same field-union mechanism already used for x/x2 and y/y2 (color has no color2 counterpart in the schema, so this is wired in directly rather than through a second encoding channel) - respecting shared vs. independent facet resolve like any ordinary field-driven domain, and never overriding an explicit user-specified domain. - array.ts's heatmap color expression uses raw datum.$value instead of the normalized ratio, since the scale's domain now reflects the real range. Without minField/maxField, behavior is unchanged (existing demos are unaffected). Verified end-to-end: with genuinely different per-grid ranges (0-100 vs 0-40) faceted under independent resolve, each panel's legend now shows its own real range; under shared resolve, one legend unions both (0-100), and the 0-40 panel visibly compresses into the lower part of the color scheme - confirming shared/independent resolve is now doing something real, not just changing how many legends render. Full test/ suite (3442 tests) passes; tsc clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ields An array mark with a color encoding but no minField/maxField and no explicit scale domain fell through to the ordinary field-extent domain path. That computes the extent of the raster field itself - which holds arrays, not scalars - producing: WARN Infinite extent for field "values": [Infinity, -Infinity] ERROR TypeError: I[i] is not a function i.e. a hard render failure, not just a wrong-looking scale. Every demo spec so far passed an explicit domain: [0, 1], which masked this. Since the heatmap transform normalizes color by each grid's own maximum (datum.$value / datum.$max) whenever minField/maxField are absent, the scale only ever sees a 0-1 ratio - so [0, 1] is the correct default. User-specified domains and the minField/maxField path are unaffected. Full test/ suite (3443 tests) passes; tsc clean. Verified live in the Vega Editor: the failing spec now renders with a 0.0-1.0 legend and clean logs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…nField/maxField Replaces the minField/maxField markDef properties with an automatically derived extent, removing the API surface entirely. Those props were off-idiom: they were the only properties in all of MarkDef naming a data field, so nothing else in Vega-Lite looked like them, and they pushed the work of precomputing each grid's min/max onto the user. Vega's expression language has had extent(array) since v4.0, so Vega-Lite can just compute it. parseArrayExtent adds two formula transforms deriving array_min_<field> / array_max_<field>, and the color scale unions them for its domain exactly as x/x2 does. Because Vega evaluates these at runtime, this also works for data Vega-Lite never sees at compile time (a `url` source), which a compile-time data-key convention could not. An isArray guard keeps a scalar color field working: extent expects an array, and min = max = the value itself is exactly the right per-datum contribution to the domain. Also fixes a semantic wrinkle in the previous commit: an explicitly specified scale domain no longer silently flips the mark back to normalized 0-1 values. Whether the heatmap expression uses raw datum.$value now depends only on whether color is a field def - so an explicit domain is interpreted in real data units, as written. The normalized $value/$max path (against the [0,1] default) now applies only to a color datum/value def, where there is no field to take an extent of. Full test/ suite (3444 tests) passes; tsc clean. Verified live in the Vega Editor and the Vega test page: with no min/max fields in the data and no explicit domain, independent resolve gives each facet its real legend (0-100 and 20-40) and shared resolve unions to 0-100 with the narrower grid correctly compressed. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
An array mark with no position encoding fell through defaultUnitSize to
the discrete-step default, so a spec that omitted width/height rendered
its raster into a 20x20 box - too small to read anything from.
Mirrors the arc precedent immediately above it ("otherwise the pie is
extremely small"): like arc, an array mark fills the view with its own
content and so needs no position encoding to qualify for a full-size
view. Now defaults to 300x300.
Note this does not derive the view size from the grid's own
width/height: those are data fields (Vega's heatmap transform reads them
to size its canvas), and layout is resolved at compile time, where the
data may not be available at all - a url source, or grids of differing
dimensions across facets. Users who want undistorted pixels still set
width/height proportional to the grid, since the image is stretched to
fill the view (aspect: false).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three fixes for footguns hit while documenting the mark, plus docs.
1. Hand the heatmap transform a sanitized grid instead of the datum.
Vega's heatmap transform reads x1/x2/y1/y2 off the grid object for an
undocumented pixel-crop feature, and the mark was passing the whole
tuple as that grid. A spec that named its extent fields x1/y1 - the
obvious names, and the ones the upstream issue's own exploration
reached for - silently rendered a corrupted raster with no warning.
parseArrayData now builds {width, height, values} via a formula
transform and points the transform at that, so no user field can
reach the crop path. This eliminates the collision rather than
warning about it: x1/y1 are now free to use, which the demo specs do
(dropping the x1_/y1_ workaround).
2. Do not nice or zero an array mark's position scales.
A raster spans its extent exactly and is stretched to fill the view,
so rounding the domain outward left the axis quietly disagreeing with
the image: a 48x32 grid drew axes running 0-50 and 0-35. Zero is
equally wrong for extents that legitimately exclude it, such as a
latitude band. Specs no longer need the nice: false, zero: false
incantation the demos previously carried.
3. Document the mark (site/docs/mark/array.md, plus TOC).
Covers the two width/height pairs and why they are unrelated - grid
resolution vs. plot-area pixels - how to enable axes with datum defs
(no extra data fields needed) versus field defs for per-datum
extents, and the row-order convention: rows draw top-down while the y
axis runs bottom-up, so the first row sits at the highest y. That
matches imshow(origin="upper"); the docs give both the np.flipud and
scale reverse fixes for origin="lower" data.
Full test/ suite (3448 tests) passes; tsc clean. Verified live: a spec
using x1/x2/y1/y2 as extent fields now renders cleanly with exact axes.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Observable Plot's raster mark exposes imageRendering: "pixelated" for this; the equivalent already works here with no code change, since Vega's image mark honors `smooth` in the canvas, SVG, and SVG-string renderers and Vega-Lite already passes it through from the mark def. Worth documenting because the default (bilinear upscaling) blurs away cell boundaries on a coarse grid, which is the wrong default for looking at discrete cells even though it is right for a continuous field. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both are things xarray exposes as dedicated 2D-plot arguments (center= and levels=), and both already work here as ordinary Vega-Lite scale properties - domainMid for a centred diverging scheme, and a quantize scale type for banded colour - now that the mark feeds the scale real data values rather than a normalized 0-1 ratio. Also notes that a derived min/max domain is outlier-sensitive, with an explicit domain as the way to clip. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The sidebar Mark list lives in site/_layouts/docs.html, not in _includes/docs_toc.md (which only the Documentation Overview page includes), so the page was reachable by URL but missing from the nav. Caught by actually building the Jekyll site. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The page read as a description of how the mark is implemented rather than how to use it, and it was the only mark page with no live examples. Restructured to follow the other mark pages (image.md, tick.md): a one-paragraph intro, the auto-generated Documentation Overview, a schema-driven property table, then Examples, then Config. Four small examples (16x12 grids, ~4KB each) cover the things someone would actually try first: a plain raster, crisp cells via smooth, labelled axes, and faceted grids with independent color scales. Rationale that belonged in commit messages rather than user docs is gone - why position scales skip nice/zero, why the color domain is derived the way it is, how the grid reaches the heatmap transform. What survives is what a reader needs to get their own array rendered: the data shape, that width/height count cells rather than pixels, and the row-order convention with its NumPy equivalent. tsc clean; 6741 tests pass (unit + examples), including the examples suite's no-warnings check on the four new specs. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…erences
The examples inlined hundreds of generated numbers, which is a lot of
spec to read past to see how the mark is used. The bundled volcano
dataset is already stored as {width, height, values}, exactly the shape
this mark expects, so three of the four examples now just point at it:
array_grid drops from 3547 to 395 bytes, and the charts show real
terrain instead of a synthetic ripple.
The faceted example keeps inline data, since faceting needs several
grids in one dataset and no bundled dataset provides that. Its two
grids are 8x6 to keep the spec short.
Also removes em dashes throughout, and rewrites the row-order note
without the numpy/imshow framing. The point stands on its own: the
first row is drawn at the top, so reverse the y scale if the grid is
stored the other way up.
tsc clean; 6741 tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…tions The page referred to clipping the color domain, centring a diverging scheme, and taking the axis extent from fields, without showing any of them. Each now has its own example: - array_color_domain: an explicit domain with clamp, so a few extreme cells stop crowding the rest of the grid into a narrow band - array_color_diverging: a diverging scheme with domainMid placed at a chosen elevation - array_axis_field: extent taken from fields, derived here from the grid's own width and height with a calculate transform Faceting now leads with the default shared color scale before the independent one, so the two can be read against each other. The shared example makes the trade-off visible: the narrower grid uses only part of the scheme, which is what keeps the panels comparable. Inline grid values are kept on a single line rather than one value per row, and all specs are formatted with json-stringify-pretty-compact to match the surrounding examples (these files are prettier-ignored, so formatting is by convention). tsc clean; 6757 tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The intro said the mark "stays responsive at resolutions where a rect mark would not". That asserts a benchmark that was never run, in terms vague enough to be unfalsifiable, and no other mark page makes performance claims at all: they say what the mark is and what it is useful for. Replaced with the distinction a reader actually needs when choosing between this and rect, which is the shape of their data: one datum holds a whole grid here, rather than one row per cell. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The property table listed `aspect`, but setting it did nothing: the
mark hardcoded aspect: {value: false} after spreading the base encode
entry, so the value a user put in the mark def was silently discarded.
It was documented purely because it appears on MarkConfig.
encodeEntry now reads the property with getMarkPropOrConfig, keeping
false as the default. Verified for a mark def value, a config.array
value, and the default, with a test covering the first and last.
aspect: true is worth having: it fits the grid inside the view rather
than stretching it, so cells stay square when the view is not
proportional to the grid. Added array_aspect showing an 87 by 61 grid
in a square view, which is otherwise noticeably distorted.
Also rewords the Grid Data section, which introduced the volcano
dataset as though its shape were a happy accident. It now states the
shape the mark needs, shows it, then names volcano as a dataset already
distributed that way.
tsc clean; 6762 tests pass.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
"in row-major order: left to right, then top to bottom" restated the term it was glossing. Row-major already means rows are contiguous and traversed before moving on, so "left to right" adds nothing, and which row comes first is a separate convention that the paragraph below already covers. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
… [SVG] CONTRIBUTING is explicit that SVG and PNG output should be regenerated by CI rather than committed, since Vega's renderer differs slightly across machines. I had committed both for all nine array examples. The deterministic artifacts that CI does verify, examples/compiled/*.vg.json, stay. Also removes two files a shell mistake created: zsh does not word-split an unquoted variable, so a loop over "$NAMES" produced single files named after all nine examples concatenated. [SVG] asks CI to run build:examples-full so the images are regenerated from scratch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Found while pre-running the CI checks:
- site/_includes/docs_toc.md is auto-generated by scripts/generate-toc,
which I had hand-edited. Regenerated it properly by building the site
and running the generator, which adds the Documentation Overview entry
my hand-written version was missing. The source of truth for the nav
is the mark list in site/_layouts/docs.html.
- Two comments still referred to parseArrayExtent, which was renamed to
parseArrayData when the module absorbed the grid sanitization.
- Reflowed a comment in the mark compiler that had run into itself
("the fixed [0, 1] domain domain.ts falls back to").
- Prettier reformats prose in site/docs to single lines, matching the
other mark pages.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CI committed a schema update to this branch back when the mark still had minField/maxField properties. Rebasing onto that commit dropped my later regeneration as "already upstream", leaving the committed schema advertising an API that no longer exists. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Each grid covers two dimensions, and row/column place grids by two more, so four dimensions can be shown at once. That already worked, since the array mark is in ALL_MARKS for the facet channels, but nothing demonstrated it and I had only tested the facet operator. Verified row alone, column alone, and row by column all compile without warnings and render. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reverts the Further Dimensions section and its example. The capability is real (row, column, and row by column all compile without warnings and render), but it does not need to land with the mark itself and can be added as an example later. Faceting is still shown by facet_array and facet_array_independent_color. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…example CI generated the SVG and PNG for trellis_array_row_column between my pushes, so removing the spec left them orphaned. build-examples would prune them on the next full run, but no reason to carry them. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Two branches were defending against cases that do not need defending: - The extent formula guarded its input with isArray, falling back to the value itself. extent() returns undefined rather than throwing on a non-array, and a scalar color field is meaningless for a mark that colors per cell, so the guard only made a nonsensical spec look supported. The emitted expression is now just extent(datum.f)[0]. - A color datum/value def forced the domain to [0, 1] and switched the transform to a normalized expression. Forcing the domain was wrong on its own terms, since a datum def already has one. Color now needs a field: without one the transform shades by opacity, exactly as it does when color is not encoded at all. Also trims the comments through the array mark's code paths to what is not evident from the code, mostly the two pieces of Vega behaviour worth knowing: that the heatmap transform crops on x1/y1 read off the grid, and that a post-encoding transform sees scenegraph items. tsc clean; 6762 tests pass; the nine docs examples render unchanged. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
348 by 244 reads as an arbitrary pair until you notice it is 87 by 61 times four. The view has to stay proportional to the grid for cells to come out square, since the image is stretched to fill it, so array_grid now says so and the rest of the volcano examples follow the same multiple. array_aspect is the deliberate exception, and its description now mentions the square view that makes aspect worth showing. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The file had accumulated overlap and one stale claim: - A test name still said the extent formula was "guarded for non-array fields" after that guard was removed. - aspect was asserted twice, and that width/height carry a signal twice, the latter only checking that the property existed. - Two tests asserted the same color expression. - One test was named "renders as an image mark" without ever checking vgMark, which is now asserted. - A test claiming to leave x1/y1 free for the user only re-checked that a domain unions two fields. What actually prevents that collision is the sanitized grid, which its own test covers, so it is gone. Regrouped into encodeEntry, postEncodingTransform, color domain, and scales and layout, with names that say the behaviour rather than the mechanism. Twelve tests, no assertion made twice. Checked they fail when they should: reverting the nice/zero rule fails the position scale test rather than passing quietly. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Contributor
Author
|
Finally was able to spend some time with a coding agent to add support for array data. Open for questions and review. Docs that I added with examples can also be previewed by this pdf: Array | Vega-Lite.pdf Btw even though I don't see the cloudflare bot comment with the preview vega-editor urls, I used the name of the branch as base-url, just like other PRs, and that seems to work fine (had to change to absolute paths instead of relative paths for datasets, so the urls in OP also work. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #9389.
This PR adds an
arraymark that draws a grid of values as a single raster image, compiling to Vega'simagemark plus itsheatmaptransform. Gridded data is common in modelling and imaging. Plotting it today means either reshaping the grid into one row per cell and using arectheatmap, or writing the chart in Vega instead of Vega-Lite.New syntax
One datum describes one grid,
{width, height, values}withvaluesin row-major order, which is howvolcanois already distributed:{ "data": {"url": "data/volcano.json"}, "mark": "array", "encoding": {"color": {"field": "values", "type": "quantitative"}} }Faceting works as usual, so several grids can sit side by side, or be laid out by row and column.
Axes are optional. To label one, give the extent the grid covers with
x/x2andy/y2. Use constantdatumvalues when the extent is fixed, or fields when each grid covers a different one.How it works
extent()in a formula transform. The legend therefore reads in data units, and shared or independent facet resolve behaves as it does for any other mark. Deriving it at runtime also covers aurlsource, whose contents Vega-Lite never sees.width,heightandvalues. Vega'sheatmapalso readsx1/x2/y1/y2off the grid to crop the raster, so building the object explicitly leaves those names free for a spec to use as its extent fields.arc, since the mark fills the view with its own content rather than sizing from a position encoding.Try it
These open in the editor built from this branch, which will be auto-build for the PR, so they start working once the first deploy finishes.
Editor links:
smooth: false)aspect: true)