diff --git a/.changeset/centred-zoom-controls.md b/.changeset/centred-zoom-controls.md new file mode 100644 index 0000000000..eb82b4cdd8 --- /dev/null +++ b/.changeset/centred-zoom-controls.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(TransformContext): Zoom in / out about the middle of the plot area, and center a `canvas` zoom without resetting its scale diff --git a/.changeset/colored-contour-lines.md b/.changeset/colored-contour-lines.md new file mode 100644 index 0000000000..2cdeea6983 --- /dev/null +++ b/.changeset/colored-contour-lines.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Contour): Stroke unfilled contours in the bands' colors when no `stroke` is given diff --git a/.changeset/floating-points-walls.md b/.changeset/floating-points-walls.md new file mode 100644 index 0000000000..20fbab958a --- /dev/null +++ b/.changeset/floating-points-walls.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Frame|Grid|Axis|Highlight): Draw an `isometric` chart's back walls, their gridlines (``), a height axis (``), and highlights at a point's height diff --git a/.changeset/geo-path-motion.md b/.changeset/geo-path-motion.md new file mode 100644 index 0000000000..2aae4d5198 --- /dev/null +++ b/.changeset/geo-path-motion.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(GeoPath): Start a tweened map shape as itself rather than rising from a baseline diff --git a/.changeset/isometric-heights.md b/.changeset/isometric-heights.md new file mode 100644 index 0000000000..936a6df76f --- /dev/null +++ b/.changeset/isometric-heights.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat: Support `z` heights on an `isometric` chart — boxes (`Rect`, `Cell`, `Calendar`, `Bars` with `valueAxis="z"`), floating `Circle`, `Text`, `Labels` and `ArcLabel`, `Line` stems, and extruded `Polygon`, `Path`, `GeoPath`, `Contour`, `Pie`, `Area` and `Spline` diff --git a/.changeset/split-paths-by-g.md b/.changeset/split-paths-by-g.md new file mode 100644 index 0000000000..9147a74844 --- /dev/null +++ b/.changeset/split-paths-by-g.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +breaking(Chart|Spline|Area): Rename the `z` grouping channel to `g` (ex. ``), freeing `z` for a third position diff --git a/.changeset/steady-radial-ticks.md b/.changeset/steady-radial-ticks.md new file mode 100644 index 0000000000..4246878417 --- /dev/null +++ b/.changeset/steady-radial-ticks.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Axis): Size an `angle` axis' ticks by its circle rather than the chart's width, so a continuous angle scale's tick count no longer changes with width (square charts get more ticks) diff --git a/.changeset/straight-links.md b/.changeset/straight-links.md new file mode 100644 index 0000000000..5eeed33e45 --- /dev/null +++ b/.changeset/straight-links.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Link): Draw links whose ends share an `x` or `y` diff --git a/.changeset/switchable-transform-mode.md b/.changeset/switchable-transform-mode.md new file mode 100644 index 0000000000..7047066679 --- /dev/null +++ b/.changeset/switchable-transform-mode.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(TransformContext): Switch `mode` without remounting the chart diff --git a/.changeset/tilted-floors-turn.md b/.changeset/tilted-floors-turn.md new file mode 100644 index 0000000000..03a111264e --- /dev/null +++ b/.changeset/tilted-floors-turn.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Chart): Support `view={isometric}` to draw the plot area as a floor seen at an angle, turned with `isometric({ rotate, tilt })` or by dragging (`transform={{ drag: 'rotate' }}`), with `viewport` text facing the viewer diff --git a/.changeset/transformed-brush.md b/.changeset/transformed-brush.md new file mode 100644 index 0000000000..c29c565430 --- /dev/null +++ b/.changeset/transformed-brush.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(BrushContext): Draw and measure the brush through a `canvas` pan / zoom diff --git a/.changeset/turned-ellipses.md b/.changeset/turned-ellipses.md new file mode 100644 index 0000000000..07fd68a3f3 --- /dev/null +++ b/.changeset/turned-ellipses.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Ellipse): Support `rotate` to turn the ellipse about its center diff --git a/.changeset/zoomed-tooltips-find.md b/.changeset/zoomed-tooltips-find.md new file mode 100644 index 0000000000..3cd502ba90 --- /dev/null +++ b/.changeset/zoomed-tooltips-find.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(TooltipContext): Account for `transform.mode: 'canvas'` pan / zoom in bisect modes and `'data'` tooltip placement diff --git a/docs/src/content/components/Area.md b/docs/src/content/components/Area.md index 7e0b583c4f..a51696b6c1 100644 --- a/docs/src/content/components/Area.md +++ b/docs/src/content/components/Area.md @@ -13,9 +13,9 @@ See also: [AreaChart](/docs/components/AreaChart) for simplified examples :example{ name="basic" showCode } -### Multiple areas (`z`) +### Multiple areas (`g`) -Set `z` to draw a separate area per distinct value, all from one mark — the same channel [`Spline`](/docs/components/Spline#multiple-lines-z) uses. A `fill` (or `stroke`) that names a data property implies it, so this draws one area per fruit, colored through the chart's [`c` scale](/docs/guides/scales): +Set `g` to draw a separate area per distinct value, all from one mark — the same channel [`Spline`](/docs/components/Spline#multiple-lines-g) uses. A `fill` (or `stroke`) that names a data property implies it, so this draws one area per fruit, colored through the chart's [`c` scale](/docs/guides/scales): ```svelte @@ -34,3 +34,9 @@ This replaces grouping the data yourself and rendering an `Area` per group — e ### Playground :example{ name="playground" } + +## Isometric ridgeline + +On an [isometric](/docs/guides/isometric) chart, `z` gives each point a height, and an `Area` stands up as a curtain from the floor to those heights. One curtain per row of a band `y`, drawn back to front, makes a 3D ridgeline. + +:example{ name="oscilloscope-ridgeline-isometric" } diff --git a/docs/src/content/components/Bars.md b/docs/src/content/components/Bars.md index 377b399169..ebbcf44e62 100644 --- a/docs/src/content/components/Bars.md +++ b/docs/src/content/components/Bars.md @@ -18,3 +18,9 @@ See also: [BarChart](/docs/components/BarChart) for simplified examples Use `width` or `height` to override the scale-derived size with a fixed pixel value. The bar is centered within its band. :example{ name="vertical-fixed-width" showCode } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart with `valueAxis="z"`, bars stand up off the floor as 3D columns, stacked by the chart. + +:example{ component="Chart" name="isometric-stacked-columns" } diff --git a/docs/src/content/components/Calendar.md b/docs/src/content/components/Calendar.md index 179d2f8fc8..ea4c873f8d 100644 --- a/docs/src/content/components/Calendar.md +++ b/docs/src/content/components/Calendar.md @@ -8,3 +8,9 @@ related: [Month] ## Usage :example{ name="basic" showCode } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart, `z` stands each day up to its value. + +:example{ name="isometric" } diff --git a/docs/src/content/components/Chart.md b/docs/src/content/components/Chart.md index 54014430db..f2256ccee4 100644 --- a/docs/src/content/components/Chart.md +++ b/docs/src/content/components/Chart.md @@ -34,6 +34,32 @@ The same chart from long data needs no `series` at all — `c` names the layers, :example{ name="mixed-marks-with-stack-long-data" } +## Isometric + +`view={isometric}` draws the plot area as a floor seen from above at an angle, for a faux 3D look in any layer. See the [Isometric guide](/docs/guides/isometric) for the view options, height, text, and tooltips. + +```svelte + +``` + +:example{ name="isometric-heatmap" } + +:example{ name="isometric-columns" } + +:example{ name="isometric-stacked-columns" } + +:example{ name="isometric-areas" } + +:example{ name="isometric-scatter-3d" } + +:example{ name="isometric-treemap" } + +:example{ name="isometric-scatter" } + +:example{ name="isometric-playground" } + +:example{ name="isometric-pan-zoom" } + ## Text selection Charts are treated as interactive widgets: `user-select: none` is applied to the root container (`.lc-root-container`) so dragging to brush, pan, or zoom never selects axis labels or surrounding page text. Since `user-select` inherits, this covers the whole chart. diff --git a/docs/src/content/components/Contour.md b/docs/src/content/components/Contour.md index 3590be91c9..5df3806719 100644 --- a/docs/src/content/components/Contour.md +++ b/docs/src/content/components/Contour.md @@ -23,6 +23,20 @@ Filled contour bands of the same Maungawhau elevation data, colored by threshold :example{ name="volcano-filled" showCode } +## Isometric terrain + +On an [isometric](/docs/guides/isometric) chart, `z` raises each band. With `z="value"`, each band stands at its threshold, measured by `zScale`. Filled bands stack into terraces, and unfilled ones float as lines in the bands' colors. Drag to turn it. + +```svelte + + + + + +``` + +:example{ name="volcano-isometric" } + ## Interactive controls Adjust the number of **thresholds** and **blur** radius interactively. diff --git a/docs/src/content/components/GeoPath.md b/docs/src/content/components/GeoPath.md index 9ac97de014..6d7a03e765 100644 --- a/docs/src/content/components/GeoPath.md +++ b/docs/src/content/components/GeoPath.md @@ -12,3 +12,9 @@ See the [Geo guide](/docs/guides/geo) for a full walkthrough of projections, fit ## Usage :example{ name="tooltip" showCode } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart, `z` stands each shape up off the floor. Here each state stands at its share of the 2020 presidential vote. Drag to turn it. + +:example{ name="election-isometric" } diff --git a/docs/src/content/components/Line.md b/docs/src/content/components/Line.md index 3e952d0f6a..ae8c648abd 100644 --- a/docs/src/content/components/Line.md +++ b/docs/src/content/components/Line.md @@ -30,3 +30,11 @@ Use `stroke` with a data property name to color each line through the chart's co Use a threshold scale to color lines based on value ranges. :example{ name="color-via-threshold-scale" showCode } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart, `z1` / `z2` raise each end of a line. A line from the floor up to a point is a stem: + +```svelte + +``` diff --git a/docs/src/content/components/Pie.md b/docs/src/content/components/Pie.md index 011dcb7e53..975ee5cf44 100644 --- a/docs/src/content/components/Pie.md +++ b/docs/src/content/components/Pie.md @@ -13,106 +13,8 @@ See also: [PieChart](/docs/components/PieChart) for simplified examples :example{ name="basic" showCode } - +:example{ name="isometric" } diff --git a/docs/src/content/components/Polygon.md b/docs/src/content/components/Polygon.md index 418bd318f2..e98ec9a334 100644 --- a/docs/src/content/components/Polygon.md +++ b/docs/src/content/components/Polygon.md @@ -30,3 +30,7 @@ Use `fill` or `stroke` with a data property name to color each item through the Use a threshold scale to color items based on value ranges. :example{ name="color-via-threshold-scale" showCode } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart, `z` stands a polygon up into a prism. See the [isometric tree](/docs/components/Tree#isometric). diff --git a/docs/src/content/components/Spline.md b/docs/src/content/components/Spline.md index c84a7aec9a..32eb3e7f82 100644 --- a/docs/src/content/components/Spline.md +++ b/docs/src/content/components/Spline.md @@ -13,9 +13,9 @@ See also: [LineChart](/docs/components/LineChart) for simplified examples :example{ name="basic" showCode } -### Multiple lines (`z`) +### Multiple lines (`g`) -Set `z` to draw a separate line per distinct value, all from one mark — one line per series, without a mark each. +Set `g` to draw a separate line per distinct value, all from one mark — one line per series, without a mark each. ```svelte @@ -23,23 +23,15 @@ Set `z` to draw a separate line per distinct value, all from one mark — one li ``` -Most of the time you don't set it at all: a `stroke` (or `fill`) that names a data property implies it, so the line above draws one line per fruit. `z` resolves in order — this mark's `z`, the Chart's `z`, then whatever `stroke` / `fill` names. +Most of the time you don't set it at all: a `stroke` (or `fill`) that names a data property implies it, so the line above draws one line per fruit. `g` resolves in order — this mark's `g`, the Chart's `g`, then whatever `stroke` / `fill` names. This replaces grouping the data yourself and rendering a `Spline` per group. Prefer it: every mark registers with the chart and rebuilds its domains, so a mark per line costs O(lines × rows) at mount — with a few hundred lines that's the difference between ~1.9s and under 100ms. -`z` keeps its scale, so the same field can group the lines _and_ encode them: - -```svelte - - context.zScale(d.year)} /> - -``` - -Set it explicitly when the grouping isn't the color — [parallel coordinates](#parallel-coordinates) groups by row (`z="index"`) while coloring by species. +Set it explicitly when the grouping isn't the color — [parallel coordinates](#parallel-coordinates) groups by row (`g="id"`) while coloring by species. ### Colors -`stroke` and `fill` accept a CSS color, or the name of a data property — which resolves through the chart's [`c` scale](/docs/guides/scales), like every other mark. With `z`, the color resolves per line. +`stroke` and `fill` accept a CSS color, or the name of a data property — which resolves through the chart's [`c` scale](/docs/guides/scales), like every other mark. With `g`, the color resolves per line. ```svelte @@ -47,10 +39,10 @@ Set it explicitly when the grouping isn't the color — [parallel coordinates](# ### Per-segment styling -Pass a function to `stroke`, `fill`, `opacity`, or `class` to style each segment independently. Consecutive data points with the same resolved value are grouped into separate path segments. This composes with `z` — each line is split into its own segments, so a function is the way to style a grouped `Spline` per line: +Pass a function to `stroke`, `fill`, `opacity`, or `class` to style each segment independently. Consecutive data points with the same resolved value are grouped into separate path segments. This composes with `g` — each line is split into its own segments, so a function is the way to style a grouped `Spline` per line: ```svelte - (d.year === 2024 ? 'stroke-primary' : 'stroke-surface-content')} /> + (d.year === 2024 ? 'stroke-primary' : 'stroke-surface-content')} /> ``` :example{ name="stroke-grouping" } @@ -86,7 +78,7 @@ When inside a `GeoProjection` context, Spline automatically renders as a project ### Parallel coordinates -One line per row across an axis per dimension, from a single `Spline` grouped by `z`. Each dimension keeps its own domain — `Axis` takes a `scale` override, so the ticks read in real units — while positions are normalized to a shared `0–1` domain so every dimension can share the chart's `y` scale. `Group` places each axis at its point on the categorical `x` scale. +One line per row across an axis per dimension, from a single `Spline` grouped by `g`. Each dimension keeps its own domain — `Axis` takes a `scale` override, so the ticks read in real units — while positions are normalized to a shared `0–1` domain so every dimension can share the chart's `y` scale. `Group` places each axis at its point on the categorical `x` scale. :example{ name="parallel-coordinates" } diff --git a/docs/src/content/components/Tree.md b/docs/src/content/components/Tree.md index a8ab8c1598..af10650748 100644 --- a/docs/src/content/components/Tree.md +++ b/docs/src/content/components/Tree.md @@ -12,3 +12,9 @@ related: [] ### Playground :example{ name="playground" } + +## Isometric + +On an [isometric](/docs/guides/isometric) chart, nodes can stand up as prisms (`Polygon`s with a `z`) over links lying on the floor. + +:example{ name="isometric" } diff --git a/docs/src/content/guides/data.md b/docs/src/content/guides/data.md index 2fe54cbd5c..d0ff056f54 100644 --- a/docs/src/content/guides/data.md +++ b/docs/src/content/guides/data.md @@ -203,17 +203,17 @@ Naming that column as the `c` channel is enough to draw it. There's no `series` | Source | Example | Notes | | ----------------- | --------------------------- | ------------------------------------------- | -| The mark's `z` | `` | Most specific | -| The chart's `z` | `` | Applies to every mark inside | +| The mark's `g` | `` | Most specific | +| The chart's `g` | `` | Applies to every mark inside | | `stroke` / `fill` | `` | Names a data property, colored via `cScale` | | The chart's `c` | `` | Only when it names a column (see below) | `c` only splits when it **names a column**. `c="fruit"` says that column holds the category; a computed accessor like `c={(d) => (d.value < 0 ? 'under' : 'over')}` is a color per row, and grouping on it would cut one series into a path per color, joining points that aren't next to each other. A continuous `c` (`c="value"` with a sequential scale) is a color ramp for the same reason — measurements aren't category names. Neither does a mark handed its own rows via `data` split — whoever grouped them already decided. -Use `z` when the split and the color aren't the same thing. Here each line is one flower, colored by its species: +Use `g` when the split and the color aren't the same thing. Here each line is one flower, colored by its species: ```svelte - + ``` ### Legend diff --git a/docs/src/content/guides/isometric.md b/docs/src/content/guides/isometric.md new file mode 100644 index 0000000000..11ed65688d --- /dev/null +++ b/docs/src/content/guides/isometric.md @@ -0,0 +1,315 @@ +--- +title: Isometric (Faux 3D) +category: state +--- + +`view={isometric}` draws a chart's plot area as a floor seen from above at an angle. Works in any layer type (`Svg`, `Canvas`, `Html`) and marks (ex. `Axis`, `Rect`, etc). + +## Quick start + +Import `isometric` and pass as `view` to `Chart`. + +```svelte + + + + + + + + + +``` + +:example{ component="Chart" name="isometric-heatmap" } + +To try it on every chart at once, set it as the `view` setting. Charts that set their own `view` (including `null`) keep it. LayerChart docs support this and exposed via the **Isometric** switch in a component page's settings menu. + +```svelte + +``` + +## How it works + +The scales, marks, and axes lay out on a flat **floor**, exactly as they would on a flat chart. Each layer then draws the floor turned and tipped away from the viewer, centered in the chart. A cell becomes a diamond, a circle an ellipse, and an axis runs along the floor's edge. + +The floor keeps its own proportions, so resizing the chart scales it rather than stretching it. Text and strokes stay their natural size. It's a parallel projection: the far side of the floor is the same size as the near side. + +## The view + +Call `isometric` with options to change the view: + +| Option | What it does | Default | +| -------- | --------------------------------------------------------------------------------- | -------------------------- | +| `rotate` | Turns the floor about its center, clockwise, in degrees | `-45` | +| `tilt` | Tips it away from the viewer, from `0` (seen from directly above) towards edge on | `54.7356` (true isometric) | +| `aspect` | The floor's width over its depth, or `'auto'` to take it from the data | `'auto'` | +| `motion` | Eases the view to new `rotate` / `tilt` values ([below](#animating-the-view)) | | + +The defaults are true isometric: the origin sits at the front corner, and both axes meet the horizontal at 30°. + +```svelte + + + + + + + + + + + +``` + +`aspect: 'auto'` picks the floor's proportions from the scales: + +- **Two band scales:** columns over rows, so every cell is square. A 20-week calendar is a floor 20 cells by 7. +- **Two continuous scales:** the domains' spans, so a unit runs as far across the floor as into it. If one span is more than four times the other, the floor is square instead. +- **Anything else:** square. + +A `radial` chart's floor is always square. + +### Animating the view + +`motion` eases the view to new `rotate` / `tilt` values instead of jumping. `rotate: 0, tilt: 0` shows the floor from directly above, so toggling between that and the tilted view animates smoothly: + +```svelte + + + +``` + +Any `motion` works. A `'spring'` suits values that change continuously, like a slider. + +### Dragging the view + +With a `transform`, an isometric chart can pan and zoom, and it can also turn: drag across to rotate it, up and down to tilt it. `drag` picks what a plain drag does. Holding `dragSwitchKey` (`shift` by default) as the drag starts does the other. + +```svelte + + + + + +``` + +The view starts at its `rotate` and `tilt`, and moves to new values when they change. `onTransform` reports the current angles as `rotation` (`x` is the turn, `y` the tilt, in degrees). `reset()` restores them along with the pan and zoom. `transform.drag` is reactive, so a control can switch it. `TransformContextControls` has one built in. + +In the playground below, the sliders and dragging set the same angles. + +:example{ component="Chart" name="isometric-playground" } + +`mode: 'domain'` works too: panning and zooming change the domains rather than the drawing, so the axes re-tick and marks keep their size. Wrap the marks in `ChartClipPath` to clip them to the floor as they pan past its edge. Switch modes below to compare. + +:example{ component="Chart" name="isometric-pan-zoom" } + +## Height + +`z` raises marks off the floor. Set it on the chart and every `Rect` and `Cell` stands up into a box that tall, with the sides facing the viewer shaded: + +```svelte + + + + + +``` + +:example{ component="Chart" name="isometric-columns" } + +- **The `z` scale** measures up from the floor. Its domain includes `0`, and its range defaults to half the floor's shorter side. Set `zDomain` / `zRange` to change them. The chart leaves room above the floor for the top of the range. +- **A mark's own `z`** overrides the chart's. A value of `[start, end]` floats the box between the two heights. +- **A `Rect` placed in pixels** takes a pixel height: ``. +- **`shade={false}`** draws the sides in the same color as the top. +- **Nearer boxes draw over farther ones.** +- **On a flat chart**, or in a layer with `ignoreTransform`, `z` has no effect. + +### 3D scatter + +`Circle` floats each point at its `z`. Add `viewport` to keep it round instead of lying flat as an ellipse. To frame the points: + +- `Frame` stands walls up the floor's two far edges, as tall as the `z` range. +- `Grid z` draws gridlines on the walls. +- `Axis placement="back"` runs a height axis up the back corner. + +```svelte + + + + + + + + + + +``` + +A `zRange` as tall as the floor is deep makes a cube: `zRange={({ height }) => [0, height]}`. + +:example{ component="Chart" name="isometric-scatter-3d" } + +`quadtree` tooltips find each point where it floats, and `Highlight` follows the hovered point. Its `lines` trace the point on the floor and on each wall, and `shadows` marks where those lines cross. + +```svelte + + +``` + +### Treemap + +Floating each node's `Rect` one step above its parent makes a 3D treemap. Use `zScale` to measure the steps: a `zDomain` with one step per level, and a `zRange` for the total height. + +```svelte + + {#snippet children({ context })} + + + {#snippet children({ nodes })} + {#each nodes as node} + + {/each} + {/snippet} + + + {/snippet} + +``` + +On an isometric chart, `Treemap` passes `nodes` in drawing order, so nearer tiers cover farther ones. Leave the `{#each}` unkeyed: the order changes as the view turns, and a `Canvas` layer draws in the order its marks mounted. + +To label a node on its top, draw a `Text` right after its `Rect`, with the tier's top as its `z`. + +:example{ component="Chart" name="isometric-treemap" } + +### Contours + +`Contour` raises each band with `z`. With `z="value"`, each band stands at its threshold, measured by `zScale`. Filled bands stack into terraces, and unfilled ones float as lines. See [Contour](/docs/components/Contour#isometric-terrain). + +:example{ component="Contour" name="volcano-isometric" } + +### Paths and maps + +`Path` takes a `z` in pixels, or `[start, end]` to float it, and stands its shape up with shaded sides. An unfilled path is raised as a line. Marks drawn with `Path` pass it through, so a `GeoPath` can stand a region up: + +```svelte + +``` + +When shapes stand side by side, draw nearer ones last. With `m = context.isometricMatrix`, a point `[x, y]` on the floor is nearer the viewer the larger `m.b * x + m.d * y` is. The map below sorts its states by their centers this way: + +```svelte +{@const m = context.isometricMatrix} +{@const depth = ([x, y]) => m.b * x + m.d * y} +{#each states.toSorted((a, b) => depth(geoPath().centroid(a)) - depth(geoPath().centroid(b))) as state} +``` + +Sorting by center works for shapes of similar size. A large shape wrapped around a smaller one can still draw in the wrong order. + +:example{ component="GeoPath" name="election-isometric" } + +### More marks with height + +- **Bars:** with `valueAxis="z"`, values stand up off the floor instead of running along it. Each bar stands on its `x` / `y` band cell, and the chart stacks the values per cell, so hiding a series in the legend closes up the stacks. + + ```svelte + + + + + + ``` + + :example{ component="Chart" name="isometric-stacked-columns" } + +- **Calendar:** `z` stands each day up to its value. + + :example{ component="Calendar" name="isometric" } + +- **Area and Spline:** `z` gives each point a height. A `Spline` runs through the points in 3D, and an `Area` stands up as a curtain from the floor to each point. One curtain per row of a band `y`, drawn back to front, makes a 3D area chart or ridgeline. + + :example{ component="Chart" name="isometric-areas" } + +- **Polygon:** `z` stands a polygon up into a prism. + + :example{ component="Tree" name="isometric" } + +- **Pie:** `z` stands every slice up, for a 3D pie or donut. Give an `ArcLabel` the same `z` and its callouts keep clear of the slices' sides. The `arcs` it passes to `children` come in drawing order, so match a slice to its data with `arc.index` or `arc.data` rather than the `{#each}` index. + + :example{ component="Pie" name="isometric" } + +- **Line:** `z1` / `z2` raise each end. A line from the floor up to a point is a stem: + + ```svelte + + ``` + +- **Labels** float with their points, at the chart's `z` or their own. + +Heights animate with a mark's `motion`, like its position. The map above eases its states between parties with `motion` on each `GeoPath`. + +## Text + +Text lies on the floor like everything else. To keep it readable, set `viewport`: the text stays at its spot on the floor but faces the viewer, left to right at its natural size. + +```svelte + +``` + +On an `Axis`, pass it through `tickLabelProps` and `labelProps`: + +```svelte + + +``` + +The **Labels** toggle in the [playground](#dragging-the-view) compares the two. Text along a `path`, or positioned with a CSS value such as `x="50%"`, stays on the floor. + +`z` raises text like any other mark. + +## Flat overlays + +A layer with `ignoreTransform` draws flat, over the floor. Use it for a title, legend, or annotation that shouldn't tilt: + +```svelte + + + + + + + + +``` + +## Tooltips + +Tooltips follow the pointer onto the floor, including on the simplified charts: + +:example{ component="Chart" name="isometric-scatter" } + +- **`bisect-x`, `bisect-y`, `bisect-band`** look up the data under the pointer on the floor. +- **`quadtree` and `voronoi`** find the point nearest the pointer on screen, where you see it, raised by its height. +- **`band` and `bounds`** hit areas lie on the floor with the marks. For boxes with height, let each box be its own hit area instead: `` with `tooltipContext={{ mode: 'manual' }}`. +- **A pointer off the floor** shows nothing. +- **`x="data"` / `y="data"`** placement puts the tooltip where the point is drawn. diff --git a/docs/src/content/guides/transform.md b/docs/src/content/guides/transform.md index 28df60b6c9..189588507b 100644 --- a/docs/src/content/guides/transform.md +++ b/docs/src/content/guides/transform.md @@ -151,6 +151,7 @@ When set, scroll events without the key held are ignored (no `preventDefault`), ### Pointer interactions - **Drag** — pan (click and drag to move the view) +- **Shift + drag** — on an `isometric` chart, turn the view instead. `drag: 'rotate'` swaps the two, and `dragSwitchKey` changes the key (see [Dragging the view](/docs/guides/isometric#dragging-the-view)) - **Double-click** — zoom in 2x at the click point - **Shift + double-click** — zoom out 0.5x - **Trackpad pinch** — detected as ctrl+wheel events, always zooms regardless of scroll mode @@ -235,7 +236,7 @@ Use `setScale()` and `reset()` to build animated zoom controls, like this solar | `zoomIn()` | Zoom in by 1.25x from center | | `zoomOut()` | Zoom out by 0.8x from center | | `reset()` | Restore initial scale and translate | -| `translateCenter()` | Center the view (translate to 0,0) | +| `translateCenter()` | Center the view, keeping the zoom | | `zoomTo(center, rect?)` | Zoom to fit a point or bounding rectangle | | `setScale(value, options?)` | Set scale directly | | `setTranslate(point, options?)` | Set translate directly | @@ -509,7 +510,7 @@ Inertia works with all transform modes and respects `translateExtent`, `constrai ## Controls -The `TransformContextControls` component provides a UI overlay with zoom/pan buttons and scroll mode selector: +The `TransformContextControls` component provides a UI overlay with zoom buttons, a reset back to where the chart started, and a scroll mode selector: ```svelte @@ -520,6 +521,8 @@ The `TransformContextControls` component provides a UI overlay with zoom/pan but It supports placement (`'top-left'`, `'top-right'`, `'bottom-left'`, etc.), orientation (`'horizontal'` or `'vertical'`), and selective display of controls via the `show` prop. +On an `isometric` chart, it also has a menu to pick what a drag does: pan or turn the view (`transform.drag`). + ## Quick reference | Use case | Configuration | Example | diff --git a/docs/src/examples/catalog/Axis.json b/docs/src/examples/catalog/Axis.json index 119742ca59..d40edb9a54 100644 --- a/docs/src/examples/catalog/Axis.json +++ b/docs/src/examples/catalog/Axis.json @@ -3453,6 +3453,48 @@ "lineNumber": 29, "line": "" }, + { + "example": "isometric-columns", + "component": "Chart", + "path": "/docs/components/Chart/isometric-columns", + "lineNumber": 36, + "line": " 'W' + d} tickLabelProps={{ viewport: true }} />" + }, + { + "example": "isometric-columns", + "component": "Chart", + "path": "/docs/components/Chart/isometric-columns", + "lineNumber": 37, + "line": " daysOfWeek[d]} tickLabelProps={{ viewport: true }} />" + }, + { + "example": "isometric-heatmap", + "component": "Chart", + "path": "/docs/components/Chart/isometric-heatmap", + "lineNumber": 35, + "line": " 'W' + d} tickLabelProps={{ viewport: true }} />" + }, + { + "example": "isometric-heatmap", + "component": "Chart", + "path": "/docs/components/Chart/isometric-heatmap", + "lineNumber": 36, + "line": " daysOfWeek[d]} tickLabelProps={{ viewport: true }} />" + }, + { + "example": "isometric-playground", + "component": "Chart", + "path": "/docs/components/Chart/isometric-playground", + "lineNumber": 67, + "line": "" + }, + { + "example": "isometric-playground", + "component": "Chart", + "path": "/docs/components/Chart/isometric-playground", + "lineNumber": 68, + "line": "" + }, { "example": "mixed-marks-with-stack", "component": "Chart", @@ -4867,6 +4909,34 @@ "lineNumber": 34, "line": "" }, + { + "example": "missing-data-dashed", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-dashed", + "lineNumber": 69, + "line": "" + }, + { + "example": "missing-data-dashed", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-dashed", + "lineNumber": 70, + "line": "" + }, + { + "example": "missing-data-with-markers", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-with-markers", + "lineNumber": 56, + "line": "" + }, + { + "example": "missing-data-with-markers", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-with-markers", + "lineNumber": 57, + "line": "" + }, { "example": "multiple-series", "component": "Spline", @@ -5638,5 +5708,5 @@ "line": "" + }, + { + "example": "isometric-columns", + "component": "Chart", + "path": "/docs/components/Chart/isometric-columns", + "lineNumber": 38, + "line": "" } ], - "updatedAt": "2026-04-13T13:16:04.354Z" + "updatedAt": "2026-10-05T19:24:20.287Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Chart.json b/docs/src/examples/catalog/Chart.json index 10195ebc81..2f5868c8d2 100644 --- a/docs/src/examples/catalog/Chart.json +++ b/docs/src/examples/catalog/Chart.json @@ -693,6 +693,114 @@ } ] }, + { + "name": "isometric-columns", + "title": "isometric columns", + "path": "/docs/components/Chart/isometric-columns", + "components": [ + { + "component": "Chart", + "lineNumber": 14, + "line": "" + }, + { + "component": "Grid", + "lineNumber": 35, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 36, + "line": " 'W' + d} tickLabelProps={{ viewport: true }} />" + }, + { + "component": "Cell", + "lineNumber": 38, + "line": "" + }, + { + "component": "Grid", + "lineNumber": 34, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 35, + "line": " 'W' + d} tickLabelProps={{ viewport: true }} />" + }, + { + "component": "Cell", + "lineNumber": 37, + "line": "" + }, + { + "component": "Grid", + "lineNumber": 66, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 67, + "line": "" + }, + { + "component": "Cell", + "lineNumber": 69, + "line": "" + } + ] + }, + { + "name": "isometric-scatter", + "title": "isometric scatter", + "path": "/docs/components/Chart/isometric-scatter", + "components": [ + { + "component": "ScatterChart", + "lineNumber": 9, + "line": "" + } + ] + }, { "name": "mixed-marks-with-stack", "title": "mixed marks with stack", @@ -2490,6 +2598,27 @@ "lineNumber": 13, "line": "" }, + { + "example": "missing-data-dashed", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-dashed", + "lineNumber": 56, + "line": "" }, + { + "example": "series-state", + "component": "Group", + "path": "/docs/components/Group/series-state", + "lineNumber": 34, + "line": "" + }, { "example": "geo", "component": "Hull", @@ -1129,6 +1136,13 @@ "lineNumber": 43, "line": "" }, + { + "example": "missing-data-with-markers", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-with-markers", + "lineNumber": 64, + "line": "" + }, { "example": "multiple-series", "component": "Spline", @@ -1214,5 +1228,5 @@ "line": "" } ], - "updatedAt": "2026-09-03T20:00:06.279Z" + "updatedAt": "2026-09-25T04:42:17.383Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Grid.json b/docs/src/examples/catalog/Grid.json index 6a986a83cb..b15d5a1d50 100644 --- a/docs/src/examples/catalog/Grid.json +++ b/docs/src/examples/catalog/Grid.json @@ -374,6 +374,27 @@ "lineNumber": 27, "line": "" }, + { + "example": "isometric-columns", + "component": "Chart", + "path": "/docs/components/Chart/isometric-columns", + "lineNumber": 35, + "line": "" + }, + { + "example": "isometric-heatmap", + "component": "Chart", + "path": "/docs/components/Chart/isometric-heatmap", + "lineNumber": 34, + "line": "" + }, + { + "example": "isometric-playground", + "component": "Chart", + "path": "/docs/components/Chart/isometric-playground", + "lineNumber": 66, + "line": "" + }, { "example": "band-scale-between", "component": "Grid", @@ -473,5 +494,5 @@ "line": "" } ], - "updatedAt": "2026-09-03T20:00:07.354Z" + "updatedAt": "2026-10-05T19:24:21.093Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Group.json b/docs/src/examples/catalog/Group.json index 8487372ad6..cc1ef6b52b 100644 --- a/docs/src/examples/catalog/Group.json +++ b/docs/src/examples/catalog/Group.json @@ -74,6 +74,33 @@ "line": "" } ] + }, + { + "name": "series-state", + "title": "series state", + "path": "/docs/components/Group/series-state", + "components": [ + { + "component": "LineChart", + "lineNumber": 23, + "line": "" + }, + { + "component": "Circle", + "lineNumber": 34, + "line": "" + }, + { + "component": "Text", + "lineNumber": 35, + "line": "" + } + ] } ], "usage": [ @@ -231,6 +258,13 @@ "lineNumber": 25, "line": "" }, + { + "example": "series-state", + "component": "Group", + "path": "/docs/components/Group/series-state", + "lineNumber": 33, + "line": "" + }, { "example": "basic", "component": "Pack", @@ -442,5 +476,5 @@ "line": "" }, + { + "example": "isometric-columns", + "component": "Chart", + "path": "/docs/components/Chart/isometric-columns", + "lineNumber": 34, + "line": "" + }, + { + "example": "isometric-heatmap", + "component": "Chart", + "path": "/docs/components/Chart/isometric-heatmap", + "lineNumber": 33, + "line": "" + }, + { + "example": "isometric-playground", + "component": "Chart", + "path": "/docs/components/Chart/isometric-playground", + "lineNumber": 65, + "line": "" + }, { "example": "mixed-marks-with-stack", "component": "Chart", @@ -3908,6 +3929,20 @@ "lineNumber": 32, "line": "" }, + { + "example": "missing-data-dashed", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-dashed", + "lineNumber": 68, + "line": "" + }, + { + "example": "missing-data-with-markers", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-with-markers", + "lineNumber": 55, + "line": "" + }, { "example": "multiple-series", "component": "Spline", @@ -4434,5 +4469,5 @@ "line": "" } ], - "updatedAt": "2026-09-03T20:00:07.575Z" + "updatedAt": "2026-10-05T19:24:21.243Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Legend.json b/docs/src/examples/catalog/Legend.json index 4a9a5d14a4..5dde0d2acb 100644 --- a/docs/src/examples/catalog/Legend.json +++ b/docs/src/examples/catalog/Legend.json @@ -570,6 +570,13 @@ "lineNumber": 30, "line": "" + }, { "example": "parallel-coordinates", "component": "Spline", @@ -606,5 +613,5 @@ "line": "" } ], - "updatedAt": "2026-08-20T07:29:18.939Z" + "updatedAt": "2026-09-25T04:42:18.051Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/LineChart.json b/docs/src/examples/catalog/LineChart.json index 53a621e701..b0b7901ee7 100644 --- a/docs/src/examples/catalog/LineChart.json +++ b/docs/src/examples/catalog/LineChart.json @@ -1284,6 +1284,13 @@ "lineNumber": 32, "line": "" + }, { "example": "basic", "component": "ScatterChart", @@ -565,5 +572,5 @@ "line": "" + }, + { + "component": "Axis", + "lineNumber": 69, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 71, + "line": "" + } + ] + }, + { + "name": "missing-data-with-markers", + "title": "missing data with markers", + "path": "/docs/components/Spline/missing-data-with-markers", + "components": [ + { + "component": "Chart", + "lineNumber": 45, + "line": "" + }, + { + "component": "Axis", + "lineNumber": 56, + "line": "" + }, + { + "component": "Spline", + "lineNumber": 58, + "line": "" + }, + { + "component": "Text", + "lineNumber": 65, + "line": "" }, + { + "example": "missing-data-dashed", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-dashed", + "lineNumber": 71, + "line": "" } ], - "updatedAt": "2026-09-03T20:00:08.496Z" + "updatedAt": "2026-10-05T16:56:46.250Z" } \ No newline at end of file diff --git a/docs/src/examples/catalog/Text.json b/docs/src/examples/catalog/Text.json index 16240e90d8..79be53d29b 100644 --- a/docs/src/examples/catalog/Text.json +++ b/docs/src/examples/catalog/Text.json @@ -738,6 +738,13 @@ "lineNumber": 27, "line": "" }, + { + "example": "series-state", + "component": "Group", + "path": "/docs/components/Group/series-state", + "lineNumber": 35, + "line": "" + }, { "example": "geo", "component": "Hull", @@ -934,6 +941,13 @@ "lineNumber": 44, "line": "" }, + { + "example": "missing-data-with-markers", + "component": "Spline", + "path": "/docs/components/Spline/missing-data-with-markers", + "lineNumber": 65, + "line": " + import { Area, Chart, isometric, Layer } from 'layerchart'; + import { scaleBand, scaleSequential } from 'd3-scale'; + import { interpolateTurbo } from 'd3-scale-chromatic'; + import { curveBasis } from 'd3-shape'; + import { Field, RangeField, Switch } from 'svelte-ux'; + import TransformContextControls from '$lib/components/controls/TransformContextControls.svelte'; + import OscilloscopeField from '$lib/components/controls/fields/OscilloscopeField.svelte'; + + const FFT_SIZE = 512; + const HISTORY_SIZE = 12; + const UPDATE_INTERVAL = 6; // capture every N animation frames (~100ms at 60fps) + + type WaveFrame = { key: number; value: number }[]; + + function makeMockFrame(seed: number): WaveFrame { + const amp = 50 + 40 * Math.sin(seed * 0.5); + return Array.from({ length: FFT_SIZE }, (_, i) => ({ + key: i, + // Absolute deviation from center — creates organic ridge shapes + value: Math.max( + 0, + amp * Math.abs(Math.sin((i / FFT_SIZE) * Math.PI * 6 * (1 + seed * 0.05))) + + 15 * Math.random() + ) + })); + } + + const mockHistory: WaveFrame[] = Array.from({ length: HISTORY_SIZE }, (_, i) => makeMockFrame(i)); + + let history: WaveFrame[] = $state([...mockHistory]); + let audioContext: AudioContext | null = $state(null); + let analyser: AnalyserNode | null = $state(null); + let dataArray: Uint8Array | null = $state(null); + let animationId: number | null = $state(null); + let isListening = $state(false); + let error = $state(''); + let frameCount = 0; + + // Turn it up for a quiet microphone + let gain = $state(1); + let opaque = $state(false); + + const N = HISTORY_SIZE; + const colorScale = scaleSequential([0, N - 1], interpolateTurbo); + + // Every frame's samples, each tagged with its row — newest (0) at the front + const rows = $derived( + history.flatMap((frame, row) => + frame.map((d) => ({ key: d.key, row, value: Math.min(128, d.value * gain) })) + ) + ); + + async function startMicrophone() { + try { + error = ''; + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + audioContext = new AudioContext(); + analyser = audioContext.createAnalyser(); + analyser.fftSize = FFT_SIZE; + const source = audioContext.createMediaStreamSource(stream); + source.connect(analyser); + dataArray = new Uint8Array(new ArrayBuffer(analyser.fftSize)); + isListening = true; + frameCount = 0; + updateData(); + } catch (err) { + error = err instanceof Error ? err.message : 'Failed to access microphone'; + console.error('Error accessing microphone:', err); + } + } + + function stopMicrophone() { + if (animationId !== null) { + cancelAnimationFrame(animationId); + animationId = null; + } + if (audioContext) { + audioContext.close(); + audioContext = null; + } + analyser = null; + dataArray = null; + isListening = false; + history = [...mockHistory]; + } + + function updateData() { + if (!analyser || !dataArray) return; + + frameCount++; + if (frameCount % UPDATE_INTERVAL === 0) { + analyser.getByteTimeDomainData(dataArray); + // Absolute deviation from center (128) gives upward ridge shapes + const frame: WaveFrame = Array.from(dataArray, (v, i) => ({ + key: i, + value: Math.abs(v - 128) + })); + // Newest frame at front (top row), oldest dropped + history = [frame, ...history.slice(0, HISTORY_SIZE - 1)]; + } + + animationId = requestAnimationFrame(updateData); + } + + $effect(() => { + return () => stopMicrophone(); + }); + + export { history as data }; + + +
+ + + + +
+ + + + + N - 1 - i)} + z="value" + zDomain={[0, 128]} + zRange={({ height }) => [0, height * 0.6]} + view={isometric({ rotate: -20, tilt: 60, aspect: 1.6 })} + transform={{ mode: 'canvas', drag: 'rotate', scrollMode: 'scale' }} + padding={16} + height={500} + tooltipContext={{ mode: 'manual' }} + clip +> + {#snippet children({ context })} + {@const m = context.isometricMatrix} + + + + + {#each Array.from({ length: N }, (_, row) => row).sort( (a, b) => (m ? m.d * (context.yScale(a) - context.yScale(b)) : 0) ) as row} + d.row === row)} + curve={curveBasis} + fill={opaque ? 'var(--color-surface-100)' : colorScale(row)} + fillOpacity={opaque ? 1 : 0.15} + line={{ stroke: colorScale(row), strokeWidth: 1.5 }} + /> + {/each} + + {/snippet} + diff --git a/docs/src/examples/components/Calendar/isometric.svelte b/docs/src/examples/components/Calendar/isometric.svelte new file mode 100644 index 0000000000..f7e8ff8e01 --- /dev/null +++ b/docs/src/examples/components/Calendar/isometric.svelte @@ -0,0 +1,51 @@ + + + + + + + + + + + + {#snippet children({ data })} + + + + + {/snippet} + + diff --git a/docs/src/examples/components/Chart/isometric-areas.svelte b/docs/src/examples/components/Chart/isometric-areas.svelte new file mode 100644 index 0000000000..9d6b0171e1 --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-areas.svelte @@ -0,0 +1,80 @@ + + + + + Flat + Isometric + + + + + [0, width * 0.3]} + c="series" + cScale={scaleOrdinal()} + cRange={['#2caffe', '#544fc5', '#00e272', '#fe6a35']} + view={isometric({ + // Flat is seen from the front, edge on to the floor — an overlapping area chart + rotate: view === 'flat' ? 0 : -40, + tilt: view === 'flat' ? 90 : 65, + // A floor wider than it is deep + aspect: 2.5, + motion: { type: 'tween', duration: 800, easing: cubicInOut } + })} + transform={{ mode: 'canvas', drag: 'rotate', scrollMode: 'scale' }} + padding={{ top: 24, bottom: 40, left: 40, right: 24 }} + height={500} + clip +> + {#snippet children({ context })} + {@const m = context.isometricMatrix} + + + + + + + + {#if view === 'isometric'} + + {/if} + + + + {#each [...series].sort( (a, b) => (m ? m.d * (context.yScale(a) - context.yScale(b)) : 0) ) as name} + d.series === name)} + fill={context.cScale?.(name)} + fillOpacity={0.75} + line={{ stroke: context.cScale?.(name), strokeWidth: 2 }} + /> + {/each} + + {/snippet} + diff --git a/docs/src/examples/components/Chart/isometric-columns.svelte b/docs/src/examples/components/Chart/isometric-columns.svelte new file mode 100644 index 0000000000..4beebc501b --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-columns.svelte @@ -0,0 +1,45 @@ + + + timeWeek.count(timeYear(d.date), d.date)} + xScale={scaleBand()} + y={(d) => d.date.getDay()} + yScale={scaleBand()} + yDomain={range(7)} + z="value" + c="value" + cDomain={[25, 50, 75]} + cRange={[ + 'var(--color-primary-100)', + 'var(--color-primary-300)', + 'var(--color-primary-500)', + 'var(--color-primary-700)' + ]} + view={isometric} + padding={{ left: 32, bottom: 20, top: 8, right: 8 }} + height={400} +> + + + 'W' + d} tickLabelProps={{ viewport: true }} /> + daysOfWeek[d]} tickLabelProps={{ viewport: true }} /> + timeWeek.count(timeYear(d.date), d.date)} + y={(d) => d.date.getDay()} + fill="value" + insets={{ all: 1 }} + /> + + diff --git a/docs/src/examples/components/Chart/isometric-heatmap.svelte b/docs/src/examples/components/Chart/isometric-heatmap.svelte new file mode 100644 index 0000000000..d2f71912c1 --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-heatmap.svelte @@ -0,0 +1,44 @@ + + + timeWeek.count(timeYear(d.date), d.date)} + xScale={scaleBand()} + y={(d) => d.date.getDay()} + yScale={scaleBand()} + yDomain={range(7)} + c="value" + cDomain={[25, 50, 75]} + cRange={[ + 'var(--color-primary-100)', + 'var(--color-primary-300)', + 'var(--color-primary-500)', + 'var(--color-primary-700)' + ]} + view={isometric} + padding={{ left: 32, bottom: 20, top: 8, right: 8 }} + height={400} +> + + + 'W' + d} tickLabelProps={{ viewport: true }} /> + daysOfWeek[d]} tickLabelProps={{ viewport: true }} /> + timeWeek.count(timeYear(d.date), d.date)} + y={(d) => d.date.getDay()} + fill="value" + insets={{ all: 1 }} + /> + + diff --git a/docs/src/examples/components/Chart/isometric-pan-zoom.svelte b/docs/src/examples/components/Chart/isometric-pan-zoom.svelte new file mode 100644 index 0000000000..8806090ddd --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-pan-zoom.svelte @@ -0,0 +1,53 @@ + + + + + Domain + Canvas + + + + + + + + + + + + + + + + + + diff --git a/docs/src/examples/components/Chart/isometric-playground.svelte b/docs/src/examples/components/Chart/isometric-playground.svelte new file mode 100644 index 0000000000..c33d3628a0 --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-playground.svelte @@ -0,0 +1,118 @@ + + +
+ + + Flat + Isometric + + + + + Default + Viewport + + + + + +
+ + d.value)} + cRange={[ + 'var(--color-primary-100)', + 'var(--color-primary-300)', + 'var(--color-primary-500)', + 'var(--color-primary-700)' + ]} + view={isometric({ + rotate: flat ? 0 : rotate, + tilt: flat ? 0 : tilt, + motion: 'spring' + })} + transform={{ mode: 'canvas', drag: 'rotate', disablePointer: flat }} + onTransform={({ rotation }) => { + // Keep the sliders in step with a drag. Only while dragging, since easing to new angles + // reports them too + if (!dragging || !rotation) return; + // Wrapped to the slider's ±180° — the same turn either way round + rotate = ((((rotation.x + 180) % 360) + 360) % 360) - 180; + tilt = rotation.y; + }} + ondragstart={() => (dragging = true)} + ondragend={() => (dragging = false)} + padding={{ left: 20, bottom: 20, top: 8, right: 8 }} + height={400} +> + + + + + + + diff --git a/docs/src/examples/components/Chart/isometric-scatter-3d.svelte b/docs/src/examples/components/Chart/isometric-scatter-3d.svelte new file mode 100644 index 0000000000..7873df645f --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-scatter-3d.svelte @@ -0,0 +1,93 @@ + + + [0, height]} + c="z" + cScale={scaleQuantize()} + cDomain={[0, 10]} + cRange={[ + 'var(--color-primary-300)', + 'var(--color-primary-500)', + 'var(--color-primary-700)', + 'var(--color-primary-900)' + ]} + view={isometric({ rotate: -30, tilt: 70 })} + transform={{ mode: 'canvas', drag: 'rotate' }} + tooltipContext={{ mode: 'quadtree', radius: 20 }} + padding={{ top: 24, bottom: 24, left: 40, right: 24 }} + height={500} + class="cursor-grab active:cursor-grabbing" +> + {#snippet children({ context })} + {@const hovered = context.tooltip.data} + + + + + + + + + + + + + + + + + + {#snippet children({ data })} + + + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Chart/isometric-scatter.svelte b/docs/src/examples/components/Chart/isometric-scatter.svelte new file mode 100644 index 0000000000..9852a1cefc --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-scatter.svelte @@ -0,0 +1,13 @@ + + + diff --git a/docs/src/examples/components/Chart/isometric-stacked-columns.svelte b/docs/src/examples/components/Chart/isometric-stacked-columns.svelte new file mode 100644 index 0000000000..4bdc75abca --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-stacked-columns.svelte @@ -0,0 +1,70 @@ + + + + [0, height * 1.2]} + c="fruit" + cRange={[ + 'var(--color-green-500)', + 'var(--color-yellow-500)', + 'var(--color-red-500)', + 'var(--color-purple-500)' + ]} + view={isometric({ rotate: -25, tilt: 65 })} + transform={{ mode: 'canvas', drag: 'rotate', scrollMode: 'scale' }} + tooltipContext={{ mode: 'manual' }} + padding={{ top: 24, bottom: 40, left: 40, right: 24 }} + height={500} + clip +> + + + + + + + `Basket ${d}`} tickLabelProps={{ viewport: true }} /> + + + + + + + + + + {#snippet children({ data })} + {data.year} · Basket {data.basket} + + + + {/snippet} + + diff --git a/docs/src/examples/components/Chart/isometric-treemap.svelte b/docs/src/examples/components/Chart/isometric-treemap.svelte new file mode 100644 index 0000000000..10260be452 --- /dev/null +++ b/docs/src/examples/components/Chart/isometric-treemap.svelte @@ -0,0 +1,154 @@ + + + + + + + Flat + Isometric + + + + + [0, ((root.height + 1) * height) / 80]} + view={isometric({ + // Seen from directly above when flat + rotate: view === 'flat' ? 0 : -45, + tilt: view === 'flat' ? 0 : 60, + motion: { type: 'tween', duration: 800, easing: cubicInOut } + })} + transform={{ + mode: 'canvas', + scrollMode: 'scale', + // Drag to pan and shift-drag to turn, or the reverse via the controls. Eases the zoom + // buttons and reset + motion: { type: 'tween', duration: 800, easing: cubicInOut } + }} + padding={24} + height={600} + clip +> + {#snippet children({ context })} + + + + + {#snippet children({ nodes })} + + {#each nodes as node} + {@const top = context.zScale(node.depth + 1)} + context.tooltip.show(e, node)} + onpointerleave={context.tooltip.hide} + /> + {#if !node.children} + {@const label = leafLabel(node)} + {#if label.fontSize >= 2} + + + {/if} + {/if} + {/each} + {/snippet} + + + + + {#snippet children({ data })} + + {data + .ancestors() + .reverse() + .slice(1) + .map((n: HierarchyNode) => n.data.name) + .join(' / ') || data.data.name} + + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Contour/volcano-isometric.svelte b/docs/src/examples/components/Contour/volcano-isometric.svelte new file mode 100644 index 0000000000..6103b6d799 --- /dev/null +++ b/docs/src/examples/components/Contour/volcano-isometric.svelte @@ -0,0 +1,68 @@ + + + + +
+ + + Flat + Isometric + + + + + Lines + Filled + + +
+ + + [0, height * 0.25]} + view={isometric({ + // Seen from directly above when flat + rotate: view === 'flat' ? 0 : -35, + tilt: view === 'flat' ? 0 : 60, + motion: { type: 'tween', duration: 800, easing: cubicInOut } + })} + transform={{ mode: 'canvas', drag: 'rotate', scrollMode: 'scale' }} + padding={24} + height={500} + clip +> + {#snippet children({ context })} + + + + + + + {/snippet} + diff --git a/docs/src/examples/components/GeoPath/election-isometric.svelte b/docs/src/examples/components/GeoPath/election-isometric.svelte new file mode 100644 index 0000000000..411f178137 --- /dev/null +++ b/docs/src/examples/components/GeoPath/election-isometric.svelte @@ -0,0 +1,92 @@ + + + + + + + Democratic + Republican + + + + GeoProjection, fitGeojson: states }} + view={isometric({ rotate: -2, tilt: 40, aspect: 975 / 610 })} + transform={{ mode: 'canvas', drag: 'rotate', scrollMode: 'scale' }} + padding={24} + height={550} + clip +> + {#snippet children({ context })} + {@const m = context.isometricMatrix} + + + + {#each [...states.features].sort((a, b) => { + const [ax, ay] = centroids.get(a)!; + const [bx, by] = centroids.get(b)!; + return m ? m.b * (ax - bx) + m.d * (ay - by) : 0; + }) as state (state.properties.name)} + {@const share = shares.get(state.properties.name)?.[party] ?? 0} + + {/each} + + + + {#snippet children({ data })} + {@const share = shares.get(data.properties.name)} + {data.properties.name} + + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Pie/default-slot-render-each-arc.svelte b/docs/src/examples/components/Pie/default-slot-render-each-arc.svelte index 1ac92ae074..2ca9a5809c 100644 --- a/docs/src/examples/components/Pie/default-slot-render-each-arc.svelte +++ b/docs/src/examples/components/Pie/default-slot-render-each-arc.svelte @@ -36,13 +36,13 @@ {#snippet children({ arcs })} - {#each arcs as arc, index} + {#each arcs as arc} {/each} {/snippet} diff --git a/docs/src/examples/components/Pie/isometric.svelte b/docs/src/examples/components/Pie/isometric.svelte new file mode 100644 index 0000000000..25b1f13142 --- /dev/null +++ b/docs/src/examples/components/Pie/isometric.svelte @@ -0,0 +1,113 @@ + + +
+ + + Flat + Isometric + + + + + Pie + Donut + + +
+ + + + {#snippet children({ context })} + + + {#snippet children({ arcs })} + + {#each arcs as arc} + + {#snippet children({ + centroid, + startAngle, + endAngle, + innerRadius, + outerRadius, + getArcTextProps + })} + + + {/snippet} + + {/each} + {/snippet} + + + + + {#snippet children({ data })} + {data.fruit} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Pie/labels-centroid-multiple.svelte b/docs/src/examples/components/Pie/labels-centroid-multiple.svelte index c8facc0d37..10c502b8c4 100644 --- a/docs/src/examples/components/Pie/labels-centroid-multiple.svelte +++ b/docs/src/examples/components/Pie/labels-centroid-multiple.svelte @@ -22,8 +22,8 @@ {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {#snippet children({ arcs })} - {#each arcs as arc, index} - {@const colors = keyClasses[index]} + {#each arcs as arc} + {@const colors = keyClasses[arc.index]} {@const isHighlighted = context.tooltip.data?.date === arc.data.date} {@const isFaded = context.tooltip.data != null && context.tooltip.data.date !== arc.data.date} diff --git a/docs/src/examples/components/Spline/parallel-coordinates-brush.svelte b/docs/src/examples/components/Spline/parallel-coordinates-brush.svelte index 0bc121b557..55a1db03aa 100644 --- a/docs/src/examples/components/Spline/parallel-coordinates-brush.svelte +++ b/docs/src/examples/components/Spline/parallel-coordinates-brush.svelte @@ -70,7 +70,7 @@ xDomain={keys} y={(d) => scales.get(d.dimension)?.(d.value)} yDomain={[0, 1]} - z="id" + g="id" padding={{ left: 48, right: 48, top: 32, bottom: 8 }} height={400} > @@ -102,7 +102,7 @@ {/snippet} {#snippet marks()} - + selectedIds.has(d.id) ? 'var(--color-primary)' : 'var(--color-surface-content)'} diff --git a/docs/src/examples/components/Spline/parallel-coordinates-faceted.svelte b/docs/src/examples/components/Spline/parallel-coordinates-faceted.svelte index 43c8fe0681..d09c5cd5bd 100644 --- a/docs/src/examples/components/Spline/parallel-coordinates-faceted.svelte +++ b/docs/src/examples/components/Spline/parallel-coordinates-faceted.svelte @@ -51,7 +51,7 @@ xDomain={keys} y={(d) => scales.get(d.dimension)?.(d.value)} yDomain={[0, 1]} - z="id" + g="id" fy="species" {series} padding={{ left: 48, right: 76, top: 32, bottom: 8 }} diff --git a/docs/src/examples/components/Spline/parallel-coordinates-mixed.svelte b/docs/src/examples/components/Spline/parallel-coordinates-mixed.svelte index f2dde96216..8e9a8716fe 100644 --- a/docs/src/examples/components/Spline/parallel-coordinates-mixed.svelte +++ b/docs/src/examples/components/Spline/parallel-coordinates-mixed.svelte @@ -78,7 +78,7 @@ xDomain={keys} y={(d) => scales.get(d.dimension)?.(d.value)} yDomain={[0, 1]} - z="id" + g="id" c="group" cDomain={species} cRange={['var(--color-info)', 'var(--color-success)', 'var(--color-warning)']} @@ -110,7 +110,7 @@ {/snippet} {#snippet marks({ context })} - + selectedIds.has(d.id) ? context.cScale?.(d.group) : 'var(--color-surface-content)'} diff --git a/docs/src/examples/components/Spline/parallel-coordinates.svelte b/docs/src/examples/components/Spline/parallel-coordinates.svelte index 241cd8a7ab..f48ebbd423 100644 --- a/docs/src/examples/components/Spline/parallel-coordinates.svelte +++ b/docs/src/examples/components/Spline/parallel-coordinates.svelte @@ -29,7 +29,7 @@ ]) ); - // One row per (penguin, dimension), carrying `id` and `species` along for `z` and the color + // One row per (penguin, dimension), carrying `id` and `species` along for `g` and the color const data = pivotLonger(rows, keys, 'dimension', 'value'); const series = [ @@ -48,7 +48,7 @@ xDomain={keys} y={(d) => scales.get(d.dimension)?.(d.value)} yDomain={[0, 1]} - z="id" + g="id" {series} padding={{ left: 48, right: 48, top: 48, bottom: 8 }} height={400} @@ -77,7 +77,7 @@ {/snippet} {#snippet marks({ context })} - + context.series.isVisible(d.species))} stroke="species" diff --git a/docs/src/examples/components/Spline/radial-multi-year-lines.svelte b/docs/src/examples/components/Spline/radial-multi-year-lines.svelte index 8d50c308e8..bf9ed43d85 100644 --- a/docs/src/examples/components/Spline/radial-multi-year-lines.svelte +++ b/docs/src/examples/components/Spline/radial-multi-year-lines.svelte @@ -4,10 +4,13 @@ @@ -18,33 +21,29 @@ y="value" yRange={({ height }) => [height / 5, height / 2]} yPadding={[0, 20]} - z="year" - zDomain={[1940, 2024]} - zRange={[0.1, 0.2]} + g="year" radial padding={{ top: 12, bottom: 12 }} height={500} > - {#snippet children({ context })} - - - d.year === 2024 - ? 'stroke-primary' - : d.year === 2023 - ? 'stroke-primary/50' - : 'stroke-surface-content'} - opacity={(d) => ([2023, 2024].includes(d.year) ? 1 : context.zScale(d.year))} - /> - - v + '° F'} - /> - - {/snippet} + + + d.year === 2024 + ? 'stroke-primary' + : d.year === 2023 + ? 'stroke-primary/50' + : 'stroke-surface-content'} + opacity={(d) => ([2023, 2024].includes(d.year) ? 1 : yearOpacity(d.year))} + /> + + v + '° F'} + /> +
diff --git a/docs/src/examples/components/Tree/isometric.svelte b/docs/src/examples/components/Tree/isometric.svelte new file mode 100644 index 0000000000..481054c32e --- /dev/null +++ b/docs/src/examples/components/Tree/isometric.svelte @@ -0,0 +1,85 @@ + + + + {#snippet children({ context })} + {@const m = context.isometricMatrix} + + + + + {#snippet children({ nodes, links })} + + {#each links as link} + + {/each} + + + {#each [...nodes].sort((a, b) => (m ? m.b * (a.x - b.x) + m.d * (a.y - b.y) : 0)) as node} + {@const shape = shapeOf(node)} + + + {/each} + {/snippet} + + + {/snippet} + diff --git a/docs/src/lib/components/controls/TransformContextControls.svelte b/docs/src/lib/components/controls/TransformContextControls.svelte index 97c0347599..a3c16a7dd5 100644 --- a/docs/src/lib/components/controls/TransformContextControls.svelte +++ b/docs/src/lib/components/controls/TransformContextControls.svelte @@ -1,7 +1,7 @@ - + diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.base.svelte b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.base.svelte index c77d34e112..5cca694ca0 100644 --- a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.base.svelte +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.base.svelte @@ -30,6 +30,7 @@ calloutPadding = 4, line, offset = 0, + z, ...restProps }: ArcLabelBaseProps = $props(); @@ -50,12 +51,17 @@ calloutPadding, line, offset, + z, }) as ArcLabelProps ); {#if placement === 'callout' && c.calloutGeometry} - + {/if} - + diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.isometric.svelte.test.ts b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.isometric.svelte.test.ts new file mode 100644 index 0000000000..3147d4855c --- /dev/null +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.isometric.svelte.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { page } from 'vitest/browser'; + +import MarkHarness from '$lib/tests/MarkHarness.svelte'; +import { chartTestId } from '$lib/tests/TestHarness.svelte'; +import ArcLabel from './ArcLabel.svelte'; +import { isometric } from '$lib/views/isometric.js'; + +/** A callout for a slice centred on `angle` (radians, clockwise from 12 o'clock) */ +async function callout(angle: number, angles: Record | null) { + const screen = render(MarkHarness, { + chartProps: { width: 400, height: 300, view: angles && isometric(angles) }, + layerProps: { center: true }, + component: ArcLabel, + componentProps: { + placement: 'callout', + startAngle: angle - 0.2, + endAngle: angle + 0.2, + innerRadius: 0, + outerRadius: 80, + value: 'label', + z: 30, + viewport: true, + }, + }); + await expect.element(page.getByTestId(chartTestId)).toBeInTheDocument(); + const text = (page.getByTestId(chartTestId).element() as HTMLElement).querySelector('text')!; + const result = { + anchor: text.getAttribute('text-anchor'), + raised: text.getAttribute('transform')?.includes('translate') ?? false, + x: +text.getAttribute('x')!, + y: +text.getAttribute('y')!, + }; + screen.unmount(); + return result; +} + +describe('ArcLabel callouts on an isometric floor', () => { + it('picks its side by where the slice falls on screen', async () => { + // A slice at 3 o'clock: right on screen as laid out, left once the view turns half round + expect((await callout(Math.PI / 2, { rotate: 0, tilt: 50 })).anchor).toBe('start'); + expect((await callout(Math.PI / 2, { rotate: 180, tilt: 50 })).anchor).toBe('end'); + }); + + it('hangs below the pie for a slice facing the viewer, and off its top for one behind', async () => { + // 6 o'clock faces the viewer; 12 o'clock is at the back + const front = await callout(Math.PI, { rotate: 0, tilt: 50 }); + const back = await callout(0, { rotate: 0, tilt: 50 }); + const flatFront = await callout(Math.PI, null); + const flatBack = await callout(0, null); + // In front: on the floor, where it'd be flat. Behind: raised (its anchor lifted up the screen) + expect(front.y).toBeCloseTo(flatFront.y); + expect(back.y).toBeLessThan(flatBack.y); + }); +}); diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts index dee5104830..58647322b6 100644 --- a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.shared.svelte.ts @@ -1,4 +1,5 @@ import { radiansToDegrees } from '$lib/utils/math.js'; +import { getLayerIsometric } from '$lib/contexts/isometric.js'; import type { PathProps } from '../Path/Path.shared.svelte.js'; import type { TextProps } from '../Text/Text.shared.svelte.js'; import type { GetArcTextProps, ArcTextOptions } from '$lib/utils/arcText.svelte.js'; @@ -33,8 +34,13 @@ export type ArcLabelConfig = { line?: Omit; /** Radial offset for the label, interpreted per-placement. @default 0 */ offset?: number; + /** + * On an `isometric` chart, the height of the labelled slice, so the label sits on top of it. A + * callout on a slice facing the viewer hangs below the pie instead, clear of its sides. + */ + z?: number; } & ArcTextOptions & - Omit; + Omit; export type ArcLabelProps = { /** Function from `Arc` children snippet for `inner`/`middle`/`outer` placements. */ @@ -72,6 +78,18 @@ export class ArcLabelState { */ #props: ArcLabelProps = $derived(this.#getProps()); + #layerIsometric = getLayerIsometric(); + + /** The label's height: the slice's top, or the floor for a callout on a slice facing the viewer */ + labelHeight = $derived.by(() => { + const { z, placement } = this.#props; + if (z == null) return undefined; + const m = this.#layerIsometric(); + if (!m || placement !== 'callout') return z; + const angle = this.midAngle - Math.PI / 2; + return m.b * Math.cos(angle) + m.d * Math.sin(angle) > 0 ? 0 : z; + }); + constructor(getProps: () => ArcLabelProps) { this.#getProps = getProps; } @@ -147,14 +165,20 @@ export class ArcLabelState { const x1 = cos * bendRadius; const y1 = sin * bendRadius; - const onRightSide = cos >= 0; - const x2 = x1 + (onRightSide ? calloutLabelOffset : -calloutLabelOffset); - const y2 = y1; + // On an isometric floor, the callout's side and level run follow the screen: `level` is the + // flat vector that lands one pixel rightwards on screen + const m = this.#layerIsometric(); + const det = m ? m.a * m.d - m.b * m.c : 1; + const level = m && det ? { x: m.d / det, y: -m.b / det } : { x: 1, y: 0 }; + const onRightSide = m ? m.a * cos + m.c * sin >= 0 : cos >= 0; + const side = onRightSide ? 1 : -1; + const x2 = x1 + level.x * calloutLabelOffset * side; + const y2 = y1 + level.y * calloutLabelOffset * side; return { pathData: `M${x0},${y0}L${x1},${y1}L${x2},${y2}`, - labelX: x2 + (onRightSide ? calloutPadding : -calloutPadding), - labelY: y2, + labelX: x2 + level.x * calloutPadding * side, + labelY: y2 + level.y * calloutPadding * side, textAnchor: onRightSide ? 'start' : 'end', }; }); diff --git a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svg.svelte b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svg.svelte index cc65aae5f1..a5cbdaccc5 100644 --- a/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svg.svelte +++ b/packages/layerchart/src/lib/components/ArcLabel/ArcLabel.svg.svelte @@ -7,6 +7,7 @@ - + diff --git a/packages/layerchart/src/lib/components/Area/Area.base.svelte b/packages/layerchart/src/lib/components/Area/Area.base.svelte index 36d38fa172..027b60e37f 100644 --- a/packages/layerchart/src/lib/components/Area/Area.base.svelte +++ b/packages/layerchart/src/lib/components/Area/Area.base.svelte @@ -31,7 +31,9 @@ x, y0, y1, + // Each point's height, not `Path`'s, which would raise the whole area z, + g, seriesKey, ...restProps }: AreaBaseProps = $props(); @@ -53,6 +55,7 @@ y0, y1, z, + g, seriesKey, }) as AreaProps ); @@ -74,13 +77,14 @@ {#if c.areas} - + {#each c.areas as area, i (i)} {#if line} = {}) { + let ctx: ChartState = null!; + render(TestHarness, { + chartProps: { + data, + x: 'x', + y: 'row', + yScale: scaleBand(), + z: 'value', + zDomain: [0, 10], + zRange: [0, 50], + width: 400, + height: 300, + view: isometric, + ...chartProps, + }, + layerProps: { center: false }, + component, + oncontext: (c: any) => (ctx = c), + }); + await expect.element(page.getByTestId(chartTestId)).toBeInTheDocument(); + await vi.waitFor(() => expect(ctx?.width).toBeGreaterThan(0)); + const path = (page.getByTestId(chartTestId).element() as HTMLElement).querySelector( + 'path.lc-path' + )!; + return { ctx, points: pathRings(path.getAttribute('d')!).flat() }; +} + +/** Where `d` is drawn on the flat plot — on the floor, then raised by its height */ +function at(ctx: ChartState, d: (typeof data)[number], raised = true) { + const x = ctx.xScale(d.x); + const y = ctx.yScale(d.row) + ctx.yScale.bandwidth!() / 2; + const h = raised ? ctx.zScale(d.value) : 0; + return [x + ctx.isometricLift!.x * h, y + ctx.isometricLift!.y * h]; +} + +const near = (points: number[][], [x, y]: number[]) => + points.some(([px, py]) => Math.abs(px - x) < 0.01 && Math.abs(py - y) < 0.01); + +describe('Area and Spline with heights on an isometric floor', () => { + it("stands an area up as a curtain, from the floor to each point's height", async () => { + const { ctx, points } = await renderRaised(Area); + for (const d of data) { + expect(near(points, at(ctx, d, false))).toBe(true); + expect(near(points, at(ctx, d))).toBe(true); + } + }); + + it('runs a line through each point at its height', async () => { + const { ctx, points } = await renderRaised(Spline); + for (const d of data) expect(near(points, at(ctx, d))).toBe(true); + }); + + it('lies flat on a flat chart', async () => { + const { ctx, points } = await renderRaised(Spline, { view: null }); + expect(points[0][1]).toBeCloseTo(ctx.yScale('a') + ctx.yScale.bandwidth!() / 2); + }); + + for (const [name, component] of [ + ['Area', Area], + ['Spline', Spline], + ] as const) { + it(`follows the view as it turns at once with \`motion\` (${name})`, async () => { + const props = (rotate: number) => ({ + chartProps: { + data, + x: 'x', + y: 'row', + yScale: scaleBand(), + z: 'value', + zDomain: [0, 10], + zRange: [0, 50], + width: 400, + height: 300, + view: isometric({ rotate, tilt: 60 }), + }, + layerProps: { center: false }, + component, + componentProps: { motion: { type: 'tween', duration: 1000 } }, + }); + const screen = render(TestHarness, props(-45)); + await expect.element(page.getByTestId(chartTestId)).toBeInTheDocument(); + const el = page.getByTestId(chartTestId).element() as HTMLElement; + const d = () => el.querySelector('path.lc-path')!.getAttribute('d'); + await new Promise((resolve) => setTimeout(resolve, 1200)); + + await screen.rerender(props(30)); + await new Promise((resolve) => setTimeout(resolve, 100)); + const turned = d(); + // Easing would still be on its way there + await new Promise((resolve) => setTimeout(resolve, 500)); + expect(d()).toEqual(turned); + }); + } +}); diff --git a/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts b/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts index 62f3bc3287..20309ecb65 100644 --- a/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Area/Area.line-props.svelte.test.ts @@ -76,9 +76,9 @@ describe('Area line prop forwarding', () => { expect(clicks).toBe(1); }); - it('forwards to every line when grouped by `z`', async () => { + it('forwards to every line when grouped by `g`', async () => { render(TestHarness, { - chartProps: chartProps({ z: 'group' }), + chartProps: chartProps({ g: 'group' }), component: Area, componentProps: { line: true, mask: 'url(#reveal)' }, }); diff --git a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts index 12bb41d79c..c988225320 100644 --- a/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Area/Area.shared.svelte.ts @@ -11,6 +11,7 @@ import { resolveColorProp, resolveStyleProp, type ColorProp, + type DataProp, type StyleProp, } from '$lib/utils/dataProp.js'; import { isScaleBand } from '$lib/utils/scales.svelte.js'; @@ -23,6 +24,8 @@ import { type ResolvedMotion, } from '$lib/utils/motion.svelte.js'; import { getChartContext } from '$lib/contexts/chart.js'; +import { getLayerIsometric } from '$lib/contexts/isometric.js'; +import { liftChanged } from '$lib/utils/isometric.js'; import { getMarkData } from '$lib/contexts/facet.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; import type Spline from '../Spline/Spline.svelte'; @@ -31,6 +34,15 @@ import type { PathProps } from '../Path/Path.shared.svelte.js'; export type AreaPropsWithoutHTML = { /** Override data instead of using context */ data?: any; + /** + * Height to raise each point off an `isometric` floor, defaulting to the chart's `z`. The area + * stands on the floor as a curtain up to each point's height. + * - `string`: data property name, resolved via zScale + * - `function(d)`: accessor called per data item, result passed through zScale + * - `number`: pixel height + */ + z?: DataProp; + /** Pass `` explicitly instead of calculating from data / context */ pathData?: string | null; /** Override x accessor */ @@ -41,12 +53,12 @@ export type AreaPropsWithoutHTML = { y1?: Accessor; /** * Group the data into a separate area per distinct value, drawing them all from this one mark. - * Defaults to the Chart's `z` accessor, then to the data property named by `fill` / `stroke`. + * Defaults to the Chart's `g` accessor, then to the data property named by `fill` / `stroke`. * * Replaces grouping the data and rendering an `Area` per group — which registers a mark, and * rebuilds the chart's domains, once per area. */ - z?: Accessor; + g?: Accessor; /** Series key to use for accessor. */ seriesKey?: string; /** Whether to tween the interpolated path data using d3-interpolate-path */ @@ -83,12 +95,26 @@ export class AreaState { #props: AreaProps = $derived(this.#getProps()); ctx: ChartState = getChartContext(); + #layerIsometric = getLayerIsometric(); + + /** The chart's `isometricLift` when the area has heights, else `null` */ + lift = $derived.by(() => { + if (this.ctx.radial || !this.#layerIsometric()) return null; + if (this.#props.z == null && this.ctx.props.z == null) return null; + // Even seen from directly above, where the curtain is edge on + return this.ctx.isometricLift; + }); + + /** A point's height off the floor, in pixels */ + height(d: any) { + return this.ctx.heightOf(d, this.#props.z)[1]; + } markData = getMarkData(); #tweenState!: ReturnType>; - /** One tween per `z` group, for the grouped branch that draws an area each */ + /** One tween per `g` group, for the grouped branch that draws an area each */ #areaTweens: ReturnType = null; constructor(getProps: () => AreaProps) { @@ -122,7 +148,8 @@ export class AreaState { this.#tweenState = createMotion( this.#defaultPathData(tweenOptions), () => this.d, - tweenOptions + tweenOptions, + { instant: liftChanged(() => this.lift) } ); // `#tweenState` animates the single-path case; grouped areas each need their own, since the @@ -130,16 +157,23 @@ export class AreaState { this.#areaTweens = createPathMotionMap(initial.motion, interpolatePath); if (this.#areaTweens) { const tweens = this.#areaTweens; + const viewTurned = liftChanged(() => this.lift); $effect(() => { const targets = this.#areaTargets; if (!targets) return; + const instant = viewTurned(); const active = new Set(); for (const area of targets) { active.add(area.key); // `update` reads and writes the tween's own state, so it must not be tracked here untrack(() => - tweens.update(area.key, area.d, () => this.#defaultPathData(tweenOptions, area.data)) + tweens.update( + area.key, + area.d, + () => this.#defaultPathData(tweenOptions, area.data), + instant + ) ); } untrack(() => tweens.cleanup(active)); @@ -194,12 +228,12 @@ export class AreaState { /** * Accessor grouping the data into one area per distinct value, or `null` for a single area. - * Mirrors `Spline` — see its `zAccessor` for why the chart's raw `z` prop is the honest check. + * Resolves the same way as `Spline`'s `gAccessor`. */ - zAccessor = $derived.by<((d: any) => any) | null>(() => { + gAccessor = $derived.by<((d: any) => any) | null>(() => { const props = this.#props; - const z = props.z ?? this.ctx.props.z; - if (z != null) return accessor(z); + const g = props.g ?? this.ctx.props.g; + if (g != null) return accessor(g); const first = this.resolvedData?.[0]; const implied = colorPropDataKey(props.fill, first) ?? colorPropDataKey(props.stroke, first); @@ -225,13 +259,13 @@ export class AreaState { * targets without reading the tweens' own output, which would be a cycle. */ #areaTargets = $derived.by(() => { - if (!this.zAccessor || this.#props.pathData) return null; + if (!this.gAccessor || this.#props.pathData) return null; const props = this.#props; - const zAccessor = this.zAccessor; + const gAccessor = this.gAccessor; - return Array.from(d3Group(this.resolvedData, zAccessor).values()).map((data) => ({ + return Array.from(d3Group(this.resolvedData, gAccessor).values()).map((data) => ({ data, - key: zAccessor(data[0]), + key: gAccessor(data[0]), d: this.#buildPath(data), // Styles are uniform across an area, so they resolve from its first point fill: @@ -301,6 +335,20 @@ export class AreaState { #buildPath(data: any[]): string { const props = this.#props; + const lift = this.lift; + if (lift) { + // A curtain from the floor up to each point's height + const x = (d: any) => this.ctx.xScale(this.xAccessor(d)) + this.xOffset; + const y = (d: any) => this.ctx.yScale(this.y1Accessor(d)) + this.yOffset; + const curtain = d3Area() + .x0(x) + .y0(y) + .x1((d) => x(d) + lift.x * this.height(d)) + .y1((d) => y(d) + lift.y * this.height(d)); + curtain.defined(props.defined ?? ((d: any) => this.xAccessor(d) != null)); + if (props.curve) curtain.curve(props.curve); + return curtain(data) ?? ''; + } const _path = this.ctx.radial ? areaRadial() .angle((d) => this.ctx.xScale(this.xAccessor(d))) diff --git a/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts b/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts index 65f17f6c08..06404a57c1 100644 --- a/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Area/Area.svelte.test.ts @@ -28,12 +28,12 @@ function areaPaths() { } describe('Area', () => { - describe('z (grouping)', () => { + describe('g (grouping)', () => { it('draws one area per group from a single mark', async () => { render(TestHarness, { component: Area, chartProps: chartProps(), - componentProps: { z: 'group' }, + componentProps: { g: 'group' }, }); await expect.poll(() => areaPaths().length).toBe(2); @@ -49,10 +49,10 @@ describe('Area', () => { await expect.poll(() => areaPaths().length).toBe(1); }); - it('falls back to the chart`s `z` accessor', async () => { + it('falls back to the chart`s `g` accessor', async () => { render(TestHarness, { component: Area, - chartProps: chartProps({ z: 'group' }), + chartProps: chartProps({ g: 'group' }), componentProps: {}, }); @@ -88,7 +88,7 @@ describe('Area', () => { render(TestHarness, { component: Area, chartProps: chartProps(), - componentProps: { z: 'group', line: true }, + componentProps: { g: 'group', line: true }, }); await expect.poll(() => areaPaths().length).toBe(2); @@ -102,7 +102,7 @@ describe('Area', () => { component: Area, chartProps: chartProps(), componentProps: { - z: 'group', + g: 'group', class: (d: any) => (d.group === 'a' ? 'first-area' : 'second-area'), }, }); diff --git a/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts b/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts index aa6035e13b..87c318e778 100644 --- a/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Area/grouped-motion.svelte.test.ts @@ -6,10 +6,10 @@ import Area from './Area.svelte'; import Spline from '../Spline/Spline.svelte'; /** - * A mark grouped by `z` draws one path per group, and those paths used to be built straight from + * A mark grouped by `g` draws one path per group, and those paths used to be built straight from * the data on every change — `Spline` renders `seg.d` and `Area` renders `area.d`, neither of * which consulted the mark's single `tweenedPath`. So `motion` animated an ungrouped mark and - * did nothing at all once `z` (or the chart's `c`) split it. + * did nothing at all once `g` (or the chart's `c`) split it. * * A tween caught between two shapes is the observable: sampling `d` every frame during a data * change gives one value if the path snapped, and a spread of them if it animated. @@ -75,13 +75,13 @@ async function sampleDuringDataChange(options: { }; } -describe('marks grouped by `z` animate their paths', () => { +describe('marks grouped by `g` animate their paths', () => { beforeEach(cleanup); it('tweens each line of a grouped `Spline`', async () => { const result = await sampleDuringDataChange({ component: Spline, - chartExtra: { z: 'group' }, + chartExtra: { g: 'group' }, selector: 'path', }); @@ -93,7 +93,7 @@ describe('marks grouped by `z` animate their paths', () => { it("tweens each group's fill in a grouped `Area`", async () => { const result = await sampleDuringDataChange({ component: Area, - chartExtra: { z: 'group' }, + chartExtra: { g: 'group' }, selector: '.lc-area-path', }); @@ -106,7 +106,7 @@ describe('marks grouped by `z` animate their paths', () => { const result = await sampleDuringDataChange({ component: Area, componentProps: { line: true }, - chartExtra: { z: 'group' }, + chartExtra: { g: 'group' }, selector: '.lc-area-line', }); @@ -130,7 +130,7 @@ describe('marks grouped by `z` animate their paths', () => { const result = await sampleDuringDataChange({ component: Area, componentProps: { motion: undefined }, - chartExtra: { z: 'group' }, + chartExtra: { g: 'group' }, selector: '.lc-area-path', }); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.base.svelte b/packages/layerchart/src/lib/components/Axis/Axis.base.svelte index f0577b71c7..d3cd583342 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.base.svelte +++ b/packages/layerchart/src/lib/components/Axis/Axis.base.svelte @@ -92,7 +92,18 @@ data-placement={placement} class={cls('lc-axis', `placement-${placement}`, classes.root, className)} > - {#if rule !== false} + {#if rule !== false && placement === 'back'} + {#if c.back} + + {/if} + {:else if rule !== false} - {#if grid !== false} + {#if grid !== false && c.orientation !== 'back'} + {:else if c.orientation === 'back' && c.back} + {:else if c.orientation === 'angle'} document.querySelectorAll('.lc-axis-tick-label').length) + .toBeGreaterThan(0); + const labels = Array.from(document.querySelectorAll('.lc-axis-tick-label')).map( + (el) => el.textContent?.trim() ?? '' + ); + screen.unmount(); + return labels; +} + +describe('Axis angle placement', () => { + it('keeps its ticks as the chart widens — the circle is sized by its radius', async () => { + const narrow = await angleLabels(400, 300); + const wide = await angleLabels(1200, 300); + expect(wide).toEqual(narrow); + }); + + it('adds ticks as the circle grows', async () => { + const small = await angleLabels(800, 200); + const large = await angleLabels(800, 800); + expect(large.length).toBeGreaterThan(small.length); + }); +}); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts index 338c657824..1b0b3b9358 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts @@ -15,6 +15,7 @@ import { isScaleBand, isScaleUtc } from '$lib/utils/scales.svelte.js'; import { occlude } from '$lib/utils/occlusion.js'; import { getTextRect } from '$lib/utils/string.js'; import { getChartContext } from '$lib/contexts/chart.js'; +import { getLayerIsometric } from '$lib/contexts/isometric.js'; import { getFacetPanel } from '$lib/contexts/facet.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; import { type MotionProp } from '$lib/utils/motion.svelte.js'; @@ -27,9 +28,9 @@ import { export type AxisPropsWithoutHTML = { /** - * Location of axis + * Location of axis. `'back'` runs along `z` up the back corner of an `isometric` chart's floor. */ - placement: 'top' | 'bottom' | 'left' | 'right' | 'angle' | 'radius'; + placement: 'top' | 'bottom' | 'left' | 'right' | 'angle' | 'radius' | 'back'; /** * The label for the axis. @@ -213,6 +214,42 @@ export class AxisState { ctx: ChartState = getChartContext(); #facetPanel = getFacetPanel(); + #layerIsometric = getLayerIsometric(); + + /** + * Anchors for a `viewport` label hanging off this axis' edge of an `isometric` floor (see + * `viewportAnchors`), or `undefined` to keep the flat ones. + */ + #viewportAnchors(viewport: boolean | undefined) { + const m = this.#layerIsometric(); + if (!viewport || !m) return undefined; + const outward = { + top: { x: 0, y: -1 }, + bottom: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 }, + }[this.#props.placement as string]; + return outward ? this.ctx.isometric!.viewportAnchors(m, outward) : undefined; + } + /** + * For `placement="back"`: the floor corner the axis stands on, the lift, and screen offsets on + * the floor. `null` without an isometric floor and a `z`. + */ + back = $derived.by(() => { + const m = this.#layerIsometric(); + const lift = this.ctx.isometricLift; + if (!m || !lift || this.ctx.props.z == null) return null; + const iso = this.ctx.isometric!; + const corner = iso.floorCorner({ width: this.ctx.width, height: this.ctx.height }, m, 'left'); + const left = iso.screenToFloor(m, { x: -1, y: 0 }); + const up = iso.screenToFloor(m, { x: 0, y: -1 }); + if (!left || !up) return null; + const raise = (height: number) => ({ + x: corner.x + lift.x * height, + y: corner.y + lift.y * height, + }); + return { corner, raise, left, up, top: raise(this.ctx.zDepth) }; + }); /** * Whether to draw at all. In a faceted chart an axis belongs on the grid's outer edge — a @@ -220,6 +257,8 @@ export class AxisState { * panels don't redraw the same ticks. `facetAll` opts back into one per panel. */ visible = $derived.by(() => { + // Height only stands up off an isometric floor + if (this.#props.placement === 'back' && !this.back) return false; const facet = this.#facetPanel?.(); if (!facet || this.#props.facetAll) return true; @@ -247,6 +286,7 @@ export class AxisState { orientation = $derived.by(() => { const placement = this.#props.placement; + if (placement === 'back') return 'back'; return placement === 'angle' ? 'angle' : placement === 'radius' @@ -258,21 +298,24 @@ export class AxisState { scale = $derived.by(() => { const scaleProp = this.#props.scale; - return ( - scaleProp ?? - (['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale) - ); + if (scaleProp) return scaleProp; + if (this.orientation === 'back') return this.ctx.zScale; + return ['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale; }); interval = $derived( - ['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xInterval : this.ctx.yInterval + this.orientation === 'back' + ? null + : ['horizontal', 'angle'].includes(this.orientation) + ? this.ctx.xInterval + : this.ctx.yInterval ); defaultTickSpacing = $derived.by(() => { const placement = this.#props.placement; return ['top', 'bottom', 'angle'].includes(placement) ? 80 - : ['left', 'right', 'radius'].includes(placement) + : ['left', 'right', 'radius', 'back'].includes(placement) ? 50 : undefined; }); @@ -307,7 +350,14 @@ export class AxisState { if (this.orientation === 'vertical') return this.ctx.height; if (this.orientation === 'horizontal') return this.ctx.width; if (this.orientation === 'radius') return this.ctx.height / 2; - if (this.orientation === 'angle') return this.ctx.width; + if (this.orientation === 'back') return this.ctx.zDepth; + if (this.orientation === 'angle') { + // The arc the labels run around, so the ticks don't change with the chart's width + const [a0, a1] = this.xRangeMinMax; + const outerRadius = Math.max(...this.yRangeMinMax.map(Math.abs)); + const arc = Math.abs(a1 - a0) * outerRadius; + return Number.isFinite(arc) && arc > 0 ? arc : null; + } return null; }); @@ -406,6 +456,9 @@ export class AxisState { x: this.xRangeMinMax[0], y: scale(tick) + (isScaleBand(scale) ? scale.bandwidth() / 2 : 0), }; + + case 'back': + return this.back ? this.back.raise(scale(tick)) : { x: 0, y: 0 }; } return { x: 0, y: 0 }; } @@ -469,6 +522,18 @@ export class AxisState { verticalAnchor: 'middle', dx: 2, }; + + case 'back': { + // Left of the edge on screen, however the floor is turned + const gap = tickLength + labelPadding; + return { + textAnchor: 'end', + verticalAnchor: 'middle', + dx: (this.back?.left.x ?? 0) * gap, + dy: (this.back?.left.y ?? 0) * gap, + viewport: true, + }; + } } return {}; } @@ -527,13 +592,36 @@ export class AxisState { fill, classes = {}, } = this.#props; + const anchors = this.#viewportAnchors(labelProps?.viewport); + + // Atop the edge, facing the viewer + if (this.back && this.#props.placement === 'back') { + const gap = 8; + return { + value: typeof label === 'function' ? '' : label, + x: this.back.top.x + this.back.up.x * gap, + y: this.back.top.y + this.back.up.y * gap, + textAnchor: 'middle', + verticalAnchor: 'end', + viewport: true, + capHeight: '7px', + lineHeight: '11px', + fill, + stroke, + ...labelProps, + class: cls('lc-axis-label', classes.label, labelProps?.class), + } as TextProps; + } + return { value: typeof label === 'function' ? '' : label, x: this.resolvedLabelX, y: this.resolvedLabelY, textAnchor: this.resolvedLabelTextAnchor, verticalAnchor: this.resolvedLabelVerticalAnchor, - rotate: this.orientation === 'vertical' && labelPlacement === 'middle' ? -90 : 0, + // Facing the viewer, a title reads left to right like the tick labels + rotate: !anchors && this.orientation === 'vertical' && labelPlacement === 'middle' ? -90 : 0, + ...anchors, capHeight: '7px', lineHeight: '11px', fill, @@ -616,6 +704,7 @@ export class AxisState { y: this.orientation === 'angle' ? radialTickCoordsY : tickCoords.y, value: this.tickFormat(tick, index), ...this.getDefaultTickLabelProps(tick), + ...this.#viewportAnchors(tickLabelProps?.viewport), motion, capHeight: '7px', lineHeight: '11px', diff --git a/packages/layerchart/src/lib/components/Bar/Bar.base.svelte b/packages/layerchart/src/lib/components/Bar/Bar.base.svelte index d50d1e10e6..099fbf4e3e 100644 --- a/packages/layerchart/src/lib/components/Bar/Bar.base.svelte +++ b/packages/layerchart/src/lib/components/Bar/Bar.base.svelte @@ -137,6 +137,7 @@ initialHeight={c.resolvedInitialHeight} initialWidth={c.resolvedInitialWidth} {...c.dimensions} + z={c.zExtent} onpointerenter={onPointerEnter} onpointermove={onPointerMove} onpointerleave={onPointerLeave} diff --git a/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts b/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts index 609f60e568..e5bc4ab16f 100644 --- a/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Bar/Bar.shared.svelte.ts @@ -1,7 +1,7 @@ import type { SVGAttributes } from 'svelte/elements'; import { greatestAbs } from '@layerstack/utils'; -import { createDimensionGetter, type Insets } from '$lib/utils/rect.svelte.js'; +import { createDimensionGetter, resolveInsets, type Insets } from '$lib/utils/rect.svelte.js'; import { accessor, type Accessor } from '$lib/utils/common.js'; import { getChartContext } from '$lib/contexts/chart.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; @@ -141,7 +141,11 @@ export class BarState { stackInsets = $derived.by(() => { const stackPadding = this.#props.stackPadding ?? 0; - if (this.ctx.series.stackLayout == null || stackPadding === 0 || this.seriesIndex === undefined) { + if ( + this.ctx.series.stackLayout == null || + stackPadding === 0 || + this.seriesIndex === undefined + ) { return undefined; } @@ -173,8 +177,58 @@ export class BarState { })) ); + /** With `valueAxis="z"`, the `x` × `y` band cell the bar stands on, else `null` */ + #footprint = $derived.by(() => { + if (this.ctx.valueAxis !== 'z') return null; + const d = this.#props.data; + const insets = resolveInsets(this.insets); + const band = ( + scale: any, + value: any, + subScale: any, + subValue: (() => any) | null, + before: number, + after: number + ) => { + const sub = subValue && subScale; + const start = (scale(value) ?? 0) + (sub ? (subScale(subValue()) ?? 0) : 0) + before; + const size = (sub ? subScale.bandwidth?.() : scale.bandwidth?.()) ?? 0; + return [start, Math.max(0, size - before - after)]; + }; + const x1 = this.x1 ?? this.ctx.config.x1; + const y1 = this.y1 ?? this.ctx.config.y1; + const [x, width] = band( + this.ctx.xScale, + accessor(this.x)(d), + this.ctx.x1Scale, + x1 != null ? () => accessor(x1)(d) : null, + insets.left, + insets.right + ); + const [y, height] = band( + this.ctx.yScale, + accessor(this.y)(d), + this.ctx.y1Scale, + y1 != null ? () => accessor(y1)(d) : null, + insets.top, + insets.bottom + ); + return { x, y, width, height }; + }); + + /** With `valueAxis="z"`, the bar's `[start, end]` heights in pixels, else `undefined` */ + zExtent = $derived.by((): [number, number] | undefined => { + if (this.ctx.valueAxis !== 'z') return undefined; + const d = this.#props.data; + const value = this.stackAccessors?.value(d) ?? this.ctx.z(d); + const [a, b] = Array.isArray(value) ? value : [0, value]; + const z0 = this.ctx.zScale(Number(a) || 0); + const z1 = this.ctx.zScale(Number(b) || 0); + return [Math.min(z0, z1), Math.max(z0, z1)]; + }); + scaleDimensions = $derived( - this.getDimensions(this.#props.data) ?? { x: 0, y: 0, width: 0, height: 0 } + this.#footprint ?? this.getDimensions(this.#props.data) ?? { x: 0, y: 0, width: 0, height: 0 } ); dimensions = $derived.by(() => { diff --git a/packages/layerchart/src/lib/components/Bars/Bars.base.svelte b/packages/layerchart/src/lib/components/Bars/Bars.base.svelte index 4775b1cd3f..bfb67dfbe4 100644 --- a/packages/layerchart/src/lib/components/Bars/Bars.base.svelte +++ b/packages/layerchart/src/lib/components/Bars/Bars.base.svelte @@ -50,7 +50,7 @@ {#if children} {@render children()} {:else} - {#each c.data as d, i (key(d, i))} + {#each c.paintedData as d, i (key(d, i))} = null!; + render(MarkHarness, { + chartProps: { + data, + x: 'x', + xScale: scaleBand(), + y: 'row', + yScale: scaleBand(), + z: 'value', + valueAxis: 'z', + zRange: [0, 100], + c: 'fruit', + cScale: scaleOrdinal(), + cRange: ['red', 'blue'], + width: 400, + height: 300, + view: isometric, + }, + layerProps: { center: false }, + component: Bars, + oncontext: (c: any) => (ctx = c), + }); + await expect.element(page.getByTestId(chartTestId)).toBeInTheDocument(); + await vi.waitFor(() => expect(ctx?.width).toBeGreaterThan(0)); + const el = page.getByTestId(chartTestId).element() as HTMLElement; + await vi.waitFor(() => expect(el.querySelectorAll('.lc-rect-box')).toHaveLength(4)); + return { ctx, el }; +} + +describe('Bars with values along `z`', () => { + it('stacks heights per `x` and `y` row, the domain reaching the tallest stack', async () => { + const { ctx } = await renderBars(); + expect(ctx.valueAxis).toBe('z'); + expect(ctx.zScale.domain()).toEqual([0, 5]); + }); + + it('stands each bar on its band cell, each stack drawn bottom up', async () => { + const { el } = await renderBars(); + const boxes = [...el.querySelectorAll('.lc-rect-box')]; + const tops = boxes.map((box) => box.querySelector('.lc-rect-top')!.getBoundingClientRect()); + // Two per stack, in order: the lower one first, its top lower on screen + for (const i of [0, 2]) { + expect(Math.round(tops[i].left)).toBe(Math.round(tops[i + 1].left)); + expect(tops[i].top).toBeGreaterThan(tops[i + 1].top); + } + }); + + it("gives every mark a row's height in its stack", async () => { + const { ctx } = await renderBars(); + // Pears stand on apples (2 of 5, then 3 more) — on a 0..100 range + const pears = data[1]; + const [base, top] = ctx.heightOf(pears); + expect(base).toBeCloseTo(40); + expect(top).toBeCloseTo(100); + // A mark's own `z` overrides it: a number in pixels + expect(ctx.heightOf(pears, 10)).toEqual([0, 10]); + }); +}); diff --git a/packages/layerchart/src/lib/components/Bars/Bars.shared.svelte.ts b/packages/layerchart/src/lib/components/Bars/Bars.shared.svelte.ts index 8f483018be..5222bb0648 100644 --- a/packages/layerchart/src/lib/components/Bars/Bars.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Bars/Bars.shared.svelte.ts @@ -2,6 +2,7 @@ import type { Snippet } from 'svelte'; import type { BarProps, BarPropsWithoutHTML } from '../Bar/Bar.shared.svelte.js'; import {} from '$lib/utils/common.js'; import { getChartContext } from '$lib/contexts/chart.js'; +import { getLayerIsometric } from '$lib/contexts/isometric.js'; import { getMarkData } from '$lib/contexts/facet.js'; import type { ChartState } from '$lib/states/chart.svelte.js'; @@ -36,6 +37,8 @@ export class BarsState { this.ctx.registerComponent({ name: 'Bars', kind: 'mark', + // Its bars are keyed, in depth order on an isometric floor + paintByDepth: true, markInfo: () => { const p = getProps(); return { @@ -54,4 +57,28 @@ export class BarsState { }); seriesData = $derived(this.series?.data); data = $derived(this.markData(this.#getProps().data ?? this.seriesData)); + + #layerIsometric = getLayerIsometric(); + + /** + * `data` in draw order: with `valueAxis="z"` on an isometric floor, back to front and each stack + * bottom up, else as given. + */ + paintedData = $derived.by(() => { + const m = this.#layerIsometric(); + const ctx = this.ctx; + if (!m || ctx.valueAxis !== 'z') return this.data; + const centre = (scale: any, value: any) => (scale(value) ?? 0) + (scale.bandwidth?.() ?? 0) / 2; + const depth = (d: any) => + m.b * centre(ctx.xScale, ctx.x(d)) + m.d * centre(ctx.yScale, ctx.y(d)); + const stack = ctx.stackAccessorsFor({ + seriesKey: this.#getProps().seriesKey, + stacksImplicitly: true, + }); + const base = (d: any) => { + const value = stack?.value(d); + return Array.isArray(value) ? Math.min(...value) : 0; + }; + return [...this.data].sort((a, b) => depth(a) - depth(b) || base(a) - base(b)); + }); } diff --git a/packages/layerchart/src/lib/components/BrushContext.svelte b/packages/layerchart/src/lib/components/BrushContext.svelte index f4638a8f67..6c4e98337d 100644 --- a/packages/layerchart/src/lib/components/BrushContext.svelte +++ b/packages/layerchart/src/lib/components/BrushContext.svelte @@ -123,6 +123,7 @@ import { untrack } from 'svelte'; import { brushable, type BrushMode } from '$lib/attachments/brushable.js'; import { cls } from '@layerstack/tailwind'; + import { applyMatrix, invertMatrix, matrixToString } from '$lib/utils/isometric.js'; import { Logger } from '@layerstack/utils'; import type { HTMLAttributes } from 'svelte/elements'; @@ -180,6 +181,10 @@ const logger = new Logger('BrushContext'); const RESET_THRESHOLD = 1; // size of pointer delta to ignore + /** The layers' matrix (an isometric view or a zoom) the brush is drawn through, or `null` */ + const plotMatrix = $derived(ctx.layerMatrix?.() ?? null); + const plotInverse = $derived(plotMatrix ? invertMatrix(plotMatrix) : null); + /** * The gesture, from the `brushable` attachment — the same one a chart can attach to elements of * its own. Each part of the brush takes the mode it represents, so the handles keep their own @@ -192,6 +197,7 @@ mode, // Every part measures against the root, not against itself — a handle is only a few pixels bounds: () => rootEl?.getBoundingClientRect(), + toPlot: (point) => (plotInverse ? applyMatrix(plotInverse, point) : point), // The gesture belongs to the panel it started in, and stays there — the scales are shared, // so the selection it produces applies to every panel. Unfaceted charts resolve to the // single full-size panel, and a point in the gap between panels to none, which ignores it. @@ -255,108 +261,125 @@ One selection, drawn in every panel — the panels share the position scales, so a range of the domain is the same range in each of them. --> - {#each ctx.facet.panels as panel (panel.key)} -
-
{ - // Stopped as the handles do — the root takes a double-click as "select all", which - // would otherwise land right back on top of the selection just cleared - e.stopPropagation(); - brushState.reset(); - onChange({ brush: brushState }); - }} - >
- - {#if axis === 'both' || axis === 'y'} + +
+ {#each ctx.facet.panels as panel (panel.key)} +
{ - e.stopPropagation(); - if (brushState.y[0]) { - brushState.y[0] = brushState.yDomainMin; - onChange({ brush: brushState }); - } - }} - >
- -
{ - e.stopPropagation(); - if (brushState.y[1]) { - brushState.y[1] = brushState.yDomainMax; - onChange({ brush: brushState }); - } - }} - >
- {/if} - - {#if axis === 'both' || axis === 'x'} -
{ + // Stopped as the handles do — the root takes a double-click as "select all", which + // would otherwise land right back on top of the selection just cleared e.stopPropagation(); - if (brushState.x[0]) { - brushState.x[0] = brushState.xDomainMin; - onChange({ brush: brushState }); - } + brushState.reset(); + onChange({ brush: brushState }); }} >
-
{ - e.stopPropagation(); - if (brushState.x[1]) { - brushState.x[1] = brushState.xDomainMax; - onChange({ brush: brushState }); - } - }} - >
- {/if} -
- {/each} + {#if axis === 'both' || axis === 'y'} +
{ + e.stopPropagation(); + if (brushState.y[0]) { + brushState.y[0] = brushState.yDomainMin; + onChange({ brush: brushState }); + } + }} + >
+ +
{ + e.stopPropagation(); + if (brushState.y[1]) { + brushState.y[1] = brushState.yDomainMax; + onChange({ brush: brushState }); + } + }} + >
+ {/if} + + {#if axis === 'both' || axis === 'x'} +
{ + e.stopPropagation(); + if (brushState.x[0]) { + brushState.x[0] = brushState.xDomainMin; + onChange({ brush: brushState }); + } + }} + >
+ +
{ + e.stopPropagation(); + if (brushState.x[1]) { + brushState.x[1] = brushState.xDomainMax; + onChange({ brush: brushState }); + } + }} + >
+ {/if} +
+ {/each} +
{/if} {/if}