svelteplot 0.14.2-pr-552.0 → 0.14.2-pr-552.1

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.
@@ -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,293 @@
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
+ /**
25
+ * Hex cell pitch in pixels (distance between adjacent cell centers
26
+ * along x). Default 20, matching `<Hexgrid />`.
27
+ */
28
+ binWidth?: number;
29
+ /**
30
+ * Fill: a reducer name (`'count'`, `'mean'`, …) to map aggregated
31
+ * values through the color scale, OR a CSS color for a constant fill.
32
+ * Default `'count'` (maps bin counts through the color scale).
33
+ */
34
+ fill?: ReducerName | string;
35
+ /** Stroke: same shape as `fill`. Default `'none'`. */
36
+ stroke?: ReducerName | string;
37
+ strokeWidth?: number;
38
+ fillOpacity?: number;
39
+ strokeOpacity?: number;
40
+ opacity?: number;
41
+ clipPath?: string;
42
+ class?: string;
43
+ }
44
+
45
+ import Mark from '../Mark.svelte';
46
+ import { usePlot } from '../hooks/usePlot.svelte.js';
47
+ import { getPlotDefaults } from '../hooks/plotDefaults.js';
48
+ import { reduceOutputs, type ReducerName } from '../helpers/reduce.js';
49
+ import { resolveProp } from '../helpers/resolve.js';
50
+ import { isColorOrNull } from '../helpers/typeChecks.js';
51
+ import {
52
+ hexLattice,
53
+ pointToHex,
54
+ hexagonSubpath,
55
+ HEX_DEFAULT_BIN_WIDTH
56
+ } from '../helpers/hexLattice.js';
57
+ import type {
58
+ ChannelAccessor,
59
+ DataRecord,
60
+ MarkType,
61
+ ScaledDataRecord
62
+ } from '../types/index.js';
63
+
64
+ // Per-record symbols for the synthetic data passed to <Mark>. Density
65
+ // uses the same pattern (Density.svelte:108-115) — the scale system reads
66
+ // channel values via Symbol-keyed accessors, so user data fields can't
67
+ // collide with our internal channels. Hexbin uses x/y point channels
68
+ // (not Density's x1/x2/y1/y2 range channels) because a bin IS a point;
69
+ // CHANNEL_SCALE maps both to the same x/y scale (see constants.ts:110).
70
+ const GEOM = Symbol('hexbin_geom');
71
+ const X_VAL = Symbol('hexbin_x');
72
+ const Y_VAL = Symbol('hexbin_y');
73
+ const FILL_VAL = Symbol('hexbin_fill');
74
+ const STROKE_VAL = Symbol('hexbin_stroke');
75
+
76
+ const DEFAULTS = {
77
+ ...getPlotDefaults().hexbin
78
+ };
79
+
80
+ let markProps: HexbinMarkProps = $props();
81
+
82
+ const {
83
+ data,
84
+ x: xAcc,
85
+ y: yAcc,
86
+ binWidth = HEX_DEFAULT_BIN_WIDTH,
87
+ fill: rawFill = 'count',
88
+ stroke: rawStroke = 'none',
89
+ strokeWidth,
90
+ fillOpacity,
91
+ strokeOpacity,
92
+ opacity,
93
+ clipPath,
94
+ class: className = 'hexbin',
95
+ ...options
96
+ }: HexbinMarkProps = $derived({ ...DEFAULTS, ...markProps });
97
+
98
+ const plot = usePlot();
99
+
100
+ // A fill/stroke value is a reducer when it's a function or a string that
101
+ // looks like a reducer name rather than a CSS color. Mirrors Density's
102
+ // isDensityAccessor logic (Density.svelte:159-175).
103
+ function isReducer(v: unknown): boolean {
104
+ if (typeof v === 'function') return true;
105
+ if (typeof v !== 'string') return false;
106
+ const lower = v.toLowerCase();
107
+ if (lower === 'none' || lower === 'inherit' || lower === 'currentcolor') return false;
108
+ return !isColorOrNull(v);
109
+ }
110
+
111
+ const fillIsReducer = $derived(isReducer(rawFill));
112
+ const strokeIsReducer = $derived(isReducer(rawStroke));
113
+
114
+ // Raw data extent in data units, used to bootstrap x/y scale domains
115
+ // before bins are computed (the chicken-and-egg: bins need pixel-space
116
+ // scales, scales need a domain). Same approach as Density.svelte:385-426.
117
+ const extent = $derived.by(() => {
118
+ if (!data?.length || xAcc == null || yAcc == null) return null;
119
+ let xMin = Infinity,
120
+ xMax = -Infinity,
121
+ yMin = Infinity,
122
+ yMax = -Infinity;
123
+ for (const d of data as any[]) {
124
+ const xv = resolveProp(xAcc as any, d);
125
+ const yv = resolveProp(yAcc as any, d);
126
+ if (typeof xv === 'number' && Number.isFinite(xv)) {
127
+ if (xv < xMin) xMin = xv;
128
+ if (xv > xMax) xMax = xv;
129
+ }
130
+ if (typeof yv === 'number' && Number.isFinite(yv)) {
131
+ if (yv < yMin) yMin = yv;
132
+ if (yv > yMax) yMax = yv;
133
+ }
134
+ }
135
+ if (!isFinite(xMin) || !isFinite(yMin)) return null;
136
+ return { x1: xMin, x2: xMax, y1: yMin, y2: yMax };
137
+ });
138
+
139
+ // Pixel-space binning. Builds a regular hex lattice from binWidth (px),
140
+ // projects each raw point through the live x/y scales to pixel space,
141
+ // and snaps to the nearest hex center via pointToHex. Returns null on
142
+ // the bootstrap pass (before scales are computable).
143
+ const binResult = $derived.by(() => {
144
+ if (!data?.length || xAcc == null || yAcc == null) return null;
145
+ const sx = plot.scales.x?.fn as ((v: any) => number) | undefined;
146
+ const sy = plot.scales.y?.fn as ((v: any) => number) | undefined;
147
+ if (!sx || !sy) return null;
148
+
149
+ const ml = plot.options.marginLeft ?? 0;
150
+ const mt = plot.options.marginTop ?? 0;
151
+ // Origin matches Hexgrid (Hexgrid.svelte:53) and the existing hexbin
152
+ // transform (transforms/hexbin.ts:85) so a default Hexbin and a default
153
+ // Hexgrid tile the same lattice without coordination. Cell (0,0)
154
+ // straddles the top-left corner — symmetric with the X half-pitch
155
+ // offset, which puts the cell half-inside the left edge.
156
+ const lattice = hexLattice(binWidth, ml + binWidth / 2, mt);
157
+
158
+ const map = new Map<
159
+ string,
160
+ { i: number; j: number; cx: number; cy: number; items: any[] }
161
+ >();
162
+ for (const d of data as any[]) {
163
+ const xv = resolveProp(xAcc as any, d);
164
+ const yv = resolveProp(yAcc as any, d);
165
+ const px = sx(xv);
166
+ const py = sy(yv);
167
+ if (!Number.isFinite(px) || !Number.isFinite(py)) continue;
168
+ const { i, j, cx, cy } = pointToHex(px, py, lattice);
169
+ const key = `${i},${j}`;
170
+ let bin = map.get(key);
171
+ if (!bin) {
172
+ bin = { i, j, cx, cy, items: [] };
173
+ map.set(key, bin);
174
+ }
175
+ bin.items.push(d);
176
+ }
177
+ return { lattice, bins: [...map.values()] };
178
+ });
179
+
180
+ // Synthetic records for <Mark>. Bootstrap pass emits two corner records so
181
+ // x/y scales compute a domain before bins exist; result pass emits the same
182
+ // two corner records plus one per bin carrying:
183
+ // X_VAL / Y_VAL raw data extent → x/y scale domain (one value per record;
184
+ // two records together span the extent)
185
+ // FILL_VAL reducer output → color scale domain (when fill is a reducer)
186
+ // STROKE_VAL reducer output → color scale domain (when stroke is a reducer)
187
+ // GEOM pixel-space hex geometry → rendered by children snippet
188
+ const markData = $derived.by((): DataRecord[] => {
189
+ const ext = extent;
190
+ const br = binResult;
191
+ if (!ext) return [];
192
+
193
+ if (!br) {
194
+ return [
195
+ { [X_VAL]: ext.x1, [Y_VAL]: ext.y1 } as DataRecord,
196
+ { [X_VAL]: ext.x2, [Y_VAL]: ext.y2 } as DataRecord
197
+ ];
198
+ }
199
+
200
+ const reducerOpts: any = {};
201
+ const outputs: string[] = [];
202
+ if (fillIsReducer) {
203
+ reducerOpts.fill = rawFill;
204
+ outputs.push('fill');
205
+ }
206
+ if (strokeIsReducer) {
207
+ reducerOpts.stroke = rawStroke;
208
+ outputs.push('stroke');
209
+ }
210
+
211
+ const records: any[] = [
212
+ // Persistent corner records so x/y scale domain stays at [x1,x2]
213
+ // / [y1,y2] across re-derivations. Without them, after the bootstrap
214
+ // pass replaces markData with bin records (all near a single x/y),
215
+ // the scale domain collapses and the next pass produces NaN.
216
+ { [X_VAL]: ext.x1, [Y_VAL]: ext.y1 } as any,
217
+ { [X_VAL]: ext.x2, [Y_VAL]: ext.y2 } as any
218
+ ];
219
+ for (const bin of br.bins) {
220
+ const item: any = {
221
+ // Channel values irrelevant for rendering (GEOM drives that),
222
+ // but must be inside the extent so they don't expand the domain.
223
+ [X_VAL]: ext.x1,
224
+ [Y_VAL]: ext.y1,
225
+ [GEOM]: {
226
+ cx: bin.cx,
227
+ cy: bin.cy,
228
+ rx: br.lattice.rx,
229
+ ry: br.lattice.ry
230
+ }
231
+ };
232
+
233
+ // reduceOutputs writes item.__fill = countValue etc. (note the
234
+ // `__` prefix — see reduce.ts:113); copy onto Symbol keys so the
235
+ // channel accessors below resolve them and user-data field names
236
+ // can't collide.
237
+ if (outputs.length > 0) {
238
+ reduceOutputs(
239
+ item,
240
+ bin.items,
241
+ reducerOpts,
242
+ outputs as any,
243
+ { x: xAcc, y: yAcc } as any,
244
+ {} as any
245
+ );
246
+ if (fillIsReducer) item[FILL_VAL] = item['__fill'];
247
+ if (strokeIsReducer) item[STROKE_VAL] = item['__stroke'];
248
+ }
249
+
250
+ records.push(item);
251
+ }
252
+ return records;
253
+ });
254
+
255
+ const markChannels = $derived([
256
+ 'x',
257
+ 'y',
258
+ ...(fillIsReducer ? ['fill'] : []),
259
+ ...(strokeIsReducer ? ['stroke'] : [])
260
+ ] as const);
261
+
262
+ const markChannelProps = $derived({
263
+ x: X_VAL as any,
264
+ y: Y_VAL as any,
265
+ ...(fillIsReducer ? { fill: FILL_VAL as any } : {}),
266
+ ...(strokeIsReducer ? { stroke: STROKE_VAL as any } : {})
267
+ });
268
+ </script>
269
+
270
+ <Mark
271
+ type={'hexbin' as MarkType}
272
+ data={markData}
273
+ channels={markChannels as any}
274
+ {...options}
275
+ {...markChannelProps}>
276
+ {#snippet children({ scaledData }: { scaledData: ScaledDataRecord[] })}
277
+ <g class={className} clip-path={clipPath}>
278
+ {#each scaledData as d, i (i)}
279
+ {@const geom = (d.datum as any)?.[GEOM]}
280
+ {#if geom}
281
+ <path
282
+ d={hexagonSubpath(geom.cx, geom.cy, geom.rx, geom.ry)}
283
+ fill={fillIsReducer ? ((d as any).fill ?? 'currentColor') : rawFill}
284
+ stroke={strokeIsReducer ? ((d as any).stroke ?? 'none') : rawStroke}
285
+ stroke-width={strokeWidth}
286
+ fill-opacity={fillOpacity}
287
+ stroke-opacity={strokeOpacity}
288
+ {opacity} />
289
+ {/if}
290
+ {/each}
291
+ </g>
292
+ {/snippet}
293
+ </Mark>
@@ -0,0 +1,67 @@
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
+ /**
12
+ * Hex cell pitch in pixels (distance between adjacent cell centers
13
+ * along x). Default 20, matching `<Hexgrid />`.
14
+ */
15
+ binWidth?: number;
16
+ /**
17
+ * Fill: a reducer name (`'count'`, `'mean'`, …) to map aggregated
18
+ * values through the color scale, OR a CSS color for a constant fill.
19
+ * Default `'count'` (maps bin counts through the color scale).
20
+ */
21
+ fill?: ReducerName | string;
22
+ /** Stroke: same shape as `fill`. Default `'none'`. */
23
+ stroke?: ReducerName | string;
24
+ strokeWidth?: number;
25
+ fillOpacity?: number;
26
+ strokeOpacity?: number;
27
+ opacity?: number;
28
+ clipPath?: string;
29
+ class?: string;
30
+ };
31
+ exports: {};
32
+ bindings: "";
33
+ slots: {};
34
+ events: {};
35
+ };
36
+ declare class __sveltets_Render<Datum extends DataRecord> {
37
+ props(): ReturnType<typeof $$render<Datum>>['props'];
38
+ events(): ReturnType<typeof $$render<Datum>>['events'];
39
+ slots(): ReturnType<typeof $$render<Datum>>['slots'];
40
+ bindings(): "";
41
+ exports(): {};
42
+ }
43
+ interface $$IsomorphicComponent {
44
+ 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']>> & {
45
+ $$bindings?: ReturnType<__sveltets_Render<Datum>['bindings']>;
46
+ } & ReturnType<__sveltets_Render<Datum>['exports']>;
47
+ <Datum extends DataRecord>(internal: unknown, props: ReturnType<__sveltets_Render<Datum>['props']> & {}): ReturnType<__sveltets_Render<Datum>['exports']>;
48
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
49
+ }
50
+ /**
51
+ * Renders a hexagonal binning of 2D scatter data: groups raw points into a
52
+ * pixel-space hex lattice, runs a reducer per bin, and draws each non-empty
53
+ * bin as a regular hexagonal cell. Cells are regular by construction because
54
+ * the lattice is computed in pixel space (after scales exist) rather than in
55
+ * data space, so they tile correctly under any axis aspect ratio.
56
+ *
57
+ * Pairs naturally with the data-less `<Hexgrid />` mark for an empty-cell
58
+ * backdrop — both default to `binWidth=20` (px), so a default `<Hexbin>` and
59
+ * a default `<Hexgrid>` align by convention without user coordination.
60
+ *
61
+ * The `fill` prop accepts a reducer name (`'count'`, `'mean'`, etc.) to map
62
+ * aggregated values through the plot's color scale, or a CSS color for a
63
+ * constant fill. Same shape for `stroke`.
64
+ */
65
+ declare const Hexbin: $$IsomorphicComponent;
66
+ type Hexbin<Datum extends DataRecord> = InstanceType<typeof Hexbin<Datum>>;
67
+ export default Hexbin;
@@ -23,11 +23,17 @@
23
23
  import Mark from '../Mark.svelte';
