From 34d5f2daee2204991fa3f8f518f87ed3850c46ae Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 6 May 2026 08:02:29 -0400 Subject: [PATCH 01/14] Add Waffle component (WIP) --- .changeset/add-pattern-rects.md | 5 + .changeset/add-waffle-component.md | 5 + docs/src/content/components/Pattern.md | 6 +- docs/src/content/components/Waffle.md | 70 +++ docs/src/content/guides/structure.md | 7 +- .../examples/components/Pattern/rects.svelte | 101 ++++ .../examples/components/Waffle/basic.svelte | 42 ++ .../components/Waffle/circular-cells.svelte | 43 ++ .../examples/components/Waffle/fruits.svelte | 40 ++ .../components/Waffle/horizontal.svelte | 40 ++ .../examples/components/Waffle/months.svelte | 77 +++ .../components/Waffle/multiple.svelte | 57 +++ .../Waffle/olympians-by-birth-year.svelte | 74 +++ .../components/Waffle/olympians-by-sex.svelte | 49 ++ .../Waffle/olympians-weight-by-sex.svelte | 82 ++++ .../components/Waffle/penguins.svelte | 80 +++ .../examples/components/Waffle/stacked.svelte | 69 +++ docs/src/lib/data.remote.ts | 7 + .../components/Pattern/Pattern.canvas.svelte | 5 +- .../Pattern/Pattern.shared.svelte.ts | 56 ++- .../lib/components/Pattern/Pattern.svg.svelte | 18 +- .../components/Waffle/Waffle.shared.svelte.ts | 458 ++++++++++++++++++ .../src/lib/components/Waffle/Waffle.svelte | 127 +++++ .../layerchart/src/lib/components/index.ts | 2 + packages/layerchart/src/lib/utils/canvas.ts | 19 + 25 files changed, 1531 insertions(+), 8 deletions(-) create mode 100644 .changeset/add-pattern-rects.md create mode 100644 .changeset/add-waffle-component.md create mode 100644 docs/src/content/components/Waffle.md create mode 100644 docs/src/examples/components/Pattern/rects.svelte create mode 100644 docs/src/examples/components/Waffle/basic.svelte create mode 100644 docs/src/examples/components/Waffle/circular-cells.svelte create mode 100644 docs/src/examples/components/Waffle/fruits.svelte create mode 100644 docs/src/examples/components/Waffle/horizontal.svelte create mode 100644 docs/src/examples/components/Waffle/months.svelte create mode 100644 docs/src/examples/components/Waffle/multiple.svelte create mode 100644 docs/src/examples/components/Waffle/olympians-by-birth-year.svelte create mode 100644 docs/src/examples/components/Waffle/olympians-by-sex.svelte create mode 100644 docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte create mode 100644 docs/src/examples/components/Waffle/penguins.svelte create mode 100644 docs/src/examples/components/Waffle/stacked.svelte create mode 100644 packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts create mode 100644 packages/layerchart/src/lib/components/Waffle/Waffle.svelte 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/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..2d366c49c --- /dev/null +++ b/docs/src/content/components/Waffle.md @@ -0,0 +1,70 @@ +--- +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="fruits" } + +## Axis + +`axis` selects the value axis. The other axis is the **anchor axis** — typically a band scale of categories. + +| `axis` | Direction | Plot equivalent | +| ------ | --------------------------------- | --------------- | +| `'y'` | Cells stack upward from `y=0` | `waffleY` | +| `'x'` | Cells extend rightward from `x=0` | `waffleX` | + +When omitted, `axis` falls back to the chart's `valueAxis`. + +:example{ name="horizontal" } + +A 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" } + +## Cells (`unit`, `multiple`, `gap`) + +| 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`). | + +Larger units shrink the cell count — useful when raw values produce thousands of cells. Try a few values: + +:example{ name="olympians-by-sex" } + +For finer-grained binning, group by an interval (e.g. 5-year birth cohorts) and pick a unit that keeps each group readable: + +:example{ name="olympians-by-birth-year" } + +Tweak `unit` and `multiple` interactively to see how they affect cell count and grid shape: + +:example{ name="multiple" } + +## 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" } + +## Stacking + +Stacked waffles share a cell grid across the stack so cells line up across series — the cleanest way to compare composition. Use [`groupStackData`](/docs/utils/stack) to prepare the data and color via the chart's `c` accessor: + +:example{ name="penguins" } + +A wider stack with finer bins — athletes by 10kg weight cohort, split by sex: + +:example{ name="olympians-weight-by-sex" } + +For per-series rendering with explicit `seriesKey`, see the basic stacked example: + +:example{ name="stacked" } 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/basic.svelte b/docs/src/examples/components/Waffle/basic.svelte new file mode 100644 index 000000000..49f1d6417 --- /dev/null +++ b/docs/src/examples/components/Waffle/basic.svelte @@ -0,0 +1,42 @@ + + + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.letter} + + + + {/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..c8a922b25 --- /dev/null +++ b/docs/src/examples/components/Waffle/circular-cells.svelte @@ -0,0 +1,43 @@ + + + + + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.letter} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/fruits.svelte b/docs/src/examples/components/Waffle/fruits.svelte new file mode 100644 index 000000000..a1dd3591b --- /dev/null +++ b/docs/src/examples/components/Waffle/fruits.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/horizontal.svelte b/docs/src/examples/components/Waffle/horizontal.svelte new file mode 100644 index 000000000..6c7ac7d5a --- /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..9872a6fdb --- /dev/null +++ b/docs/src/examples/components/Waffle/months.svelte @@ -0,0 +1,77 @@ + + + d.month)} + cRange={colors} + padding={{ left: 8, bottom: 24, top: 8, right: 8 }} + height={150} + rule +> + {#snippet legend()} + + {/snippet} + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.month} + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/Waffle/multiple.svelte b/docs/src/examples/components/Waffle/multiple.svelte new file mode 100644 index 000000000..d88a98ca7 --- /dev/null +++ b/docs/src/examples/components/Waffle/multiple.svelte @@ -0,0 +1,57 @@ + + + + +
+ + + + + + +
+ + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.letter} + + + + + {/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..c62f4c999 --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte @@ -0,0 +1,74 @@ + + + + +
+ + + {#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..33a3acefd --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-by-sex.svelte @@ -0,0 +1,49 @@ + + + + + + {#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..b7cda20ce --- /dev/null +++ b/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte @@ -0,0 +1,82 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.weight}–{data.weight + 9} kg + + {#each data.data as d (d.sex)} + + {/each} + + d.value)} + 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..ce1af1263 --- /dev/null +++ b/docs/src/examples/components/Waffle/penguins.svelte @@ -0,0 +1,80 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks()} + + {/snippet} + + {#snippet tooltip({ context })} + + {#snippet children({ data })} + {data.island} + + {#each data.data as d (d.species)} + + {/each} + + d.value)} + 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..871ea5aff --- /dev/null +++ b/docs/src/examples/components/Waffle/stacked.svelte @@ -0,0 +1,69 @@ + + + + {#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/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/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..23786f192 --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -0,0 +1,458 @@ +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; +} & CommonStyleProps; + +export type WaffleProps = WafflePropsWithoutHTML & + Without< + Omit, '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; + }); + + 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..c14ce92a5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte @@ -0,0 +1,127 @@ + + + + +{#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 }); + }} + + + {#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/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index d2d3ce66f..9b273626c 100644 --- a/packages/layerchart/src/lib/utils/canvas.ts +++ b/packages/layerchart/src/lib/utils/canvas.ts @@ -584,6 +584,15 @@ 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(); } @@ -600,3 +609,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; +} From e8247d0e9e3e1845a493f1d7b41575c1159b3e67 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 6 May 2026 10:59:42 -0400 Subject: [PATCH 02/14] Add auto multiple example --- docs/src/content/components/Waffle.md | 23 +++++++++------- .../components/Waffle/auto-multiple.svelte | 27 +++++++++++++++++++ 2 files changed, 41 insertions(+), 9 deletions(-) create mode 100644 docs/src/examples/components/Waffle/auto-multiple.svelte diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index 2d366c49c..49437f830 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -13,14 +13,7 @@ Waffles are rendered as a single `` per datum filled with a tiled ` + import { Field, RangeField } from 'svelte-ux'; + import { Chart, Waffle } from 'layerchart'; + + let apples = $state(500); + const data = $derived([{ label: 'apples', count: apples }]); + + +
+ + + +
+ + + {#snippet marks()} + + {/snippet} + From efc17e5fb845ebcfd6f789a585e9a51ed3d7619c Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 6 May 2026 23:26:38 -0400 Subject: [PATCH 03/14] Support custom waffle symobols (svg-only) --- docs/src/content/components/Waffle.md | 22 +++++- .../components/Waffle/custom-symbol.svelte | 74 +++++++++++++++++++ .../components/Waffle/Waffle.shared.svelte.ts | 20 +++++ .../src/lib/components/Waffle/Waffle.svelte | 18 ++++- packages/layerchart/src/lib/utils/stack.ts | 16 ++-- 5 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 docs/src/examples/components/Waffle/custom-symbol.svelte diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index 49437f830..c4c84a5a5 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -13,7 +13,7 @@ Waffles are rendered as a single `` per datum filled with a tiled `` whose `fill` is pre-set to the resolved cell color (from the chart's `c` scale, the series, or the `fill` prop), so a `` without an explicit fill inherits it. The snippet receives the cell `width`/`height` (after `gap` inset), the `datum`, and the resolved `fill`: + +```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. Use [`groupStackData`](/docs/utils/stack) to prepare the data and color via the chart's `c` accessor: 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..a721b760c --- /dev/null +++ b/docs/src/examples/components/Waffle/custom-symbol.svelte @@ -0,0 +1,74 @@ + + + + + + {#snippet legend()} + + {/snippet} + + {#snippet marks()} + + {#snippet symbol({ width, height })} + + + + {/snippet} + + {/snippet} + + {#snippet tooltip()} + + {#snippet children({ data })} + {data.island} — {data.species} + + + + {/snippet} + + {/snippet} + diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts index 23786f192..ab6b1c5a2 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -1,3 +1,4 @@ +import type { Snippet } from 'svelte'; import type { SVGAttributes } from 'svelte/elements'; import type { ChartState } from '$lib/states/chart.svelte.js'; @@ -71,6 +72,25 @@ export type WafflePropsWithoutHTML = { 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 + * renders in cell-local coordinates (origin at the inner cell, after `gap` + * inset) inside a `` whose `fill` is pre-set to the resolved cell color, + * so a `` without an explicit `fill` inherits it. + * + * SVG layer only — canvas falls back to the default rect. + */ + symbol?: Snippet< + [ + { + width: number; + height: number; + datum: any; + /** Resolved cell color (also pre-applied to the wrapping ``). */ + fill: string; + }, + ] + >; } & CommonStyleProps; export type WaffleProps = WafflePropsWithoutHTML & diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte index c14ce92a5..edadb9799 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte @@ -41,6 +41,7 @@ onpointermove, onpointerleave, onclick, + symbol, ...rest }: WaffleProps = $props(); @@ -85,19 +86,34 @@ 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 symbolFill = item.fill ?? (typeof fill === 'string' ? fill : undefined) ?? 'currentColor'} + {#snippet symbolPatternContent()} + + {@render symbol?.({ + width: innerWidth, + height: innerHeight, + datum: item.data, + fill: symbolFill, + })} + + {/snippet} {#snippet children({ pattern })} ( ...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) => { From 5689f9cc6b33dfce32ae4f43c44d807cabb3d336 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 6 May 2026 23:51:02 -0400 Subject: [PATCH 04/14] cleanup examples --- .../examples/components/Waffle/auto-multiple.svelte | 2 +- docs/src/examples/components/Waffle/basic.svelte | 8 +++----- .../examples/components/Waffle/circular-cells.svelte | 6 ++---- .../examples/components/Waffle/custom-symbol.svelte | 8 +++----- docs/src/examples/components/Waffle/fruits.svelte | 3 +-- docs/src/examples/components/Waffle/horizontal.svelte | 4 ++-- docs/src/examples/components/Waffle/multiple.svelte | 6 ++---- .../components/Waffle/olympians-by-birth-year.svelte | 8 +++----- .../components/Waffle/olympians-by-sex.svelte | 8 +++----- .../components/Waffle/olympians-weight-by-sex.svelte | 11 ++++------- docs/src/examples/components/Waffle/penguins.svelte | 8 +++----- docs/src/examples/components/Waffle/stacked.svelte | 5 ++--- 12 files changed, 29 insertions(+), 48 deletions(-) diff --git a/docs/src/examples/components/Waffle/auto-multiple.svelte b/docs/src/examples/components/Waffle/auto-multiple.svelte index 2067105a4..fbf2aadcc 100644 --- a/docs/src/examples/components/Waffle/auto-multiple.svelte +++ b/docs/src/examples/components/Waffle/auto-multiple.svelte @@ -1,6 +1,6 @@
@@ -30,7 +28,7 @@
@@ -48,7 +46,7 @@ {#snippet legend()} {/snippet} - {#snippet marks()} - - {#snippet symbol({ width, height })} - - - - {/snippet} - + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {#snippet symbol({ width, height })} + + + + + {/snippet} + + {/each} {/snippet} - {#snippet tooltip()} + {#snippet tooltip({ context })} {#snippet children({ data })} - {data.island} — {data.species} + {data.island} - + {#each context.series.visibleSeries as s (s.key)} + + {/each} + + Number(data[s.key]) || 0)} + format="integer" + valueAlign="right" + /> {/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 index 4758675d4..fba63e272 100644 --- a/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte +++ b/docs/src/examples/components/Waffle/olympians-weight-by-sex.svelte @@ -4,39 +4,34 @@ {/snippet} - {#snippet marks()} - + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {/each} {/snippet} {#snippet tooltip({ context })} @@ -56,11 +53,11 @@ {#snippet children({ data })} {data.weight}–{data.weight + 9} kg - {#each data.data as d (d.sex)} + {#each context.series.visibleSeries as s (s.key)} @@ -68,7 +65,7 @@ d.value)} + value={sum(context.series.visibleSeries, (s) => Number(data[s.key]) || 0)} format="integer" valueAlign="right" /> diff --git a/docs/src/examples/components/Waffle/penguins.svelte b/docs/src/examples/components/Waffle/penguins.svelte index 34409d6cd..17a67bfd2 100644 --- a/docs/src/examples/components/Waffle/penguins.svelte +++ b/docs/src/examples/components/Waffle/penguins.svelte @@ -4,38 +4,33 @@ {/snippet} - {#snippet marks()} - + {#snippet marks({ context })} + {#each context.series.visibleSeries as s (s.key)} + + {/each} {/snippet} {#snippet tooltip({ context })} @@ -55,11 +52,11 @@ {#snippet children({ data })} {data.island} - {#each data.data as d (d.species)} + {#each context.series.visibleSeries as s (s.key)} @@ -67,7 +64,7 @@ d.value)} + value={sum(context.series.visibleSeries, (s) => Number(data[s.key]) || 0)} format="integer" valueAlign="right" /> From b7d1afb79868892de51dc5a059b2a80f5e80a2c6 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 7 May 2026 12:38:51 -0400 Subject: [PATCH 06/14] Add survey results example --- docs/src/content/components/Waffle.md | 6 ++ .../examples/components/Waffle/survey.svelte | 59 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 docs/src/examples/components/Waffle/survey.svelte diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index f59bc8642..4e70d7a68 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -60,6 +60,12 @@ Tweak `unit` and `multiple` interactively to see how they affect cell count and :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 For full control over the cell's appearance — icons, glyphs, or any SVG — pass a `symbol` snippet. It renders in cell-local coordinates inside a `` whose `fill` is pre-set to the resolved cell color (from the chart's `c` scale, the series, or the `fill` prop), so a `` without an explicit fill inherits it. The snippet receives the cell `width`/`height` (after `gap` inset), the `datum`, and the resolved `fill`: 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} + From ea6fabc72a9d03e45e8de50b6c8b2f2ca46e3e39 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 7 May 2026 15:15:37 -0400 Subject: [PATCH 07/14] Improve Waffle/months example --- .../examples/components/Waffle/months.svelte | 39 ++++++------------- 1 file changed, 11 insertions(+), 28 deletions(-) diff --git a/docs/src/examples/components/Waffle/months.svelte b/docs/src/examples/components/Waffle/months.svelte index 9872a6fdb..fbc4d829d 100644 --- a/docs/src/examples/components/Waffle/months.svelte +++ b/docs/src/examples/components/Waffle/months.svelte @@ -1,6 +1,6 @@ ''} c="month" - cDomain={months.map((d) => d.month)} cRange={colors} - padding={{ left: 8, bottom: 24, top: 8, right: 8 }} - height={150} - rule + padding={{ left: 8, bottom: 32, top: 8, right: 8 }} + height={140} + axis={{ placement: 'bottom', label: 'days →', labelPlacement: 'end' }} > - {#snippet legend()} - - {/snippet} - {#snippet marks()} {/snippet} From 45d9ab890dccd303f75efc4ab880b6a8d90df8c0 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Thu, 7 May 2026 22:21:16 -0400 Subject: [PATCH 08/14] fix(Pattern): fix alignment and sharply render on high-DPI displays when using Canavs layers --- .changeset/fix-canvas-pattern-rendering.md | 5 ++++ packages/layerchart/src/lib/utils/canvas.ts | 26 +++++++++++++++++---- 2 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-canvas-pattern-rendering.md 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/packages/layerchart/src/lib/utils/canvas.ts b/packages/layerchart/src/lib/utils/canvas.ts index ac83e024a..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; @@ -608,6 +616,16 @@ export function _createPattern( 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); From 79e8ba5ec9e6ed439bb9243a5eb6fed1e03c348e Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 8 May 2026 08:27:14 -0400 Subject: [PATCH 09/14] Add new Waffle component to all layer-specific exports --- packages/layerchart/src/lib/canvas.ts | 2 ++ packages/layerchart/src/lib/html.ts | 2 ++ packages/layerchart/src/lib/svg.ts | 2 ++ 3 files changed, 6 insertions(+) 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/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'; From d6e51437ff21244f407478de395d10dad89b970f Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 8 May 2026 08:46:57 -0400 Subject: [PATCH 10/14] improve examples --- docs/src/examples/components/Waffle/custom-symbol.svelte | 2 +- docs/src/examples/components/Waffle/penguins.svelte | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/src/examples/components/Waffle/custom-symbol.svelte b/docs/src/examples/components/Waffle/custom-symbol.svelte index 719a2990d..1bc962b09 100644 --- a/docs/src/examples/components/Waffle/custom-symbol.svelte +++ b/docs/src/examples/components/Waffle/custom-symbol.svelte @@ -31,7 +31,7 @@ { key: 'Gentoo', color: 'var(--color-success)' } ]} seriesLayout="stack" - padding={{ left: 36, bottom: 24, top: 8, right: 8 }} + padding={{ left: 36, bottom: 24, top: 32, right: 8 }} tooltipContext={{ mode: 'band' }} height={400} rule diff --git a/docs/src/examples/components/Waffle/penguins.svelte b/docs/src/examples/components/Waffle/penguins.svelte index 17a67bfd2..4e89cdf18 100644 --- a/docs/src/examples/components/Waffle/penguins.svelte +++ b/docs/src/examples/components/Waffle/penguins.svelte @@ -31,7 +31,7 @@ { key: 'Gentoo', color: 'var(--color-success)' } ]} seriesLayout="stack" - padding={{ left: 36, bottom: 24, top: 8, right: 8 }} + padding={{ left: 36, bottom: 24, top: 32, right: 8 }} tooltipContext={{ mode: 'band' }} height={400} rule From 514ecc7a9982f2c1f0a43edcacbdde449c761eeb Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 8 May 2026 08:56:25 -0400 Subject: [PATCH 11/14] fade non-highlighted series (legend, etc) --- .../lib/components/Waffle/Waffle.shared.svelte.ts | 12 ++++++++++++ .../src/lib/components/Waffle/Waffle.svelte | 7 ++++++- 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts index ab6b1c5a2..83c47a982 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -172,6 +172,18 @@ export class WaffleState { 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 ); diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte index edadb9799..4f9b7076e 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte @@ -100,7 +100,12 @@ })}
{/snippet} - + Date: Fri, 8 May 2026 11:21:37 -0400 Subject: [PATCH 12/14] improve unit/multiple examples --- docs/src/content/components/Waffle.md | 20 +++--- .../components/Waffle/auto-multiple.svelte | 8 +-- .../components/Waffle/band-padding.svelte | 63 ++++++++++++++++ .../components/Waffle/multiple.svelte | 55 -------------- .../Waffle/olympians-by-birth-year.svelte | 2 +- .../components/Waffle/unit-multiple.svelte | 71 +++++++++++++++++++ 6 files changed, 147 insertions(+), 72 deletions(-) create mode 100644 docs/src/examples/components/Waffle/band-padding.svelte delete mode 100644 docs/src/examples/components/Waffle/multiple.svelte create mode 100644 docs/src/examples/components/Waffle/unit-multiple.svelte diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index 4e70d7a68..8a4d86360 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -23,6 +23,8 @@ A horizontal waffle works equally well for showing a single composition broken i ## 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. | @@ -30,7 +32,7 @@ A horizontal waffle works equally well for showing a single composition broken i | `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`). | -The waffle mark 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. Drag the slider to see how the layout adapts as the value changes: +Drag the slider to see how the layout adapts as the value changes: :example{ name="auto-multiple" } @@ -38,21 +40,17 @@ The waffle mark automatically determines the appropriate number of cells along t 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. ::: -:::tip -To set `multiple` directly, pass the prop — though note that manually setting it may produce non-square cells if there isn't enough room. Alternatively, you can bias the automatic value while preserving square cells by adjusting the band scale's padding: `multiple = floor(sqrt(bandwidth / scale))`, so a tighter band (`scaleBand().padding(0.4)`) produces a **smaller** `multiple` than a looser one (`padding(0.1)`). -::: +You can also set `multiple` directly, though note that manually setting it may produce non-square cells if there isn't enough room. -Larger `unit` values shrink the cell count — useful when raw values would otherwise produce thousands of cells: +:example{ name="unit-multiple" } -:example{ name="olympians-by-sex" } +Alternatively, you can bias the automatic `multiple` value while preserving square cells by adjusting the band scale's padding. -For finer-grained binning, group by an interval (e.g. 5-year birth cohorts) and pick a unit that keeps each group readable: +:example{ name="band-padding" } -:example{ name="olympians-by-birth-year" } +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: -Tweak `unit` and `multiple` interactively to see how they affect cell count and grid shape: - -:example{ name="multiple" } +:example{ name="olympians-by-birth-year" } ## Cell shape (`rx` / `ry`) diff --git a/docs/src/examples/components/Waffle/auto-multiple.svelte b/docs/src/examples/components/Waffle/auto-multiple.svelte index fbf2aadcc..9504874ec 100644 --- a/docs/src/examples/components/Waffle/auto-multiple.svelte +++ b/docs/src/examples/components/Waffle/auto-multiple.svelte @@ -1,15 +1,13 @@ -
- - - +
+
+ import { Chart, Tooltip, Waffle } from 'layerchart'; + import { Field, RangeField, ToggleGroup, ToggleOption } from 'svelte-ux'; + + let bandPadding = $state(0.2); + let unit = $state(5); + + const data = [ + { fruit: 'Apple', count: 212 }, + { fruit: 'Banana', count: 207 }, + { fruit: 'Cherry', count: 315 }, + { fruit: 'Date', count: 11 } + ]; + export { data }; + + +
+ + + {#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/multiple.svelte b/docs/src/examples/components/Waffle/multiple.svelte deleted file mode 100644 index 327986202..000000000 --- a/docs/src/examples/components/Waffle/multiple.svelte +++ /dev/null @@ -1,55 +0,0 @@ - - - - -
- - - - - - -
- - - {#snippet marks()} - - {/snippet} - - {#snippet tooltip()} - - {#snippet children({ data })} - {data.letter} - - - - - {/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 index f3b4231da..7cd705182 100644 --- a/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte +++ b/docs/src/examples/components/Waffle/olympians-by-birth-year.svelte @@ -8,7 +8,7 @@ import { rollup } from 'd3-array'; import { Field, ToggleGroup, ToggleOption } from 'svelte-ux'; - let unit = $state(10); + let unit = $state(50); let round = $state(false); const unitOptions = [1, 2, 5, 10, 25, 50, 100]; 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} + From 0bbbd272a4abeb759472876f717950fab38d3ded Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 8 May 2026 11:57:31 -0400 Subject: [PATCH 13/14] improve custom symbol --- docs/src/content/components/Waffle.md | 18 +++---- .../examples/components/Waffle/basic.svelte | 25 +++++----- .../components/Waffle/custom-symbol.svelte | 13 +++-- .../examples/components/Waffle/fruits.svelte | 39 --------------- .../components/Waffle/Waffle.shared.svelte.ts | 48 ++++++------------- .../src/lib/components/Waffle/Waffle.svelte | 6 +-- 6 files changed, 47 insertions(+), 102 deletions(-) delete mode 100644 docs/src/examples/components/Waffle/fruits.svelte diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index 8a4d86360..522fdce54 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -9,7 +9,7 @@ The **Waffle** mark visualizes quantities as a grid of small, countable square c 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="fruits" } +:example{ name="basic" } ## Axis @@ -17,10 +17,6 @@ The waffle mark comes in two orientations. `axis="y"` extends vertically; `axis= :example{ name="horizontal" } -A 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" } - ## 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. @@ -66,14 +62,14 @@ Two waffle marks layered together — a faded one sized to the total and an opaq ## Custom symbol -For full control over the cell's appearance — icons, glyphs, or any SVG — pass a `symbol` snippet. It renders in cell-local coordinates inside a `` whose `fill` is pre-set to the resolved cell color (from the chart's `c` scale, the series, or the `fill` prop), so a `` without an explicit fill inherits it. The snippet receives the cell `width`/`height` (after `gap` inset), the `datum`, and the resolved `fill`: +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} ``` @@ -99,3 +95,7 @@ A wider stack with finer bins — athletes by 10kg weight cohort, split by sex: The same pattern with already-wide data: :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/examples/components/Waffle/basic.svelte b/docs/src/examples/components/Waffle/basic.svelte index bfb92709a..77fff7387 100644 --- a/docs/src/examples/components/Waffle/basic.svelte +++ b/docs/src/examples/components/Waffle/basic.svelte @@ -1,24 +1,23 @@ - - {#snippet children({ data })} - {data.letter} + {data.fruit} - + {/snippet} diff --git a/docs/src/examples/components/Waffle/custom-symbol.svelte b/docs/src/examples/components/Waffle/custom-symbol.svelte index 1bc962b09..9f37dd5c1 100644 --- a/docs/src/examples/components/Waffle/custom-symbol.svelte +++ b/docs/src/examples/components/Waffle/custom-symbol.svelte @@ -44,12 +44,17 @@ {#each context.series.visibleSeries as s (s.key)} {#snippet symbol({ width, height })} - - + + - + + {/snippet} {/each} diff --git a/docs/src/examples/components/Waffle/fruits.svelte b/docs/src/examples/components/Waffle/fruits.svelte deleted file mode 100644 index 77fff7387..000000000 --- a/docs/src/examples/components/Waffle/fruits.svelte +++ /dev/null @@ -1,39 +0,0 @@ - - - - {#snippet marks()} - - {/snippet} - - {#snippet tooltip()} - - {#snippet children({ data })} - {data.fruit} - - - - {/snippet} - - {/snippet} - diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts index 83c47a982..d220a6761 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.shared.svelte.ts @@ -74,9 +74,9 @@ export type WafflePropsWithoutHTML = { onWaffleClick?: (e: MouseEvent, detail: { data: any }) => void; /** * Render a custom symbol per cell instead of the default rect. The snippet - * renders in cell-local coordinates (origin at the inner cell, after `gap` - * inset) inside a `` whose `fill` is pre-set to the resolved cell color, - * so a `` without an explicit `fill` inherits it. + * 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. */ @@ -86,18 +86,14 @@ export type WafflePropsWithoutHTML = { width: number; height: number; datum: any; - /** Resolved cell color (also pre-applied to the wrapping ``). */ - fill: string; + color: string; }, ] >; } & CommonStyleProps; export type WaffleProps = WafflePropsWithoutHTML & - Without< - Omit, 'width' | 'height' | 'x' | 'y'>, - WafflePropsWithoutHTML - >; + Without, 'width' | 'height' | 'x' | 'y'>, WafflePropsWithoutHTML>; /** Per-datum, fully-resolved waffle layout — pixel coords ready to render. */ export type WaffleItem = { @@ -185,7 +181,9 @@ export class WaffleState { }); seriesAccessor = $derived( - this.series ? (this.series.value ?? (this.series.data ? undefined : this.series.key)) : undefined + this.series + ? (this.series.value ?? (this.series.data ? undefined : this.series.key)) + : undefined ); stackAccessors = $derived.by(() => { @@ -270,14 +268,8 @@ export class WaffleState { // 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 + ? (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++) { @@ -315,9 +307,7 @@ export class WaffleState { if (i1 === i2) continue; // Default `multiple` from Plot: keep cells approximately square. - const multiple = - multipleProp ?? - Math.max(1, Math.floor(Math.sqrt(barSize / cellPixels))); + 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); @@ -342,11 +332,7 @@ export class WaffleState { const pts = polyPoints.map(transformPoint); const pathData = - pts.length === 0 - ? '' - : 'M' + - pts.map((p) => `${p[0]},${p[1]}`).join('L') + - 'Z'; + pts.length === 0 ? '' : 'M' + pts.map((p) => `${p[0]},${p[1]}`).join('L') + 'Z'; const cPx = transformPoint(centroid); @@ -453,10 +439,7 @@ function waffleCentroid(i1: number, i2: number, columns: number): [number, numbe 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), - ]; + 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); @@ -469,10 +452,7 @@ function waffleCentroid(i1: number, i2: number, columns: number): [number, numbe 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), - ]; + 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) { diff --git a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte index 4f9b7076e..10aed715d 100644 --- a/packages/layerchart/src/lib/components/Waffle/Waffle.svelte +++ b/packages/layerchart/src/lib/components/Waffle/Waffle.svelte @@ -89,14 +89,14 @@ {@const cellInset = c.gap / 2} {@const innerWidth = Math.max(0, item.cx - 2 * cellInset)} {@const innerHeight = Math.max(0, item.cy - 2 * cellInset)} - {@const symbolFill = item.fill ?? (typeof fill === 'string' ? fill : undefined) ?? 'currentColor'} + {@const color = item.fill ?? (typeof fill === 'string' ? fill : undefined) ?? 'currentColor'} {#snippet symbolPatternContent()} - + {@render symbol?.({ width: innerWidth, height: innerHeight, datum: item.data, - fill: symbolFill, + color, })} {/snippet} From e2cd883584af1f21f8bb35e24773d00352ec6112 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Fri, 8 May 2026 12:04:01 -0400 Subject: [PATCH 14/14] improve descriptions --- docs/src/content/components/Waffle.md | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/docs/src/content/components/Waffle.md b/docs/src/content/components/Waffle.md index 522fdce54..327b9963e 100644 --- a/docs/src/content/components/Waffle.md +++ b/docs/src/content/components/Waffle.md @@ -84,15 +84,13 @@ Pass a `symbol` snippet to render an icon, glyph, or arbitrary SVG in each cell 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. -For long-format input (one row per category × stack key), pivot to wide format with [`pivotWider`](/docs/utils/pivot) so each row has one column per series key: - :example{ name="penguins" } -A wider stack with finer bins — athletes by 10kg weight cohort, split by sex: +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" } -The same pattern with already-wide data: +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" }