From 8aa98c7b10371f965c752e76244808ffbbf7697b Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 9 Sep 2026 10:47:19 -0400 Subject: [PATCH 1/2] feat(Group): Support `seriesKey` for series highlighting and visibility --- .changeset/quick-melons-clap.md | 5 ++ .gitignore | 3 +- docs/src/content/components/Group.md | 8 +- .../components/Group/series-state.svelte | 39 +++++++++ .../lib/components/Group/Group.canvas.svelte | 10 ++- .../lib/components/Group/Group.html.svelte | 12 ++- .../components/Group/Group.shared.svelte.ts | 50 +++++++++++ .../lib/components/Group/Group.svelte.test.ts | 86 +++++++++++++++++++ .../src/lib/components/Group/Group.svg.svelte | 12 ++- 9 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 .changeset/quick-melons-clap.md create mode 100644 docs/src/examples/components/Group/series-state.svelte diff --git a/.changeset/quick-melons-clap.md b/.changeset/quick-melons-clap.md new file mode 100644 index 000000000..843202624 --- /dev/null +++ b/.changeset/quick-melons-clap.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Group): Support `seriesKey` prop to fade the group while another series is highlighted and remove it while the legend has that series hidden diff --git a/.gitignore b/.gitignore index d963778ea..e66fd2bb2 100644 --- a/.gitignore +++ b/.gitignore @@ -25,4 +25,5 @@ bench-results/ # Generated StackBlitz files **/static/stackblitz-files.json -.playwright-mcp \ No newline at end of file +.playwright-mcp +.openchamber \ No newline at end of file diff --git a/docs/src/content/components/Group.md b/docs/src/content/components/Group.md index b70a628b3..eccbfd887 100644 --- a/docs/src/content/components/Group.md +++ b/docs/src/content/components/Group.md @@ -9,7 +9,7 @@ related: [] ### Pixel mode -Pass numeric pixel values for `x` and `y` to translate the group to an exact position. Use `center` to center within the chart. +Pass numeric pixel values for `x` and `y` to translate the group to an exact position, or `center` to center within the chart. :example{ name="basic" showCode } @@ -18,3 +18,9 @@ Pass numeric pixel values for `x` and `y` to translate the group to an exact pos Pass string property names or accessor functions to `x` and `y` to position groups from data. The component renders one group per data item, useful for placing compound elements (e.g. circle + label) at each data point. :example{ name="data-mode" showCode } + +### Series state + +Pass `seriesKey` to follow a [series](/docs/guides/series). The group fades while another series is highlighted, and is removed while the legend has that series hidden — so hand-composed content stays in step with the marks it belongs to. + +:example{ name="series-state" } diff --git a/docs/src/examples/components/Group/series-state.svelte b/docs/src/examples/components/Group/series-state.svelte new file mode 100644 index 000000000..725f78709 --- /dev/null +++ b/docs/src/examples/components/Group/series-state.svelte @@ -0,0 +1,39 @@ + + + + {#snippet aboveMarks()} + {#each series as s (s.key)} + + + + + {/each} + {/snippet} + diff --git a/packages/layerchart/src/lib/components/Group/Group.canvas.svelte b/packages/layerchart/src/lib/components/Group/Group.canvas.svelte index 3b8e4a9ae..e67f87b21 100644 --- a/packages/layerchart/src/lib/components/Group/Group.canvas.svelte +++ b/packages/layerchart/src/lib/components/Group/Group.canvas.svelte @@ -15,8 +15,8 @@ canvasRender: { render: (ctx) => { ctx.translate(c.motionX ?? 0, c.motionY ?? 0); - if (rest.opacity != null) { - ctx.globalAlpha *= rest.opacity; + if (c.opacity != null) { + ctx.globalAlpha *= c.opacity; } }, events: { @@ -27,9 +27,11 @@ pointerleave: (rest as any).onpointerleave, pointerdown: (rest as any).onpointerdown, }, - deps: () => [c.motionX, c.motionY, rest.opacity], + deps: () => [c.motionX, c.motionY, c.opacity], }, }); -{@render children?.()} +{#if !c.hidden} + {@render children?.()} +{/if} diff --git a/packages/layerchart/src/lib/components/Group/Group.html.svelte b/packages/layerchart/src/lib/components/Group/Group.html.svelte index befdbd099..310970684 100644 --- a/packages/layerchart/src/lib/components/Group/Group.html.svelte +++ b/packages/layerchart/src/lib/components/Group/Group.html.svelte @@ -25,6 +25,8 @@ key, center, motion, + seriesKey, + opacity, ...rest }: GroupProps = $props(); @@ -44,6 +46,8 @@ key, center, motion, + seriesKey, + opacity, ...rest, }) as GroupProps ); @@ -65,11 +69,13 @@ }; -{#if c.dataMode} +{#if c.hidden} + +{:else if c.dataMode} {#each c.resolvedItems as item (item.key)}
= { */ key?: (d: any, index: number) => any; + /** + * Series key to follow, fading the group while another series is highlighted and removing it + * while the series is hidden. Only applicable if `` uses `series`. + */ + seriesKey?: string; + /** Center within chart. @default false */ center?: boolean | 'x' | 'y'; @@ -162,6 +168,50 @@ export class GroupState { return 0; }); + /** + * The series this group follows, or `undefined` when `seriesKey` names none. + * + * Guarded on the prop rather than looking the key up unconditionally: every layout in the + * library is built out of `Group`, so an unset `seriesKey` must not subscribe them all to the + * series state. + */ + series = $derived.by(() => { + const seriesKey = this.#props.seriesKey; + if (seriesKey == null) return undefined; + return this.chartCtx.series.series.find((s) => s.key === seriesKey); + }); + + /** + * Whether the legend has this group's series hidden — the marks of a hidden series aren't + * drawn, so whatever this group holds for it shouldn't be either. + * + * A `seriesKey` naming no series leaves the group alone: nothing on the chart claims it, so + * there's no state to follow. + */ + hidden = $derived(this.series != null && !this.chartCtx.series.isVisible(this.series.key)); + + /** Faded while another series is highlighted, matching what the marks of this series do */ + seriesOpacity = $derived.by(() => { + if ( + this.series?.key == null || + this.chartCtx.series.visibleSeries.length <= 1 || + this.chartCtx.series.isHighlighted(this.series.key, true) + ) { + return 1; + } + return 0.1; + }); + + /** + * The `opacity` to render with, dimmed by the series' state. Stays `undefined` when neither + * the prop nor the series asks for one, so the attribute stays off the element. + */ + opacity = $derived.by(() => { + const opacity = this.#props.opacity; + if (this.seriesOpacity === 1) return opacity; + return (opacity ?? 1) * this.seriesOpacity; + }); + #dataMotionMap: ReturnType = null; #motionX!: ReturnType>; #motionY!: ReturnType>; diff --git a/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts index 29d453114..58482a967 100644 --- a/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts @@ -69,4 +69,90 @@ describe('Group', () => { await expect.poll(() => groups.length).toBe(1); }); }); + + describe('seriesKey', () => { + const seriesChartProps = { + data: [{ date: new Date('2024-01-01'), apples: 20, bananas: 10 }], + x: 'date', + series: [ + { key: 'apples', value: 'apples' }, + { key: 'bananas', value: 'bananas' }, + ], + }; + + function groups() { + return page.getByTestId(componentTestId).elements(); + } + + it('fades while another series is highlighted', async () => { + let ctx: any; + render(TestHarness, { + component: Group, + chartProps: seriesChartProps, + componentProps: { seriesKey: 'apples' }, + oncontext: (c: any) => (ctx = c), + } as any); + + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe(null); + + ctx.series.setHighlight('bananas'); + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe('0.1'); + + ctx.series.setHighlight('apples'); + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe(null); + + ctx.series.setHighlight(null); + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe(null); + }); + + it('dims the `opacity` prop rather than replacing it', async () => { + let ctx: any; + render(TestHarness, { + component: Group, + chartProps: seriesChartProps, + componentProps: { seriesKey: 'apples', opacity: 0.5 }, + oncontext: (c: any) => (ctx = c), + } as any); + + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe('0.5'); + + ctx.series.setHighlight('bananas'); + await expect.poll(() => groups()[0]?.getAttribute('opacity')).toBe('0.05'); + }); + + it('is removed while the legend has its series hidden', async () => { + let ctx: any; + render(TestHarness, { + component: Group, + chartProps: seriesChartProps, + componentProps: { seriesKey: 'apples' }, + oncontext: (c: any) => (ctx = c), + } as any); + + await expect.poll(() => groups().length).toBe(1); + + ctx.series.selectedKeys.toggle('bananas'); + await expect.poll(() => groups().length).toBe(0); + + ctx.series.selectedKeys.toggle('bananas'); + await expect.poll(() => groups().length).toBe(1); + }); + + it('leaves a group alone when its key names no series', async () => { + let ctx: any; + render(TestHarness, { + component: Group, + chartProps: seriesChartProps, + componentProps: { seriesKey: 'unrelated' }, + oncontext: (c: any) => (ctx = c), + } as any); + + ctx.series.setHighlight('bananas'); + await expect.poll(() => groups().length).toBe(1); + expect(groups()[0]?.getAttribute('opacity')).toBe(null); + + ctx.series.selectedKeys.toggle('bananas'); + await expect.poll(() => groups().length).toBe(1); + }); + }); }); diff --git a/packages/layerchart/src/lib/components/Group/Group.svg.svelte b/packages/layerchart/src/lib/components/Group/Group.svg.svelte index 87b28e7b0..998b85d20 100644 --- a/packages/layerchart/src/lib/components/Group/Group.svg.svelte +++ b/packages/layerchart/src/lib/components/Group/Group.svg.svelte @@ -26,6 +26,8 @@ key, center, motion, + seriesKey, + opacity, ...rest }: GroupProps = $props(); @@ -45,6 +47,8 @@ key, center, motion, + seriesKey, + opacity, ...rest, }) as GroupProps ); @@ -66,13 +70,15 @@ }; -{#if c.dataMode} +{#if c.hidden} + +{:else if c.dataMode} {#each c.resolvedItems as item (item.key)} {@render children?.()} @@ -83,8 +89,8 @@ style:transform={c.transform} class={['lc-group-g', className]} in:transitionIn={transitionInParams} - opacity={rest.opacity} {...rest} + opacity={c.opacity} ontouchmove={handleTouchMove} bind:this={ref} > From dad1198ad5a7ff83f92bd7fe8c59453a978f7d46 Mon Sep 17 00:00:00 2001 From: Sean Lynch Date: Wed, 9 Sep 2026 12:31:33 -0400 Subject: [PATCH 2/2] feat(Hull|Rule): Support `seriesKey` for series highlighting and visibility --- .changeset/chilled-pears-tap.md | 5 ++ .changeset/quick-melons-clap.md | 5 -- .../lib/components/Hull/Hull.shared.svelte.ts | 5 ++ .../src/lib/components/Rule/Rule.base.svelte | 3 +- .../lib/components/Rule/Rule.shared.svelte.ts | 6 ++ .../lib/components/Rule/Rule.svelte.test.ts | 90 +++++++++++++++++++ 6 files changed, 108 insertions(+), 6 deletions(-) create mode 100644 .changeset/chilled-pears-tap.md delete mode 100644 .changeset/quick-melons-clap.md create mode 100644 packages/layerchart/src/lib/components/Rule/Rule.svelte.test.ts diff --git a/.changeset/chilled-pears-tap.md b/.changeset/chilled-pears-tap.md new file mode 100644 index 000000000..b874551c6 --- /dev/null +++ b/.changeset/chilled-pears-tap.md @@ -0,0 +1,5 @@ +--- +'layerchart': minor +--- + +feat(Group|Hull|Rule): Support `seriesKey` prop to fade the mark while another series is highlighted and remove it while the legend has that series hidden diff --git a/.changeset/quick-melons-clap.md b/.changeset/quick-melons-clap.md deleted file mode 100644 index 843202624..000000000 --- a/.changeset/quick-melons-clap.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'layerchart': minor ---- - -feat(Group): Support `seriesKey` prop to fade the group while another series is highlighted and remove it while the legend has that series hidden diff --git a/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts b/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts index a178943d8..0ff0a4a3f 100644 --- a/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts @@ -7,6 +7,11 @@ import type { GroupProps } from '../Group/Group.shared.svelte.js'; export type HullPropsWithoutHTML = { data?: any; + /** + * Series key to follow, fading the hull while another series is highlighted and removing it + * while the series is hidden. Only applicable if `` uses `series`. + */ + seriesKey?: string; /** @default curveLinearClosed */ curve?: ComponentProps['curve']; classes?: { diff --git a/packages/layerchart/src/lib/components/Rule/Rule.base.svelte b/packages/layerchart/src/lib/components/Rule/Rule.base.svelte index 2cb2e44a1..73d8421bc 100644 --- a/packages/layerchart/src/lib/components/Rule/Rule.base.svelte +++ b/packages/layerchart/src/lib/components/Rule/Rule.base.svelte @@ -30,6 +30,7 @@ y = false, yOffset = 0, stroke: strokeProp, + seriesKey, class: className, children, ...restProps @@ -48,7 +49,7 @@ ); - + {#each c.lines as line} {@const stroke = line.stroke ?? strokeProp} diff --git a/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts b/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts index 54471afd2..9165f2288 100644 --- a/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts +++ b/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts @@ -50,6 +50,12 @@ export type BaseRulePropsWithoutHTML = { * @default 0 */ yOffset?: number; + + /** + * Series key to follow, fading the rule while another series is highlighted and removing it + * while the series is hidden. Only applicable if `` uses `series`. + */ + seriesKey?: string; }; export type RulePropsWithoutHTML = BaseRulePropsWithoutHTML & diff --git a/packages/layerchart/src/lib/components/Rule/Rule.svelte.test.ts b/packages/layerchart/src/lib/components/Rule/Rule.svelte.test.ts new file mode 100644 index 000000000..b0df4c9c4 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.svelte.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; + +import TestHarness from '$lib/tests/TestHarness.svelte'; +import Rule from './Rule.svelte'; + +describe('Rule', () => { + describe('seriesKey', () => { + const chartProps = { + data: [ + { date: new Date('2024-01-01'), apples: 20, bananas: 10 }, + { date: new Date('2024-02-01'), apples: 40, bananas: 30 }, + ], + x: 'date', + yDomain: [0, 100], + series: [ + { key: 'apples', value: 'apples' }, + { key: 'bananas', value: 'bananas' }, + ], + }; + + function group() { + return document.querySelector('.lc-rule-g'); + } + + it('fades while another series is highlighted', async () => { + let ctx: any; + render(TestHarness, { + component: Rule, + chartProps, + componentProps: { y: 30, seriesKey: 'apples' }, + oncontext: (c: any) => (ctx = c), + } as any); + + await expect.poll(() => group()).not.toBe(null); + expect(group()?.getAttribute('opacity')).toBe(null); + + ctx.series.setHighlight('bananas'); + await expect.poll(() => group()?.getAttribute('opacity')).toBe('0.1'); + + ctx.series.setHighlight('apples'); + await expect.poll(() => group()?.getAttribute('opacity')).toBe(null); + }); + + it('is removed while the legend has its series hidden', async () => { + let ctx: any; + render(TestHarness, { + component: Rule, + chartProps, + componentProps: { y: 30, seriesKey: 'apples' }, + oncontext: (c: any) => (ctx = c), + } as any); + + await expect.poll(() => document.querySelectorAll('.lc-rule-y-line').length).toBe(1); + + ctx.series.selectedKeys.toggle('bananas'); + await expect.poll(() => document.querySelectorAll('.lc-rule-y-line').length).toBe(0); + + ctx.series.selectedKeys.toggle('bananas'); + await expect.poll(() => document.querySelectorAll('.lc-rule-y-line').length).toBe(1); + }); + + it('does not put `seriesKey` on the rendered line', async () => { + render(TestHarness, { + component: Rule, + chartProps, + componentProps: { y: 30, seriesKey: 'apples' }, + }); + + await expect.poll(() => document.querySelector('.lc-rule-y-line')).not.toBe(null); + expect(document.querySelector('.lc-rule-y-line')?.getAttribute('seriesKey')).toBe(null); + }); + + it('leaves the rule alone without a `seriesKey`', async () => { + let ctx: any; + render(TestHarness, { + component: Rule, + chartProps, + componentProps: { y: 30 }, + oncontext: (c: any) => (ctx = c), + } as any); + + ctx.series.setHighlight('bananas'); + ctx.series.selectedKeys.toggle('bananas'); + + await expect.poll(() => document.querySelectorAll('.lc-rule-y-line').length).toBe(1); + expect(group()?.getAttribute('opacity')).toBe(null); + }); + }); +});