svelteplot 0.14.2-pr-555.10 → 0.14.2-pr-552.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/constants.js CHANGED
@@ -120,7 +120,7 @@ export const CHANNEL_SCALE = {
120
120
  strokeOpacity: 'opacity'
121
121
  };
122
122
  export const CSS_VAR = /^var\(--([a-z-0-9,\s]+)\)$/;
123
- export const CSS_COLOR = /^(?:color|light-dark)\(/;
123
+ export const CSS_COLOR = /^color\(/;
124
124
  export const CSS_COLOR_MIX = /^color-mix\(/; // just check for prefix
125
125
  export const CSS_COLOR_CONTRAST = /^color-contrast\(/; // just check for prefix
126
126
  export const CSS_RGBA = /^rgba\(/; // just check for prefix
@@ -0,0 +1,20 @@
1
+ export declare const HEX_DEFAULT_BIN_WIDTH = 20;
2
+ export type HexLattice = {
3
+ rx: number;
4
+ ry: number;
5
+ dx: number;
6
+ dy: number;
7
+ originX: number;
8
+ originY: number;
9
+ };
10
+ export declare function hexLattice(binWidth: number, originX?: number, originY?: number): HexLattice;
11
+ export declare function hexLatticeXY(dx: number, dy: number, originX?: number, originY?: number): HexLattice;
12
+ export declare function hexCenter(i: number, j: number, lattice: HexLattice): [number, number];
13
+ export declare function pointToHex(px: number, py: number, lattice: HexLattice): {
14
+ i: number;
15
+ j: number;
16
+ cx: number;
17
+ cy: number;
18
+ };
19
+ export declare function hexagonSubpath(cx: number, cy: number, rx: number, ry: number): string;
20
+ export declare function hexCellsInRect(lattice: HexLattice, x0: number, y0: number, x1: number, y1: number): Generator<[number, number]>;
@@ -0,0 +1,73 @@
1
+ // Shared hex lattice math used by the `hexbin` transform and the `Hexgrid`
2
+ // mark so binning and the grid overlay always tile the same lattice. Pointy-
3
+ // topped hexagons; odd rows are offset horizontally by rx. The lattice is
4
+ // unit-agnostic — callers pass pitches in pixels (Hexgrid pixel mode) or in
5
+ // data units (data-space mode).
6
+ const SQRT3 = Math.sqrt(3);
7
+ export const HEX_DEFAULT_BIN_WIDTH = 20;
8
+ // Build a regular pointy-topped hex lattice from a single pitch. dy is the
9
+ // canonical dx*√3/2 row pitch for tiling regular hexagons — used by Hexgrid
10
+ // pixel mode and any caller working in a single coordinate system.
11
+ export function hexLattice(binWidth, originX = 0, originY = 0) {
12
+ const rx = binWidth / 2;
13
+ const ry = (rx * 2) / SQRT3;
14
+ return { rx, ry, dx: binWidth, dy: ry * 1.5, originX, originY };
15
+ }
16
+ // Build a lattice with independent column and row pitches. Use this when the
17
+ // x and y axes have different units (data-space hexbin), so each axis's pitch
18
+ // can be scaled by its own data extent and still tile cleanly.
19
+ export function hexLatticeXY(dx, dy, originX = 0, originY = 0) {
20
+ // ry is the vertical half-bounding-box; row pitch dy = 1.5 * ry, so ry = 2/3 dy.
21
+ return { rx: dx / 2, ry: (dy * 2) / 3, dx, dy, originX, originY };
22
+ }
23
+ export function hexCenter(i, j, lattice) {
24
+ const cx = lattice.originX + (i + (j & 1) / 2) * lattice.dx;
25
+ const cy = lattice.originY + j * lattice.dy;
26
+ return [cx, cy];
27
+ }
28
+ // Snaps a pixel point to the nearest hex cell. Must check the rounded row and
29
+ // both neighbors because the row-offset lattice lets an adjacent row be closer
30
+ // than the rounded one.
31
+ export function pointToHex(px, py, lattice) {
32
+ const { dx, dy, originX, originY } = lattice;
33
+ const rx = dx / 2;
34
+ const pj = Math.round((py - originY) / dy) || 0;
35
+ let best = { i: 0, j: 0, cx: 0, cy: 0, d2: Infinity };
36
+ for (const dj of [0, 1, -1]) {
37
+ const cj = pj + dj;
38
+ const ci = Math.round((px - originX - (cj & 1) * rx) / dx) || 0;
39
+ const cx = originX + (ci + (cj & 1) / 2) * dx;
40
+ const cy = originY + cj * dy;
41
+ const d2 = (px - cx) ** 2 + (py - cy) ** 2;
42
+ if (d2 < best.d2)
43
+ best = { i: ci, j: cj, cx, cy, d2 };
44
+ }
45
+ return { i: best.i, j: best.j, cx: best.cx, cy: best.cy };
46
+ }
47
+ // SVG subpath for a single pointy-topped hex, rounded to compact the output.
48
+ export function hexagonSubpath(cx, cy, rx, ry) {
49
+ const r3 = (n) => Math.round(n * 1000) / 1000;
50
+ return (`M${r3(cx)},${r3(cy - ry)}` +
51
+ `l${r3(rx)},${r3(ry / 2)}` +
52
+ `v${r3(ry)}` +
53
+ `l${r3(-rx)},${r3(ry / 2)}` +
54
+ `l${r3(-rx)},${r3(-ry / 2)}` +
55
+ `v${r3(-ry)}Z`);
56
+ }
57
+ // Iterate hex cell centers covering the given pixel rect. Pads by one cell so
58
+ // a clipPath can trim the edges cleanly without gaps.
59
+ export function* hexCellsInRect(lattice, x0, y0, x1, y1) {
60
+ const { dx, dy, originX, originY, rx, ry } = lattice;
61
+ const jMin = Math.floor((y0 - originY - ry) / dy);
62
+ const jMax = Math.ceil((y1 - originY + ry) / dy);
63
+ for (let j = jMin; j <= jMax; j++) {
64
+ const xOffset = (j & 1) * (dx / 2);
65
+ const iMin = Math.floor((x0 - originX - xOffset - rx) / dx);
66
+ const iMax = Math.ceil((x1 - originX - xOffset + rx) / dx);
67
+ for (let i = iMin; i <= iMax; i++) {
68
+ const cx = originX + (i + (j & 1) / 2) * dx;
69
+ const cy = originY + j * dy;
70
+ yield [cx, cy];
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,374 @@
1
+ <!-- @component
2
+ Renders a hexagonal binning of 2D scatter data: groups raw points into a
3
+ pixel-space hex lattice, runs a reducer per bin, and draws each non-empty
4
+ bin as a regular hexagonal cell. Cells are regular by construction because
5
+ the lattice is computed in pixel space (after scales exist) rather than in
6
+ data space, so they tile correctly under any axis aspect ratio.
7
+
8
+ Pairs naturally with the data-less `<Hexgrid />` mark for an empty-cell
9
+ backdrop — both default to `binWidth=20` (px), so a default `<Hexbin>` and
10
+ a default `<Hexgrid>` align by convention without user coordination.
11
+
12
+ The `fill` prop accepts a reducer name (`'count'`, `'mean'`, etc.) to map
13
+ aggregated values through the plot's color scale, or a CSS color for a
14
+ constant fill. Same shape for `stroke`.
15
+ -->
16
+ <script lang="ts" generics="Datum extends DataRecord">
17
+ interface HexbinMarkProps {
18
+ /** Input data — array of records with x/y positions. */
19
+ data?: Datum[] | null;
20
+ /** x position channel (data space). */
21
+ x?: ChannelAccessor<Datum>;
22
+ /** y position channel (data space). */
23
+ y?: ChannelAccessor<Datum>;
24
+ /** horizontal facet channel — bins are computed independently per facet. */
25
+ fx?: ChannelAccessor<Datum>;
26
+ /** vertical facet channel — bins are computed independently per facet. */
27
+ fy?: ChannelAccessor<Datum>;
28
+ /**
29
+ * Hex cell pitch in pixels (distance between adjacent cell centers
30
+ * along x). Default 20, matching `<Hexgrid />`.
31
+ */
32
+ binWidth?: number;
33
+ /**
34
+ * Fill: a reducer name (`'count'`, `'mean'`, …) to map aggregated
35
+ * values through the color scale, OR a CSS color for a constant fill.
36
+ * Default `'count'` (maps bin counts through the color scale).
37
+ */
38
+ fill?: ReducerName | string;
39
+ /** Stroke: same shape as `fill`. Default `'none'`. */
40
+ stroke?: ReducerName | string;
41
+ strokeWidth?: number;
42
+ fillOpacity?: number;
43
+ strokeOpacity?: number;
44
+ opacity?: number;
45
+ clipPath?: string;
46
+ class?: string;
47
+ }
48
+
49
+ import Mark from '../Mark.svelte';
50
+ import { usePlot } from '../hooks/usePlot.svelte.js';
51
+ import { getPlotDefaults } from '../hooks/plotDefaults.js';
52
+ import { reduceOutputs, type ReducerName } from '../helpers/reduce.js';
53
+ import { resolveProp } from '../helpers/resolve.js';
54
+ import { isColorOrNull } from '../helpers/typeChecks.js';
55
+ import {
56
+ hexLattice,
57
+ pointToHex,
58
+ hexagonSubpath,
59
+ HEX_DEFAULT_BIN_WIDTH
60
+ } from '../helpers/hexLattice.js';
61
+ import type {
62
+ ChannelAccessor,
63
+ DataRecord,
64
+ MarkType,
65
+ ScaledDataRecord
66
+ } from '../types/index.js';
67
+
68
+ // Per-record symbols for the synthetic data passed to <Mark>. Density
69
+ // uses the same pattern (Density.svelte:108-115) — the scale system reads
70
+ // channel values via Symbol-keyed accessors, so user data fields can't
71
+ // collide with our internal channels. Hexbin uses x/y point channels
72
+ // (not Density's x1/x2/y1/y2 range channels) because a bin IS a point;
73
+ // CHANNEL_SCALE maps both to the same x/y scale (see constants.ts:110).
74
+ const GEOM = Symbol('hexbin_geom');
75
+ const X_VAL = Symbol('hexbin_x');
76
+ const Y_VAL = Symbol('hexbin_y');
77
+ const FILL_VAL = Symbol('hexbin_fill');
78
+ const STROKE_VAL = Symbol('hexbin_stroke');
79
+ const FX_VAL = Symbol('hexbin_fx');
80
+ const FY_VAL = Symbol('hexbin_fy');
81
+
82
+ const DEFAULTS = {
83
+ ...getPlotDefaults().hexbin
84
+ };
85
+
86
+ let markProps: HexbinMarkProps = $props();
87
+
88
+ const {
89
+ data,
90
+ x: xAcc,
91
+ y: yAcc,
92
+ fx: fxAcc,
93
+ fy: fyAcc,
94
+ binWidth = HEX_DEFAULT_BIN_WIDTH,
95
+ fill: rawFill = 'count',
96
+ stroke: rawStroke = 'none',
97
+ strokeWidth,
98
+ fillOpacity,
99
+ strokeOpacity,
100
+ opacity,
101
+ clipPath,
102
+ class: className = 'hexbin',
103
+ ...options
104
+ }: HexbinMarkProps = $derived({ ...DEFAULTS, ...markProps });
105
+
106
+ const plot = usePlot();
107
+
108
+ // A fill/stroke value is a reducer when it's a function or a string that
109
+ // looks like a reducer name rather than a CSS color. Mirrors Density's
110
+ // isDensityAccessor logic (Density.svelte:159-175).
111
+ function isReducer(v: unknown): boolean {
112
+ if (typeof v === 'function') return true;
113
+ if (typeof v !== 'string') return false;
114
+ const lower = v.toLowerCase();
115
+ if (lower === 'none' || lower === 'inherit' || lower === 'currentcolor') return false;
116
+ return !isColorOrNull(v);
117
+ }
118
+
119
+ const fillIsReducer = $derived(isReducer(rawFill));
120
+ const strokeIsReducer = $derived(isReducer(rawStroke));
121
+
122
+ // Raw data extent in data units, used to bootstrap x/y scale domains
123
+ // before bins are computed (the chicken-and-egg: bins need pixel-space
124
+ // scales, scales need a domain). Same approach as Density.svelte:385-426.
125
+ const extent = $derived.by(() => {
126
+ if (!data?.length || xAcc == null || yAcc == null) return null;
127
+ let xMin = Infinity,
128
+ xMax = -Infinity,
129
+ yMin = Infinity,
130
+ yMax = -Infinity;
131
+ for (const d of data as any[]) {
132
+ const xv = resolveProp(xAcc as any, d);
133
+ const yv = resolveProp(yAcc as any, d);
134
+ if (typeof xv === 'number' && Number.isFinite(xv)) {
135
+ if (xv < xMin) xMin = xv;
136
+ if (xv > xMax) xMax = xv;
137
+ }
138
+ if (typeof yv === 'number' && Number.isFinite(yv)) {
139
+ if (yv < yMin) yMin = yv;
140
+ if (yv > yMax) yMax = yv;
141
+ }
142
+ }
143
+ if (!isFinite(xMin) || !isFinite(yMin)) return null;
144
+ return { x1: xMin, x2: xMax, y1: yMin, y2: yMax };
145
+ });
146
+
147
+ // Pixel-space binning. Builds a regular hex lattice from binWidth (px),
148
+ // projects each raw point through the live x/y scales to pixel space,
149
+ // and snaps to the nearest hex center via pointToHex. Returns null on
150
+ // the bootstrap pass (before scales are computable).
151
+ //
152
+ // Faceting: when fx/fy accessors are provided, raw records are partitioned
153
+ // by (fxVal, fyVal) before binning so each facet panel gets independent
154
+ // bin counts. The lattice itself stays single-source — Mark.svelte applies
155
+ // the per-facet group transform at render time, and pointToHex outputs in
156
+ // plot-global pixel space (same as scales.x/y.fn). Mirrors Density's
157
+ // group-by-facet pattern (Density.svelte:272-286).
158
+ type Bin = {
159
+ i: number;
160
+ j: number;
161
+ cx: number;
162
+ cy: number;
163
+ items: any[];
164
+ fxVal: any;
165
+ fyVal: any;
166
+ };
167
+ const binResult = $derived.by(() => {
168
+ if (!data?.length || xAcc == null || yAcc == null) return null;
169
+ const sx = plot.scales.x?.fn as ((v: any) => number) | undefined;
170
+ const sy = plot.scales.y?.fn as ((v: any) => number) | undefined;
171
+ if (!sx || !sy) return null;
172
+
173
+ const ml = plot.options.marginLeft ?? 0;
174
+ const mt = plot.options.marginTop ?? 0;
175
+ // Origin matches Hexgrid (Hexgrid.svelte:53) and the existing hexbin
176
+ // transform (transforms/hexbin.ts:85) so a default Hexbin and a default
177
+ // Hexgrid tile the same lattice without coordination. Cell (0,0)
178
+ // straddles the top-left corner — symmetric with the X half-pitch
179
+ // offset, which puts the cell half-inside the left edge.
180
+ const lattice = hexLattice(binWidth, ml + binWidth / 2, mt);
181
+
182
+ // Two-level map: (fxVal, fyVal) → (i, j) → bin. Single outer entry
183
+ // when not faceted.
184
+ const groups = new Map<string, Map<string, Bin>>();
185
+ const facetKeys: { key: string; fxVal: any; fyVal: any }[] = [];
186
+ for (const d of data as any[]) {
187
+ const xv = resolveProp(xAcc as any, d);
188
+ const yv = resolveProp(yAcc as any, d);
189
+ const px = sx(xv);
190
+ const py = sy(yv);
191
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
192
+ const fxVal = fxAcc != null ? resolveProp(fxAcc as any, d) : undefined;
193
+ const fyVal = fyAcc != null ? resolveProp(fyAcc as any, d) : undefined;
194
+ const groupKey = `${String(fxVal)}\0${String(fyVal)}`;
195
+ let group = groups.get(groupKey);
196
+ if (!group) {
197
+ group = new Map();
198
+ groups.set(groupKey, group);
199
+ facetKeys.push({ key: groupKey, fxVal, fyVal });
200
+ }
201
+ const { i, j, cx, cy } = pointToHex(px, py, lattice);
202
+ const binKey = `${i},${j}`;
203
+ let bin = group.get(binKey);
204
+ if (!bin) {
205
+ bin = { i, j, cx, cy, items: [], fxVal, fyVal };
206
+ group.set(binKey, bin);
207
+ }
208
+ bin.items.push(d);
209
+ }
210
+ const bins: Bin[] = [];
211
+ for (const g of groups.values()) for (const bin of g.values()) bins.push(bin);
212
+ return { lattice, bins, facetKeys };
213
+ });
214
+
215
+ // Synthetic records for <Mark>. Bootstrap pass emits corner records so
216
+ // x/y scales compute a domain before bins exist; result pass emits the
217
+ // persistent corner records plus one per bin carrying:
218
+ // X_VAL / Y_VAL raw data extent → x/y scale domain (one value per record;
219
+ // two records together span the extent)
220
+ // FILL_VAL reducer output → color scale domain (when fill is a reducer)
221
+ // STROKE_VAL reducer output → color scale domain (when stroke is a reducer)
222
+ // FX_VAL / FY_VAL facet values → Mark facet filtering (when faceted)
223
+ // GEOM pixel-space hex geometry → rendered by children snippet
224
+ //
225
+ // When faceted, we emit one corner-pair per unique (fxVal, fyVal) so no
226
+ // record carries an undefined facet value (which would create a spurious
227
+ // null facet panel). Mirrors Density.svelte:441-497.
228
+ const markData = $derived.by((): DataRecord[] => {
229
+ const ext = extent;
230
+ const br = binResult;
231
+ const isFaceted = fxAcc != null || fyAcc != null;
232
+ if (!ext) return [];
233
+
234
+ // Build the list of (fxVal, fyVal) pairs the corner records should be
235
+ // emitted for. binResult.facetKeys is the authoritative list once
236
+ // binning has happened; on the bootstrap pass we walk data ourselves
237
+ // since binResult is null.
238
+ type FK = { fxVal: any; fyVal: any };
239
+ let facetKeys: FK[];
240
+ if (br) {
241
+ facetKeys = br.facetKeys.map(({ fxVal, fyVal }) => ({ fxVal, fyVal }));
242
+ if (facetKeys.length === 0) facetKeys = [{ fxVal: undefined, fyVal: undefined }];
243
+ } else if (isFaceted && data?.length) {
244
+ const seen = new Set<string>();
245
+ facetKeys = [];
246
+ for (const d of data as any[]) {
247
+ const fxVal = fxAcc != null ? resolveProp(fxAcc as any, d) : undefined;
248
+ const fyVal = fyAcc != null ? resolveProp(fyAcc as any, d) : undefined;
249
+ const key = `${String(fxVal)}\0${String(fyVal)}`;
250
+ if (!seen.has(key)) {
251
+ seen.add(key);
252
+ facetKeys.push({ fxVal, fyVal });
253
+ }
254
+ }
255
+ } else {
256
+ facetKeys = [{ fxVal: undefined, fyVal: undefined }];
257
+ }
258
+
259
+ const records: any[] = [];
260
+ // Persistent corner records (one pair per facet) keep x/y scale domains
261
+ // anchored at [x1,x2]/[y1,y2] across re-derivations and ensure each
262
+ // facet panel registers a domain even when no data lands in some bin.
263
+ for (const { fxVal, fyVal } of facetKeys) {
264
+ const c1: any = { [X_VAL]: ext.x1, [Y_VAL]: ext.y1 };
265
+ const c2: any = { [X_VAL]: ext.x2, [Y_VAL]: ext.y2 };
266
+ if (isFaceted) {
267
+ if (fxAcc != null) {
268
+ c1[FX_VAL] = fxVal;
269
+ c2[FX_VAL] = fxVal;
270
+ }
271
+ if (fyAcc != null) {
272
+ c1[FY_VAL] = fyVal;
273
+ c2[FY_VAL] = fyVal;
274
+ }
275
+ }
276
+ records.push(c1, c2);
277
+ }
278
+
279
+ if (!br) return records;
280
+
281
+ const reducerOpts: any = {};
282
+ const outputs: string[] = [];
283
+ if (fillIsReducer) {
284
+ reducerOpts.fill = rawFill;
285
+ outputs.push('fill');
286
+ }
287
+ if (strokeIsReducer) {
288
+ reducerOpts.stroke = rawStroke;
289
+ outputs.push('stroke');
290
+ }
291
+
292
+ for (const bin of br.bins) {
293
+ const item: any = {
294
+ // Channel values irrelevant for rendering (GEOM drives that),
295
+ // but must be inside the extent so they don't expand the domain.
296
+ [X_VAL]: ext.x1,
297
+ [Y_VAL]: ext.y1,
298
+ [GEOM]: {
299
+ cx: bin.cx,
300
+ cy: bin.cy,
301
+ rx: br.lattice.rx,
302
+ ry: br.lattice.ry
303
+ }
304
+ };
305
+ if (isFaceted) {
306
+ if (fxAcc != null) item[FX_VAL] = bin.fxVal;
307
+ if (fyAcc != null) item[FY_VAL] = bin.fyVal;
308
+ }
309
+
310
+ // reduceOutputs writes item.__fill = countValue etc. (note the
311
+ // `__` prefix — see reduce.ts:113); copy onto Symbol keys so the
312
+ // channel accessors below resolve them and user-data field names
313
+ // can't collide.
314
+ if (outputs.length > 0) {
315
+ reduceOutputs(
316
+ item,
317
+ bin.items,
318
+ reducerOpts,
319
+ outputs as any,
320
+ { x: xAcc, y: yAcc } as any,
321
+ {} as any
322
+ );
323
+ if (fillIsReducer) item[FILL_VAL] = item['__fill'];
324
+ if (strokeIsReducer) item[STROKE_VAL] = item['__stroke'];
325
+ }
326
+
327
+ records.push(item);
328
+ }
329
+ return records;
330
+ });
331
+
332
+ const markChannels = $derived([
333
+ 'x',
334
+ 'y',
335
+ ...(fillIsReducer ? ['fill'] : []),
336
+ ...(strokeIsReducer ? ['stroke'] : []),
337
+ ...(fxAcc != null ? ['fx'] : []),
338
+ ...(fyAcc != null ? ['fy'] : [])
339
+ ] as const);
340
+
341
+ const markChannelProps = $derived({
342
+ x: X_VAL as any,
343
+ y: Y_VAL as any,
344
+ ...(fillIsReducer ? { fill: FILL_VAL as any } : {}),
345
+ ...(strokeIsReducer ? { stroke: STROKE_VAL as any } : {}),
346
+ ...(fxAcc != null ? { fx: FX_VAL as any } : {}),
347
+ ...(fyAcc != null ? { fy: FY_VAL as any } : {})
348
+ });
349
+ </script>
350
+
351
+ <Mark
352
+ type={'hexbin' as MarkType}
353
+ data={markData}
354
+ channels={markChannels as any}
355
+ {...options}
356
+ {...markChannelProps}>
357
+ {#snippet children({ scaledData }: { scaledData: ScaledDataRecord[] })}
358
+ <g class={className} clip-path={clipPath}>
359
+ {#each scaledData as d, i (i)}
360
+ {@const geom = (d.datum as any)?.[GEOM]}
361
+ {#if geom}
362
+ <path
363
+ d={hexagonSubpath(geom.cx, geom.cy, geom.rx, geom.ry)}
364
+ fill={fillIsReducer ? ((d as any).fill ?? 'currentColor') : rawFill}
365
+ stroke={strokeIsReducer ? ((d as any).stroke ?? 'none') : rawStroke}
366
+ stroke-width={strokeWidth}
367
+ fill-opacity={fillOpacity}
368
+ stroke-opacity={strokeOpacity}
369
+ {opacity} />
370
+ {/if}
371
+ {/each}
372
+ </g>
373
+ {/snippet}
374
+ </Mark>
@@ -0,0 +1,71 @@
1
+ import { type ReducerName } from '../helpers/reduce.js';
2
+ import type { ChannelAccessor, DataRecord } from '../types/index.js';
3
+ declare function $$render<Datum extends DataRecord>(): {
4
+ props: {
5
+ /** Input data — array of records with x/y positions. */
6
+ data?: Datum[] | null;
7
+ /** x position channel (data space). */
8
+ x?: ChannelAccessor<Datum>;
9
+ /** y position channel (data space). */
10
+ y?: ChannelAccessor<Datum>;
11
+ /** horizontal facet channel — bins are computed independently per facet. */
12
+ fx?: ChannelAccessor<Datum>;
13
+ /** vertical facet channel — bins are computed independently per facet. */
14
+ fy?: ChannelAccessor<Datum>;
15
+ /**
16
+ * Hex cell pitch in pixels (distance between adjacent cell centers
17
+ * along x). Default 20, matching `<Hexgrid />`.
18
+ */
19
+ binWidth?: number;
20
+ /**
21
+ * Fill: a reducer name (`'count'`, `'mean'`, …) to map aggregated
22
+ * values through the color scale, OR a CSS color for a constant fill.
23
+ * Default `'count'` (maps bin counts through the color scale).
24
+ */
25
+ fill?: ReducerName | string;
26
+ /** Stroke: same shape as `fill`. Default `'none'`. */
27
+ stroke?: ReducerName | string;
28
+ strokeWidth?: number;
29
+ fillOpacity?: number;
30
+ strokeOpacity?: number;
31
+ opacity?: number;
32
+ clipPath?: string;
33
+ class?: string;
34
+ };
35
+ exports: {};
36
+ bindings: "";
37
+ slots: {};
38
+ events: {};
39
+ };
40
+ declare class __sveltets_Render<Datum extends DataRecord> {
41
+ props(): ReturnType<typeof $$render<Datum>>['props'];
42
+ events(): ReturnType<typeof $$render<Datum>>['events'];
43
+ slots(): ReturnType<typeof $$render<Datum>>['slots'];
44
+ bindings(): "";
45
+ exports(): {};
46
+ }
47
+ interface $$IsomorphicComponent {
48
+ new <Datum extends DataRecord>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<Datum>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<Datum>['props']>, ReturnType<__sveltets_Render<Datum>['events']>, ReturnType<__sveltets_Render<Datum>['slots']>> & {
49
+ $$bindings?: ReturnType<__sveltets_Render<Datum>['bindings']>;
50
+ } & ReturnType<__sveltets_Render<Datum>['exports']>;
51
+ <Datum extends DataRecord>(internal: unknown, props: ReturnType<__sveltets_Render<Datum>['props']> & {}): ReturnType<__sveltets_Render<Datum>['exports']>;
52
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
53
+ }
54
+ /**
55
+ * Renders a hexagonal binning of 2D scatter data: groups raw points into a
56
+ * pixel-space hex lattice, runs a reducer per bin, and draws each non-empty
57
+ * bin as a regular hexagonal cell. Cells are regular by construction because
58
+ * the lattice is computed in pixel space (after scales exist) rather than in
59
+ * data space, so they tile correctly under any axis aspect ratio.
60
+ *
61
+ * Pairs naturally with the data-less `<Hexgrid />` mark for an empty-cell
62
+ * backdrop — both default to `binWidth=20` (px), so a default `<Hexbin>` and
63
+ * a default `<Hexgrid>` align by convention without user coordination.
64
+ *
65
+ * The `fill` prop accepts a reducer name (`'count'`, `'mean'`, etc.) to map
66
+ * aggregated values through the plot's color scale, or a CSS color for a
67
+ * constant fill. Same shape for `stroke`.
68
+ */
69
+ declare const Hexbin: $$IsomorphicComponent;
70
+ type Hexbin<Datum extends DataRecord> = InstanceType<typeof Hexbin<Datum>>;
71
+ export default Hexbin;
@@ -0,0 +1,72 @@
1
+ <!-- @component
2
+ Renders a hexagonal grid decoration, typically used alongside hexbin-transformed data.
3
+ A data-less mark similar to Frame.
4
+ -->
5
+ <script lang="ts">
6
+ interface HexgridMarkProps {
7
+ /** the hexagon bin width in pixels */
8
+ binWidth?: number;
9
+ /** the stroke color of the grid lines */
10
+ stroke?: string;
11
+ /** the stroke opacity of the grid lines */
12
+ strokeOpacity?: number;
13
+ /** the stroke width of the grid lines */
14
+ strokeWidth?: number;
15
+ /** the fill color of the hexagons */
16
+ fill?: string;
17
+ /** the fill opacity of the hexagons */
18
+ fillOpacity?: number;
19
+ /** CSS class name */
20
+ class?: string;
21
+ }
22
+
23
+ import Mark from '../Mark.svelte';
24
+ import type { MarkType } from '../types/index.js';
25
+ import { usePlot } from '../hooks/usePlot.svelte.js';
26
+ import {
27
+ hexLattice,
28
+ hexCellsInRect,
29
+ hexagonSubpath,
30
+ HEX_DEFAULT_BIN_WIDTH
31
+ } from '../helpers/hexLattice.js';
32
+
33
+ let markProps: HexgridMarkProps = $props();
34
+
35
+ const {
36
+ binWidth = HEX_DEFAULT_BIN_WIDTH,
37
+ stroke = 'currentColor',
38
+ strokeOpacity = 0.1,
39
+ strokeWidth = 1,
40
+ fill = 'none',
41
+ fillOpacity,
42
+ class: className = 'hexgrid'
43
+ }: HexgridMarkProps = $derived({ ...markProps });
44
+
45
+ const plot = usePlot();
46
+
47
+ const pathData = $derived.by(() => {
48
+ const ml = plot.options.marginLeft;
49
+ const mt = plot.options.marginTop;
50
+ const w = plot.facetWidth;
51
+ const h = plot.facetHeight;
52
+
53
+ const lattice = hexLattice(binWidth, ml + binWidth / 2, mt);
54
+ let path = '';
55
+ for (const [cx, cy] of hexCellsInRect(lattice, ml, mt, ml + w, mt + h)) {
56
+ path += hexagonSubpath(cx, cy, lattice.rx, lattice.ry);
57
+ }
58
+ return path;
59
+ });
60
+ </script>
61
+
62
+ <Mark type={'hexgrid' as MarkType}>
63
+ <g class={className}>
64
+ <path
65
+ d={pathData}
66
+ {fill}
67
+ fill-opacity={fillOpacity}
68
+ {stroke}
69
+ stroke-opacity={strokeOpacity}
70
+ stroke-width={strokeWidth} />
71
+ </g>
72
+ </Mark>
@@ -0,0 +1,23 @@
1
+ interface HexgridMarkProps {
2
+ /** the hexagon bin width in pixels */
3
+ binWidth?: number;
4
+ /** the stroke color of the grid lines */
5
+ stroke?: string;
6
+ /** the stroke opacity of the grid lines */
7
+ strokeOpacity?: number;
8
+ /** the stroke width of the grid lines */
9
+ strokeWidth?: number;
10
+ /** the fill color of the hexagons */
11
+ fill?: string;
12
+ /** the fill opacity of the hexagons */
13
+ fillOpacity?: number;
14
+ /** CSS class name */
15
+ class?: string;
16
+ }
17
+ /**
18
+ * Renders a hexagonal grid decoration, typically used alongside hexbin-transformed data.
19
+ * A data-less mark similar to Frame.
20
+ */
21
+ declare const Hexgrid: import("svelte").Component<HexgridMarkProps, {}, "">;
22
+ type Hexgrid = ReturnType<typeof Hexgrid>;
23
+ export default Hexgrid;
@@ -31,6 +31,8 @@ export { default as Geo } from './Geo.svelte';
31
31
  export { default as Graticule } from './Graticule.svelte';
32
32
  export { default as GridX } from './GridX.svelte';
33
33
  export { default as GridY } from './GridY.svelte';
34
+ export { default as Hexbin } from './Hexbin.svelte';
35
+ export { default as Hexgrid } from './Hexgrid.svelte';
34
36
  export { default as Hull } from './Hull.svelte';
35
37
  export { default as Image } from './Image.svelte';
36
38
  export { default as Line } from './Line.svelte';
@@ -31,6 +31,8 @@ export { default as Geo } from './Geo.svelte';
31
31
  export { default as Graticule } from './Graticule.svelte';
32
32
  export { default as GridX } from './GridX.svelte';
33
33
  export { default as GridY } from './GridY.svelte';
34
+ export { default as Hexbin } from './Hexbin.svelte';
35
+ export { default as Hexgrid } from './Hexgrid.svelte';
34
36
  export { default as Hull } from './Hull.svelte';
35
37
  export { default as Image } from './Image.svelte';
36
38
  export { default as Line } from './Line.svelte';
@@ -0,0 +1,37 @@
1
+ import { type ReducerName } from '../helpers/reduce.js';
2
+ import type { DataRecord, RawValue, TransformArg } from '../types/index.js';
3
+ type ReducerOption = ReducerName | ((group: DataRecord[]) => RawValue);
4
+ type HexbinOutputChannels = Partial<{
5
+ fill: ReducerOption;
6
+ stroke: ReducerOption;
7
+ r: ReducerOption;
8
+ opacity: ReducerOption;
9
+ fillOpacity: ReducerOption;
10
+ strokeOpacity: ReducerOption;
11
+ }>;
12
+ export type HexbinOptions = HexbinOutputChannels & {
13
+ /**
14
+ * Approximate number of hex bins along the x-axis.
15
+ * The actual bin width is computed from the data extent.
16
+ * Default: 20.
17
+ */
18
+ bins?: number;
19
+ /**
20
+ * Explicit bin width in data units. Overrides `bins` if set.
21
+ */
22
+ binWidth?: number;
23
+ };
24
+ /**
25
+ * Bins data points into hexagonal cells and applies reducers to produce
26
+ * aggregated output channels (e.g. fill="count", r="count").
27
+ *
28
+ * Usage:
29
+ * ```svelte
30
+ * <Dot {...hexbin(
31
+ * { data: penguins, x: "culmen_length_mm", y: "culmen_depth_mm" },
32
+ * { fill: "count", r: "count", bins: 15 }
33
+ * )} symbol="hexagon" />
34
+ * ```
35
+ */
36
+ export declare function hexbin({ data, ...channels }: TransformArg<DataRecord>, options?: HexbinOptions): TransformArg<DataRecord>;
37
+ export {};
@@ -0,0 +1,81 @@
1
+ import { resolveChannel } from '../helpers/resolve.js';
2
+ import { extent } from 'd3-array';
3
+ import { reduceOutputs } from '../helpers/reduce.js';
4
+ import { groupFacetsAndZ } from '../helpers/group.js';
5
+ import { hexLattice, pointToHex } from '../helpers/hexLattice.js';
6
+ const CHANNELS = {
7
+ x: Symbol('hexbin_x'),
8
+ y: Symbol('hexbin_y')
9
+ };
10
+ /**
11
+ * Bins data points into hexagonal cells and applies reducers to produce
12
+ * aggregated output channels (e.g. fill="count", r="count").
13
+ *
14
+ * Usage:
15
+ * ```svelte
16
+ * <Dot {...hexbin(
17
+ * { data: penguins, x: "culmen_length_mm", y: "culmen_depth_mm" },
18
+ * { fill: "count", r: "count", bins: 15 }
19
+ * )} symbol="hexagon" />
20
+ * ```
21
+ */
22
+ export function hexbin({ data, ...channels }, options = {}) {
23
+ const { bins = 20, binWidth: explicitBinWidth, ...reducerOptions } = options;
24
+ if (channels.x == null || channels.y == null) {
25
+ throw new Error('hexbin requires both x and y channels');
26
+ }
27
+ // Resolve x, y values from data
28
+ const xValues = data.map((d) => resolveChannel('x', d, channels));
29
+ const yValues = data.map((d) => resolveChannel('y', d, channels));
30
+ const [xMin, xMax] = extent(xValues);
31
+ const [yMin, yMax] = extent(yValues);
32
+ if (xMin == null || yMin == null) {
33
+ return { data: [], ...channels, x: CHANNELS.x, y: CHANNELS.y };
34
+ }
35
+ // Cell pitch in data units. Pointy-topped hex with regular geometry — dy
36
+ // derived from dx, so dy is in the same data units as dx. Cells are visually
37
+ // regular only when the data units happen to match across axes; for
38
+ // pixel-correct cells under arbitrary data extents, use the <Hexbin> mark
39
+ // instead, which builds its lattice in pixel space after scales exist.
40
+ const dx = explicitBinWidth ?? (xMax - xMin) / Math.max(1, bins);
41
+ const lattice = hexLattice(dx, xMin + dx / 2, yMin);
42
+ const binMap = new Map();
43
+ for (let i = 0; i < data.length; i++) {
44
+ const px = xValues[i];
45
+ const py = yValues[i];
46
+ if (px == null || py == null || isNaN(px) || isNaN(py))
47
+ continue;
48
+ const { i: pi, j: pj, cx, cy } = pointToHex(px, py, lattice);
49
+ const key = `${pi},${pj}`;
50
+ let bin = binMap.get(key);
51
+ if (!bin) {
52
+ bin = { index: [], cx, cy };
53
+ binMap.set(key, bin);
54
+ }
55
+ bin.index.push(i);
56
+ }
57
+ // Build output data from bins
58
+ const xChannel = typeof channels.x === 'string' ? channels.x : '__hexbin_x';
59
+ const yChannel = typeof channels.y === 'string' ? channels.y : '__hexbin_y';
60
+ let newChannels = {
61
+ ...channels,
62
+ x: xChannel,
63
+ y: yChannel
64
+ };
65
+ const outputs = ['fill', 'stroke', 'r', 'opacity', 'fillOpacity', 'strokeOpacity'];
66
+ const newData = [];
67
+ for (const [, bin] of binMap) {
68
+ const items = bin.index.map((i) => data[i]);
69
+ const newGroupChannels = groupFacetsAndZ(items, channels, (groupItems, groupProps) => {
70
+ const item = {
71
+ [xChannel]: bin.cx,
72
+ [yChannel]: bin.cy,
73
+ ...groupProps
74
+ };
75
+ reduceOutputs(item, groupItems, reducerOptions, outputs, channels, newChannels);
76
+ newData.push(item);
77
+ });
78
+ newChannels = { ...newChannels, ...newGroupChannels };
79
+ }
80
+ return { data: newData, ...newChannels };
81
+ }
@@ -6,6 +6,7 @@ export { filter } from './filter.js';
6
6
  export { map, mapX, mapY } from './map.js';
7
7
  export { normalizeX, normalizeY, normalizeParallelX, normalizeParallelY } from './normalize.js';
8
8
  export { group, groupX, groupY, groupZ } from './group.js';
9
+ export { hexbin } from './hexbin.js';
9
10
  export { intervalX, intervalY } from './interval.js';
10
11
  export { jitter, jitterX, jitterY } from './jitter.js';
11
12
  export { recordizeX, recordizeY } from './recordize.js';
@@ -6,6 +6,7 @@ export { filter } from './filter.js';
6
6
  export { map, mapX, mapY } from './map.js';
7
7
  export { normalizeX, normalizeY, normalizeParallelX, normalizeParallelY } from './normalize.js';
8
8
  export { group, groupX, groupY, groupZ } from './group.js';
9
+ export { hexbin } from './hexbin.js';
9
10
  export { intervalX, intervalY } from './interval.js';
10
11
  export { jitter, jitterX, jitterY } from './jitter.js';
11
12
  export { recordizeX, recordizeY } from './recordize.js';
@@ -6,7 +6,7 @@ export type Mark<T> = {
6
6
  data: DataRecord<T>[];
7
7
  options: T;
8
8
  };
9
- export type MarkType = 'area' | 'arrow' | 'axisX' | 'axisY' | 'barX' | 'barY' | 'cell' | 'contour' | 'custom' | 'delaunayLink' | 'delaunayMesh' | 'density' | 'dot' | 'vector' | 'frame' | 'geo' | 'gridX' | 'gridY' | 'hull' | 'image' | 'link' | 'line' | 'raster' | 'rect' | 'regression' | 'ruleX' | 'ruleY' | 'swoopyArrow' | 'text' | 'tickX' | 'tickY' | 'trail' | 'voronoi' | 'voronoiMesh' | 'waffleX' | 'waffleY';
9
+ export type MarkType = 'area' | 'arrow' | 'axisX' | 'axisY' | 'barX' | 'barY' | 'cell' | 'contour' | 'custom' | 'delaunayLink' | 'delaunayMesh' | 'density' | 'dot' | 'vector' | 'frame' | 'geo' | 'gridX' | 'gridY' | 'hexbin' | 'hexgrid' | 'hull' | 'image' | 'link' | 'line' | 'raster' | 'rect' | 'regression' | 'ruleX' | 'ruleY' | 'swoopyArrow' | 'text' | 'tickX' | 'tickY' | 'trail' | 'voronoi' | 'voronoiMesh' | 'waffleX' | 'waffleY';
10
10
  export type MarkStyleProps = 'strokeDasharray' | 'strokeLinejoin' | 'strokeLinecap' | 'opacity' | 'cursor' | 'pointerEvents' | 'blend' | 'fill' | 'fillOpacity' | 'fontFamily' | 'fontWeight' | 'fontVariant' | 'fontSize' | 'fontStyle' | 'letterSpacing' | 'wordSpacing' | 'stroke' | 'strokeWidth' | 'strokeOpacity' | 'x' | 'y' | 'clipPath' | 'mask' | 'filter' | 'angle' | 'radius' | 'symbol' | 'textAnchor' | 'textTransform' | 'textDecoration' | 'width';
11
11
  import type { ChannelAccessor, ConstantAccessor, DataRecord, RawValue } from './index.js';
12
12
  import type * as CSS from 'csstype';
@@ -5,7 +5,7 @@ import type { ChannelAccessor, ChannelName, ColorScaleOptions, DataRecord, Legen
5
5
  import type { Snippet } from 'svelte';
6
6
  import type { GenericMarkOptions, Mark } from './index.js';
7
7
  import type { Clip } from '../helpers/projection.js';
8
- import type { Area, AreaX, AreaY, Arrow, AxisX, AxisY, BarX, BarY, BoxX, BoxY, Brush, BrushX, BrushY, Cell, Contour, DelaunayLink, DelaunayMesh, Density, DifferenceY, Dot, Frame, Geo, Graticule, GridX, GridY, Hull, Image, Line, Link, Pointer, Raster, Rect, RectX, RectY, RuleX, RuleY, Sphere, Spike, Text, TickX, TickY, Trail, Vector, Voronoi, VoronoiMesh } from '../marks/index.js';
8
+ import type { Area, AreaX, AreaY, Arrow, AxisX, AxisY, BarX, BarY, BoxX, BoxY, Brush, BrushX, BrushY, Cell, Contour, DelaunayLink, DelaunayMesh, Density, DifferenceY, Dot, Frame, Geo, Graticule, GridX, GridY, Hexbin, Hull, Image, Line, Link, Pointer, Raster, Rect, RectX, RectY, RuleX, RuleY, Sphere, Spike, Text, TickX, TickY, Trail, Vector, Voronoi, VoronoiMesh } from '../marks/index.js';
9
9
  import type WaffleX from '../marks/WaffleX.svelte';
10
10
  import type WaffleY from '../marks/WaffleY.svelte';
11
11
  import type BaseAxisX from '../marks/helpers/BaseAxisX.svelte';
@@ -260,6 +260,10 @@ export type PlotDefaults = {
260
260
  gridY: Partial<Omit<ComponentProps<typeof GridY>, IgnoreDefaults> & {
261
261
  implicit: boolean;
262
262
  }> | true;
263
+ /**
264
+ * default props for hexbin marks
265
+ */
266
+ hexbin: Partial<Omit<ComponentProps<typeof Hexbin>, IgnoreDefaults>>;
263
267
  /**
264
268
  * default props for hull marks
265
269
  */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "svelteplot",
3
- "version": "0.14.2-pr-555.10",
3
+ "version": "0.14.2-pr-552.2",
4
4
  "description": "A Svelte-native data visualization framework based on the layered grammar of graphics principles.",
5
5
  "keywords": [
6
6
  "svelte",