= $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}
>
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);
+ });
+ });
+});