diff --git a/CHANGELOG.md b/CHANGELOG.md index 3105e1d5..8c1bd5a9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ A full Svelte 5 rewrite. Stores are gone: the context is a reactive getter objec - `debug` no longer prints during server-side rendering; it prints in the browser after hydration. - Unknown props are reported with a console warning unless `verbose={false}`. - A range you customized on a passed-in scale is now preserved instead of being overwritten with the dimension's default – so `zScale={scaleOrdinal(schemeCategory10)}` keeps its colors. Layer Cake still manages the range of pristine scales, and an explicit `[name]Range` prop always wins. Per [#364](https://github.com/mhkeller/layercake/issues/364). +- `` now owns the canvas. It fills the whole chart container. Before every repaint it scales the canvas for the screen, clears it and moves the origin to the top-left of the chart area. Components draw by calling `getCanvasContext().draw(ctx => { ... })` once while they set up. Several can share one ``. Drawings can run into the padding like Svg and Html children do. To migrate, move your drawing into a `draw` function and delete the `scaleCanvas`, `clearRect` and `$effect` around it. Pointer coordinates read off the `` element (`offsetX`, `getBoundingClientRect()`) are now relative to the container, so subtract `k.padding.left`/`top` if you hit-test that way. See the [Canvas guide](https://layercake.graphics/guide#canvas). **New features** @@ -28,6 +29,9 @@ A full Svelte 5 rewrite. Stores are gone: the context is a reactive getter objec - New `x2DomainSort`, `y2DomainSort`, `cDomainSort` and `c2DomainSort` props. - The context exposes `element`, the `.layercake-container` div. - Dimensions are defined as data in a registry (`settings/dimensions.js`); prop handling, scale creation, context keys and TypeScript definitions are all generated from it. +- `getCanvasContext()` returns the typed canvas context: `draw(fn)` to add a layer, `redraw()` to repaint by hand and `ctx` to read the canvas. +- `` accepts the same `overflow` prop as the other layouts. `overflow="hidden"` clips drawings at the edge of the chart area. +- `k.pointer(event)` returns chart-area `[x, y]` for a pointer event, the same on every layer. Useful for hit-testing on canvas, where the element covers the whole container. **Performance** @@ -47,16 +51,18 @@ A full Svelte 5 rewrite. Stores are gone: the context is a reactive getter objec - The zero-width/zero-height container warning now always fires when the container is unsized, not only when a child happens to read a size-dependent value. - Axis components fall back gracefully on charts that don't configure the opposite dimension. - The declared svelte peer dependency now matches the version the library actually requires. +- The WebGL layout's `` no longer spills past the container by the padding. Its CSS was over-constrained, so the element was container-sized but offset by the top and left padding. ## Migrating from 10.x -| 10.x | 11.0 | -| ------------------------------------------------ | ------------------------------------------------------------------ | -| `const { data, xGet } = getContext('LayerCake')` | `const k = getLayerCakeContext()` | -| `$xGet(d)`, `$yScale.ticks()`, `$width` | `k.xGet(d)`, `k.yScale.ticks()`, `k.width` | -| `` | `{#snippet children(k)}...{/snippet}` or read `k.width` in a child | -| Color via `z` | Still works, but `c` is now the dedicated color dimension | -| `getContext('canvas')` store | `getContext('canvas').ctx` getter object (same for `'gl'`) | +| 10.x | 11.0 | +| ---------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | +| `const { data, xGet } = getContext('LayerCake')` | `const k = getLayerCakeContext()` | +| `$xGet(d)`, `$yScale.ticks()`, `$width` | `k.xGet(d)`, `k.yScale.ticks()`, `k.width` | +| `` | `{#snippet children(k)}...{/snippet}` or read `k.width` in a child | +| Color via `z` | Still works, but `c` is now the dedicated color dimension | +| `$ctx` from `getContext('canvas')`, then `scaleCanvas` + `clearRect` + draw in an effect | `getCanvasContext().draw(ctx => { ...draw... })` – no scaling, clearing or effect needed | +| `getContext('gl')` store | `getContext('gl').gl` getter object | # 10.0.3 diff --git a/src/_components/Map.canvas.svelte b/src/_components/Map.canvas.svelte index 87850604..a71ec366 100644 --- a/src/_components/Map.canvas.svelte +++ b/src/_components/Map.canvas.svelte @@ -3,13 +3,11 @@ Generates a canvas map using the `geoPath` function from [d3-geo](https://github.com/d3/d3-geo). --> diff --git a/src/content/examples/MapLayered.md b/src/content/examples/MapLayered.md index 565c3803..e036d4e4 100644 --- a/src/content/examples/MapLayered.md +++ b/src/content/examples/MapLayered.md @@ -1 +1 @@ -A canvas layer and an SVG layer. This technique is useful if you have a background layer that would require a large number of DOM nodes. Rendering that layer with canvas will speed up the page. The shapes you actually care about are in SVG to make styling and mouse interaction easier. +A canvas layer and an SVG layer. This technique is useful if you have a background layer that would require a large number of DOM nodes. Rendering that layer with canvas will possibly improve performance. The shapes you actually care about are in SVG to make styling and mouse interaction easier. The canvas layer holds two components, the state shapes and a dot for each state too small to label; one `` paints both. diff --git a/src/content/guide/03-layercake-props.md b/src/content/guide/03-layercake-props.md index 6333458a..b4f20ae2 100644 --- a/src/content/guide/03-layercake-props.md +++ b/src/content/guide/03-layercake-props.md @@ -40,7 +40,7 @@ A few shapes repeat across the props below. Every accessor – `x`, `y`, `c` and ``` -Two more names come from the context: `LayerCakeContext` for the object you get back from `getLayerCakeContext()`, and `Scale` for the d3 scales hanging off it. See [Typing the context](/guide#typing-the-context). +Two more names come from the context: `LayerCakeContext` for the object you get back from `getLayerCakeContext()`, and `Scale` for the d3 scales hanging off it. Canvas layers have `CanvasContext` and `CanvasDrawFn`. See [Typing the context](/guide#typing-the-context). The headings below spell out the full shape rather than the alias, so you can see what a prop takes without looking anything up. diff --git a/src/content/guide/04-computed-context-values.md b/src/content/guide/04-computed-context-values.md index 12c60e22..b1675192 100644 --- a/src/content/guide/04-computed-context-values.md +++ b/src/content/guide/04-computed-context-values.md @@ -102,6 +102,19 @@ The width of the drawable space for the chart. This is the width of the parent c The height of the drawable space for the chart. This is the height of the parent container taking into account any padding. It's also on the children snippet, as `k.height`. +### pointer(event: `MouseEvent`) + +Chart-area coordinates for a pointer event, as `[x, y]`. Layers cover different boxes – Canvas covers the whole container while Svg and Html cover the chart area – so `offsetX` and friends change meaning depending on where you listen. `k.pointer` measures against the container and subtracts the padding, so it gives the same answer everywhere. + +```svelte +
{ + const [x, y] = k.pointer(e); + const nearestYear = k.xScale.invert(x); + }} +>
+``` + ### x `Function` The x accessor. This will always be a function regardless of whether you passed in a string or an array as a prop. If you passed in an array, it will return an array of equal length. diff --git a/src/content/guide/05-layout-components.md b/src/content/guide/05-layout-components.md index de12c7b8..bbbf8d26 100644 --- a/src/content/guide/05-layout-components.md +++ b/src/content/guide/05-layout-components.md @@ -6,7 +6,7 @@ Layer Cake comes with layout components that provide HTML, Svg, ScaledSvg, Canva You must wrap your chart components in these layout components for them to appear properly scaled. For Html and Svg components, they create a `
` and ``, respectively. -The Canvas and WebGL layout components also create rendering contexts that are made available to your layer components on their own Svelte contexts, under the `'canvas'` and `'gl'` keys, respectively. See the [Canvas](/guide#canvas) and [WebGL](/guide#webgl) sections below for details. +The Canvas and WebGL layout components also create rendering contexts that are made available to your layer components on their own Svelte contexts, under the `'canvas'` and `'gl'` keys, respectively – `getCanvasContext()` returns the canvas one. See the [Canvas](/guide#canvas) and [WebGL](/guide#webgl) sections below for details. Each of these components also takes props. See the next section [Layout component props](/guide#layout-component-props) for more info. @@ -186,23 +186,17 @@ This component also has a named `defs` [snippet](https://svelte.dev/docs/svelte/ ``` -In the component, you access the canvas context with `const canvasCtx = getContext('canvas');` and read the 2d context as `canvasCtx.ctx`. This value is on a different context from the LayerCake one because you could have multiple canvas layers and there wouldn't be an easy way to grab the right one. This way, the component always has access to just its parent Canvas component. +The `` element covers the whole chart container, padding included. Layer Cake moves its origin to the top-left of the chart area, so you draw in the same coordinates as an Svg or Html child: `k.xGet(d)` lands in the same spot on every layout. Anything you draw past the edges shows up in the padding, the way it does on the other layouts. Pass `overflow="hidden"` to clip at the chart area instead. -> Warning: If you want to draw multiple canvas layers, use one `` tag each. There is a bug in [Svelte's reactivity](https://github.com/mhkeller/layercake/issues/50) that will cause an infinite loop if you add two or more components in a single `` tag. - -> Since the `canvasCtx.ctx` value is a normal 2d context, the underlying canvas element is accessible under `canvasCtx.ctx.canvas`. - -Here's an example showing a scatter plot. +Components draw by handing Layer Cake a function. Get the canvas context with `getCanvasContext()` and call `canvas.draw(ctx => { ... })`. Here's a scatter plot: ```svelte ``` +Call `draw` once while your component is setting up. Layer Cake runs your function every time the chart repaints: on resize, new data or a prop change. Before each repaint it scales the canvas for the screen, clears it and moves the origin, so your function only draws. Everything the function reads (props, `$state`, `k.*`) is tracked, so changing any of it repaints. The function runs inside an effect: it can read reactive values but should not write them. + +Several components can draw on one ``. They paint in the order they called `draw`, so the first component ends up at the bottom. A component that is removed and added back by an `{#if}` goes to the top of the stack. Each component's layer is removed when the component is destroyed. `draw` also returns a function that removes it sooner. + +```svelte + + + + +``` + +`canvas.ctx` is the canvas's 2d context (`null` until the canvas mounts) for reading – the pixel under the pointer, `canvas.ctx.canvas.toDataURL()` – rather than drawing. If your draw function reads something Svelte can't see change, like an array you mutate in place or an image that just finished loading, call `canvas.redraw()` from wherever that change happens. That runs the whole paint again, the same as after a resize: the canvas is cleared and every draw function is called, not just yours. + +The canvas context is separate from the LayerCake one because you could have multiple canvas layers and there wouldn't be an easy way to grab the right one. This way, the component always has access to just its parent Canvas component. + ### WebGL ```svelte diff --git a/src/content/guide/06-layout-component-props.md b/src/content/guide/06-layout-component-props.md index 83c07064..27d38f90 100644 --- a/src/content/guide/06-layout-component-props.md +++ b/src/content/guide/06-layout-component-props.md @@ -7,7 +7,7 @@ In addition to the [accessibility props](guide#accessibility) described above, a - [zIndex](guide#zindex) `number|string` - [pointerEvents](guide#pointerevents) `boolean` -The Html, Svg and ScaledSvg layout components also accept: +The Html, Svg, ScaledSvg and Canvas layout components also accept: - [overflow](guide#overflow) `'visible'|'hidden'` @@ -28,7 +28,7 @@ Each layout component also export an `element` prop that you can bind to and rep - In the `Html` component, `element` equals the `
` tag. - In the `Svg` component, `element` equals the `` tag. - In the `ScaledSvg` component, `element` equals the `` tag. -- In the `Canvas` component, `element` equals the `` tag. +- In the `Canvas` component, `element` equals the `` tag. It covers the whole container, padding included – see the [Canvas](/guide#canvas) section. - In the `WebGL` component, `element` equals the `` tag. The `Canvas` and the `WebGL` components also export a `context` variable that you can bind to and is also available as a slot prop. @@ -81,7 +81,7 @@ Useful for tooltip layers that need to be display above chart elements but not c ### overflow `'visible'|'hidden'` -For Html, Svg and ScaledSvg components, whether or not the CSS `overflow` property is set to `'visible'` or `'hidden'`. Useful if you want to hide overflow during an animation or values that exceed the bounds of your chart. See [PR#311](https://github.com/mhkeller/layercake/pull/311) for some examples. +For Html, Svg and ScaledSvg components, whether the CSS `overflow` property is set to `'visible'` or `'hidden'`. For Canvas, whether drawings are clipped at the edge of the chart area. Useful if you want to hide overflow during an animation or values that exceed the bounds of your chart. See [PR#311](https://github.com/mhkeller/layercake/pull/311) for some examples. ```svelte diff --git a/src/content/guide/99-helper-functions.md b/src/content/guide/99-helper-functions.md index a5783d5b..c0d31540 100644 --- a/src/content/guide/99-helper-functions.md +++ b/src/content/guide/99-helper-functions.md @@ -316,50 +316,15 @@ This is useful for hovering over SVG maps so that the hovered-over feature is no ### scaleCanvas(ctx: `CanvasRenderingContext2D`, width: `number`, height: `number`) -Scale your canvas size to retina screens. This function will modify the canvas, if necessary, and return an object with the new `width` and `height` as properties. - -Such as in the [Scatter canvas](/example/Scatter) example: - -```svelte - - +Size a canvas for retina screens. It sets the drawing buffer to `width` by `height` times the device pixel ratio, the element's CSS size to `width` by `height` and the context's transform to match. It returns an object with the buffer's `width` and `height`. Called again with the same size it only resets the transform, so it's cheap to call before every draw. + +The [Canvas layout component](/guide#canvas) calls this for you before every repaint, so components that draw through `canvas.draw` don't need it. It's exported for canvases you manage yourself: + +```js +import { scaleCanvas } from 'layercake'; + +const ctx = myCanvas.getContext('2d'); +scaleCanvas(ctx, 600, 400); ``` ### stack(data: `Array|Object`[, keys: `string[]`, { value: `string|Function`, order: `Array|Function`, offset: `Array|Function` }]) diff --git a/src/lib/LayerCake.svelte b/src/lib/LayerCake.svelte index e15baa82..a6fd8e71 100644 --- a/src/lib/LayerCake.svelte +++ b/src/lib/LayerCake.svelte @@ -271,6 +271,22 @@ dims[dimension.name] = createDimension(dimension, () => dimProps, dimensionCtx); } + // Chart-area coordinates for a pointer event, whatever layer the listener + // sits on. Layers cover different boxes – Canvas covers the whole container + // while Svg and Html cover the chart area – so offsetX and friends change + // meaning depending on where you listen. Measuring against the container + // and subtracting the padding gives one answer everywhere. + /** + * @param {MouseEvent} event + * @returns {[number, number]} + */ + function pointer(event) { + // Nothing to measure against before the container mounts + if (!element) return [NaN, NaN]; + const rect = element.getBoundingClientRect(); + return [event.clientX - rect.left - padding.left, event.clientY - rect.top - padding.top]; + } + // Assemble the context. Every property is a getter into reactive state // so child components read live values as `k.width`, `k.xGet(d)` etc. const context = {}; @@ -283,6 +299,7 @@ containerWidth: { get: () => containerWidth, enumerable: true }, containerHeight: { get: () => containerHeight, enumerable: true }, element: { get: () => element, enumerable: true }, + pointer: { value: pointer, enumerable: true }, custom: { get: () => custom, enumerable: true }, data: { get: () => data, enumerable: true }, padding: { get: () => padding, enumerable: true }, diff --git a/src/lib/context.js b/src/lib/context.js index d03e72cb..78cb2da4 100644 --- a/src/lib/context.js +++ b/src/lib/context.js @@ -1,4 +1,4 @@ -import { createContext } from 'svelte'; +import { createContext, getContext, hasContext } from 'svelte'; /** * A d3 scale: something you call, carrying whatever methods that particular @@ -44,6 +44,7 @@ import { createContext } from 'svelte'; * @property {Object} custom Any extra values passed via the `custom` prop. * @property {Object|Array} data The chart data. * @property {{top: number, right: number, bottom: number, left: number}} padding The chart padding. + * @property {(event: MouseEvent) => [number, number]} pointer Chart-area `[x, y]` for a pointer event, the same on every layer. Measures against the container and subtracts the padding. `[NaN, NaN]` before the chart mounts. * @property {Object|Array} flatData The flat version of the data. * @property {Object.>} extents The measured extents keyed by dimension name. * @property {Object.} config A read-only copy of the accessor, domain and range props the user set. @@ -117,3 +118,32 @@ import { createContext } from 'svelte'; * @type {[() => LayerCakeContext, (context: LayerCakeContext) => LayerCakeContext]} */ export const [getLayerCakeContext, setLayerCakeContext] = createContext(); + +/** + * A function that draws one layer on a ``. The context it gets is + * already scaled for the screen and its origin sits at the top-left of the + * chart area, the same origin Svg and Html children use, so it draws in chart + * coordinates. Anything past the edges lands in the padding. + * @typedef {(ctx: CanvasRenderingContext2D) => void} CanvasDrawFn + */ + +/** + * What `` hands its children. Get it with `getCanvasContext()`. + * @typedef {Object} CanvasContext + * @property {CanvasRenderingContext2D|null} ctx The 2d context for reading the canvas directly: a pixel under the pointer, `ctx.canvas.toDataURL()` and the like. `null` until the canvas mounts. Drawing goes through `draw`. + * @property {(fn: CanvasDrawFn) => () => void} draw Gives Layer Cake a function that draws one layer. It runs on every repaint in the order components called `draw`. The function is dropped when the component is destroyed. Returns a function that drops it sooner. Call it at the top level of your component during setup. + * @property {() => void} redraw Run the whole paint again, the same as after a resize: the canvas is cleared and every draw function on it is called, yours included. It happens at the end of the current tick. Several calls in one tick become one repaint. Only needed when a draw function reads something Svelte can't see change, such as an array you mutate in place or an image that finishes loading later. Props, `$state` and `k.*` values repaint on their own. + */ + +/** + * The context of the nearest parent `` layout. + * @returns {CanvasContext} + */ +export function getCanvasContext() { + if (!hasContext('canvas')) { + throw new Error( + '[LayerCake] getCanvasContext() only works in a component inside a layout' + ); + } + return getContext('canvas'); +} diff --git a/src/lib/helpers/paintLayers.js b/src/lib/helpers/paintLayers.js new file mode 100644 index 00000000..a38a18af --- /dev/null +++ b/src/lib/helpers/paintLayers.js @@ -0,0 +1,52 @@ +import scaleCanvas from '../lib/scaleCanvas.js'; + +/** + * Paint every layer of a `` in one pass. The canvas covers the whole + * chart container area so children can draw into the padding. + * Each layer gets its own save/restore, so the styles and transforms it sets + * don't reach the next one. + * @param {CanvasRenderingContext2D} ctx + * @param {Array<{ fn: (ctx: CanvasRenderingContext2D) => void }>} layers + * @param {{ containerWidth: number, containerHeight: number, width: number, height: number, padding: { top: number, left: number }, overflow?: 'visible'|'hidden' }} chart + */ +export default function paintLayers(ctx, layers, chart) { + const { containerWidth, containerHeight, width, height, padding, overflow } = chart; + const canvas = ctx.canvas; + + // Clear in device pixels. A clear in CSS pixels can miss a sliver at + // fractional zoom levels. + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, canvas.width, canvas.height); + scaleCanvas(ctx, containerWidth, containerHeight); + + // translate() moves (0, 0) from the canvas corner to the top-left of the + // chart area, so draw functions use chart coordinates like Svg/Html children do. + // With overflow="hidden", clip to that area. The save/restore around both + // leaves the context as scaleCanvas set it once this run ends. We need this + // because the clip stays until restore() is called. The next run's clear and + // clip() would be stuck inside it. + ctx.save(); + ctx.translate(padding.left, padding.top); + if (overflow === 'hidden') { + ctx.beginPath(); + ctx.rect(0, 0, width, height); + ctx.clip(); + } + for (const layer of layers) { + ctx.save(); + // Safety net: a layer that forgets beginPath() would otherwise draw the + // clip rect or the previous layer's path + ctx.beginPath(); + try { + layer.fn(ctx); + } catch (err) { + // A throwing layer shouldn't be fatal. If the error escaped the + // effect, Svelte would never wire up its dependencies and the canvas would + // stay blank. + console.error('[LayerCake] A canvas draw function threw an error:', err); + } finally { + ctx.restore(); + } + } + ctx.restore(); +} diff --git a/src/lib/index.js b/src/lib/index.js index c264de93..ec58559f 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -1,5 +1,5 @@ export { default as LayerCake } from './LayerCake.svelte'; -export { getLayerCakeContext, setLayerCakeContext } from './context.js'; +export { getLayerCakeContext, setLayerCakeContext, getCanvasContext } from './context.js'; /** * Re-exported so consumers can name the context type when they write their own @@ -21,6 +21,13 @@ export { getLayerCakeContext, setLayerCakeContext } from './context.js'; * @typedef {import('./types.js').DimensionRange} DimensionRange * @typedef {import('./context.js').Scale} Scale */ + +/** + * The canvas context and the draw function it takes, for typing your own + * canvas layers: `@type {import('layercake').CanvasDrawFn}`. + * @typedef {import('./context.js').CanvasContext} CanvasContext + * @typedef {import('./context.js').CanvasDrawFn} CanvasDrawFn + */ export { default as Html } from './layouts/Html.svelte'; export { default as Svg } from './layouts/Svg.svelte'; export { default as ScaledSvg } from './layouts/ScaledSvg.svelte'; diff --git a/src/lib/layouts/Canvas.svelte b/src/lib/layouts/Canvas.svelte index ac5654b6..01a348ba 100644 --- a/src/lib/layouts/Canvas.svelte +++ b/src/lib/layouts/Canvas.svelte @@ -3,9 +3,9 @@ Canvas layout component --> labelsToExclude.includes(d[labelNameKey])) + .map(/** @param {any} d */ d => ({ geometry: { coordinates: d[labelCoordinatesKey] } })); + // Create a flat array of objects that LayerCake can use to measure // extents for the color scale const flatData = geojson.features @@ -63,6 +70,7 @@ > + diff --git a/src/routes/_examples_ssr/MapLayered.svelte b/src/routes/_examples_ssr/MapLayered.svelte index 88cf71bd..45e56ed0 100644 --- a/src/routes/_examples_ssr/MapLayered.svelte +++ b/src/routes/_examples_ssr/MapLayered.svelte @@ -8,6 +8,7 @@ import MapSvg from '../../_components/Map.svg.svelte'; import MapCanvas from '../../_components/Map.canvas.svelte'; + import MapPointsCanvas from '../../_components/MapPoints.canvas.svelte'; import MapLabels from '../../_components/MapLabels.html.svelte'; // This example loads json data as json using @rollup/plugin-json @@ -41,6 +42,12 @@ return !labelsToExclude.includes(d[labelNameKey]); }); + // The states too small to carry a label get a dot instead. MapPoints wants + // GeoJSON-shaped features, so wrap each label's coordinates that way. + const dotFeatures = stateLabels + .filter(/** @param {any} d */ d => labelsToExclude.includes(d[labelNameKey])) + .map(/** @param {any} d */ d => ({ geometry: { coordinates: d[labelCoordinatesKey] } })); + // Create a flat array of objects that LayerCake can use to measure // extents for the color scale const flatData = geojson.features.map(d => d.properties); @@ -49,8 +56,10 @@
+ + diff --git a/test/e2e/canvas-layout.spec.js b/test/e2e/canvas-layout.spec.js new file mode 100644 index 00000000..b3c895de --- /dev/null +++ b/test/e2e/canvas-layout.spec.js @@ -0,0 +1,116 @@ +import { test, expect } from '@playwright/test'; +import { readdirSync } from 'fs'; + +// covers the whole chart container and paints with its origin moved to +// the chart area. These tests read the real element and its pixels instead of +// comparing screenshots. + +const canvasPages = readdirSync('./docs/components') + .filter(f => f.endsWith('.canvas.svelte.html')) + .map(f => f.replace('.html', '')); + +canvasPages.forEach(slug => { + test(` covers the container and paints on /components/${slug}`, async ({ page }) => { + await page.goto(`/components/${slug}`); + + const measure = () => + page.evaluate(() => { + const container = document.querySelector('.chart-hero .layercake-container'); + const canvas = container?.querySelector('canvas.layercake-layout-canvas'); + if (!container || !canvas) return null; + const c = container.getBoundingClientRect(); + const r = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; + let painted = false; + for (let i = 3; i < data.length; i += 4) { + if (data[i] > 0) { + painted = true; + break; + } + } + return { + painted, + sameBox: + Math.abs(c.x - r.x) < 1 && + Math.abs(c.y - r.y) < 1 && + Math.abs(c.width - r.width) < 1 && + Math.abs(c.height - r.height) < 1, + bufferMatchesBox: + Math.abs(canvas.width - r.width * dpr) < 2 && + Math.abs(canvas.height - r.height * dpr) < 2 + }; + }); + + // The first paint lands after the container is measured, so poll for it + await expect + .poll(measure) + .toMatchObject({ painted: true, sameBox: true, bufferMatchesBox: true }); + }); +}); + +// The Scatter example draws the same points on an Svg layer and on a Canvas +// layer. When the canvas origin is moved by the padding correctly, the canvas +// is painted under the centre of every the Svg drew. +test('canvas points line up with the svg points on /example/Scatter', async ({ page }) => { + await page.goto('/example/Scatter'); + + const compare = () => + page.evaluate(() => { + const container = document.querySelector('.chart-hero .layercake-container'); + const canvas = container?.querySelector('canvas.layercake-layout-canvas'); + const circles = container + ? [...container.querySelectorAll('.layercake-layout-svg circle')] + : []; + if (!canvas || circles.length === 0) return null; + const ctx = canvas.getContext('2d'); + const box = canvas.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + let hits = 0; + for (const circle of circles) { + const b = circle.getBoundingClientRect(); + const x = Math.round((b.x + b.width / 2 - box.x) * dpr); + const y = Math.round((b.y + b.height / 2 - box.y) * dpr); + if (ctx.getImageData(x, y, 1, 1).data[3] > 0) hits++; + } + return { circles: circles.length, hits }; + }); + + await expect + .poll(async () => { + const r = await compare(); + return r ? r.hits === r.circles && r.circles > 0 : false; + }) + .toBe(true); +}); + +// MapLayered puts two components on one : white state shapes, then a +// dark dot for each state too small to label. The dots sit inside states, so +// dark pixels only show up if both layers painted and the dots came out on top. +test('two components share one canvas, painted in order, on /example/MapLayered', async ({ + page +}) => { + await page.goto('/example/MapLayered'); + + const count = () => + page.evaluate(() => { + const canvas = document.querySelector('.chart-hero canvas.layercake-layout-canvas'); + if (!canvas) return null; + const data = canvas.getContext('2d').getImageData(0, 0, canvas.width, canvas.height).data; + let white = 0; + let dark = 0; + for (let i = 0; i < data.length; i += 4) { + if (data[i + 3] < 200) continue; + if (data[i] > 240 && data[i + 1] > 240 && data[i + 2] > 240) white++; + else if (data[i] < 70 && data[i + 1] < 70 && data[i + 2] < 70) dark++; + } + return { white, dark }; + }); + + await expect + .poll(async () => { + const r = await count(); + return r ? r.white > 1000 && r.dark > 20 : false; + }) + .toBe(true); +}); diff --git a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-MapLayered-1-chromium-darwin.png b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-MapLayered-1-chromium-darwin.png index a4cdb79f..7cd7ea2c 100644 Binary files a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-MapLayered-1-chromium-darwin.png and b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-MapLayered-1-chromium-darwin.png differ diff --git a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-ssr-MapLayered-1-chromium-darwin.png b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-ssr-MapLayered-1-chromium-darwin.png index 4d3ea127..07585a50 100644 Binary files a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-ssr-MapLayered-1-chromium-darwin.png and b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-example-ssr-MapLayered-1-chromium-darwin.png differ diff --git a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-1-chromium-darwin.png b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-1-chromium-darwin.png index dd6a915d..82c17130 100644 Binary files a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-1-chromium-darwin.png and b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-1-chromium-darwin.png differ diff --git a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-2-chromium-darwin.png b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-2-chromium-darwin.png index 1ebcad94..bf4d701c 100644 Binary files a/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-2-chromium-darwin.png and b/test/e2e/chart-screenshots.spec.js-snapshots/Snapshot-for-landing-page-galleries-2-chromium-darwin.png differ diff --git a/test/e2e/copy-clipboard.spec.js b/test/e2e/copy-clipboard.spec.js index bcf55f54..58f4d751 100644 --- a/test/e2e/copy-clipboard.spec.js +++ b/test/e2e/copy-clipboard.spec.js @@ -1,5 +1,10 @@ import { test, expect } from '@playwright/test'; +// The clipboard is one shared thing on the machine, so these tests have to take +// turns. Run in parallel they overwrite each other's text in between the copy and +// the read. CI already runs one worker at a time – this makes local runs match. +test.describe.configure({ mode: 'default' }); + const urls = ['/components/AxisRadial.svelte', '/example/Bar', '/example-ssr/ScatterWebgl']; urls.forEach(url => { diff --git a/test/lib/paintLayers.test.js b/test/lib/paintLayers.test.js new file mode 100644 index 00000000..616538c0 --- /dev/null +++ b/test/lib/paintLayers.test.js @@ -0,0 +1,133 @@ +/* globals describe it beforeEach afterEach globalThis */ +import * as assert from 'assert'; +import paintLayers from '../../src/lib/helpers/paintLayers.js'; + +const name = 'paintLayers'; + +// A stand-in for a 2d context that records every call so the tests can read +// the order things happened in +function makeCtx() { + const calls = []; + const canvas = { width: 300, height: 150, style: {} }; + const record = + method => + (...args) => { + calls.push([method, ...args]); + }; + const ctx = { + canvas, + calls, + setTransform: record('setTransform'), + clearRect: record('clearRect'), + save: record('save'), + restore: record('restore'), + translate: record('translate'), + beginPath: record('beginPath'), + rect: record('rect'), + clip: record('clip') + }; + return ctx; +} + +const chart = { + containerWidth: 600, + containerHeight: 400, + width: 550, + height: 350, + padding: { top: 20, right: 10, bottom: 30, left: 40 }, + overflow: 'visible' +}; + +describe(name, () => { + const originalWindow = globalThis.window; + const originalError = console.error; + + beforeEach(() => { + globalThis.window = { devicePixelRatio: 2 }; + }); + + afterEach(() => { + globalThis.window = originalWindow; + console.error = originalError; + }); + + it('runs every layer in order, each inside its own save/restore with a fresh path', () => { + const ctx = makeCtx(); + const ran = []; + paintLayers(ctx, [{ fn: () => ran.push('a') }, { fn: () => ran.push('b') }], chart); + assert.deepStrictEqual(ran, ['a', 'b']); + + const methods = ctx.calls.map(c => c[0]); + const firstSave = methods.indexOf('save'); + // outer save, then per layer: save, beginPath ... restore, then outer restore + assert.deepStrictEqual(methods.slice(firstSave), [ + 'save', + 'translate', + 'save', + 'beginPath', + 'restore', + 'save', + 'beginPath', + 'restore', + 'restore' + ]); + }); + + it('sizes the canvas to the container and moves the origin by the padding', () => { + const ctx = makeCtx(); + paintLayers(ctx, [], chart); + assert.strictEqual(ctx.canvas.style.width, '600px'); + assert.strictEqual(ctx.canvas.style.height, '400px'); + assert.deepStrictEqual(ctx.canvas.width, 1200); + assert.ok(ctx.calls.some(c => c[0] === 'translate' && c[1] === 40 && c[2] === 20)); + }); + + it('clears the whole old bitmap in device pixels before resizing it', () => { + const ctx = makeCtx(); + paintLayers(ctx, [], chart); + assert.deepStrictEqual(ctx.calls[0], ['setTransform', 1, 0, 0, 1, 0, 0]); + // 300x150 is the size the canvas had before this paint + assert.deepStrictEqual(ctx.calls[1], ['clearRect', 0, 0, 300, 150]); + }); + + it('clips to the chart area only when overflow is hidden', () => { + const visible = makeCtx(); + paintLayers(visible, [], chart); + assert.ok(!visible.calls.some(c => c[0] === 'clip')); + + const hidden = makeCtx(); + paintLayers(hidden, [], { ...chart, overflow: 'hidden' }); + const clipIdx = hidden.calls.findIndex(c => c[0] === 'clip'); + assert.ok(clipIdx > 0); + assert.deepStrictEqual(hidden.calls[clipIdx - 1], ['rect', 0, 0, 550, 350]); + assert.deepStrictEqual(hidden.calls[clipIdx - 2], ['beginPath']); + }); + + it('keeps painting the other layers when one throws and still restores the context', () => { + const ctx = makeCtx(); + const errors = []; + console.error = (...args) => errors.push(args); + const ran = []; + paintLayers( + ctx, + [ + { fn: () => ran.push('a') }, + { + fn: () => { + throw new Error('boom'); + } + }, + { fn: () => ran.push('c') } + ], + chart + ); + assert.deepStrictEqual(ran, ['a', 'c']); + assert.strictEqual(errors.length, 1); + assert.ok(String(errors[0][0]).startsWith('[LayerCake]')); + assert.strictEqual(errors[0][1].message, 'boom'); + + const saves = ctx.calls.filter(c => c[0] === 'save').length; + const restores = ctx.calls.filter(c => c[0] === 'restore').length; + assert.strictEqual(saves, restores); + }); +}); diff --git a/test/lib/scaleCanvas.test.js b/test/lib/scaleCanvas.test.js new file mode 100644 index 00000000..3f07c717 --- /dev/null +++ b/test/lib/scaleCanvas.test.js @@ -0,0 +1,146 @@ +/* globals describe it beforeEach afterEach globalThis */ +import * as assert from 'assert'; +import fn from '../../src/lib/lib/scaleCanvas.js'; + +const name = 'scaleCanvas'; + +/** + * Minimal stand-in for a canvas 2d context. The width/height setters + * mimic the DOM's unsigned-long coercion (truncation) and reset counting + * so tests can assert when the buffer was actually cleared. + */ +function makeCtx() { + const canvas = { + _width: 300, + _height: 150, + style: {}, + get width() { + return this._width; + }, + set width(v) { + this._width = Math.trunc(v); + this.resets += 1; + }, + get height() { + return this._height; + }, + set height(v) { + this._height = Math.trunc(v); + this.resets += 1; + }, + resets: 0 + }; + const transforms = []; + const ctx = { + canvas, + setTransform(a, b, c, d, e, f) { + transforms.push([a, b, c, d, e, f]); + } + }; + return { ctx, canvas, transforms }; +} + +describe(name, () => { + const originalWindow = globalThis.window; + + beforeEach(() => { + globalThis.window = { devicePixelRatio: 1 }; + }); + + afterEach(() => { + globalThis.window = originalWindow; + }); + + it('applies the dpr transform even when the target buffer matches the default 300x150', () => { + globalThis.window.devicePixelRatio = 2; + const { ctx, canvas, transforms } = makeCtx(); + + // 150x75 at dpr=2 computes exactly the default 300x150 buffer + const result = fn(ctx, 150, 75); + + assert.deepStrictEqual(transforms, [[2, 0, 0, 2, 0, 0]]); + assert.strictEqual(canvas.style.width, '150px'); + assert.strictEqual(canvas.style.height, '75px'); + assert.deepStrictEqual(result, { width: 300, height: 150 }); + }); + + it('skips the buffer reset on a repeated call with the same dimensions', () => { + globalThis.window.devicePixelRatio = 2; + const { ctx, canvas, transforms } = makeCtx(); + + fn(ctx, 200, 100); + const resetsAfterFirst = canvas.resets; + const result = fn(ctx, 200, 100); + + assert.strictEqual(canvas.resets, resetsAfterFirst); + assert.strictEqual(transforms.length, 2); + assert.deepStrictEqual(result, { width: 400, height: 200 }); + }); + + it('skips the buffer reset on repeated calls under a fractional devicePixelRatio', () => { + globalThis.window.devicePixelRatio = 1.5; + const { ctx, canvas } = makeCtx(); + + const first = fn(ctx, 333, 111); + const resetsAfterFirst = canvas.resets; + const second = fn(ctx, 333, 111); + + assert.strictEqual(canvas.resets, resetsAfterFirst); + // Math.round(333 * 1.5) = 500; the returned size matches the real integer buffer + assert.deepStrictEqual(first, { width: 500, height: 167 }); + assert.deepStrictEqual(second, first); + assert.strictEqual(canvas.width, 500); + }); + + it('resizes the buffer when the container size changes', () => { + const { ctx, canvas } = makeCtx(); + + fn(ctx, 200, 100); + const resetsAfterFirst = canvas.resets; + fn(ctx, 400, 300); + + assert.ok(canvas.resets > resetsAfterFirst); + assert.strictEqual(canvas.width, 400); + assert.strictEqual(canvas.height, 300); + }); + + it('reapplies the transform and style when dpr changes even if the buffer size coincides', () => { + const { ctx, canvas, transforms } = makeCtx(); + + globalThis.window.devicePixelRatio = 1; + fn(ctx, 300, 150); + globalThis.window.devicePixelRatio = 2; + // 150x75 at dpr=2 → same 300x150 buffer, but the transform must be reapplied + fn(ctx, 150, 75); + + assert.deepStrictEqual(transforms, [ + [1, 0, 0, 1, 0, 0], + [2, 0, 0, 2, 0, 0] + ]); + assert.strictEqual(canvas.style.width, '150px'); + }); + + it('normalizes the transform on every call so a dirtied transform cannot persist', () => { + globalThis.window.devicePixelRatio = 2; + const { ctx, transforms } = makeCtx(); + + fn(ctx, 200, 100); + // caller translates/scales for its own drawing, then draws again + fn(ctx, 200, 100); + + assert.deepStrictEqual(transforms, [ + [2, 0, 0, 2, 0, 0], + [2, 0, 0, 2, 0, 0] + ]); + }); + + it('resizes when something external resized the canvas buffer', () => { + const { ctx, canvas } = makeCtx(); + + fn(ctx, 200, 100); + canvas.width = 10; + fn(ctx, 200, 100); + + assert.strictEqual(canvas.width, 200); + }); +});