diff --git a/.changeset/add-pattern-rects.md b/.changeset/add-pattern-rects.md new file mode 100644 index 000000000..a3871c072 --- /dev/null +++ b/.changeset/add-pattern-rects.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Pattern): Add `rects` shape definition for tile patterns for rendering one or more rectangles per pattern tile diff --git a/.changeset/add-waffle-component.md b/.changeset/add-waffle-component.md new file mode 100644 index 000000000..c78c7a3e9 --- /dev/null +++ b/.changeset/add-waffle-component.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Waffle): Add Waffle component for countable-cell visualizations diff --git a/.changeset/fix-canvas-pattern-rendering.md b/.changeset/fix-canvas-pattern-rendering.md new file mode 100644 index 000000000..3dc499d50 --- /dev/null +++ b/.changeset/fix-canvas-pattern-rendering.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Pattern): fix alignment and sharply render on high-DPI displays when using Canvas layers diff --git a/docs/src/content/components/Pattern.md b/docs/src/content/components/Pattern.md index dd7454ee1..92d2274eb 100644 --- a/docs/src/content/components/Pattern.md +++ b/docs/src/content/components/Pattern.md @@ -1,5 +1,5 @@ --- -description: Fill component which provides a line or circle-based fill pattern for chart elements. +description: Fill component which provides a line, circle, or rect-based fill pattern for chart elements. category: fill layers: [svg, canvas, html] related: [LinearGradient, RadialGradient] @@ -15,6 +15,10 @@ related: [LinearGradient, RadialGradient] :example{ name="circles" noResize clip} +### Rects + +:example{ name="rects" noResize clip} + ### With Fill color :example{ name="with-fill-color" noResize clip} diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md new file mode 100644 index 000000000..327b9963e --- /dev/null +++ b/docs/src/content/components/Waffle.md @@ -0,0 +1,99 @@ +--- +description: Subdivides each bar into countable square cells, ideal for showing exact quantities. +category: marks +layers: [svg, canvas] +related: [Bar, Bars, BarChart] +--- + +The **Waffle** mark visualizes quantities as a grid of small, countable square cells. Each cell represents a discrete `unit` of value, so a waffle of 24 cells unambiguously says "twenty-four" — much easier to read at a glance than a continuous bar of equivalent height. + +Waffles are rendered as a single `` per datum filled with a tiled `` (one cell per tile), so even a 1,000-cell waffle costs a single path node. Cells aren't individually addressable for hover/click — for that, use a [Cell](/docs/components/Cell) grid instead. + +:example{ name="basic" } + +## Axis + +The waffle mark comes in two orientations. `axis="y"` extends vertically; `axis="x"` extends horizontally. The other axis is the **anchor axis** — typically a band scale of categories. When `axis` is omitted, it falls back to the chart's `valueAxis`. + +:example{ name="horizontal" } + +## Cells (`unit`, `multiple`, `gap`) + +`Waffle` automatically determines the appropriate number of cells along the anchor axis (`multiple`) so that cells stay square, don't overlap, and remain consistent with position scales. + +| Prop | Default | Purpose | +| ---------- | ------- | ---------------------------------------------------------------------------------------------------------------- | +| `unit` | `1` | Quantity each cell represents. For large values, increase to keep cell counts manageable. | +| `multiple` | _auto_ | Cells per row (along the anchor axis). Auto-computed from bar width and unit so cells stay approximately square. | +| `gap` | `1` | Pixel separation between adjacent cells. | +| `round` | `false` | Partial-cell handling: `true` for `Math.round`, or pass a custom function (`Math.floor`, `Math.ceil`). | + +Drag the slider to see how the layout adapts as the value changes: + +:example{ name="auto-multiple" } + +:::note +The number of cells along the anchor axis is guaranteed to be an integer, but it might not be a multiple or factor of the value-axis tick interval. For example, the waffle might have 5 rows while the x-axis shows ticks every 20 units. +::: + +You can also set `multiple` directly, though note that manually setting it may produce non-square cells if there isn't enough room. + +:example{ name="unit-multiple" } + +Alternatively, you can bias the automatic `multiple` value while preserving square cells by adjusting the band scale's padding. + +:example{ name="band-padding" } + +For large values, increasing `unit` keeps cell counts manageable while still showing the discrete nature of the data. Here, each cell represents 5 Olympians born in the same 5-year period: + +:example{ name="olympians-by-birth-year" } + +## Cell shape (`rx` / `ry`) + +`rx` and `ry` round each cell's corners (in pixels, or `"100%"` for circles — a stacked-dots look). + +:example{ name="circular-cells" } + +## Proportion of a whole + +Two waffle marks layered together — a faded one sized to the total and an opaque one sized to the value — turn each band into a "X out of N" graphic. After ["Teens in Syria"](https://www.economist.com/graphic-detail/2015/08/19/teens-in-syria) (_The Economist_, August 2015): + +:example{ name="survey" } + +## Custom symbol + +Pass a `symbol` snippet to render an icon, glyph, or arbitrary SVG in each cell instead of the default rect. The snippet receives the cell's `width` and `height` — pass them straight to an inner `` with the icon's native `viewBox` to scale the shape to fit. The cell's resolved color (from the chart's `c` scale, the series, or `fill`) is set via CSS `color` on the wrapping element, so any `fill="currentColor"` (or stroke) inside the snippet inherits it. + +```svelte + + {#snippet symbol({ width, height })} + + + + {/snippet} + +``` + +:example{ name="custom-symbol" } + +:::note +`symbol` is supported on the SVG layer only — canvas falls back to the default rect. +::: + +## Stacking + +Stacked waffles share a cell grid across the stack so cells line up across series — the cleanest way to compare composition. Configure `series` + `seriesLayout="stack"` and render one `` per visible series. Toggling the legend hides a series and restacks the remaining segments to the baseline. + +:example{ name="penguins" } + +For continuous variables, bin into discrete intervals before stacking — here athletes are grouped into 10-kg weight cohorts and stacked by sex. Setting `unit={10}` (one cell per ten athletes) keeps the cell count readable when each bin holds hundreds of values: + +:example{ name="olympians-weight-by-sex" } + +If your data is already in wide format (one row per category with one column per series), pass it straight to `` — no `pivotWider` step needed: + +:example{ name="stacked" } + +A stacked horizontal waffle works equally well for showing a single composition broken into named segments — here, the days of each month across a year: + +:example{ name="months" } diff --git a/docs/src/content/guides/structure.md b/docs/src/content/guides/structure.md index fab35b66c..37168c1d5 100644 --- a/docs/src/content/guides/structure.md +++ b/docs/src/content/guides/structure.md @@ -237,11 +237,10 @@ A stacked bar chart comparison: x="category" xScale={scaleBand()} xDomain={data.map((d) => d.category)} - y={['q1', 'q2']} yBaseline={0} series={[ - { key: 'q1', color: 'steelblue' }, - { key: 'q2', color: 'coral' } + { key: 'q1', value: 'q1', color: 'steelblue' }, + { key: 'q2', value: 'q2', color: 'coral' } ]} seriesLayout="stack" tooltipContext={{ mode: 'band' }} @@ -256,6 +255,8 @@ A stacked bar chart comparison: ``` +> **Note:** when stacking against shared chart data (no per-series `data`), each series needs an explicit `value` accessor so the stack layout knows which field to read for that series. With `seriesLayout="stack"`, ``'s internal stack value function falls through `s.value` → chart-level `y` → `s.key`; setting `y={['q1', 'q2']}` (an array) on `` here would shadow the `s.key` fallback and feed the stack the whole `[d.q1, d.q2]` tuple per series, producing zero-height bars. `` doesn't hit this because it doesn't forward an array `y` to `` when you pass `series=[…]`. + ### Escape hatches Simplified charts accept the same snippets as ``. When you need to customize beyond what props offer, use a snippet to override just that part: diff --git a/docs/src/examples/components/Pattern/rects.svelte b/docs/src/examples/components/Pattern/rects.svelte new file mode 100644 index 000000000..d7c5fe40d --- /dev/null +++ b/docs/src/examples/components/Pattern/rects.svelte @@ -0,0 +1,101 @@ + + + + + + + {#snippet children({ pattern })} + + {/snippet} + + + + + {#snippet children({ pattern })} + + {/snippet} + + + + + {#snippet children({ pattern })} + + {/snippet} + + + + + {#snippet children({ pattern })} + + {/snippet} + + + + + {#snippet children({ pattern })} + + {/snippet} + + + + + {#snippet children({ pattern })} + + {/snippet} + + + diff --git a/docs/src/examples/components/Waffle/auto-multiple.svelte b/docs/src/examples/components/Waffle/auto-multiple.svelte new file mode 100644 index 000000000..9504874ec --- /dev/null +++ b/docs/src/examples/components/Waffle/auto-multiple.svelte @@ -0,0 +1,25 @@ + + +
+ +
+ + + {#snippet marks()} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/band-padding.svelte b/docs/src/examples/components/Waffle/band-padding.svelte new file mode 100644 index 000000000..458bd2a40 --- /dev/null +++ b/docs/src/examples/components/Waffle/band-padding.svelte @@ -0,0 +1,63 @@ + + +
+ + + {#each [1, 2, 5, 10, 25, 50, 100] as opt (opt)} + {opt} + {/each} + + + + +
+ + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.fruit} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/basic.svelte b/docs/src/examples/components/Waffle/basic.svelte new file mode 100644 index 000000000..77fff7387 --- /dev/null +++ b/docs/src/examples/components/Waffle/basic.svelte @@ -0,0 +1,39 @@ + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.fruit} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/circular-cells.svelte b/docs/src/examples/components/Waffle/circular-cells.svelte new file mode 100644 index 000000000..8f17d393a --- /dev/null +++ b/docs/src/examples/components/Waffle/circular-cells.svelte @@ -0,0 +1,41 @@ + + + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.letter} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/custom-symbol.svelte b/docs/src/examples/components/Waffle/custom-symbol.svelte new file mode 100644 index 000000000..9f37dd5c1 --- /dev/null +++ b/docs/src/examples/components/Waffle/custom-symbol.svelte @@ -0,0 +1,88 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {#snippet symbol({ width, height })} + + + + + + {/snippet} + + {/each} + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.island} + + {#each context.series.visibleSeries as s (s.key)} + + {/each} + + Number(data[s.key]) || 0)} + format="integer" + valueAlign="right" + /> + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/horizontal.svelte b/docs/src/examples/components/Waffle/horizontal.svelte new file mode 100644 index 000000000..b40c6f247 --- /dev/null +++ b/docs/src/examples/components/Waffle/horizontal.svelte @@ -0,0 +1,40 @@ + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.fruit} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/months.svelte b/docs/src/examples/components/Waffle/months.svelte new file mode 100644 index 000000000..fbc4d829d --- /dev/null +++ b/docs/src/examples/components/Waffle/months.svelte @@ -0,0 +1,60 @@ + + + ''} + c="month" + cRange={colors} + padding={{ left: 8, bottom: 32, top: 8, right: 8 }} + height={140} + axis={{ placement: 'bottom', label: 'days →', labelPlacement: 'end' }} +> + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.month} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte b/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte new file mode 100644 index 000000000..7cd705182 --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte @@ -0,0 +1,72 @@ + + + + +
+ + + {#each unitOptions as opt (opt)} + {opt} + {/each} + + + + + Off + On + + +
+ + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.year}–{data.year + 4} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/olympians-by-sex.svelte b/docs/src/examples/components/Waffle/olympians-by-sex.svelte new file mode 100644 index 000000000..db902d292 --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-by-sex.svelte @@ -0,0 +1,47 @@ + + + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.sex} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte b/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte new file mode 100644 index 000000000..fba63e272 --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte @@ -0,0 +1,76 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {/each} + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.weight}–{data.weight + 9} kg + + {#each context.series.visibleSeries as s (s.key)} + + {/each} + + Number(data[s.key]) || 0)} + format="integer" + valueAlign="right" + /> + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/penguins.svelte b/docs/src/examples/components/Waffle/penguins.svelte new file mode 100644 index 000000000..4e89cdf18 --- /dev/null +++ b/docs/src/examples/components/Waffle/penguins.svelte @@ -0,0 +1,75 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {/each} + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.island} + + {#each context.series.visibleSeries as s (s.key)} + + {/each} + + Number(data[s.key]) || 0)} + format="integer" + valueAlign="right" + /> + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/stacked.svelte b/docs/src/examples/components/Waffle/stacked.svelte new file mode 100644 index 000000000..ab6dccb12 --- /dev/null +++ b/docs/src/examples/components/Waffle/stacked.svelte @@ -0,0 +1,68 @@ + + + + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {/each} + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.period} + + {#each context.series.visibleSeries as s (s.key)} + + {/each} + + Number(data[s.key]) || 0)} + format="integer" + valueAlign="right" + /> + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/survey.svelte b/docs/src/examples/components/Waffle/survey.svelte new file mode 100644 index 000000000..8faca6bd6 --- /dev/null +++ b/docs/src/examples/components/Waffle/survey.svelte @@ -0,0 +1,59 @@ + + +
+

Subdued

+

Of {TOTAL} surveyed Syrian teenagers:

+
+ + + {#snippet axis({ context })} + + {/snippet} + + {#snippet marks({ context })} + {@const bw = context.xScale.bandwidth?.() ?? 0} + TOTAL} fill="currentColor" fillOpacity={0.2} rx="100%" gap={2} /> + + `${Math.round((d.yes / TOTAL) * 100)}%`} + textAnchor="middle" + verticalAnchor="start" + class="text-xl font-bold" + fill="orange" + /> + {/snippet} + diff --git a/docs/src/examples/components/Waffle/unit-multiple.svelte b/docs/src/examples/components/Waffle/unit-multiple.svelte new file mode 100644 index 000000000..49f303033 --- /dev/null +++ b/docs/src/examples/components/Waffle/unit-multiple.svelte @@ -0,0 +1,71 @@ + + +
+ + + {#each [1, 2, 5, 10, 25, 50, 100] as opt (opt)} + {opt} + {/each} + + + + + + unset + {#each [1, 2, 5, 10] as opt (opt)} + {opt} + {/each} + + + + + + Off + On + + +
+ + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.fruit} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/lib/data.remote.ts b/docs/src/lib/data.remote.ts index c65df3af8..9b64c4400 100644 --- a/docs/src/lib/data.remote.ts +++ b/docs/src/lib/data.remote.ts @@ -199,8 +199,15 @@ export const getOlympians = prerender(async () => { const { fetch } = getRequestEvent(); const data = (await fetch('/data/examples/olympians.json').then((r) => r.json())) as { name: string; + nationality: string; + sex: 'male' | 'female'; + date_of_birth: string; weight: number; height: number; + sport: string; + gold: number; + silver: number; + bronze: number; }[]; return data; }); diff --git a/packages/layerchart/src/lib/canvas.ts b/packages/layerchart/src/lib/canvas.ts index aa80557d5..69808c971 100644 --- a/packages/layerchart/src/lib/canvas.ts +++ b/packages/layerchart/src/lib/canvas.ts @@ -337,6 +337,8 @@ export { default as ForceSimulation } from './components/force/ForceSimulation.s export * from './components/force/ForceSimulation.svelte'; export { default as Dodge } from './components/Dodge/Dodge.svelte'; export * from './components/Dodge/Dodge.svelte'; +export { default as Waffle } from './components/Waffle/Waffle.svelte'; +export * from './components/Waffle/Waffle.svelte'; // Geo helpers (no per-layer rendering) export { default as GeoLegend } from './components/geo/GeoLegend/GeoLegend.svelte'; diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte index b689b8e31..18e492371 100644 --- a/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte @@ -22,11 +22,14 @@ height = size, lines: linesProp, circles: circlesProp, + rects: rectsProp, background, children, }: PatternProps = $props(); - const shapes = $derived(buildPatternShapes(linesProp, circlesProp, size, width, height)); + const shapes = $derived( + buildPatternShapes(linesProp, circlesProp, size, width, height, rectsProp) + ); let canvasPattern = $state(null); diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts b/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts index 0939d025a..45e37a456 100644 --- a/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts @@ -24,6 +24,23 @@ export type PatternCircleDef = { opacity?: number; }; +export type PatternRectDef = { + /** + * Inset from each edge of the pattern tile, in pixels. Useful for cell + * grids — set to half the desired gap between cells. + * @default 0 + */ + inset?: number; + /** Horizontal corner radius, or `"100%"` for a full ellipse / circle. */ + rx?: number | string; + /** Vertical corner radius. Defaults to `rx` if not provided. */ + ry?: number | string; + /** Fill color @default 'var(--color-surface-content)' */ + color?: string; + /** Opacity @default 1 */ + opacity?: number; +}; + export type PatternPropsWithoutHTML = { /** The id of the pattern */ id?: string; @@ -43,6 +60,9 @@ export type PatternPropsWithoutHTML = { /** The number of circles to render */ circles?: boolean | PatternCircleDef | PatternCircleDef[]; + /** Rect(s) to render in each pattern tile */ + rects?: boolean | PatternRectDef | PatternRectDef[]; + /** The background color of the pattern */ background?: string; @@ -70,7 +90,18 @@ export type LineShape = { strokeWidth: string | number; opacity: number; }; -export type PatternShape = CircleShape | LineShape; +export type RectShape = { + type: 'rect'; + x: number; + y: number; + width: number; + height: number; + rx?: number | string; + ry?: number | string; + fill: string; + opacity: number; +}; +export type PatternShape = CircleShape | LineShape | RectShape; /** * Build the SVG/canvas shape descriptors for a pattern's lines/circles. @@ -81,7 +112,8 @@ export function buildPatternShapes( circlesProp: PatternPropsWithoutHTML['circles'], size: number, width: number, - height: number + height: number, + rectsProp?: PatternPropsWithoutHTML['rects'] ): PatternShape[] { const shapes: PatternShape[] = []; @@ -148,5 +180,25 @@ export function buildPatternShapes( } } + if (rectsProp) { + const rectDefs = Array.isArray(rectsProp) ? rectsProp : rectsProp === true ? [{}] : [rectsProp]; + for (const rect of rectDefs) { + const inset = rect.inset ?? 0; + const fill = rect.color ?? 'var(--color-surface-content, currentColor)'; + const opacity = rect.opacity ?? 1; + shapes.push({ + type: 'rect', + x: inset, + y: inset, + width: Math.max(0, width - 2 * inset), + height: Math.max(0, height - 2 * inset), + rx: rect.rx, + ry: rect.ry, + fill, + opacity, + }); + } + } + return shapes; } diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte index 4535091a1..f32b25550 100644 --- a/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte @@ -19,13 +19,16 @@ height = size, lines: linesProp, circles: circlesProp, + rects: rectsProp, background, patternContent, children, ...rest }: PatternProps = $props(); - const shapes = $derived(buildPatternShapes(linesProp, circlesProp, size, width, height)); + const shapes = $derived( + buildPatternShapes(linesProp, circlesProp, size, width, height, rectsProp) + ); @@ -62,6 +65,19 @@ opacity={circle.opacity} /> {/each} + + {#each shapes.filter((s) => s.type === 'rect') as rect} + + {/each} {/if}
diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts new file mode 100644 index 000000000..d220a6761 --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -0,0 +1,470 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { ChartState } from '$lib/states/chart.svelte.js'; +import { accessor, chartDataArray, type Accessor } from '$lib/utils/common.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { createDimensionGetter, type Insets } from '$lib/utils/rect.svelte.js'; +import type { CommonStyleProps, Without } from '$lib/utils/types.js'; + +export type WaffleRound = boolean | ((n: number) => number); + +export type WafflePropsWithoutHTML = { + /** Override chart context data. */ + data?: any[]; + /** Override `x` from context. @default ctx.x */ + x?: Accessor; + /** Override `y` from context. @default ctx.y */ + y?: Accessor; + /** Override `x1` from context. @default ctx.x1 */ + x1?: Accessor; + /** Override `y1` from context. @default ctx.y1 */ + y1?: Accessor; + /** + * Axis the waffle extends along (the value axis). + * + * - `'y'` (default): vertical waffle, like Plot's `waffleY`. Cells stack + * upward from the value=0 baseline. + * - `'x'`: horizontal waffle, like Plot's `waffleX`. Cells extend rightward. + * + * Falls back to the chart's `valueAxis`. + */ + axis?: 'x' | 'y'; + /** + * The quantity each cell represents. Larger units produce fewer cells. + * @default 1 + */ + unit?: number; + /** + * The number of cells per row (along the anchor axis). When omitted, + * computed automatically from the bar width and unit so cells stay + * approximately square. + */ + multiple?: number; + /** + * Pixel separation between adjacent cells. + * @default 1 + */ + gap?: number; + /** + * How to handle non-integer cell counts. + * + * - `false` (default) — keep the partial cell as a fractional cut-off + * - `true` — `Math.round` + * - function — custom rounding (e.g. `Math.floor`, `Math.ceil`) + */ + round?: WaffleRound; + /** Cell horizontal corner radius (number of pixels, or "100%" for circles). */ + rx?: number | string; + /** Cell vertical corner radius (number of pixels, or "100%" for circles). */ + ry?: number | string; + /** Series key for stacked-waffle support. */ + seriesKey?: string; + /** Insets to shrink each waffle's bounding band. */ + insets?: Insets; + /** Fixed band-axis size in pixels. Override the band width / height. */ + width?: number; + /** Fixed band-axis size in pixels. Override the band width / height. */ + height?: number; + /** Default `(d, i) => i` */ + key?: (d: any, index: number) => any; + /** Setup pointer events to show tooltip for the hovered datum. */ + tooltip?: boolean; + /** Click handler invoked with `(event, { data })` for the hovered waffle. */ + onWaffleClick?: (e: MouseEvent, detail: { data: any }) => void; + /** + * Render a custom symbol per cell instead of the default rect. The snippet + * receives the cell's `width`/`height` and the resolved `color` for the + * cell. The CSS `color` is applied to the wrapping element, so any nested + * SVG using `fill="currentColor"` (or stroke) inherits it automatically. + * + * SVG layer only — canvas falls back to the default rect. + */ + symbol?: Snippet< + [ + { + width: number; + height: number; + datum: any; + color: string; + }, + ] + >; +} & CommonStyleProps; + +export type WaffleProps = WafflePropsWithoutHTML & + Without, 'width' | 'height' | 'x' | 'y'>, WafflePropsWithoutHTML>; + +/** Per-datum, fully-resolved waffle layout — pixel coords ready to render. */ +export type WaffleItem = { + data: any; + index: number; + /** SVG path data for the waffle outline (relative to translate). */ + pathData: string; + /** Pixel translate origin — apply to the path/pattern as `translate(tx, ty)`. */ + tx: number; + ty: number; + /** Cell box width in pixels (pattern tile width). */ + cx: number; + /** Cell box height in pixels (pattern tile height). */ + cy: number; + /** Cell centroid in pixel coords (translated). */ + centroid: { x: number; y: number }; + /** Resolved fill color. */ + fill: string | null; +}; + +export type WaffleLayoutOptions = { + axis: 'x' | 'y'; + unit: number; + multiple?: number; + round: (n: number) => number; + /** Anchor-axis size in pixels (the bar's other-axis extent). */ + barSize: number; + /** Anchor-axis pixel position (the bar's other-axis start). */ + barStart: number; + /** Pixels per data unit on the value axis (signed; negative = inverted). */ + pixelsPerUnit: number; + /** Pixel position of the value=0 baseline along the value axis. */ + valueZero: number; + /** Value range in data units. */ + v1: number; + v2: number; +}; + +/** + * Shared reactive state for Waffle. Resolves accessors, computes per-datum + * dimensions and waffle layouts (pattern tile size, polygon path, centroid), + * and exposes them via `items`. + */ +export class WaffleState { + #getProps: () => WaffleProps = () => ({}) as WaffleProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => WaffleProps) { + this.#getProps = getProps; + this.ctx.registerComponent({ + name: 'Waffle', + kind: 'mark', + markInfo: () => { + const p = getProps(); + return { + data: p.data, + seriesKey: p.seriesKey, + color: p.fill as string | undefined, + }; + }, + }); + } + + axis = $derived<'x' | 'y'>(this.#getProps().axis ?? this.ctx.valueAxis); + unit = $derived(Math.max(0, this.#getProps().unit ?? 1)); + gap = $derived(+(this.#getProps().gap ?? 1)); + round = $derived(maybeRound(this.#getProps().round)); + multipleProp = $derived(maybeMultiple(this.#getProps().multiple)); + + series = $derived.by(() => { + const seriesKey = this.#getProps().seriesKey; + return seriesKey ? this.ctx.series.series.find((s) => s.key === seriesKey) : undefined; + }); + + /** Opacity multiplier — fades to 0.1 when another series is highlighted. */ + seriesOpacity = $derived.by(() => { + if ( + this.series?.key == null || + this.ctx.series.visibleSeries.length <= 1 || + this.ctx.series.isHighlighted(this.series.key, true) + ) { + return 1; + } + return 0.1; + }); + + seriesAccessor = $derived( + this.series + ? (this.series.value ?? (this.series.data ? undefined : this.series.key)) + : undefined + ); + + stackAccessors = $derived.by(() => { + const seriesKey = this.#getProps().seriesKey; + return seriesKey && this.ctx.series.isStacked + ? this.ctx.series.getStackAccessors(seriesKey) + : null; + }); + + data = $derived.by(() => { + const dataProp = this.#getProps().data; + if (dataProp) return dataProp; + return this.series?.data ?? chartDataArray(this.ctx.data); + }); + + x = $derived.by(() => { + const xProp = this.#getProps().x; + return ( + xProp ?? + (this.ctx.valueAxis === 'x' + ? (this.stackAccessors?.value ?? this.seriesAccessor) + : undefined) ?? + this.ctx.x + ); + }); + y = $derived.by(() => { + const yProp = this.#getProps().y; + return ( + yProp ?? + (this.ctx.valueAxis === 'y' + ? (this.stackAccessors?.value ?? this.seriesAccessor) + : undefined) ?? + this.ctx.y + ); + }); + + getDimensions = $derived( + createDimensionGetter(this.ctx, () => ({ + x: this.x, + y: this.y, + x1: this.#getProps().x1, + y1: this.#getProps().y1, + insets: this.#getProps().insets, + })) + ); + + /** Pixel slope of the value-axis scale (signed). */ + pixelsPerUnit = $derived.by(() => { + const scale = this.axis === 'y' ? this.ctx.yScale : this.ctx.xScale; + if (typeof scale !== 'function') return 0; + const a = Number(scale(0)); + const b = Number(scale(1)); + if (!Number.isFinite(a) || !Number.isFinite(b)) return 0; + return b - a; + }); + + /** Pixel position of value=0 along the value axis. */ + valueZero = $derived.by(() => { + const scale = this.axis === 'y' ? this.ctx.yScale : this.ctx.xScale; + if (typeof scale !== 'function') return 0; + return Number(scale(0)) || 0; + }); + + items = $derived.by(() => { + const props = this.#getProps(); + const axis = this.axis; + const unit = this.unit; + const round = this.round; + const gap = this.gap; + const multipleProp = this.multipleProp; + const data = this.data; + const pixelsPerUnit = this.pixelsPerUnit; + const valueZero = this.valueZero; + + if (!data || data.length === 0) return []; + if (!Number.isFinite(pixelsPerUnit) || pixelsPerUnit === 0 || unit <= 0) return []; + + const result: WaffleItem[] = []; + const cellPixels = unit * Math.abs(pixelsPerUnit); // pixels per cell along value axis + + // Determine value range accessor — for stacked series, reads [v1, v2] arrays + // produced by the chart's stack series; otherwise treats value as [0, v]. + const valueAccessorFn = accessor( + axis === 'y' + ? (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#getProps().y ?? this.ctx.y) + : (this.stackAccessors?.value ?? this.seriesAccessor ?? this.#getProps().x ?? this.ctx.x) + ); + + for (let i = 0; i < data.length; i++) { + const d = data[i]; + const dim = this.getDimensions(d); + if (!dim) continue; + + let { x, y, width, height } = dim; + // Width override + if (props.width != null && axis === 'y') { + x = x + (width - props.width) / 2; + width = props.width; + } + if (props.height != null && axis === 'x') { + y = y + (height - props.height) / 2; + height = props.height; + } + + const barSize = axis === 'y' ? width : height; + const barStart = axis === 'y' ? x : y; + if (barSize <= 0) continue; + + const rawValue = valueAccessorFn(d); + let v1 = 0; + let v2: number; + if (Array.isArray(rawValue)) { + v1 = Number(rawValue[0]) || 0; + v2 = Number(rawValue[1]) || 0; + } else { + v2 = Number(rawValue) || 0; + } + + const i1 = round(v1 / unit); + const i2 = round(v2 / unit); + if (i1 === i2) continue; + + // Default `multiple` from Plot: keep cells approximately square. + const multiple = multipleProp ?? Math.max(1, Math.floor(Math.sqrt(barSize / cellPixels))); + + // Outer cell tile size (along anchor and dodge axes). + const cx = Math.min(barSize / multiple, cellPixels * multiple); + const cy = cellPixels * multiple; + + // Center the cell grid within the bar. + const tx0 = barStart + (barSize - multiple * cx) / 2; + // Cells grow away from baseline toward positive values. Sign of + // `pixelsPerUnit` encodes the screen direction of value growth, so the + // same transform works for both inverted (typical svg y) and + // non-inverted scales. + const valueDir = pixelsPerUnit < 0 ? -1 : 1; + + const polyPoints = wafflePoints(i1, i2, multiple); + // Pop centroid (last point) before mapping to path string. + const centroid = polyPoints.pop()!; + + const transformPoint = + axis === 'y' + ? ([col, row]: [number, number]): [number, number] => [col * cx, valueDir * row * cy] + : ([col, row]: [number, number]): [number, number] => [valueDir * row * cy, col * cx]; + + const pts = polyPoints.map(transformPoint); + const pathData = + pts.length === 0 ? '' : 'M' + pts.map((p) => `${p[0]},${p[1]}`).join('L') + 'Z'; + + const cPx = transformPoint(centroid); + + const tx = axis === 'y' ? tx0 : valueZero; + const ty = axis === 'y' ? valueZero : tx0; + + // Resolve fill: explicit `fill` prop > series color > color scale > null + const fillProp = props.fill; + let fill: string | null = null; + if (typeof fillProp === 'string') fill = fillProp; + else if (this.series?.color) fill = this.series.color; + else if (this.ctx.config.c) fill = String(this.ctx.cGet(d) ?? '') || null; + + result.push({ + data: d, + index: i, + pathData, + tx, + ty, + cx, + cy, + centroid: { x: tx + cPx[0], y: ty + cPx[1] }, + fill, + }); + } + + return result; + }); + + /** Resolved gap. */ + resolvedGap = $derived(this.gap); +} + +function maybeMultiple(multiple: number | undefined): number | undefined { + return multiple === undefined ? undefined : Math.max(1, Math.floor(multiple)); +} + +function maybeRound(round: WaffleRound | undefined): (n: number) => number { + if (round === undefined || round === false) return Number; + if (round === true) return Math.round; + if (typeof round !== 'function') { + throw new Error(`invalid round: ${round as any}`); + } + return round; +} + +/** + * Generate the polygon outline of a waffle covering the cell interval + * `[i1, i2)` on a grid of `columns` columns. The shape is approximately + * rectangular but may have one or two corner cuts when the start or end + * value is not aligned to a row boundary, plus extra cuts for fractional + * intervals. The last point is the centroid (popped by callers for tooltips + * and tip placement). + * + * Coordinate space is `(column, row)` in cell units — callers transform to + * pixel space (and may negate the row axis for screen y). + * + * @see https://github.com/observablehq/plot/blob/main/src/marks/waffle.js + */ +export function wafflePoints(i1: number, i2: number, columns: number): [number, number][] { + if (i2 < i1) return wafflePoints(i2, i1, columns); + if (i1 < 0) { + return wafflePointsOffset(i1, i2, columns, Math.ceil(-Math.min(i1, i2) / columns)); + } + const x1f = Math.floor(i1 % columns); + const x1c = Math.ceil(i1 % columns); + const x2f = Math.floor(i2 % columns); + const x2c = Math.ceil(i2 % columns); + const y1f = Math.floor(i1 / columns); + const y1c = Math.ceil(i1 / columns); + const y2f = Math.floor(i2 / columns); + const y2c = Math.ceil(i2 / columns); + + const points: [number, number][] = []; + if (y2c > y1c) points.push([0, y1c]); + points.push([x1f, y1c], [x1f, y1f + (i1 % 1)], [x1c, y1f + (i1 % 1)]); + if (!(i1 % columns > columns - 1)) { + points.push([x1c, y1f]); + if (y2f > y1f) points.push([columns, y1f]); + } + if (y2f > y1f) points.push([columns, y2f]); + points.push([x2c, y2f], [x2c, y2f + (i2 % 1)], [x2f, y2f + (i2 % 1)]); + if (!(i2 % columns < 1)) { + points.push([x2f, y2c]); + if (y2c > y1c) points.push([0, y2c]); + } + points.push(waffleCentroid(i1, i2, columns)); + return points; +} + +function wafflePointsOffset( + i1: number, + i2: number, + columns: number, + k: number +): [number, number][] { + return wafflePoints(i1 + k * columns, i2 + k * columns, columns).map( + ([x, y]) => [x, y - k] as [number, number] + ); +} + +function waffleCentroid(i1: number, i2: number, columns: number): [number, number] { + const r = Math.floor(i2 / columns) - Math.floor(i1 / columns); + if (r === 0) return waffleRowCentroid(i1, i2, columns); + if (r === 1) { + if (Math.floor(i2 % columns) > Math.ceil(i1 % columns)) { + return [(Math.floor(i2 % columns) + Math.ceil(i1 % columns)) / 2, Math.floor(i2 / columns)]; + } + if (i2 % columns > columns - (i1 % columns)) { + return waffleRowCentroid(i2 - (i2 % columns), i2, columns); + } + return waffleRowCentroid(i1, columns * Math.ceil(i1 / columns), columns); + } + return [columns / 2, (Math.round(i1 / columns) + Math.round(i2 / columns)) / 2]; +} + +function waffleRowCentroid(i1: number, i2: number, columns: number): [number, number] { + const c = Math.floor(i2) - Math.floor(i1); + if (c === 0) { + return [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (((i1 + i2) / 2) % 1)]; + } + if (c === 1) { + if ((i2 % 1) - (i1 % 1) > 0.5) { + return [Math.ceil(i1 % columns), Math.floor(i2 / columns) + ((i1 % 1) + (i2 % 1)) / 2]; + } + if (i2 % 1 > 1 - (i1 % 1)) { + return [Math.floor(i2 % columns) + 0.5, Math.floor(i2 / columns) + (i2 % 1) / 2]; + } + return [Math.floor(i1 % columns) + 0.5, Math.floor(i1 / columns) + (1 + (i1 % 1)) / 2]; + } + return [ + Math.ceil(i1 % columns) + Math.ceil(Math.floor(i2) - Math.ceil(i1)) / 2, + Math.floor(i1 / columns) + (i2 >= 1 + i1 ? 0.5 : ((i1 + i2) / 2) % 1), + ]; +} diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte new file mode 100644 index 000000000..10aed715d --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte @@ -0,0 +1,148 @@ + + + + +{#each c.items as item (key(item.data, item.index))} + {@const onItemEnter = (e: PointerEvent) => { + onpointerenter?.(e as any); + if (tooltip) c.ctx.tooltip.show(e, item.data); + }} + {@const onItemMove = (e: PointerEvent) => { + onpointermove?.(e as any); + if (tooltip) c.ctx.tooltip.show(e, item.data); + }} + {@const onItemLeave = (e: PointerEvent) => { + onpointerleave?.(e as any); + if (tooltip) c.ctx.tooltip.hide(); + }} + {@const onItemClick = (e: MouseEvent) => { + onclick?.(e as any); + onWaffleClick?.(e, { data: item.data }); + }} + {@const cellInset = c.gap / 2} + {@const innerWidth = Math.max(0, item.cx - 2 * cellInset)} + {@const innerHeight = Math.max(0, item.cy - 2 * cellInset)} + {@const color = item.fill ?? (typeof fill === 'string' ? fill : undefined) ?? 'currentColor'} + {#snippet symbolPatternContent()} + + {@render symbol?.({ + width: innerWidth, + height: innerHeight, + datum: item.data, + color, + })} + + {/snippet} + + + {#snippet children({ pattern })} + + {/snippet} + + +{/each} + + diff --git a/packages/layerchart/src/lib/components/index.ts b/packages/layerchart/src/lib/components/index.ts index 4d8ac5557..f82a98e5d 100644 --- a/packages/layerchart/src/lib/components/index.ts +++ b/packages/layerchart/src/lib/components/index.ts @@ -130,5 +130,7 @@ export { default as Violin } from './Violin/Violin.svelte'; export * from './Violin/Violin.svelte'; export { default as Voronoi } from './Voronoi/Voronoi.svelte'; export * from './Voronoi/Voronoi.svelte'; +export { default as Waffle } from './Waffle/Waffle.svelte'; +export * from './Waffle/Waffle.svelte'; export { default as WebGL } from './layers/WebGL.svelte'; export * from './layers/WebGL.svelte'; diff --git a/packages/layerchart/src/lib/html.ts b/packages/layerchart/src/lib/html.ts index e2007fc83..f104e3b5f 100644 --- a/packages/layerchart/src/lib/html.ts +++ b/packages/layerchart/src/lib/html.ts @@ -213,3 +213,5 @@ export { default as ForceSimulation } from './components/force/ForceSimulation.s export * from './components/force/ForceSimulation.svelte'; export { default as Dodge } from './components/Dodge/Dodge.svelte'; export * from './components/Dodge/Dodge.svelte'; +export { default as Waffle } from './components/Waffle/Waffle.svelte'; +export * from './components/Waffle/Waffle.svelte'; diff --git a/packages/layerchart/src/lib/svg.ts b/packages/layerchart/src/lib/svg.ts index 85fcf178e..148e1567d 100644 --- a/packages/layerchart/src/lib/svg.ts +++ b/packages/layerchart/src/lib/svg.ts @@ -342,6 +342,8 @@ export { default as ForceSimulation } from './components/force/ForceSimulation.s export * from './components/force/ForceSimulation.svelte'; export { default as Dodge } from './components/Dodge/Dodge.svelte'; export * from './components/Dodge/Dodge.svelte'; +export { default as Waffle } from './components/Waffle/Waffle.svelte'; +export * from './components/Waffle/Waffle.svelte'; // Geo helpers (no per-layer rendering) export { default as GeoLegend } from './components/geo/GeoLegend/GeoLegend.svelte'; diff --git a/packages/layerchart/src/lib/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index 6594ea81a..cbafc6a41 100644 --- a/packages/layerchart/src/lib/utils/canvas.ts +++ b/packages/layerchart/src/lib/utils/canvas.ts @@ -571,10 +571,18 @@ export function _createPattern( // Add pattern canvas to DOM to allow computed styles to be read (`getComputedStyles()`) ctx.canvas.after(patternCanvas); - // TODO: Fix blurry pattern - // const newScale = scaleCanvas(patternCtx, width, height); - patternCanvas.width = width; - patternCanvas.height = height; + // Render the pattern at the device pixel ratio so the bitmap tile is + // sharp on high-DPI screens. Chrome samples patterns in the canvas's + // user (post-transform) coordinate space, so 1 source pixel = 1 user px + // by default — without DPR-scaling the bitmap, a 12 logical-px tile is + // sampled from 12 source pixels, leaving each device pixel of fill to + // be interpolated from a 1/dpr-th of a source pixel (blurry). Drawing + // at DPR resolution and scaling the pattern back down to user space at + // fill time keeps tiles sharp. + const dpr = (typeof window !== 'undefined' ? window.devicePixelRatio : 1) || 1; + patternCanvas.width = Math.max(1, Math.round(width * dpr)); + patternCanvas.height = Math.max(1, Math.round(height * dpr)); + patternCtx.scale(dpr, dpr); if (background) { patternCtx.fillStyle = background; @@ -593,12 +601,31 @@ export function _createPattern( renderPathData(patternCtx, shape.path, { styles: { stroke: shape.stroke, strokeWidth: shape.strokeWidth, opacity: shape.opacity }, }); + } else if (shape.type === 'rect') { + const rx = typeof shape.rx === 'string' ? toRectCornerPx(shape.rx, shape.width) : shape.rx; + const ry = + typeof shape.ry === 'string' ? toRectCornerPx(shape.ry, shape.height) : (shape.ry ?? rx); + renderRect( + patternCtx, + { x: shape.x, y: shape.y, width: shape.width, height: shape.height, rx, ry }, + { styles: { fill: shape.fill, opacity: shape.opacity } } + ); } patternCtx.restore(); } const pattern = ctx.createPattern(patternCanvas, 'repeat'); + // Scale-only matrix; no translate so the pattern anchors to the path's + // local origin at fill time (matches SVG `patternUnits="userSpaceOnUse"`). + // Use the *actual* bitmap pixel dimensions for the scale so rounding + // `width * dpr` to an integer doesn't accumulate drift across tiles. + if (pattern) { + const sx = width / patternCanvas.width; + const sy = height / patternCanvas.height; + pattern.setTransform(new DOMMatrix([sx, 0, 0, sy, 0, 0])); + } + // Cleanup ctx.canvas.parentElement?.removeChild(patternCanvas); @@ -609,3 +636,13 @@ export function _createPattern( export const createPattern = memoize(_createPattern, { cacheKey: (args) => JSON.stringify(args.slice(1)), // Ignore `ctx` argument }); + +function toRectCornerPx(value: string, max: number): number { + if (value.endsWith('%')) { + const pct = parseFloat(value); + if (!Number.isFinite(pct)) return 0; + return (max / 2) * (pct / 100); + } + const n = parseFloat(value); + return Number.isFinite(n) ? n : 0; +} diff --git a/packages/layerchart/src/lib/utils/stack.ts b/packages/layerchart/src/lib/utils/stack.ts index 795ed0766..51082674f 100644 --- a/packages/layerchart/src/lib/utils/stack.ts +++ b/packages/layerchart/src/lib/utils/stack.ts @@ -38,9 +38,11 @@ export function groupStackData( ...new Set(groupData.map((d: any) => d[options.stackBy ?? ''])), ]; // @ts-expect-error - const stackData = stack().keys(stackKeys).order(options.order).offset(options.offset)( - pivotData - ); + const stackData = stack() + .keys(stackKeys) + .value((d: any, key: any) => d[key] ?? 0) + .order(options.order) + .offset(options.offset)(pivotData); return stackData.flatMap((series) => { return series.flatMap((s) => { @@ -73,9 +75,11 @@ export function groupStackData( // @ts-expect-error const stackKeys: Array = [...new Set(data.map((d) => d[options.stackBy ?? '']))]; // @ts-expect-error - const stackData = stack().keys(stackKeys).order(options.order).offset(options.offset)( - pivotData - ); + const stackData = stack() + .keys(stackKeys) + .value((d: any, key: any) => d[key] ?? 0) + .order(options.order) + .offset(options.offset)(pivotData); const result = stackData.flatMap((series) => { return series.flatMap((s) => {