From 19780531e566ff32827496dba11b702d0b6e8046 Mon Sep 17 00:00:00 2001 From: gka Date: Tue, 27 May 2025 19:56:43 +0200 Subject: [PATCH 1/4] fix: determine point/band scale domain from interval --- src/lib/helpers/autoTicks.ts | 16 ++++++++-------- src/lib/helpers/scales.ts | 19 ++++++++++++++++++- 2 files changed, 26 insertions(+), 9 deletions(-) diff --git a/src/lib/helpers/autoTicks.ts b/src/lib/helpers/autoTicks.ts index eff9de52..6f747204 100644 --- a/src/lib/helpers/autoTicks.ts +++ b/src/lib/helpers/autoTicks.ts @@ -1,6 +1,6 @@ import type { RawValue, ScaleType } from '$lib/types.js'; import { maybeTimeInterval } from './time.js'; -import { range as rangei } from 'd3-array'; +import { extent, range as rangei } from 'd3-array'; export function maybeInterval(interval: null | number | string | ((d: T) => T)) { if (interval == null) return; @@ -38,11 +38,11 @@ export function autoTicks( scaleFn, count: number ) { - return ticks - ? ticks - : interval - ? maybeInterval(interval, type).range(domain[0], domain[1]) - : typeof scaleFn.ticks === 'function' - ? scaleFn.ticks(count) - : []; + if (ticks) return ticks; + if (interval) { + const [lo, hi] = extent(domain); + const I = maybeInterval(interval, type); + return I.range(lo, I.offset(hi)); + } + return typeof scaleFn.ticks === 'function' ? scaleFn.ticks(count) : []; } diff --git a/src/lib/helpers/scales.ts b/src/lib/helpers/scales.ts index 2d5be986..f8cca117 100644 --- a/src/lib/helpers/scales.ts +++ b/src/lib/helpers/scales.ts @@ -29,6 +29,7 @@ import type { import isDataRecord from './isDataRecord.js'; import { createProjection } from './projection.js'; +import { maybeInterval } from './autoTicks.js'; /** * compute the plot scales @@ -302,7 +303,7 @@ export function createScale( const valueArray = type === 'quantile' || type === 'quantile-cont' ? allDataValues.toSorted() : valueArr; - const domain = scaleOptions.domain + let domain = scaleOptions.domain ? isOrdinal ? scaleOptions.domain : extent(scaleOptions.zero ? [0, ...scaleOptions.domain] : scaleOptions.domain) @@ -317,6 +318,16 @@ export function createScale( : valueArray : extent(scaleOptions.zero ? [0, ...valueArray] : valueArray); + if (scaleOptions.interval) { + if (isOrdinal) { + domain = domainFromInterval(domain, scaleOptions.interval); + } else { + throw new Error( + 'Setting interval via axis options is only supported for ordinal scales' + ); + } + } + if (!scaleOptions.scale) { throw new Error(`No scale function defined for ${name}`); } @@ -350,6 +361,12 @@ export function createScale( }; } +function domainFromInterval(domain: RawValue[], interval: string | number) { + const interval_ = maybeInterval(interval); + const [lo, hi] = extent(domain); + return interval_.range(lo, interval_.offset(hi)); +} + /** * Infer a scale type based on the scale name, the data values mapped to it and * the mark types that are bound to the scale From 9867cf0cfb6ef72768b167a89c613336aa766a22 Mon Sep 17 00:00:00 2001 From: gka Date: Tue, 27 May 2025 19:57:05 +0200 Subject: [PATCH 2/4] catch and log Plot-level errors --- src/lib/Plot.svelte | 150 ++++++++++++++++++++++++-------------------- 1 file changed, 81 insertions(+), 69 deletions(-) diff --git a/src/lib/Plot.svelte b/src/lib/Plot.svelte index 665c5412..53750c28 100644 --- a/src/lib/Plot.svelte +++ b/src/lib/Plot.svelte @@ -91,89 +91,101 @@ - - {#snippet children({ - hasProjection, - hasExplicitAxisX, - hasExplicitAxisY, - hasExplicitGridX, - hasExplicitGridY, - options, - scales, - ...restProps - })} - console.warn(err)}> - - {#if !hasProjection && !hasExplicitAxisX} - {#if options.axes && (options.x.axis === 'top' || options.x.axis === 'both')} - + + + {#snippet children({ + hasProjection, + hasExplicitAxisX, + hasExplicitAxisY, + hasExplicitGridX, + hasExplicitGridY, + options, + scales, + ...restProps + })} + console.warn(err)}> + + {#if !hasProjection && !hasExplicitAxisX} + {#if options.axes && (options.x.axis === 'top' || options.x.axis === 'both')} + + {/if} + {#if options.axes && (options.x.axis === 'bottom' || options.x.axis === 'both')} + + {/if} {/if} - {#if options.axes && (options.x.axis === 'bottom' || options.x.axis === 'both')} - + {#if !hasProjection && !hasExplicitAxisY} + {#if options.axes && (options.y.axis === 'left' || options.y.axis === 'both')} + + {/if} + {#if options.axes && (options.y.axis === 'right' || options.y.axis === 'both')} + + {/if} {/if} - {/if} - {#if !hasProjection && !hasExplicitAxisY} - {#if options.axes && (options.y.axis === 'left' || options.y.axis === 'both')} - + + {#if !hasExplicitGridX && (options.grid || options.x.grid)} + {/if} - {#if options.axes && (options.y.axis === 'right' || options.y.axis === 'both')} - + {#if !hasExplicitGridY && (options.grid || options.y.grid)} + {/if} - {/if} - - {#if !hasExplicitGridX && (options.grid || options.x.grid)} - - {/if} - {#if !hasExplicitGridY && (options.grid || options.y.grid)} - - {/if} - - {#if options.frame} - - {/if} - {@render parentChildren?.({ - options, - scales, - ...restProps - })} - {#snippet failed(error, reset)} - - {#each error.message.split('\n') as line, i (i)} - {line} - {/each} - {/snippet} - - {/snippet} - {#snippet facetAxes()} - + + {#if options.frame} + + {/if} + {@render parentChildren?.({ + options, + scales, + ...restProps + })} + {#snippet failed(error, reset)} + + {#each error.message.split('\n') as line, i (i)} + {line} + {/each} + {/snippet} + + {/snippet} + {#snippet facetAxes()} + + {/snippet} + + {#snippet failed(error)} +
Error: {error.message}
{/snippet} - + From e1112f8b20f3760cad068a739f2ea30ac96bfc54 Mon Sep 17 00:00:00 2001 From: gka Date: Tue, 27 May 2025 21:30:00 +0200 Subject: [PATCH 3/4] add bar examples --- src/lib/helpers/scales.ts | 15 +++-- src/routes/marks/bar/+page.md | 119 +++++++++++++--------------------- 2 files changed, 53 insertions(+), 81 deletions(-) diff --git a/src/lib/helpers/scales.ts b/src/lib/helpers/scales.ts index f8cca117..87d3a411 100644 --- a/src/lib/helpers/scales.ts +++ b/src/lib/helpers/scales.ts @@ -320,11 +320,13 @@ export function createScale( if (scaleOptions.interval) { if (isOrdinal) { - domain = domainFromInterval(domain, scaleOptions.interval); + domain = domainFromInterval(domain, scaleOptions.interval, name); } else { - throw new Error( - 'Setting interval via axis options is only supported for ordinal scales' - ); + if (markTypes.size > 0) { + console.warn( + 'Setting interval via axis options is only supported for ordinal scales' + ); + } } } @@ -361,10 +363,11 @@ export function createScale( }; } -function domainFromInterval(domain: RawValue[], interval: string | number) { +function domainFromInterval(domain: RawValue[], interval: string | number, name: ScaleName) { const interval_ = maybeInterval(interval); const [lo, hi] = extent(domain); - return interval_.range(lo, interval_.offset(hi)); + const out = interval_.range(lo, interval_.offset(hi)); + return name === 'y' ? out.toReversed() : out; } /** diff --git a/src/routes/marks/bar/+page.md b/src/routes/marks/bar/+page.md index 5b9f2333..b23c53db 100644 --- a/src/routes/marks/bar/+page.md +++ b/src/routes/marks/bar/+page.md @@ -7,33 +7,63 @@ title: Bar mark import StackedBarPlot from './StackedBarPlot.svelte'; -Bars are cool. They come in two flavors: [BarY](#BarY) for vertical bars (columns) and [BarX](#BarX) for horizontal bars. - -Here's a very simple bar chart: +Bars are useful to show quantitative data for different categories. They come in two flavors: [BarX](#BarX) for horizontal bars (y axis requires band scale) and [BarY](#BarY) for vertical bars aka. columns (x axis requires band scale). ```svelte live - - + + ``` ```svelte - - + + + + +``` + +[fork](https://svelte.dev/playground/7a0d38cf74be4a9985feb7bef0456008?version=5) + +SveltePlot automatically infers a band scale for the y axis in the above example. but since our data is missing a value for 2023, the value `"2023"` is entirely missing from the band scale domain. We could fix this by passing the domain value manually, or by using the `interval` option of the y axis: + +```svelte live + + + + + + +``` + +``` + + ``` -You can create stacked bar charts by defining a fill channel which will be used for grouping the series by the implicit [stack transform](/transforms/stack): +You can create stacked bar charts by defining a fill channel which will be used for grouping the series by the implicit [stack transform](/transforms/stack). In the following example we're first grouping the penguins dataset by island to then stack them by species: ```svelte live - - - v ** 2)} - fill="steelblue" /> - - -``` - -```svelte - - v ** 2)} - fill="steelblue" /> - - -``` - [fork](https://svelte.dev/playground/8b9fb6c1946d4579a3dc9da32f6c983c?version=5) -For stacked bar charts, provide a `fill` channel that will be used for grouping the series: - -```svelte - - - -``` - ## Insets You can create bullet bars using the `inset` option and two `BarX` layers: From c6d785cf6900f4e0e7a585e99cc6b499aaecfa65 Mon Sep 17 00:00:00 2001 From: gka Date: Tue, 27 May 2025 21:51:31 +0200 Subject: [PATCH 4/4] add tests --- src/routes/marks/bar/+page.md | 25 ++++++++++++++ src/tests/barX.test.ts | 61 +++++++++++++++++++++++++++++++++ src/tests/barY.test.ts | 63 +++++++++++++++++++++++++++++++++++ 3 files changed, 149 insertions(+) diff --git a/src/routes/marks/bar/+page.md b/src/routes/marks/bar/+page.md index b23c53db..ab85e244 100644 --- a/src/routes/marks/bar/+page.md +++ b/src/routes/marks/bar/+page.md @@ -151,6 +151,31 @@ The `BarY` component renders vertical bars (columns), typically used with a band Additionally, `BarY` supports all common styling properties like `fill`, `stroke`, `opacity`, etc. +```svelte live + + + + + + +``` + +```svelte + + + + +``` + [fork](https://svelte.dev/playground/8b9fb6c1946d4579a3dc9da32f6c983c?version=5) ## Insets diff --git a/src/tests/barX.test.ts b/src/tests/barX.test.ts index 5b186d83..19e74f70 100644 --- a/src/tests/barX.test.ts +++ b/src/tests/barX.test.ts @@ -84,6 +84,67 @@ describe('BarX mark', () => { // // check that bar length match data expect(barDims.map((d) => d.w)).toStrictEqual([1, 2, 3, 4, 5].map((m) => barDims[0].w * m)); }); + + const timeseries = [ + { year: 2019, value: 1 }, + { year: 2020, value: 2 }, + { year: 2021, value: 3 }, + { year: 2022, value: 4 }, + { year: 2024, value: 5 } + ]; + + it('skips missing years in band scale domain', () => { + const { container } = render(BarXTest, { + props: { + plotArgs: { + height: 200, + axes: true + }, + barArgs: { + data: timeseries, + x: 'value', + y: 'year' + } + } + }); + + const bars = container.querySelectorAll('g.bar-x > rect') as NodeListOf; + expect(bars.length).toBe(5); + + const yAxisLabels = container.querySelectorAll( + 'g.axis-y .tick text' + ) as NodeListOf; + expect(yAxisLabels.length).toBe(5); + const labels = Array.from(yAxisLabels).map((d) => d.textContent); + expect(labels.sort()).toStrictEqual(['2019', '2020', '2021', '2022', '2024']); + }); + + it('includes missing years in band scale domain if interval is set', () => { + const { container } = render(BarXTest, { + props: { + plotArgs: { + height: 200, + axes: true, + y: { interval: 1 } + }, + barArgs: { + data: timeseries, + x: 'value', + y: 'year' + } + } + }); + + const bars = container.querySelectorAll('g.bar-x > rect') as NodeListOf; + expect(bars.length).toBe(5); + + const yAxisLabels = container.querySelectorAll( + 'g.axis-y .tick text' + ) as NodeListOf; + expect(yAxisLabels.length).toBe(6); + const labels = Array.from(yAxisLabels).map((d) => d.textContent); + expect(labels.sort()).toEqual(['2019', '2020', '2021', '2022', '2023', '2024']); + }); }); function getRectDims(rect: SVGRectElement) { diff --git a/src/tests/barY.test.ts b/src/tests/barY.test.ts index 118b2725..9a10b9fc 100644 --- a/src/tests/barY.test.ts +++ b/src/tests/barY.test.ts @@ -110,6 +110,69 @@ describe('BarY mark', () => { expect(barDims[3].h).toBe(barDims[0].h * 4); expect(barDims[4].h).toBe(barDims[0].h * 5); }); + + const timeseries = [ + { year: 2019, value: 1 }, + { year: 2020, value: 2 }, + { year: 2021, value: 3 }, + { year: 2022, value: 4 }, + { year: 2024, value: 5 } + ]; + + it('skips missing years in band scale domain', () => { + const { container } = render(BarYTest, { + props: { + plotArgs: { + width: 400, + height: 400, + axes: true + }, + barArgs: { + data: timeseries, + y: 'value', + x: 'year' + } + } + }); + + const bars = container.querySelectorAll('g.bar-y > rect') as NodeListOf; + expect(bars.length).toBe(5); + + const xAxisLabels = container.querySelectorAll( + 'g.axis-x .tick text' + ) as NodeListOf; + expect(xAxisLabels.length).toBe(5); + const labels = Array.from(xAxisLabels).map((d) => d.textContent); + expect(labels.sort()).toStrictEqual(['2019', '2020', '2021', '2022', '2024']); + }); + + it('includes missing years in band scale domain if interval is set', () => { + const { container } = render(BarYTest, { + props: { + plotArgs: { + width: 500, + height: 400, + axes: true, + x: { interval: 1 } + }, + barArgs: { + data: timeseries, + y: 'value', + x: 'year' + } + } + }); + + const bars = container.querySelectorAll('g.bar-y > rect') as NodeListOf; + expect(bars.length).toBe(5); + + const xAxisLabels = container.querySelectorAll( + 'g.axis-x .tick text' + ) as NodeListOf; + expect(xAxisLabels.length).toBe(6); + const labels = Array.from(xAxisLabels).map((d) => d.textContent); + expect(labels.sort()).toEqual(['2019', '2020', '2021', '2022', '2023', '2024']); + }); }); function getRectDims(rect: SVGRectElement) {