diff --git a/.changeset/axis-domain.md b/.changeset/axis-domain.md new file mode 100644 index 000000000..7ca616398 --- /dev/null +++ b/.changeset/axis-domain.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Axis): Add `domain` to label the chart's scale in other units, such as a secondary axis diff --git a/.changeset/axis-interval-last-tick.md b/.changeset/axis-interval-last-tick.md new file mode 100644 index 000000000..fd44accf4 --- /dev/null +++ b/.changeset/axis-interval-last-tick.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Axis): Only drop the last tick of an `xInterval` / `yInterval` axis when it falls on the domain's end diff --git a/.changeset/axis-utc-auto-format.md b/.changeset/axis-utc-auto-format.md new file mode 100644 index 000000000..433065140 --- /dev/null +++ b/.changeset/axis-utc-auto-format.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(Axis): Label `scaleUtc()` ticks in UTC when no `format` is given diff --git a/.changeset/grouped-series-value-sub-band.md b/.changeset/grouped-series-value-sub-band.md new file mode 100644 index 000000000..9b281b8c8 --- /dev/null +++ b/.changeset/grouped-series-value-sub-band.md @@ -0,0 +1,5 @@ +--- +'layerchart': patch +--- + +fix(BarChart): Place a grouped series in its own sub-band when its `value` is an accessor or a field other than its `key` diff --git a/.changeset/tooltip-item-series-key.md b/.changeset/tooltip-item-series-key.md new file mode 100644 index 000000000..389252333 --- /dev/null +++ b/.changeset/tooltip-item-series-key.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(TooltipItem): Add `seriesKey` to simplify setting the label, color, and value from a series diff --git a/docs/src/content/components/Axis.md b/docs/src/content/components/Axis.md index eb582aca7..9282932fa 100644 --- a/docs/src/content/components/Axis.md +++ b/docs/src/content/components/Axis.md @@ -137,6 +137,16 @@ Since band padding is not available when not using a band scale, you can leverag :example{ name="barchart-xinterval-xinset" } +### secondary axes + +`domain` ticks a different domain across the chart's own scale, keeping its range and type, so a second axis can label the same space in other units. See [multiple scales](/docs/guides/multiple-scales) for plotting a second measure against it. + +```svelte + +``` + +:example{ name="multiple-axis-same-placement-right" } + + + + {/snippet} + + {/snippet} + diff --git a/docs/src/examples/components/BarChart/series-horizontal-diverging-as-percent.svelte b/docs/src/examples/components/BarChart/series-horizontal-diverging-as-percent.svelte index abc550c43..a2bf9a65c 100644 --- a/docs/src/examples/components/BarChart/series-horizontal-diverging-as-percent.svelte +++ b/docs/src/examples/components/BarChart/series-horizontal-diverging-as-percent.svelte @@ -61,7 +61,7 @@ {#each context.series.series as s} {@const valueAccessor = accessor(s.value ?? s.key)} {@const value = Math.abs(valueAccessor(data))} - + {format(value, 'percent')} {/each} diff --git a/docs/src/examples/components/BarChart/series-horizontal-diverging.svelte b/docs/src/examples/components/BarChart/series-horizontal-diverging.svelte index af3e3c2b2..dc55c83c1 100644 --- a/docs/src/examples/components/BarChart/series-horizontal-diverging.svelte +++ b/docs/src/examples/components/BarChart/series-horizontal-diverging.svelte @@ -60,7 +60,7 @@ {#each context.series.visibleSeries as s} {@const valueAccessor = accessor(s.value ?? s.key)} {@const value = Math.abs(valueAccessor(data))} - + {format(value)} ({format(value / totalPopulation, 'percent')}) import { Area, BarChart, Tooltip } from 'layerchart'; import { createDateSeries } from '$lib/utils/data.js'; + import { timeDay, timeHour } from 'd3-time'; const data = createDateSeries({ count: 30, @@ -20,13 +21,19 @@ {data} x="date" y={['baseline', 'value']} + xInterval={timeDay} props={{ bars: { y: 'baseline' } }} height={300} > {#snippet aboveMarks()} - + timeHour.offset(d.date, 12)} + y1="value" + class="fill-secondary/20" + line={{ class: 'stroke-secondary' }} + /> {/snippet} {#snippet tooltip()} diff --git a/docs/src/examples/components/Chart/compound-dual-axis-with-single-chart-using-remapped-scale.svelte b/docs/src/examples/components/Chart/compound-dual-axis-with-single-chart-using-remapped-scale.svelte index 60fd426e7..f12f7ce37 100644 --- a/docs/src/examples/components/Chart/compound-dual-axis-with-single-chart-using-remapped-scale.svelte +++ b/docs/src/examples/components/Chart/compound-dual-axis-with-single-chart-using-remapped-scale.svelte @@ -5,7 +5,6 @@ @@ -35,7 +34,7 @@ /> -
- - - - - - - - - + + +
+ + + + + + + + + - - - - - - - - + + + + + + + - - {#snippet children({ data })} - {data.year} - - - - - {/snippet} - - -
+ + {#snippet children({ data })} + {data.year} + + + + + {/snippet} + + +
+ diff --git a/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-and-overridden-marks.svelte b/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-and-overridden-marks.svelte index a35e877b0..82c803fab 100644 --- a/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-and-overridden-marks.svelte +++ b/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-and-overridden-marks.svelte @@ -4,59 +4,71 @@ -
- - + + +
+ + - - - {#snippet marks()} - - - {/snippet} + + + {#snippet marks()} + + + {/snippet} - {#snippet tooltip({ context })} - - {#snippet children({ data })} - - - - - - - - - {/snippet} - - {/snippet} - -
+ {#snippet tooltip({ context })} + + {#snippet children({ data })} + + + + + + + + + {/snippet} + + {/snippet} +
+
+ diff --git a/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-with-inverted-range-top-down.svelte b/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-with-inverted-range-top-down.svelte index f3c5ef135..b8e0244c3 100644 --- a/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-with-inverted-range-top-down.svelte +++ b/docs/src/examples/components/Chart/compound-separate-scales-with-stacked-charts-with-inverted-range-top-down.svelte @@ -4,101 +4,103 @@ -
- - [0, height]} - padding={{ left: 32, right: 32, bottom: 20 }} - props={{ - bars: { - rounded: 'none', - class: '_stroke-none fill-blue-500' - } - }} - height={300} - /> - - Math.abs(d.infiltration), - value: (d) => (d.infiltration > 0 ? d.infiltration : 0), - color: 'hsl(25, 95%, 53%)', - props: { - rounded: 'none' - } - }, - { - key: 'dirtyh2o', - color: 'hsl(0, 84%, 60%)', - props: { - rounded: 'none' + + +
+ + [0, height]} + padding={{ left: 32, right: 32, bottom: 20 }} + props={{ + bars: { + rounded: 'none', + class: '_stroke-none fill-blue-500' } - }, - { - key: 'rain_induced', - color: 'hsl(142, 71%, 45%)', - props: { - rounded: 'none' + }} + height={300} + /> + + Math.abs(d.infiltration), + value: (d) => (d.infiltration > 0 ? d.infiltration : 0), + color: 'hsl(25, 95%, 53%)', + props: { + rounded: 'none' + } + }, + { + key: 'dirtyh2o', + color: 'hsl(0, 84%, 60%)', + props: { + rounded: 'none' + } + }, + { + key: 'rain_induced', + color: 'hsl(142, 71%, 45%)', + props: { + rounded: 'none' + } } - } - ]} - padding={defaultChartPadding({ top: 20, bottom: 30, right: 32, left: 32 })} - height={300} - > - {#snippet axis({ context })} - - - d.date), - [0, context.width] - )} - tickMultiline - rule - /> - {/snippet} + ]} + padding={defaultChartPadding({ top: 20, bottom: 30, right: 32, left: 32 })} + height={300} + > + {#snippet axis({ context })} + + + d.date), + [0, context.width] + )} + tickMultiline + rule + /> + {/snippet} - {#snippet tooltip({ context })} - - {#snippet children({ data })} - - - - - - - - {/snippet} - - {/snippet} - -
+ {#snippet tooltip({ context })} + + {#snippet children({ data })} + + + + + + + + {/snippet} + + {/snippet} +
+
+ diff --git a/docs/src/examples/components/Chart/mixed-marks-with-stack.svelte b/docs/src/examples/components/Chart/mixed-marks-with-stack.svelte index 9f659f8b7..fba2027a8 100644 --- a/docs/src/examples/components/Chart/mixed-marks-with-stack.svelte +++ b/docs/src/examples/components/Chart/mixed-marks-with-stack.svelte @@ -59,13 +59,7 @@ {data.month} {#each context.series.visibleSeries as s (s.key)} - + {/each} diff --git a/docs/src/examples/components/Waffle/custom-symbol.svelte b/docs/src/examples/components/Waffle/custom-symbol.svelte index 83cbb9721..9f0f94b52 100644 --- a/docs/src/examples/components/Waffle/custom-symbol.svelte +++ b/docs/src/examples/components/Waffle/custom-symbol.svelte @@ -65,13 +65,7 @@ {data.island} {#each context.series.visibleSeries as s (s.key)} - + {/each} {data.weight}–{data.weight + 9} kg {#each context.series.visibleSeries as s (s.key)} - + {/each} {data.island} {#each context.series.visibleSeries as s (s.key)} - + {/each} {data.period} {#each context.series.visibleSeries as s (s.key)} - + {/each} { + const r = el.getBoundingClientRect(); + return [el.textContent?.trim() ?? '', r.y + r.height / 2]; + }) + ); +} + +describe('Axis domain', () => { + it('should tick the given domain across the chart scale’s range', async () => { + render(TestHarness, { + chartProps, + component: Axis, + componentProps: { placement: 'right', domain: [0, 20] }, + }); + + await expect.poll(() => Object.keys(labelYs())).toContain('20'); + const ys = labelYs(); + expect(Object.keys(ys)).not.toContain('100'); + // Same span as the chart's own `[0, 100]`: bottom to top + expect(ys['0']).toBeGreaterThan(ys['20']); + expect(ys['0'] - ys['20']).toBeCloseTo(200, -1); + }); + + it('should follow an inverted chart range', async () => { + render(TestHarness, { + chartProps: { ...chartProps, yRange: ({ height }: { height: number }) => [0, height] }, + component: Axis, + componentProps: { placement: 'right', domain: [0, 20] }, + }); + + await expect.poll(() => Object.keys(labelYs())).toContain('20'); + const ys = labelYs(); + expect(Object.keys(ys)).not.toContain('100'); + expect(ys['0']).toBeLessThan(ys['20']); + }); +}); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.interval.svelte.test.ts b/packages/layerchart/src/lib/components/Axis/Axis.interval.svelte.test.ts new file mode 100644 index 000000000..36edde080 --- /dev/null +++ b/packages/layerchart/src/lib/components/Axis/Axis.interval.svelte.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { scaleUtc } from 'd3-scale'; +import { utcDay, utcMonth } from 'd3-time'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Axis from './Axis.svelte'; + +function labels() { + return Array.from(document.querySelectorAll('.lc-axis-tick-label')).map( + (el) => el.textContent?.trim() ?? '' + ); +} + +/** A row per UTC day from `start`, `count` days long */ +function days(start: string, count: number) { + const first = new Date(start); + return Array.from({ length: count }, (_, i) => ({ date: utcDay.offset(first, i), value: i })); +} + +describe('Axis with an interval', () => { + it('should keep a coarser last tick that is inside the domain', async () => { + // Jan 1 – Mar 10, ticked by month: Mar 1 still has bars after it + render(TestHarness, { + chartProps: { + data: days('2024-01-01T00:00:00Z', 70), + x: 'date', + xScale: scaleUtc(), + xInterval: utcDay, + y: 'value', + width: 600, + height: 100, + }, + component: Axis, + componentProps: { + placement: 'bottom', + ticks: { interval: utcMonth }, + format: { type: 'month', options: { variant: 'short' } }, + }, + }); + + await expect.poll(() => labels()).toContain('Feb'); + expect(labels()).toEqual(['Jan', 'Feb', 'Mar']); + }); + + it('should drop a tick on the domain end, past the last bar', async () => { + render(TestHarness, { + chartProps: { + data: days('2024-01-01T00:00:00Z', 5), + x: 'date', + xScale: scaleUtc(), + xInterval: utcDay, + y: 'value', + width: 600, + height: 100, + }, + component: Axis, + componentProps: { placement: 'bottom', ticks: { interval: utcDay } }, + }); + + // One tick per bar — not a sixth for the day after the last + await expect.poll(() => labels().length).toBe(5); + }); +}); diff --git a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts index 1b0b3b935..c3509611f 100644 --- a/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Axis/Axis.shared.svelte.ts @@ -157,6 +157,12 @@ export type AxisPropsWithoutHTML = { */ scale?: any; + /** + * Domain for the axis, on a copy of `scale` (or the chart's scale for this placement) that keeps + * its range and type. Useful for a secondary axis, e.g. `domain={[0, 100]}` on the right. + */ + domain?: any[]; + /** * Stroke color for axis rule, grid lines, and tick marks. * Useful for server-side rendering where CSS variables are not available. @@ -297,10 +303,15 @@ export class AxisState { }); scale = $derived.by(() => { - const scaleProp = this.#props.scale; - if (scaleProp) return scaleProp; - if (this.orientation === 'back') return this.ctx.zScale; - return ['horizontal', 'angle'].includes(this.orientation) ? this.ctx.xScale : this.ctx.yScale; + const scale = + this.#props.scale ?? + (this.orientation === 'back' + ? this.ctx.zScale + : ['horizontal', 'angle'].includes(this.orientation) + ? this.ctx.xScale + : this.ctx.yScale); + const domain = this.#props.domain; + return domain && scale ? scale.copy().domain(domain) : scale; }); interval = $derived( @@ -393,8 +404,12 @@ export class AxisState { let tickVals = autoTickVals(this.scale, ticks, this.tickCount); if (this.interval != null) { - // Remove last tick when interval is provided (such as for bar charts with center aligned (offset) ticks) - tickVals.pop(); + // Ticks are centered in their interval (such as for bar charts), so one on the domain's end + // would label a bar past the last. A coarser tick inside the domain — a month on a daily + // interval — still labels a bar, so keep it. + const last = tickVals[tickVals.length - 1]; + const domainEnd = this.scale.domain()[1]; + if (last != null && +last >= +domainEnd) tickVals.pop(); } // Filter to the boundary implied by the format, matching the scale's own (local vs UTC) diff --git a/packages/layerchart/src/lib/components/charts/BarChart.svelte.test.ts b/packages/layerchart/src/lib/components/charts/BarChart.svelte.test.ts index eed5c41a5..027d6689a 100644 --- a/packages/layerchart/src/lib/components/charts/BarChart.svelte.test.ts +++ b/packages/layerchart/src/lib/components/charts/BarChart.svelte.test.ts @@ -381,6 +381,53 @@ describe('BarChart', () => { expect(barsGroups.length).toBe(4); }); + it('should give a grouped series with a `value` accessor its own sub-band', async () => { + const { container } = render(BarChart, { + data: wideData, + x: 'year', + series: [{ key: 'apples' }, { key: 'scaledBananas', value: (d: any) => d.bananas * 2 }], + seriesLayout: 'group', + height: 300, + width: 400, + }); + + // The first bar of each series, left to right + const firstBarX = () => + Array.from(container.querySelectorAll('.lc-bars')).map((g) => { + const bar = g.querySelector('.lc-bar') as SVGGraphicsElement | null; + return bar ? bar.getBBox().x : NaN; + }); + + await vi.waitFor(() => { + const [apples, scaledBananas] = firstBarX(); + expect(Number.isFinite(apples)).toBe(true); + expect(scaledBananas).toBeGreaterThan(apples); + }); + }); + + it('should give a grouped series with a string `value` its own sub-band', async () => { + const { container } = render(BarChart, { + data: wideData, + x: 'year', + series: [{ key: 'apples' }, { key: 'yellowFruit', value: 'bananas' }], + seriesLayout: 'group', + height: 300, + width: 400, + }); + + const firstBarX = () => + Array.from(container.querySelectorAll('.lc-bars')).map((g) => { + const bar = g.querySelector('.lc-bar') as SVGGraphicsElement | null; + return bar ? bar.getBBox().x : NaN; + }); + + await vi.waitFor(() => { + const [apples, yellowFruit] = firstBarX(); + expect(Number.isFinite(apples)).toBe(true); + expect(yellowFruit).toBeGreaterThan(apples); + }); + }); + it('should render stackExpand series', async () => { const { container } = render(BarChart, { data: wideData, diff --git a/packages/layerchart/src/lib/components/charts/BarChart/BarChart.base.svelte b/packages/layerchart/src/lib/components/charts/BarChart/BarChart.base.svelte index 06b2458a8..c3f637a3b 100644 --- a/packages/layerchart/src/lib/components/charts/BarChart/BarChart.base.svelte +++ b/packages/layerchart/src/lib/components/charts/BarChart/BarChart.base.svelte @@ -124,10 +124,10 @@ s.value ?? s.key + ? (d: any) => s.key : undefined} y1={valueAxis === 'x' && isGroupSeries && restProps.y1 == null - ? (d: any) => s.value ?? s.key + ? (d: any) => s.key : undefined} rounded={context.series.stackLayout != null ? // Per row rather than per series: a sub-band or a gap in the data can leave the diff --git a/packages/layerchart/src/lib/components/charts/DefaultTooltip.svelte b/packages/layerchart/src/lib/components/charts/DefaultTooltip.svelte index 6da13d94c..50fb356c4 100644 --- a/packages/layerchart/src/lib/components/charts/DefaultTooltip.svelte +++ b/packages/layerchart/src/lib/components/charts/DefaultTooltip.svelte @@ -272,16 +272,12 @@ {#each seriesFor(data) as s, i (s.key ?? i)} (context.series.highlightKey = s.seriesKey)} - onpointerleave={() => (context.series.highlightKey = null)} {...tooltipProps?.item} /> {/each} diff --git a/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-1.png b/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-1.png new file mode 100644 index 000000000..5e6e0f079 Binary files /dev/null and b/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-1.png differ diff --git a/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-2.png b/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-2.png new file mode 100644 index 000000000..5e6e0f079 Binary files /dev/null and b/packages/layerchart/src/lib/components/charts/__screenshots__/BarChart.svelte.test.ts/BarChart-series-should-give-a-grouped-series-with-a--value--accessor-its-own-sub-band-2.png differ diff --git a/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte b/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte index b3d99d2fd..e472f01ff 100644 --- a/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte +++ b/packages/layerchart/src/lib/components/tooltip/TooltipItem.svelte @@ -3,6 +3,13 @@ import { asAny, type Without } from '$lib/utils/types.js'; export type TooltipItemPropsWithoutHTML = { + /** + * Series this item shows. Its `label`, `value` (for the row being shown), and `color` are + * used unless passed explicitly, hovering the item highlights the series, and the item isn't + * rendered while the series is hidden. + */ + seriesKey?: string; + /** * The label to display in the tooltip item. */ @@ -78,17 +85,19 @@ import { format as formatUtil, type FormatType, type FormatConfig } from '@layerstack/utils'; import { cls } from '@layerstack/tailwind'; import type { Snippet } from 'svelte'; + import { getChartContext } from '$lib/contexts/chart.js'; let { ref: refProp = $bindable(), labelRef: labelRefProp = $bindable(), valueRef: valueRefProp = $bindable(), colorRef: colorRefProp = $bindable(), - label, - value, + seriesKey, + label: labelProp, + value: valueProp, format, valueAlign = 'left', - color, + color: colorProp, classes = { root: '', label: '', @@ -103,9 +112,32 @@ }, class: className, children, + onpointerenter, + onpointerleave, ...restProps }: TooltipItemProps = $props(); + const ctx = getChartContext(); + + /** The series as resolved for the row being shown */ + const tooltipSeries = $derived( + seriesKey == null ? undefined : ctx.tooltip.series.find((s) => s.key === seriesKey) + ); + + const visible = $derived.by(() => { + if (seriesKey == null) return true; + // Read live rather than from `tooltipSeries`, which only updates when the pointer moves. A key + // that names no series is a `c` category, which the legend selects directly. + if (ctx.series.series.some((s) => s.key === seriesKey)) { + return ctx.series.isVisible(seriesKey); + } + return ctx.series.selectedKeys.isEmpty() || ctx.series.selectedKeys.isSelected(seriesKey); + }); + + const label = $derived(labelProp ?? tooltipSeries?.label ?? seriesKey); + const value = $derived(valueProp !== undefined ? valueProp : tooltipSeries?.value); + const color = $derived(colorProp ?? tooltipSeries?.color); + let ref = $state(); let labelRef = $state(); let valueRef = $state(); @@ -126,46 +158,57 @@ }); -
+{#if visible}
{ + if (seriesKey != null) ctx.series.highlightKey = seriesKey; + onpointerenter?.(e); + }} + onpointerleave={(e) => { + if (seriesKey != null) ctx.series.highlightKey = null; + onpointerleave?.(e); + }} + bind:this={ref} > - {#if color} -
- {/if} - {#if typeof label === 'function'} - {@render label()} - {:else} - {label} - {/if} -
+
+ {#if color} +
+ {/if} + {#if typeof label === 'function'} + {@render label()} + {:else} + {label} + {/if} +
-
- {#if children} - {@render children()} - {:else} - - {format ? formatUtil(value, asAny(format)) : value} - {/if} +
+ {#if children} + {@render children()} + {:else} + + {format ? formatUtil(value, asAny(format)) : value} + {/if} +
-
+{/if}