Skip to content

Commit ba0918a

Browse files
committed
feat: add jitterX and jitterY
1 parent 8166086 commit ba0918a

File tree

8 files changed

+251
-8
lines changed

8 files changed

+251
-8
lines changed

src/lib/helpers/time.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ const tickIntervals = [
8787
['100 years', 100 * durationYear] // TODO generalize to longer time scales
8888
];
8989

90-
const durations = new Map([
90+
export const durations = new Map([
9191
['second', durationSecond],
9292
['minute', durationMinute],
9393
['hour', durationHour],
@@ -193,7 +193,7 @@ const formatIntervals = [
193193
...utcFormatIntervals.slice(3)
194194
];
195195

196-
export function parseTimeInterval(input) {
196+
export function parseTimeInterval(input: string): [string, number] {
197197
let name = `${input}`.toLowerCase();
198198
if (name.endsWith('s')) name = name.slice(0, -1); // drop plural
199199
let period = 1;
@@ -218,15 +218,15 @@ export function parseTimeInterval(input) {
218218
return [name, period];
219219
}
220220

221-
export function maybeTimeInterval(input) {
221+
export function maybeTimeInterval(input: string) {
222222
return asInterval(parseTimeInterval(input), 'time');
223223
}
224224

225-
export function maybeUtcInterval(input) {
225+
export function maybeUtcInterval(input: string) {
226226
return asInterval(parseTimeInterval(input), 'utc');
227227
}
228228

229-
function asInterval([name, period], type) {
229+
function asInterval([name, period]: [string, number], type: 'time' | 'utc') {
230230
let interval = (type === 'time' ? timeIntervals : utcIntervals).get(name);
231231
if (period > 1) {
232232
interval = interval.every(period);
@@ -245,7 +245,7 @@ export function generalizeTimeInterval(interval, n) {
245245
if (duration % durationDay === 0 && durationDay < duration && duration < durationMonth) return; // not generalizable
246246
const [i] =
247247
tickIntervals[
248-
bisector(([, step]) => Math.log(step)).center(tickIntervals, Math.log(duration * n))
248+
bisector(([, step]) => Math.log(step)).center(tickIntervals, Math.log(duration * n))
249249
];
250250
return (interval[intervalType] === 'time' ? maybeTimeInterval : maybeUtcInterval)(i);
251251
}

src/lib/helpers/typeChecks.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ export function isBooleanOrNull(v: RawValue) {
1414
return v == null || typeof v === 'boolean';
1515
}
1616

17-
export function isDate(v: RawValue) {
17+
export function isDate(v: RawValue): v is Date {
1818
return v instanceof Date && !isNaN(v.getTime());
1919
}
2020

src/lib/transforms/jitter.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import type { Channels, DataRecord, TransformArg } from '$lib/types.js';
2+
import { resolveChannel } from 'svelteplot/helpers/resolve';
3+
import { randomUniform, randomNormal } from 'd3-random';
4+
import { isDate } from 'svelteplot/helpers/typeChecks';
5+
import { durations, maybeTimeInterval, parseTimeInterval } from 'svelteplot/helpers/time';
6+
7+
const JITTER_X = Symbol('jitterX');
8+
const JITTER_Y = Symbol('jitterY');
9+
10+
type JitterOptions = {
11+
type: 'uniform' | 'normal';
12+
/** width for uniform jittering */
13+
width: number;
14+
/** standard deviation for normal jittering */
15+
std: number;
16+
}
17+
18+
export function jitterX({ data, ...channels }: TransformArg<DataRecord>, options: JitterOptions): TransformArg<DataRecord> {
19+
return jitter('x', data, channels, options);
20+
}
21+
22+
export function jitterY({ data, ...channels }: TransformArg<DataRecord>, options: JitterOptions): TransformArg<DataRecord> {
23+
return jitter('y', data, channels, options);
24+
}
25+
26+
export function jitter(channel: 'x' | 'y', data: DataRecord[], channels: Channels, options: JitterOptions): TransformArg<DataRecord> {
27+
if (channels[channel]) {
28+
const type = options?.type ?? 'uniform';
29+
const width = parseNumber(options?.width ?? 0.35);
30+
const std = parseNumber(options?.std ?? 0.15);
31+
// @todo support time interval strings as width/std parameters
32+
33+
const random = type === 'uniform' ? randomUniform(-width, width) : randomNormal(0, std);
34+
const accKey = channel === 'x' ? JITTER_X : JITTER_Y;
35+
return {
36+
data: data.map(row => {
37+
const value = resolveChannel(channel, row, channels);
38+
return {
39+
...row,
40+
[accKey]: typeof value === 'number' ? value + random() : isDate(value) ? new Date(value.getTime() + random()) : value
41+
}
42+
}),
43+
...channels,
44+
// point channel to new accessor symbol
45+
[channel]: accKey
46+
}
47+
}
48+
return {
49+
data,
50+
...channels,
51+
};
52+
}
53+
54+
function parseNumber(value: number | string): number {
55+
if (typeof value === 'number') return value;
56+
if (typeof value === 'string') {
57+
try {
58+
const [name, period] = parseTimeInterval(value);
59+
return durations.get(name) * period;
60+
} catch (err) {
61+
return 0;
62+
}
63+
}
64+
return 0;
65+
}

src/routes/features/transforms/+page.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,22 @@ In case you want to use SveltePlot transforms outside of a Svelte project you ca
9090
```js
9191
import { binX } from 'svelteplot/transforms';
9292
```
93+
## Available Transforms
94+
95+
- [bin](/transforms/bin) - Groups data into discrete bins
96+
- [bollinger](/transforms/bollinger) - Creates Bollinger bands for time series data
97+
- [centroid](/transforms/centroid) - Calculates the geometric center of a set of points
98+
- [facet](/transforms/facet) - Splits data into multiple subplots
99+
- [filter](/transforms/filter) - Filters data based on a condition
100+
- [group](/transforms/group) - Groups data by specified dimensions
101+
- [interval](/transforms/interval) - Creates time or numeric intervals
102+
- [jitter](/transforms/jitter) - Adds random noise to prevent overplotting
103+
- [map](/transforms/map) - Applies a mapping function to data
104+
- [normalize](/transforms/normalize) - Scales data to a common range
105+
- [recordize](/transforms/recordize) - Converts raw data to record format
106+
- [rename](/transforms/rename) - Renames channels in a dataset
107+
- [select](/transforms/select) - Selects specific channels or data points
108+
- [shift](/transforms/shift) - Shifts data values by a specified amount
109+
- [sort](/transforms/sort) - Sorts data based on specified criteria
110+
- [stack](/transforms/stack) - Stacks data series on top of each other
111+
- [window](/transforms/window) - Creates a moving window over data
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
title: Bollinger transform
3+
---
4+
5+
TODO: show how to use the Bollinger transform directly

src/routes/transforms/interval/+page.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ The interval transform is often used for time-series bar charts. For example, co
99
```svelte live
1010
<script lang="ts">
1111
import { Plot, BarY, RuleY } from 'svelteplot';
12-
import type { Datasets } from '$lib/types.js';
1312
1413
const DAY_MONTH = new Intl.DateTimeFormat('en-US', {
1514
day: 'numeric',

src/routes/transforms/jitter/+page.md

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
title: Jitter transform
3+
---
4+
5+
The **jitter transform** adds random noise to data points, which is useful for revealing overlapping points in scatter plots and reducing overplotting. This is particularly helpful when working with discrete or categorical data where many points might share the same coordinates.
6+
7+
> **Note:** The jitter transform works in data coordinates. To jitter in screen coordinates, you can use the `dx` and `dy` mark properties instead.
8+
9+
The jitter transform spreads out overlapping points by adding random noise. This makes it easier to see the distribution and density of points:
10+
11+
```svelte live
12+
<script>
13+
import { Plot, Dot, jitterY } from 'svelteplot';
14+
import { page } from '$app/state';
15+
import { Select, Slider } from '$lib/ui';
16+
let { cars } = $derived(page.data.data);
17+
18+
let type = $state('uniform');
19+
let width = $state(0.35);
20+
let std = $state(0.15);
21+
</script>
22+
23+
<Select
24+
bind:value={type}
25+
options={['uniform', 'normal']}
26+
label="Distribution type" />
27+
{#if type === 'uniform'}
28+
<Slider
29+
bind:value={width}
30+
label="Width"
31+
min={0}
32+
max={1}
33+
step={0.01} />
34+
{:else}
35+
<Slider
36+
bind:value={std}
37+
label="Standard deviation"
38+
min={0}
39+
max={1}
40+
step={0.01} />
41+
{/if}
42+
<Plot inset={20} y={{ ticks: [3, 4, 5, 6, 8], grid: true }}>
43+
<Dot
44+
{...jitterY(
45+
{
46+
data: cars,
47+
y: 'cylinders',
48+
x: 'weight (lb)'
49+
},
50+
{
51+
type,
52+
std,
53+
width
54+
}
55+
)}
56+
fill />
57+
</Plot>
58+
```
59+
60+
## Options
61+
62+
The jitter transform accepts the following options:
63+
64+
- **type**: Distribution type, either `'uniform'` (default) or `'normal'`
65+
- **width**: Width of the uniform distribution (default: `0.35`); used when `type` is `'uniform'`
66+
- **std**: Standard deviation for the normal distribution (default: `0.15`); used when `type` is `'normal'`
67+
68+
## jitterX
69+
70+
Jitters along the x dimensio
71+
72+
```svelte
73+
<Dot
74+
{...jitterX(
75+
{ data: cars, x: 'cylinders' },
76+
{ type: 'normal' }
77+
)} />
78+
```
79+
80+
## jitterY
81+
82+
Jitters along the y dimension
83+
84+
```svelte
85+
<Dot
86+
{...jitterY(
87+
{ data: cars, y: 'cylinders' },
88+
{ type: 'normal' }
89+
)} />
90+
```
91+
92+
## Jittering with dates
93+
94+
Jittering also works for temporal data. When jittering Date objects, random time offsets are added to each date value:
95+
96+
```svelte
97+
<script>
98+
import { Plot, Dot, jitterX } from 'svelteplot';
99+
import { page } from '$app/state';
100+
import { Select, Slider } from '$lib/ui';
101+
let { aapl } = $derived(page.data.data);
102+
103+
// Use a subset of the data for this example
104+
let data = $state(aapl.slice(0, 40));
105+
106+
let type = $state('uniform');
107+
let width = $state(1000 * 60 * 60 * 24); // Default 1 day in milliseconds
108+
let std = $state(1000 * 60 * 60 * 12); // Default 12 hours in milliseconds
109+
</script>
110+
111+
<Select
112+
bind:value={type}
113+
options={['uniform', 'normal']}
114+
label="Distribution type" />
115+
{#if type === 'uniform'}
116+
<Slider
117+
bind:value={width}
118+
label="Width (milliseconds)"
119+
min={0}
120+
max={1000 * 60 * 60 * 48}
121+
step={1000 * 60 * 60} />
122+
{:else}
123+
<Slider
124+
bind:value={std}
125+
label="Standard deviation"
126+
min={0}
127+
max={1000 * 60 * 60 * 24}
128+
step={1000 * 60 * 60} />
129+
{/if}
130+
<Plot inset={20} x={{ type: 'time' }} y={{ grid: true }}>
131+
<Dot
132+
{...jitterX(
133+
{
134+
data,
135+
x: 'Date',
136+
y: 'Volume'
137+
},
138+
{
139+
type,
140+
std,
141+
width
142+
}
143+
)} />
144+
</Plot>
145+
```
146+
147+
This example shows how jittering can be applied to date values in the x-axis, which can be useful when multiple events occur at the same date and would otherwise overlap.

src/routes/transforms/jitter/+page.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { loadDatasets } from '$lib/helpers/data.js';
2+
import type { PageLoad } from './$types.js';
3+
4+
export const load: PageLoad = async ({ fetch }) => {
5+
return {
6+
data: await loadDatasets(['cars'], fetch)
7+
};
8+
};

0 commit comments

Comments
 (0)