24
24
  import type { MarkType } from '../types/index.js';
25
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';
26
32
 
27
33
  let markProps: HexgridMarkProps = $props();
28
34
 
29
35
  const {
30
- binWidth = 20,
36
+ binWidth = HEX_DEFAULT_BIN_WIDTH,
31
37
  stroke = 'currentColor',
32
38
  strokeOpacity = 0.1,
33
39
  strokeWidth = 1,
@@ -38,42 +44,16 @@
38
44
 
39
45
  const plot = usePlot();
40
46
 
41
- const sqrt3 = Math.sqrt(3);
42
-
43
- function r3(x: number) {
44
- return Math.round(x * 1000) / 1000;
45
- }
46
-
47
47
  const pathData = $derived.by(() => {
48
- // Hex offset constants matching hexbin transform
49
- const ox = 0.5;
50
- const oy = 0;
51
- const rx = binWidth / 2;
52
- const ry = (rx * 2) / sqrt3;
53
- const dx = binWidth;
54
- const dy = ry * 1.5;
55
-
56
- const w = plot.facetWidth;
57
- const h = plot.facetHeight;
58
48
  const ml = plot.options.marginLeft;
59
49
  const mt = plot.options.marginTop;
50
+ const w = plot.facetWidth;
51
+ const h = plot.facetHeight;
60
52
 
61
- const cols = Math.ceil(w / dx) + 1;
62
- const rows = Math.ceil(h / dy) + 1;
63
-
53
+ const lattice = hexLattice(binWidth, ml + binWidth / 2, mt);
64
54
  let path = '';
65
- for (let j = -1; j <= rows; j++) {
66
- for (let i = -1; i <= cols; i++) {
67
- const cx = r3((i + (j & 1) / 2 + ox) * dx + ml);
68
- const cy = r3((j + oy) * dy + mt);
69
- // Pointy-topped hexagon path
70
- path += `M${cx},${r3(cy - ry)}`;
71
- path += `l${r3(rx)},${r3(ry / 2)}`;
72
- path += `v${r3(ry)}`;
73
- path += `l${r3(-rx)},${r3(ry / 2)}`;
74
- path += `l${r3(-rx)},${r3(-ry / 2)}`;
75
- path += `v${r3(-ry)}Z`;
76
- }
55
+ for (const [cx, cy] of hexCellsInRect(lattice, ml, mt, ml + w, mt + h)) {
56
+ path += hexagonSubpath(cx, cy, lattice.rx, lattice.ry);
77
57
  }
78
58
  return path;
79
59
  });
@@ -31,6 +31,7 @@ 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';
34
35
  export { default as Hexgrid } from './Hexgrid.svelte';
35
36
  export { default as Hull } from './Hull.svelte';
36
37
  export { default as Image } from './Image.svelte';
@@ -31,6 +31,7 @@ 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';
34
35
  export { default as Hexgrid } from './Hexgrid.svelte';
35
36
  export { default as Hull } from './Hull.svelte';
36
37
  export { default as Image } from './Image.svelte';
@@ -2,7 +2,7 @@ import { resolveChannel } from '../helpers/resolve.js';
2
2
  import { extent } from 'd3-array';
3
3
  import { reduceOutputs } from '../helpers/reduce.js';
4
4
  import { groupFacetsAndZ } from '../helpers/group.js';
5
- const sqrt3 = Math.sqrt(3);
5
+ import { hexLattice, pointToHex } from '../helpers/hexLattice.js';
6
6
  const CHANNELS = {
7
7
  x: Symbol('hexbin_x'),
8
8
  y: Symbol('hexbin_y')
@@ -32,51 +32,23 @@ export function hexbin({ data, ...channels }, options = {}) {
32
32
  if (xMin == null || yMin == null) {
33
33
  return { data: [], ...channels, x: CHANNELS.x, y: CHANNELS.y };
34
34
  }
35
- // Compute hex cell width in data units
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.
36
40
  const dx = explicitBinWidth ?? (xMax - xMin) / Math.max(1, bins);
37
- // Vertical spacing between hex centers (pointy-topped hexagons)
38
- const dy = (dx * 1.5) / sqrt3;
39
- // Hex offset to avoid edge alignment
40
- const ox = dx * 0.5;
41
- const oy = 0;
42
- // Bin data into hex cells
41
+ const lattice = hexLattice(dx, xMin + dx / 2, yMin);
43
42
  const binMap = new Map();
44
43
  for (let i = 0; i < data.length; i++) {
45
44
  const px = xValues[i];
46
45
  const py = yValues[i];
47
46
  if (px == null || py == null || isNaN(px) || isNaN(py))
48
47
  continue;
49
- // Convert to hex grid coordinates
50
- let pj = Math.round((py - yMin - oy) / dy);
51
- let pi = Math.round((px - xMin - ox - (pj & 1) * (dx / 2)) / dx);
52
- // Snap to nearest hex center and check if an adjacent cell is closer
53
- const cx0 = (pi + (pj & 1) / 2) * dx + ox + xMin;
54
- const cy0 = pj * dy + oy + yMin;
55
- // Check the two candidate rows
56
- const pj1 = pj + 1;
57
- const pi1 = Math.round((px - xMin - ox - (pj1 & 1) * (dx / 2)) / dx);
58
- const cx1 = (pi1 + (pj1 & 1) / 2) * dx + ox + xMin;
59
- const cy1 = pj1 * dy + oy + yMin;
60
- const pj2 = pj - 1;
61
- const pi2 = Math.round((px - xMin - ox - (pj2 & 1) * (dx / 2)) / dx);
62
- const cx2 = (pi2 + (pj2 & 1) / 2) * dx + ox + xMin;
63
- const cy2 = pj2 * dy + oy + yMin;
64
- const d0 = (px - cx0) ** 2 + (py - cy0) ** 2;
65
- const d1 = (px - cx1) ** 2 + (py - cy1) ** 2;
66
- const d2 = (px - cx2) ** 2 + (py - cy2) ** 2;
67
- if (d1 < d0 && d1 < d2) {
68
- pj = pj1;
69
- pi = pi1;
70
- }
71
- else if (d2 < d0 && d2 < d1) {
72
- pj = pj2;
73
- pi = pi2;
74
- }
48
+ const { i: pi, j: pj, cx, cy } = pointToHex(px, py, lattice);
75
49
  const key = `${pi},${pj}`;
76
50
  let bin = binMap.get(key);
77
51
  if (!bin) {
78
- const cx = (pi + (pj & 1) / 2) * dx + ox + xMin;
79
- const cy = pj * dy + oy + yMin;
80
52
  bin = { index: [], cx, cy };
81
53
  binMap.set(key, bin);
82
54
  }
@@ -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' | 'hexgrid' | '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-552.0",
3
+ "version": "0.14.2-pr-552.1",
4
4
  "description": "A Svelte-native data visualization framework based on the layered grammar of graphics principles.",
5
5
  "keywords": [
6
6
  "svelte",