diff --git a/.github/workflows/npm-prerelease.yml b/.github/workflows/npm-prerelease.yml new file mode 100644 index 00000000..35081c69 --- /dev/null +++ b/.github/workflows/npm-prerelease.yml @@ -0,0 +1,127 @@ +name: npm prerelease + +on: + pull_request: + branches: [main] + +permissions: + contents: write + pull-requests: write + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + + - name: Install dependencies + run: pnpm install + + - name: Run Vitest tests + run: pnpm test + + publish-preview: + # Prevent this job from running on forks + if: github.repository == 'svelteplot/svelteplot' && github.event.pull_request.draft == false + needs: test + runs-on: ubuntu-latest + environment: + name: npm + + steps: + - uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v3 + with: + version: 10 + run_install: false + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: 'pnpm' + registry-url: 'https://registry.npmjs.org' + + - name: Install dependencies + run: pnpm install + + - name: Generate preview version + run: | + pr_number=${{ github.event.pull_request.number }} + + # Get existing prerelease versions for this PR + existing_versions=$(npm view svelteplot versions --json 2>/dev/null | grep -o "\"[0-9]*\.[0-9]*\.[0-9]*-pr-${pr_number}\.[0-9]*\"" | tr -d '"' || echo "") + + if [ -n "$existing_versions" ]; then + # Get the highest existing prerelease version + latest_version=$(echo "$existing_versions" | sort -V | tail -n 1) + + echo "Found latest existing prerelease version: $latest_version" + + # Extract base version (before the `-pr-N.x` part) + base_version=$(echo "$latest_version" | sed -E "s/-pr-${pr_number}\.[0-9]+//") + + # Extract current prerelease number (the `.x` part) + prerelease_num=$(echo "$latest_version" | sed -E "s/.*-pr-${pr_number}\.([0-9]+)$/\1/") + + # Increment prerelease number + next_prerelease=$((prerelease_num + 1)) + + next_version="${base_version}-pr-${pr_number}.${next_prerelease}" + + echo "Bumping to next prerelease version: $next_version" + + npm version "$next_version" --no-git-tag-version + else + # No existing prerelease, start fresh from current base version + base_version=$(node -p "require('./package.json').version") + next_version="${base_version}-pr-${pr_number}.0" + + echo "Starting fresh prerelease version: $next_version" + + npm version "$next_version" --no-git-tag-version + fi + + echo "Generated version: $(node -p "require('./package.json').version")" + + - name: Publish to npm + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_AUTH }} + run: npm publish --tag pr-${{ github.event.pull_request.number }} --access public + + # Save version for use in PR comment + - name: Save version + run: echo "PACKAGE_VERSION=$(node -p "require('./package.json').version")" >> $GITHUB_ENV + + - name: Comment on PR + uses: peter-evans/create-or-update-comment@v3 + with: + issue-number: ${{ github.event.pull_request.number }} + body: | + 📦 Preview package for this PR is published! + + Version: `${{ env.PACKAGE_VERSION }}` + + Install it with: + ```bash + npm install svelteplot@pr-${{ github.event.pull_request.number }} + # or install the specific version + npm install svelteplot@${{ env.PACKAGE_VERSION }} + ``` + reactions: '+1, rocket' + edit-mode: replace diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 00000000..0ab05dcf --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,8 @@ +{ + "recommendations": [ + "esbenp.prettier-vscode", + "svelte.svelte-vscode", + "dbaeumer.vscode-eslint", + "typescript-svelte-plugin" + ] +} diff --git a/.vscode/settings.json b/.vscode/settings.json index 0967ef42..46d9035f 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1 +1,35 @@ -{} +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[typescript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[svelte]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "svelte" + ], + "prettier.useTabs": false, + "prettier.singleQuote": true, + "prettier.tabWidth": 4, + "prettier.trailingComma": "none", + "prettier.printWidth": 100, + "svelte.plugin.svelte.format.enable": false, + "eslint.probe": [ + "javascript", + "typescript", + "svelte" + ], + "eslint.experimental.useFlatConfig": true +} diff --git a/config/sidebar.ts b/config/sidebar.ts index b6f2f6a4..69018e6f 100644 --- a/config/sidebar.ts +++ b/config/sidebar.ts @@ -82,6 +82,7 @@ export default { { title: 'Filter', to: '/transforms/filter' }, { title: 'Group', to: '/transforms/group' }, { title: 'Interval', to: '/transforms/interval' }, + { title: 'Jitter', to: '/transforms/jitter' }, { title: 'Map', to: '/transforms/map' }, { title: 'Normalize', to: '/transforms/normalize' }, { title: 'Select', to: '/transforms/select' }, diff --git a/package.json b/package.json index 8db0e438..a3d2475a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "svelteplot", - "version": "0.2.6", + "version": "0.2.7", "license": "ISC", "author": { "name": "Gregor Aisch", @@ -93,7 +93,7 @@ "svg-path-parser": "^1.1.0", "topojson-client": "^3.1.0", "tslib": "^2.8.1", - "typedoc": "^0.28.4", + "typedoc": "^0.28.5", "typedoc-plugin-markdown": "^4.6.3", "typescript": "^5.8.3", "vite": "^6.3.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2836b37a..16156d65 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -196,11 +196,11 @@ importers: specifier: ^2.8.1 version: 2.8.1 typedoc: - specifier: ^0.28.4 - version: 0.28.4(typescript@5.8.3) + specifier: ^0.28.5 + version: 0.28.5(typescript@5.8.3) typedoc-plugin-markdown: specifier: ^4.6.3 - version: 4.6.3(typedoc@0.28.4(typescript@5.8.3)) + version: 4.6.3(typedoc@0.28.5(typescript@5.8.3)) typescript: specifier: ^5.8.3 version: 5.8.3 @@ -4273,8 +4273,8 @@ packages: peerDependencies: typedoc: 0.28.x - typedoc@0.28.4: - resolution: {integrity: sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA==} + typedoc@0.28.5: + resolution: {integrity: sha512-5PzUddaA9FbaarUzIsEc4wNXCiO4Ot3bJNeMF2qKpYlTmM9TTaSHQ7162w756ERCkXER/+o2purRG6YOAv6EMA==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: @@ -9366,11 +9366,11 @@ snapshots: for-each: 0.3.3 is-typed-array: 1.1.12 - typedoc-plugin-markdown@4.6.3(typedoc@0.28.4(typescript@5.8.3)): + typedoc-plugin-markdown@4.6.3(typedoc@0.28.5(typescript@5.8.3)): dependencies: - typedoc: 0.28.4(typescript@5.8.3) + typedoc: 0.28.5(typescript@5.8.3) - typedoc@0.28.4(typescript@5.8.3): + typedoc@0.28.5(typescript@5.8.3): dependencies: '@gerrit0/mini-shiki': 3.2.2 lunr: 2.3.9 diff --git a/src/lib/helpers/time.ts b/src/lib/helpers/time.ts index 6b9a68d5..60387869 100644 --- a/src/lib/helpers/time.ts +++ b/src/lib/helpers/time.ts @@ -87,7 +87,7 @@ const tickIntervals = [ ['100 years', 100 * durationYear] // TODO generalize to longer time scales ]; -const durations = new Map([ +export const durations = new Map([ ['second', durationSecond], ['minute', durationMinute], ['hour', durationHour], @@ -193,7 +193,7 @@ const formatIntervals = [ ...utcFormatIntervals.slice(3) ]; -export function parseTimeInterval(input) { +export function parseTimeInterval(input: string): [string, number] { let name = `${input}`.toLowerCase(); if (name.endsWith('s')) name = name.slice(0, -1); // drop plural let period = 1; @@ -218,15 +218,15 @@ export function parseTimeInterval(input) { return [name, period]; } -export function maybeTimeInterval(input) { +export function maybeTimeInterval(input: string) { return asInterval(parseTimeInterval(input), 'time'); } -export function maybeUtcInterval(input) { +export function maybeUtcInterval(input: string) { return asInterval(parseTimeInterval(input), 'utc'); } -function asInterval([name, period], type) { +function asInterval([name, period]: [string, number], type: 'time' | 'utc') { let interval = (type === 'time' ? timeIntervals : utcIntervals).get(name); if (period > 1) { interval = interval.every(period); diff --git a/src/lib/helpers/typeChecks.ts b/src/lib/helpers/typeChecks.ts index 04d8ff22..8d2f985c 100644 --- a/src/lib/helpers/typeChecks.ts +++ b/src/lib/helpers/typeChecks.ts @@ -14,7 +14,7 @@ export function isBooleanOrNull(v: RawValue) { return v == null || typeof v === 'boolean'; } -export function isDate(v: RawValue) { +export function isDate(v: RawValue): v is Date { return v instanceof Date && !isNaN(v.getTime()); } diff --git a/src/lib/marks/Line.svelte b/src/lib/marks/Line.svelte index 8d524a93..8ee23417 100644 --- a/src/lib/marks/Line.svelte +++ b/src/lib/marks/Line.svelte @@ -21,7 +21,7 @@ outlineStroke?: string; outlineStrokeWidth?: number; outlineStrokeOpacity?: number; - curve?: CurveName | CurveFactory; + curve?: CurveName | CurveFactory | 'auto'; tension?: number; sort?: ConstantAccessor | { channel: 'stroke' | 'fill' }; text?: ConstantAccessor; @@ -76,10 +76,6 @@ if (groupValue === lastGroupValue) { group.push(d); } else { - if (group.length === 1) { - // just one point makes a bad line, add this one, too - group.push(d); - } // new group group = [d]; groups.push(group); diff --git a/src/lib/transforms/index.ts b/src/lib/transforms/index.ts index 96845e76..d780fa20 100644 --- a/src/lib/transforms/index.ts +++ b/src/lib/transforms/index.ts @@ -6,6 +6,7 @@ export { map, mapX, mapY } from './map.js'; export { normalizeX, normalizeY } from './normalize.js'; export { group, groupX, groupY, groupZ } from './group.js'; export { intervalX, intervalY } from './interval.js'; +export { jitterX, jitterY } from './jitter.js'; export { recordizeX, recordizeY } from './recordize.js'; export { renameChannels, replaceChannels } from './rename.js'; export { @@ -18,6 +19,7 @@ export { selectMinY } from './select.js'; export { shiftX, shiftY } from './shift.js'; + export { sort, shuffle, reverse } from './sort.js'; export { stackX, stackY } from './stack.js'; export { windowX, windowY } from './window.js'; diff --git a/src/lib/transforms/jitter.test.ts b/src/lib/transforms/jitter.test.ts new file mode 100644 index 00000000..61377fd7 --- /dev/null +++ b/src/lib/transforms/jitter.test.ts @@ -0,0 +1,165 @@ +// @ts-nocheck +import { describe, it, expect } from 'vitest'; +import { jitterX, jitterY } from './jitter.js'; +import { randomLcg } from 'd3-random'; + +// Tests for the jitter transforms +describe('jitterX', () => { + it('should add uniform jitter to x values with default options', () => { + // Create a deterministic random source that returns exactly what we need + const mockRandom = () => 1; // This will produce exactly +0.1 in range [-0.35, 0.35] + + const data = [{ x: 5 }, { x: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterX({ data, x: 'x' }, { source: mockRandom }); + // The result should add the jitter values to the original x values + const { x } = result; + // Check approximate values + expect(result.data[0][x]).toBe(5.35, 2); + expect(result.data[1][x]).toBe(10.35, 2); + }); + + it('should add uniform jitter to x values with custom width', () => { + // Create a deterministic random source that returns exactly what we need + const mockRandom = () => 1; // This will produce exactly +0.1 in range [-0.35, 0.35] + + const data = [{ x: 5 }, { x: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterX({ data, x: 'x' }, { width: 0.5, source: mockRandom }); + // The result should add the jitter values to the original x values + const { x } = result; + // Check approximate values + expect(result.data[0][x]).toBe(5.5, 2); + expect(result.data[1][x]).toBe(10.5, 2); + }); + + it('should add normal jitter to x values', () => { + // We'll simplify this test by not trying to mock d3-random directly + // Instead, we'll provide a source function that controls the output values + const data = [{ x: 5 }, { x: 10 }]; + + // Custom source function that controls the exact jitter values + let values = [0.05, -0.1]; // The exact jitter values we want + let index = 0; + + const mockSource = randomLcg(42); + + // @ts-ignore - Bypassing type checking for tests + const result = jitterX( + { data, x: 'x' }, + { + type: 'normal', + std: 0.2, + // Use our custom function as the source + // This effectively hijacks the normal distribution calculation + source: mockSource + } + ); + + // The result should add the jitter values to the original x values + const { x } = result; + expect(result.data[0][x]).toBeCloseTo(4.9318, 3); + expect(result.data[1][x]).toBeCloseTo(9.9589, 3); + }); + + // // Note: Date jittering is not yet supported, test will be added when implemented + + it('should not modify data if x channel is not provided', () => { + const mockRandom = () => 0.5; + + const data = [{ y: 5 }, { y: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterX({ data, y: 'y' }, { source: mockRandom }); + + // The result should be the same as the input + expect(result.data).toEqual(data); + expect(result.y).toBe('y'); + }); + + it('should parse time interval strings for width/std', () => { + // This isn't fully implemented in the jitter.ts but mentioned in a TODO comment + const mockRandom = () => 0.75; + + const data = [{ x: new Date(Date.UTC(2020, 0, 1)) }, { x: new Date(Date.UTC(2021, 0, 1)) }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterX({ data, x: 'x' }, { source: mockRandom, width: '1 month' }); + + const { x } = result; + expect(result.data[0][x]).toBeTypeOf('object'); + expect(result.data[0][x].getTime).toBeTypeOf('function'); + expect(result.data[0][x]).toStrictEqual(new Date(Date.UTC(2020, 0, 16))); + }); +}); + +describe('jitterY', () => { + it('should add uniform jitter to x values with default options', () => { + // Create a deterministic random source that returns exactly what we need + const mockRandom = () => 1; // This will produce exactly +0.1 in range [-0.35, 0.35] + + const data = [{ x: 5 }, { x: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterY({ data, y: 'x' }, { source: mockRandom }); + // The result should add the jitter values to the original x values + const { y } = result; + // Check approximate values + expect(result.data[0][y]).toBe(5.35, 2); + expect(result.data[1][y]).toBe(10.35, 2); + }); + + it('should add uniform jitter to x values with custom width', () => { + // Create a deterministic random source that returns exactly what we need + const mockRandom = () => 1; // This will produce exactly +0.1 in range [-0.35, 0.35] + + const data = [{ x: 5 }, { x: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterY({ data, y: 'x' }, { width: 0.5, source: mockRandom }); + // The result should add the jitter values to the original x values + const { y } = result; + // Check approximate values + expect(result.data[0][y]).toBe(5.5, 2); + expect(result.data[1][y]).toBe(10.5, 2); + }); + + it('should add normal jitter to x values', () => { + // We'll simplify this test by not trying to mock d3-random directly + // Instead, we'll provide a source function that controls the output values + const data = [{ x: 5 }, { x: 10 }]; + + // Custom source function that controls the exact jitter values + let values = [0.05, -0.1]; // The exact jitter values we want + let index = 0; + + const mockSource = randomLcg(42); + + // @ts-ignore - Bypassing type checking for tests + const result = jitterY( + { data, y: 'x' }, + { + type: 'normal', + std: 0.2, + // Use our custom function as the source + // This effectively hijacks the normal distribution calculation + source: mockSource + } + ); + + // The result should add the jitter values to the original x values + const { y } = result; + expect(result.data[0][y]).toBeCloseTo(4.9318, 3); + expect(result.data[1][y]).toBeCloseTo(9.9589, 3); + }); + + // // Note: Date jittering is not yet supported, test will be added when implemented + + it('should not modify data if y channel is not provided', () => { + const mockRandom = () => 0.5; + + const data = [{ x: 5 }, { x: 10 }]; + // @ts-ignore - Bypassing type checking for tests + const result = jitterY({ data, x: 'x' }, { source: mockRandom }); + + // The result should be the same as the input + expect(result.data).toEqual(data); + expect(result.x).toBe('x'); + }); +}); diff --git a/src/lib/transforms/jitter.ts b/src/lib/transforms/jitter.ts index e69de29b..3727d8bb 100644 --- a/src/lib/transforms/jitter.ts +++ b/src/lib/transforms/jitter.ts @@ -0,0 +1,92 @@ +import type { Channels, DataRecord, TransformArg } from '$lib/types.js'; +import { resolveChannel } from 'svelteplot/helpers/resolve'; +import { randomUniform, randomNormal } from 'd3-random'; +import { isDate } from 'svelteplot/helpers/typeChecks'; +import { durations, maybeTimeInterval, parseTimeInterval } from 'svelteplot/helpers/time'; + +const JITTER_X = Symbol('jitterX'); +const JITTER_Y = Symbol('jitterY'); + +type JitterOptions = { + type: 'uniform' | 'normal'; + /** width for uniform jittering */ + width: number; + /** standard deviation for normal jittering */ + std: number; + /** + * optional random number source that produces values in range [0,1) + * useful for testing with a deterministic source + */ + source?: () => number; +}; + +export function jitterX( + { data, ...channels }: TransformArg, + options: JitterOptions +): TransformArg { + return jitter('x', data, channels, options); +} + +export function jitterY( + { data, ...channels }: TransformArg, + options: JitterOptions +): TransformArg { + return jitter('y', data, channels, options); +} + +export function jitter( + channel: 'x' | 'y', + data: DataRecord[], + channels: Channels, + options: JitterOptions +): TransformArg { + if (channels[channel]) { + const type = options?.type ?? 'uniform'; + const width = parseNumber(options?.width ?? 0.35); + const std = parseNumber(options?.std ?? 0.15); + // @todo support time interval strings as width/std parameters + + // Use the provided source or default to Math.random + const rng = options?.source ?? Math.random; + const random = + type === 'uniform' + ? randomUniform.source(rng)(-width, width) + : randomNormal.source(rng)(0, std); + + const accKey = channel === 'x' ? JITTER_X : JITTER_Y; + return { + data: data.map((row) => { + const value = resolveChannel(channel, row, channels); + return { + ...row, + [accKey]: + typeof value === 'number' + ? value + random() + : isDate(value) + ? new Date(value.getTime() + random()) + : value + }; + }), + ...channels, + // point channel to new accessor symbol + [channel]: accKey + }; + } + return { + data, + ...channels + }; +} + +function parseNumber(value: number | string): number { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + try { + const [name, period] = parseTimeInterval(value); + return durations.get(name) * period; + } catch (err) { + return 0; + } + } + return 0; +} diff --git a/src/routes/features/transforms/+page.md b/src/routes/features/transforms/+page.md index 684408af..4075f5f6 100644 --- a/src/routes/features/transforms/+page.md +++ b/src/routes/features/transforms/+page.md @@ -90,3 +90,23 @@ In case you want to use SveltePlot transforms outside of a Svelte project you ca ```js import { binX } from 'svelteplot/transforms'; ``` + +## Available Transforms + +- [bin](/transforms/bin) - Groups data into discrete bins +- [bollinger](/transforms/bollinger) - Creates Bollinger bands for time series data +- [centroid](/transforms/centroid) - Calculates the geometric center of a set of points +- [facet](/transforms/facet) - Splits data into multiple subplots +- [filter](/transforms/filter) - Filters data based on a condition +- [group](/transforms/group) - Groups data by specified dimensions +- [interval](/transforms/interval) - Creates time or numeric intervals +- [jitter](/transforms/jitter) - Adds random noise to prevent overplotting +- [map](/transforms/map) - Applies a mapping function to data +- [normalize](/transforms/normalize) - Scales data to a common range +- [recordize](/transforms/recordize) - Converts raw data to record format +- [rename](/transforms/rename) - Renames channels in a dataset +- [select](/transforms/select) - Selects specific channels or data points +- [shift](/transforms/shift) - Shifts data values by a specified amount +- [sort](/transforms/sort) - Sorts data based on specified criteria +- [stack](/transforms/stack) - Stacks data series on top of each other +- [window](/transforms/window) - Creates a moving window over data diff --git a/src/routes/transforms/bolliner/+page.md b/src/routes/transforms/bolliner/+page.md new file mode 100644 index 00000000..4c73cae0 --- /dev/null +++ b/src/routes/transforms/bolliner/+page.md @@ -0,0 +1,5 @@ +--- +title: Bollinger transform +--- + +TODO: show how to use the Bollinger transform directly diff --git a/src/routes/transforms/interval/+page.md b/src/routes/transforms/interval/+page.md index fac7b033..9914781f 100644 --- a/src/routes/transforms/interval/+page.md +++ b/src/routes/transforms/interval/+page.md @@ -9,7 +9,6 @@ The interval transform is often used for time-series bar charts. For example, co ```svelte live + + +