Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/chilled-pears-tap.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,5 @@ bench-results/
# Generated StackBlitz files
**/static/stackblitz-files.json

.playwright-mcp
.playwright-mcp
.openchamber
8 changes: 7 additions & 1 deletion docs/src/content/components/Group.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand All @@ -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" }
39 changes: 39 additions & 0 deletions docs/src/examples/components/Group/series-state.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script lang="ts">
import { Circle, defaultChartPadding, Group, LineChart, Text } from 'layerchart';
import { createDateSeries } from '$lib/utils/data.js';

const data = createDateSeries({
count: 20,
min: 10,
max: 100,
value: 'integer',
keys: ['apples', 'bananas', 'oranges']
});
export { data };

const series = [
{ key: 'apples', color: 'var(--color-apples)' },
{ key: 'bananas', color: 'var(--color-bananas)' },
{ key: 'oranges', color: 'var(--color-oranges)' }
];

const lastPoint = data[data.length - 1];
</script>

<LineChart
{data}
x="date"
{series}
legend
padding={defaultChartPadding({ legend: true, right: 70 })}
height={300}
>
{#snippet aboveMarks()}
{#each series as s (s.key)}
<Group seriesKey={s.key} data={[lastPoint]} x="date" y={s.key}>
<Circle r={4} fill={s.color} />
<Text value={s.key} dx={8} dy={4} class="text-xs fill-surface-content" />
</Group>
{/each}
{/snippet}
</LineChart>
10 changes: 6 additions & 4 deletions packages/layerchart/src/lib/components/Group/Group.canvas.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand All @@ -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],
},
});
</script>

{@render children?.()}
{#if !c.hidden}
{@render children?.()}
{/if}
12 changes: 9 additions & 3 deletions packages/layerchart/src/lib/components/Group/Group.html.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@
key,
center,
motion,
seriesKey,
opacity,
...rest
}: GroupProps<T> = $props();

Expand All @@ -44,6 +46,8 @@
key,
center,
motion,
seriesKey,
opacity,
...rest,
}) as GroupProps
);
Expand All @@ -65,11 +69,13 @@
};
</script>

{#if c.dataMode}
{#if c.hidden}
<!-- `seriesKey` names a series the legend has hidden -->
{:else if c.dataMode}
{#each c.resolvedItems as item (item.key)}
<div
style:transform="translate({item.x}px, {item.y}px)"
style:opacity={rest.opacity}
style:opacity={c.opacity}
{...rest}
class={['lc-group-div', className]}
ontouchmove={handleTouchMove}
Expand All @@ -81,7 +87,7 @@
<div
bind:this={ref}
style:transform={c.transform}
style:opacity={rest.opacity}
style:opacity={c.opacity}
in:transitionIn={transitionInParams}
{...rest}
class={['lc-group-div', className]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export type GroupPropsWithoutHTML<In extends Transition = Transition> = {
*/
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 `<Chart>` uses `series`.
*/
seriesKey?: string;

/** Center within chart. @default false */
center?: boolean | 'x' | 'y';

Expand Down Expand Up @@ -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<typeof createDataMotionMap> = null;
#motionX!: ReturnType<typeof createMotion<number | undefined>>;
#motionY!: ReturnType<typeof createMotion<number | undefined>>;
Expand Down
86 changes: 86 additions & 0 deletions packages/layerchart/src/lib/components/Group/Group.svelte.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
});
12 changes: 9 additions & 3 deletions packages/layerchart/src/lib/components/Group/Group.svg.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@
key,
center,
motion,
seriesKey,
opacity,
...rest
}: GroupProps<T> = $props();

Expand All @@ -45,6 +47,8 @@
key,
center,
motion,
seriesKey,
opacity,
...rest,
}) as GroupProps
);
Expand All @@ -66,13 +70,15 @@
};
</script>

{#if c.dataMode}
{#if c.hidden}
<!-- `seriesKey` names a series the legend has hidden -->
{:else if c.dataMode}
{#each c.resolvedItems as item (item.key)}
<g
style:transform="translate({item.x}px, {item.y}px)"
class={['lc-group-g', className]}
opacity={rest.opacity}
{...rest}
opacity={c.opacity}
ontouchmove={handleTouchMove}
>
{@render children?.()}
Expand All @@ -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}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Chart>` uses `series`.
*/
seriesKey?: string;
/** @default curveLinearClosed */
curve?: ComponentProps<typeof Spline>['curve'];
classes?: {
Expand Down
3 changes: 2 additions & 1 deletion packages/layerchart/src/lib/components/Rule/Rule.base.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
y = false,
yOffset = 0,
stroke: strokeProp,
seriesKey,
class: className,
children,
...restProps
Expand All @@ -48,7 +49,7 @@
);
</script>

<Group class="lc-rule-g">
<Group class="lc-rule-g" {seriesKey}>
{#each c.lines as line}
{@const stroke = line.stroke ?? strokeProp}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Chart>` uses `series`.
*/
seriesKey?: string;
};

export type RulePropsWithoutHTML = BaseRulePropsWithoutHTML &
Expand Down
Loading
Loading