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.
- package/dist/helpers/hexLattice.d.ts +20 -0
- package/dist/helpers/hexLattice.js +73 -0
- package/dist/marks/Hexbin.svelte +293 -0
- package/dist/marks/Hexbin.svelte.d.ts +67 -0
- package/dist/marks/Hexgrid.svelte +12 -32
- package/dist/marks/index.d.ts +1 -0
- package/dist/marks/index.js +1 -0
- package/dist/transforms/hexbin.js +8 -36
- package/dist/types/mark.d.ts +1 -1
- package/dist/types/plot.d.ts +5 -1
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
|
62
|
-
const rows = Math.ceil(h / dy) + 1;
|
|
63
|
-
|
|
53
|
+
const lattice = hexLattice(binWidth, ml + binWidth / 2, mt);
|
|
64
54
|
let path = '';
|
|
65
|
-
for (
|
|
66
|
-
|
|
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
|
});
|
package/dist/marks/index.d.ts
CHANGED
|
@@ -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';
|
package/dist/marks/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/types/mark.d.ts
CHANGED
|
@@ -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';
|
package/dist/types/plot.d.ts
CHANGED
|
@@ -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
|
*/
|