
### Basic Chart

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend isClickable />
	<EvilRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EvilRadarChart.Radar dataKey="desktop" variant="filled" isClickable>
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="filled" isClickable>
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
</EvilRadarChart>
```

## Installation


  
  
    ### npm

```bash
npx shadcn-svelte@latest add @evilcharts/layerchart-radar-chart
```

### yarn

```bash
yarn dlx shadcn-svelte@latest add @evilcharts/layerchart-radar-chart
```

### bun

```bash
bunx --bun shadcn-svelte@latest add @evilcharts/layerchart-radar-chart
```

### pnpm

```bash
pnpm dlx shadcn-svelte@latest add @evilcharts/layerchart-radar-chart
```
  
  
    
      
        ### Install the following dependencies:
        
          ### npm

```bash
npm install layerchart
```

### yarn

```bash
yarn add layerchart
```

### bun

```bash
bun add layerchart
```

### pnpm

```bash
pnpm add layerchart
```
        
      
      
        ### Copy the following code into your project.
         

Create an `evilcharts` folder with a `charts` subfolder inside `components`, then paste the base radar-chart code into a new file there.


        
          ### $lib/components/evilcharts/charts/layerchart-radar-chart

`$lib/components/evilcharts/charts/layerchart-radar-chart/active-dot.svelte`

```svelte
<script lang="ts">
	/**
	 * Declares the hovered/active point marker for the <Radar /> it is composed
	 * inside. Like <Dot />, it is a configuration slot and renders nothing itself.
	 */
	import { useRadarSlots } from './radar-slots.svelte.js';
	import type { DotVariant } from '../../ui/layerchart-dot/types.js';

	let { variant }: { variant?: DotVariant } = $props();

	const slots = useRadarSlots();
	const token = $props.id();

	$effect.pre(() => {
		slots.registerActiveDot(token, variant);
		return () => slots.unregisterActiveDot(token);
	});
</script>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/defs/color-gradient.svelte`

```svelte
<script lang="ts">
	/**
	 * Horizontal left-to-right colour gradient for a series. Always rendered — the radar's dots
	 * paint from this single gradient.
	 */
	import { getColorsCount } from '../../../ui/layerchart-chart/colors.js';
	import type { ChartConfig } from '../../../ui/layerchart-chart/chart-config.js';
	import ColorStops from './color-stops.svelte';

	let { id, dataKey, config }: { id: string; dataKey: string; config: ChartConfig } = $props();

	const colorsCount = $derived(getColorsCount(config[dataKey] ?? {}));
</script>

<linearGradient id={`${id}-colors-${dataKey}`} x1="0" y1="0" x2="1" y2="0">
	<ColorStops {dataKey} {colorsCount} />
</linearGradient>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/defs/color-stops.svelte`

```svelte
<script lang="ts">
	/**
	 * One `<stop>` per colour, with an optional per-stop opacity ramp. A single-colour series still
	 * emits two stops so the gradient paints a flat fill rather than a fade.
	 */
	let {
		dataKey,
		colorsCount,
		opacities
	}: { dataKey: string; colorsCount: number; opacities?: number[] } = $props();
</script>

{#if colorsCount === 1}
	<stop offset="0%" stop-color={`var(--color-${dataKey}-0)`} stop-opacity={opacities?.[0]} />
	<stop
		offset="100%"
		stop-color={`var(--color-${dataKey}-0)`}
		stop-opacity={opacities?.[opacities.length - 1]}
	/>
{:else}
	{#each Array.from({ length: colorsCount }, (_, index) => index) as index (index)}
		<stop
			offset={`${(index / (colorsCount - 1)) * 100}%`}
			stop-color={`var(--color-${dataKey}-${index}, var(--color-${dataKey}-0))`}
			stop-opacity={opacities?.[index]}
		/>
	{/each}
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/defs/fill-gradient.svelte`

```svelte
<script lang="ts">
	/** Radial colour gradient used for the radar's filled area, fading toward the edge. */
	import { getColorsCount } from '../../../ui/layerchart-chart/colors.js';
	import type { ChartConfig } from '../../../ui/layerchart-chart/chart-config.js';
	import ColorStops from './color-stops.svelte';

	let { id, dataKey, config }: { id: string; dataKey: string; config: ChartConfig } = $props();

	const colorsCount = $derived(getColorsCount(config[dataKey] ?? {}));
	const opacities = $derived(
		colorsCount === 1
			? [0.8, 0.3]
			: Array.from({ length: colorsCount }, (_, index) => (index === 0 ? 0.8 : 0.3))
	);
</script>

<radialGradient id={`${id}-radar-fill-${dataKey}`} cx="50%" cy="50%" r="50%">
	<ColorStops {dataKey} {colorsCount} {opacities} />
</radialGradient>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/defs/glow-filter.svelte`

```svelte
<script lang="ts">
	/** Soft outer glow filter applied to a radar when `isGlowing` is set. */
	let { id, dataKey }: { id: string; dataKey: string } = $props();
</script>

<filter id={`${id}-radar-glow-${dataKey}`} x="-50%" y="-50%" width="200%" height="200%">
	<feGaussianBlur in="SourceGraphic" stdDeviation="4" result="blur" />
	<feColorMatrix
		in="blur"
		type="matrix"
		values="1 0 0 0 0
                0 1 0 0 0
                0 0 1 0 0
                0 0 0 0.6 0"
		result="glow"
	/>
	<feMerge>
		<feMergeNode in="glow" />
		<feMergeNode in="SourceGraphic" />
	</feMerge>
</filter>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/defs/stroke-gradient.svelte`

```svelte
<script lang="ts">
	/** Diagonal colour gradient used for the radar's outline stroke. */
	import { getColorsCount } from '../../../ui/layerchart-chart/colors.js';
	import type { ChartConfig } from '../../../ui/layerchart-chart/chart-config.js';
	import ColorStops from './color-stops.svelte';

	let { id, dataKey, config }: { id: string; dataKey: string; config: ChartConfig } = $props();

	const colorsCount = $derived(getColorsCount(config[dataKey] ?? {}));
</script>

<linearGradient id={`${id}-radar-stroke-${dataKey}`} x1="0" y1="0" x2="1" y2="1">
	<ColorStops {dataKey} {colorsCount} />
</linearGradient>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/dot.svelte`

```svelte
<script lang="ts">
	/**
	 * Declares a resting point marker for the <Radar /> it is composed inside.
	 * It renders nothing on its own — the parent <Radar /> reads its variant and
	 * wires it into the dot slot.
	 */
	import { useRadarSlots } from './radar-slots.svelte.js';
	import type { DotVariant } from '../../ui/layerchart-dot/types.js';

	let { variant }: { variant?: DotVariant } = $props();

	const slots = useRadarSlots();
	const token = $props.id();

	$effect.pre(() => {
		slots.registerDot(token, variant);
		return () => slots.unregisterDot(token);
	});
</script>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/index.ts`

```ts
import Root from './radar-chart.svelte';
import Radar from './radar.svelte';
import Dot from './dot.svelte';
import ActiveDot from './active-dot.svelte';
import PolarGrid from './polar-grid.svelte';
import PolarAngleAxis from './polar-angle-axis.svelte';
import PolarRadiusAxis from './polar-radius-axis.svelte';
import Tooltip from './tooltip.svelte';
import Legend from './legend.svelte';

type RootComponent = typeof Root;

// Compound API: every part hangs off the root as a static member, so a consumer
// writes <EvilRadarChart.Radar/>, <EvilRadarChart.Tooltip/>, … from a single import
// — no colliding named marker exports when several charts share one file.
//
// The explicit annotation is required for `svelte-package` to emit types.
export const EvilRadarChart: RootComponent & {
	Radar: typeof Radar;
	Dot: typeof Dot;
	ActiveDot: typeof ActiveDot;
	PolarGrid: typeof PolarGrid;
	PolarAngleAxis: typeof PolarAngleAxis;
	PolarRadiusAxis: typeof PolarRadiusAxis;
	Tooltip: typeof Tooltip;
	Legend: typeof Legend;
} = Object.assign(Root, {
	Radar,
	Dot,
	ActiveDot,
	PolarGrid,
	PolarAngleAxis,
	PolarRadiusAxis,
	Tooltip,
	Legend
});

export type { RadarVariant } from './types.js';
export type { ChartAccessibility, ChartConfig } from '../../ui/layerchart-chart/index.js';
export type { DitherBloom, DitherVariant, RenderStyle } from '../../ui/layerchart-dither/index.js';
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/legend-render.svelte`

```svelte
<script lang="ts">
	/** Renders the registered `<Legend />` slot as an HTML box outside the plot area. */
	import {
		ChartLegendContent,
		resolveLegendPlacement,
		type LegendPayloadItem,
		type LegendVerticalAlign
	} from '../../ui/layerchart-legend/index.js';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	let { placement }: { placement: LegendVerticalAlign } = $props();

	const chart = useRadarChart();

	const slot = $derived(chart.slots.legend);
	// Recharts defaults the radar legend to the bottom, unlike the cartesian charts.
	const resolvedPlacement = $derived(resolveLegendPlacement(slot?.verticalAlign, 'bottom'));

	const payload = $derived<LegendPayloadItem[]>(
		chart.seriesKeys.map((key) => ({ dataKey: key, value: key }))
	);
</script>

{#if slot && !chart.isLoading && resolvedPlacement === placement}
	<ChartLegendContent
		{payload}
		verticalAlign={slot.verticalAlign}
		align={slot.align}
		variant={slot.variant}
		isClickable={slot.isClickable}
		selected={chart.selectedDataKey}
		onSelectChange={chart.selectDataKey}
		class={placement === 'middle'
			? 'pointer-events-auto absolute inset-x-0 top-1/2 z-10 -translate-y-1/2 px-4'
			: placement === 'top'
				? 'pointer-events-auto absolute inset-x-[5px] top-[5px] z-10'
				: 'pointer-events-auto absolute inset-x-[5px] bottom-[5px] z-10'}
	/>
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/legend.svelte`

```svelte
<script lang="ts">
	/**
	 * The series legend. When `isClickable` is set, each entry toggles selection of
	 * its series, driving the shared selection state read by every <Radar />.
	 *
	 * Config-only: the legend is an HTML box outside the SVG, so this registers its props
	 * and the root renders it above or below the plot per `verticalAlign`.
	 */
	import type {
		ChartLegendVariant,
		LegendAlign,
		LegendVerticalAlign
	} from '../../ui/layerchart-legend/index.js';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	let {
		variant,
		align = 'center',
		verticalAlign = 'bottom',
		isClickable = false
	}: {
		variant?: ChartLegendVariant; // visual style of the legend indicators
		align?: LegendAlign; // horizontal placement
		verticalAlign?: LegendVerticalAlign; // vertical placement
		isClickable?: boolean; // lets each entry toggle selection of its series
	} = $props();

	const chart = useRadarChart();
	const token = $props.id();

	$effect.pre(() => {
		chart.slots.registerLegend(token, { variant, align, verticalAlign, isClickable });
		return () => chart.slots.unregisterLegend(token);
	});
</script>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/loading/loading-radar.svelte`

```svelte
<script lang="ts">
	/**
	 * The skeleton radar shown while the chart is loading. Rendered by the root in place of the
	 * real radars, it animates between randomised shapes as the data is regenerated.
	 */
	import { Spline } from 'layerchart';
	import { curveLinearClosed } from 'd3-shape';
	import { cubicBezier, useReducedMotion } from '@humanspeak/svelte-motion';
	import { LOADING_ANIMATION_DURATION, LOADING_RADAR_DATA_KEY } from '../types.js';

	/**
	 * Recharts' `animationEasing="ease-in-out"` is the CSS `ease-in-out` curve. LayerChart's
	 * `motion={{ type: 'tween' }}` takes an easing *function*, and the motion library solves the
	 * curve for us.
	 */
	const easeInOut = cubicBezier(0.42, 0, 0.58, 1);
	const shouldReduceMotion = useReducedMotion();
</script>

<!--
	The reference leaves Recharts' own animation on for this one mark
	(`animationDuration={LOADING_ANIMATION_DURATION} animationEasing="ease-in-out"`), so the shape
	tweens each time the data is regenerated. LayerChart's `motion` prop does the same.
-->
<Spline
	seriesKey={LOADING_RADAR_DATA_KEY}
	curve={curveLinearClosed}
	stroke="currentColor"
	strokeOpacity={0.3}
	strokeWidth={2}
	fill="currentColor"
	fillOpacity={0.1}
	motion={shouldReduceMotion.current
		? 'none'
		: { type: 'tween', duration: LOADING_ANIMATION_DURATION, easing: easeInOut }}
/>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/loading/use-loading-data.svelte.ts`

```ts
import { useReducedMotion } from '@humanspeak/svelte-motion';
import {
	LOADING_ANIMATION_DURATION,
	LOADING_CATEGORIES,
	LOADING_POINTS,
	LOADING_RADAR_DATA_KEY
} from '../types.js';

/** A fresh set of randomised loading points for the skeleton radar. */
function generateLoadingData(points: number) {
	return LOADING_CATEGORIES.slice(0, points).map((category) => ({
		skill: category,
		[LOADING_RADAR_DATA_KEY]: 30 + Math.random() * 70
	}));
}

/**
 * Regenerates the loading skeleton data on a fixed interval, so the skeleton radar keeps animating
 * between shapes while the chart is loading.
 */
export class LoadingDataState {
	#isLoading: () => boolean;
	#loadingPoints: () => number;
	/** Bumped by the interval; regenerates the skeleton shape each animation cycle. */
	#tick = $state(0);

	constructor(options: { isLoading: () => boolean; loadingPoints?: () => number }) {
		this.#isLoading = options.isLoading;
		this.#loadingPoints = options.loadingPoints ?? (() => LOADING_POINTS);
		const shouldReduceMotion = useReducedMotion();

		$effect(() => {
			if (!this.#isLoading() || shouldReduceMotion.current) return;

			const interval = setInterval(() => {
				this.#tick += 1;
			}, LOADING_ANIMATION_DURATION);

			return () => clearInterval(interval);
		});
	}

	get loadingData() {
		this.#tick;
		return generateLoadingData(this.#loadingPoints());
	}
}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/polar-angle-axis.svelte`

```svelte
<script lang="ts">
	/**
	 * The angular category axis — the labels around the chart's perimeter. Ships with the chart's
	 * flat default styling and forwards every LayerChart Axis prop. Hidden automatically while the
	 * chart is loading.
	 *
	 * `dataKey` names the category key. Recharts reads it here; LayerChart needs it on the root's
	 * `x` accessor, so it is registered into the chart context on mount.
	 */
	import { Axis } from 'layerchart';
	import { layerChartFormatter } from '../../ui/layerchart-chart/ticks.js';
	import type { ComponentProps } from 'svelte';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	type Props = Omit<ComponentProps<typeof Axis>, 'placement' | 'format'> & {
		dataKey?: string;
		tickFormatter?: (value: unknown, index: number) => string;
		tickLine?: boolean;
	};

	let { dataKey, tickLine = false, tickFormatter, ...restProps }: Props = $props();

	const chart = useRadarChart();
	const token = $props.id();

	$effect.pre(() => {
		chart.registerAngleDataKey(token, dataKey);
		return () => chart.registerAngleDataKey(token, undefined);
	});

	const format = $derived(tickFormatter ? layerChartFormatter(tickFormatter) : undefined);
</script>

{#if !chart.isLoading}
	<!-- The reference's `tick={{ fill: 'currentColor', fontSize: 12 }}`. -->
	<Axis
		placement="angle"
		rule={false}
		tickMarks={tickLine}
		tickLabelProps={{ class: 'fill-current text-xs' }}
		{format}
		{...restProps}
	/>
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/polar-grid.svelte`

```svelte
<script lang="ts">
	/**
	 * The polar grid lines. Defaults to a dashed polygon grid, matching Recharts'
	 * `<PolarGrid gridType="polygon">`; `gridType="circle"` draws concentric circles instead.
	 */
	import { Grid, type AnyScale } from 'layerchart';

	let {
		gridType = 'polygon',
		stroke = 'currentColor',
		strokeOpacity = 0.2,
		strokeDasharray = '3 4',
		...restProps
	}: {
		gridType?: 'polygon' | 'circle';
		stroke?: string;
		strokeOpacity?: number;
		strokeDasharray?: string;
		[key: string]: unknown;
	} = $props();

	/**
	 * Both spellings of each stroke prop.
	 *
	 * LayerChart renders the radial spokes as `<Line>` and the rings as `<Spline>`, and the two
	 * declare different subsets: `<Line>` accepts `dashArray` but drops a camelCase
	 * `strokeOpacity`, while `<Spline>` accepts `strokeOpacity` but has no `dashArray`. Passing the
	 * declared name *and* the raw SVG attribute means whichever one a component does not declare
	 * still reaches the element through its rest props, so the spokes and the rings end up styled
	 * identically.
	 */
	const lineProps = $derived({
		stroke,
		strokeOpacity,
		'stroke-opacity': strokeOpacity,
		dashArray: strokeDasharray,
		'stroke-dasharray': strokeDasharray
	});

	/**
	 * Ring radii, evenly divided from the centre to the data maximum.
	 *
	 * Recharts' `<PolarGrid>` draws one ring per radius-axis tick, and its radius axis divides the
	 * exact `[0, dataMax]` domain into `tickCount` (5) steps — so the outermost ring lands *on* the
	 * largest value. d3's `ticks()` instead picks round numbers (0, 100, 200, 300 for a max of 305),
	 * which leaves the outer ring inside the widest polygon.
	 */
	const RING_COUNT = 5;

	function evenRings(scale: AnyScale) {
		const domain = scale.domain() as number[];
		const min = Number(domain[0]);
		const max = Number(domain[domain.length - 1]);
		if (!Number.isFinite(min) || !Number.isFinite(max)) return undefined;

		const step = (max - min) / (RING_COUNT - 1);
		return Array.from({ length: RING_COUNT }, (_, index) => min + step * index);
	}
</script>

<!--
	Recharts draws both the radial spokes and the rings; LayerChart splits them into the `x` and `y`
	line sets. `radialY` picks the ring shape — a linear spline is the polygon web, matching
	Recharts' `gridType`.
-->
<Grid
	x={lineProps}
	y={lineProps}
	yTicks={evenRings}
	radialY={gridType === 'circle' ? 'circle' : 'linear'}
	{...restProps}
/>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/polar-radius-axis.svelte`

```svelte
<script lang="ts">
	/**
	 * The radial value axis — the scale running from the centre outward. Forwards every LayerChart
	 * Axis prop. Hidden automatically while the chart is loading.
	 */
	import { Axis } from 'layerchart';
	import { layerChartFormatter } from '../../ui/layerchart-chart/ticks.js';
	import type { ComponentProps } from 'svelte';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	type Props = Omit<ComponentProps<typeof Axis>, 'placement' | 'format'> & {
		tickFormatter?: (value: unknown, index: number) => string;
		tickLine?: boolean;
		axisLine?: boolean;
	};

	let { tickLine = false, axisLine = false, tickFormatter, ...restProps }: Props = $props();

	const chart = useRadarChart();

	const format = $derived(tickFormatter ? layerChartFormatter(tickFormatter) : undefined);
</script>

{#if !chart.isLoading}
	<!-- The reference's `tick={{ fill: 'currentColor', fontSize: 10 }}`. -->
	<Axis
		placement="radius"
		rule={axisLine}
		tickMarks={tickLine}
		tickLabelProps={{ class: 'fill-current text-[10px]' }}
		{format}
		{...restProps}
	/>
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/radar-chart-context.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import type { ChartConfig } from '../../ui/layerchart-chart/chart-config.js';
import type { DitherVariant, RenderStyle } from '../../ui/layerchart-dither/index.js';
import { ChartSlots } from '../../ui/layerchart-chart/chart-slots.svelte.js';

const RADAR_CHART_KEY = Symbol('evilcharts.radar-chart');

type Options = {
	config: () => ChartConfig;
	/** Rows currently rendered by the chart, or the loading skeleton. */
	data: () => Record<string, unknown>[];
	/** Series keys rendered by the chart, in config order. */
	seriesKeys: () => string[];
	/** Category key pushed up by `<PolarAngleAxis dataKey>`. */
	angleKey: () => string | undefined;
	isLoading: () => boolean;
	introStartedAt: () => number;
	renderStyle: () => RenderStyle;
	ditherVariant: () => DitherVariant;
	selectedDataKey: () => string | null;
	selectDataKey: (dataKey: string | null) => void;
	/** Called by each rendered `<Radar />` so config-only keys never become series. */
	registerRadar: (token: string, dataKey: string | undefined) => void;
	/**
	 * Called by `<PolarAngleAxis dataKey>` on mount.
	 *
	 * Recharts reads the category key off the angle axis; LayerChart needs it on the root's `x`
	 * accessor, so the axis pushes it up rather than the root reading down.
	 */
	registerAngleDataKey: (token: string, dataKey: string | undefined) => void;
};

/**
 * Shared state for every part of the chart. Lifted into <EvilRadarChart /> so that
 * <Radar />, <PolarAngleAxis />, <Legend />, and friends can read it without prop
 * drilling. Sub-components are composed freely — the provider is the single source
 * of truth.
 */
export class RadarChartContext {
	#options: Options;

	/** Parts that render outside `<Svg>` — see `ChartSlots`. */
	slots = new ChartSlots();

	constructor(options: Options) {
		this.#options = options;
	}

	get config() {
		return this.#options.config();
	}
	get data() {
		return this.#options.data();
	}
	get seriesKeys() {
		return this.#options.seriesKeys();
	}
	get angleKey() {
		return this.#options.angleKey();
	}
	get isLoading() {
		return this.#options.isLoading();
	}
	get introStartedAt() {
		return this.#options.introStartedAt();
	}
	get renderStyle() {
		return this.#options.renderStyle();
	}
	get ditherVariant() {
		return this.#options.ditherVariant();
	}
	get selectedDataKey() {
		return this.#options.selectedDataKey();
	}

	selectDataKey = (dataKey: string | null) => {
		this.#options.selectDataKey(dataKey);
	};

	registerRadar = (token: string, dataKey: string | undefined) => {
		this.#options.registerRadar(token, dataKey);
	};

	registerAngleDataKey = (token: string, dataKey: string | undefined) => {
		this.#options.registerAngleDataKey(token, dataKey);
	};
}

export function setRadarChartContext(options: Options) {
	const context = new RadarChartContext(options);
	setContext(RADAR_CHART_KEY, context);
	return context;
}

/** Reads the chart context, throwing a helpful error when used outside <EvilRadarChart /> */
export function useRadarChart(): RadarChartContext {
	const context = getContext<RadarChartContext | undefined>(RADAR_CHART_KEY);

	if (!context) {
		throw new Error(
			'Radar chart parts (<Radar />, <PolarAngleAxis />, …) must be used within <EvilRadarChart />'
		);
	}

	return context;
}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/radar-chart.svelte`

```svelte
<script lang="ts" generics="TData extends Record<string, unknown>">
	/**
	 * Root of the composable radar chart. Owns the data, the shared context, and the loading
	 * skeleton. Everything visual — the polar grid, axes, tooltip, legend, and the radars
	 * themselves — is composed as children, so a consumer renders exactly the parts they need.
	 */
	import { Chart, Group, Html, Svg, type ChartState } from 'layerchart';
	import { untrack, type Snippet } from 'svelte';
	import {
		ChartContainer,
		LoadingIndicator,
		type ChartAccessibility,
		type ChartConfig
	} from '../../ui/layerchart-chart/index.js';
	import { ChartBackground, type BackgroundVariant } from '../../ui/layerchart-background/index.js';
	import LegendRender from './legend-render.svelte';
	import LoadingRadar from './loading/loading-radar.svelte';
	import { LoadingDataState } from './loading/use-loading-data.svelte.js';
	import { setRadarChartContext } from './radar-chart-context.svelte.js';
	import {
		DitherDomLayer,
		type DitherBloom,
		type DitherVariant,
		type RenderStyle
	} from '../../ui/layerchart-dither/index.js';
	import TooltipRender from './tooltip-render.svelte';
	import { SvelteMap } from 'svelte/reactivity';
	import {
		DEFAULT_OUTER_RADIUS_RATIO,
		LOADING_POINTS,
		LOADING_RADAR_DATA_KEY,
		REVEAL_BEGIN,
		REVEAL_DURATION
	} from './types.js';

	let {
		config,
		data,
		children,
		class: className,
		chartProps,
		accessibility,
		backgroundVariant,
		defaultSelectedDataKey = null,
		onSelectionChange,
		isLoading = false,
		loadingPoints,
		initialDimension = { width: 320, height: 200 },
		renderStyle = 'svg',
		ditherVariant = 'gradient',
		ditherCellSize = 2,
		bloom = 'off'
	}: {
		config: ChartConfig; // series colors + labels
		data: TData[]; // rows rendered by the chart
		children: Snippet; // composed parts — <Radar />, <PolarGrid />, <Legend />, …
		class?: string; // extra classes for the chart container
		chartProps?: Record<string, unknown>; // escape hatch for the raw LayerChart Chart
		accessibility?: ChartAccessibility; // accessible name and description for the chart group
		backgroundVariant?: BackgroundVariant; // background pattern drawn behind the chart
		defaultSelectedDataKey?: string | null; // series selected on first render
		onSelectionChange?: (selectedDataKey: string | null) => void; // fires when the selected series changes
		isLoading?: boolean; // shows the animated loading skeleton
		loadingPoints?: number; // number of points in the loading skeleton
		initialDimension?: { width: number; height: number }; // zero-size/first-render fallback
		renderStyle?: RenderStyle;
		ditherVariant?: DitherVariant;
		ditherCellSize?: number;
		bloom?: DitherBloom;
	} = $props();

	// One-time initialisation, mirroring the reference's `useState(defaultSelectedDataKey)`.
	let selectedDataKey = $state<string | null>(untrack(() => defaultSelectedDataKey));
	let chartDimension = $state(untrack(() => initialDimension));
	let introStartedAt = $state(Date.now());
	let previousLoading = untrack(() => isLoading);

	$effect(() => {
		const loadingNow = isLoading;
		if (previousLoading && !loadingNow) introStartedAt = Date.now();
		previousLoading = loadingNow;
	});

	/** LayerChart's chart state, so the pointer handlers below can drive the tooltip. */
	let layerContext = $state<ChartState<Record<string, unknown>> | undefined>(undefined);
	let polarGroup = $state<Element>();

	const TAU = Math.PI * 2;

	/**
	 * Resolves the hovered category by *angle*, the way Recharts does, and drives the tooltip
	 * manually.
	 *
	 * None of LayerChart's tooltip modes matches: `band` lays out cartesian hit rects, and on a
	 * radial chart a band runs from one vertex to the *next* rather than being centred on it, so
	 * pointing straight up reported the last category instead of the first. `quadtree` picks the
	 * nearest *point*, which drifts into the wrong sector wherever a series dips. The chart
	 * therefore runs `mode: 'manual'` and the handlers here resolve the sector.
	 *
	 * Attached to the wrapper rather than an overlay inside the SVG so a pointer over a radar still
	 * reaches it — an overlay would either sit under the polygons or swallow their clicks.
	 */
	function showTooltip(event: PointerEvent) {
		const rows = chartData;
		if (rows.length === 0 || !layerContext) return;

		if (!(polarGroup instanceof SVGGraphicsElement)) return;
		const matrix = polarGroup.getScreenCTM();
		if (!matrix) return;
		const dx = event.clientX - matrix.e;
		const dy = event.clientY - matrix.f;

		const angle = (Math.atan2(dx, -dy) + TAU) % TAU;
		const step = TAU / rows.length;
		const index = Math.round(angle / step) % rows.length;

		layerContext.tooltip.show(event, rows[index]);
	}

	const loading = new LoadingDataState({
		isLoading: () => isLoading,
		loadingPoints: () => loadingPoints ?? LOADING_POINTS
	});

	/** Category key pushed up by the rendered `<PolarAngleAxis dataKey>`. */
	let registeredAngleKey = $state<string | undefined>(undefined);
	let registeredAngleToken: string | null = null;

	const configuredKeys = $derived(Object.keys(config));
	const radarKeyByToken = new SvelteMap<string, string>();
	const seriesKeys = $derived([...radarKeyByToken.values()]);
	const chartData = $derived((isLoading ? loading.loadingData : data) as Record<string, unknown>[]);

	/** Category key for the angle scale. Falls back the same way the cartesian charts do (A-1). */
	const fallbackAngleKey = $derived(
		Object.keys(chartData[0] ?? {}).find(
			(key) => !configuredKeys.includes(key) && key !== LOADING_RADAR_DATA_KEY
		)
	);
	const angleKey = $derived(registeredAngleKey ?? fallbackAngleKey);

	const series = $derived(
		isLoading
			? [{ key: LOADING_RADAR_DATA_KEY, value: LOADING_RADAR_DATA_KEY }]
			: seriesKeys.map((key) => ({ key, value: key }))
	);

	const radarContext = setRadarChartContext({
		config: () => config,
		data: () => chartData,
		seriesKeys: () => (isLoading ? [LOADING_RADAR_DATA_KEY] : seriesKeys),
		angleKey: () => angleKey,
		isLoading: () => isLoading,
		introStartedAt: () => introStartedAt,
		renderStyle: () => renderStyle,
		ditherVariant: () => ditherVariant,
		selectedDataKey: () => selectedDataKey,
		selectDataKey: (next) => {
			selectedDataKey = next;
			onSelectionChange?.(next);
		},
		registerRadar: (token, key) => {
			if (key === undefined) radarKeyByToken.delete(token);
			else radarKeyByToken.set(token, key);
		},
		registerAngleDataKey: (token, key) => {
			// Ignore a stale teardown from LayerChart's mount-time remount.
			if (key === undefined && registeredAngleToken !== token) return;
			registeredAngleToken = key === undefined ? null : token;
			registeredAngleKey = key;
		}
	});

	const CHART_MARGIN = 5;
	// The reference reserves its full 32px edge-legend band, then nices the 305 maximum to 320.
	// LayerChart's explicit count of 11 reproduces that settled domain at both preview sizes.
	const EDGE_LEGEND_HEIGHT = 32;
	const edgeLegendPlacement = $derived.by(() => {
		if (isLoading) return null;
		const align = radarContext.slots.legend?.verticalAlign;
		return align === 'top' || align === 'bottom' ? align : null;
	});
	const edgeLegendInset = $derived(edgeLegendPlacement ? EDGE_LEGEND_HEIGHT / 2 : 0);
	const padding = $derived({
		top: CHART_MARGIN + edgeLegendInset,
		right: CHART_MARGIN,
		bottom: CHART_MARGIN + edgeLegendInset,
		left: CHART_MARGIN
	});
</script>

<ChartContainer
	{config}
	{initialDimension}
	{accessibility}
	bind:dimension={chartDimension}
	class={className}
>
	<LoadingIndicator {isLoading} />
	<LegendRender placement="top" />
	<!--
		`radial` maps the x scale onto [0, 2π] and the y scale onto a radius. The angle scale must be
		a *band* scale so `n` categories sit at `i · 2π / n` and the polygon closes — a point scale
		would put the last vertex back on the first. `bandPadding={0}` keeps each vertex on its own
		band edge, which is where LayerChart's radial line generator reads the angle from.

		Recharts reserves its 5px chart margin and an edge legend before computing the radius, then
		nices the value domain. The explicit padding and `yNice` below reproduce that settled layout.
	-->
	<!--
		The pointer handlers live on this wrapper so they see a move anywhere over the chart,
		including over a radar polygon.
	-->
	<div
		class="flex min-h-0 w-full flex-1 flex-col"
		onpointermove={isLoading ? undefined : showTooltip}
		onpointerleave={() => layerContext?.tooltip.hide()}
		role="presentation"
	>
		<Chart
			width={chartDimension.width}
			height={chartDimension.height}
			bind:context={layerContext}
			data={chartData}
			x={angleKey}
			{series}
			seriesLayout="overlap"
			radial
			bandPadding={0}
			yBaseline={0}
			yNice={11}
			yRange={({ width, height }) => [
				0,
				(Math.min(width, height) / 2) * DEFAULT_OUTER_RADIUS_RATIO
			]}
			{padding}
			tooltipContext={{ mode: 'manual' }}
			class="h-full w-full"
			{...chartProps}
		>
			{#if renderStyle === 'dither'}
				<Html pointerEvents={false} clip zIndex={0}>
					<DitherDomLayer
						{ditherVariant}
						cellSize={ditherCellSize}
						{bloom}
						paused={isLoading}
						animationDuration={REVEAL_BEGIN + REVEAL_DURATION}
						animationRevision={introStartedAt}
					/>
				</Html>
			{/if}
			<Svg zIndex={1}>
				<Group center bind:ref={polarGroup}>
					{#if backgroundVariant}
						<ChartBackground variant={backgroundVariant} />
					{/if}
					{@render children()}
					{#if isLoading}
						<LoadingRadar />
					{/if}
				</Group>
			</Svg>
			<TooltipRender />
		</Chart>
	</div>
	<LegendRender placement="middle" />
	<LegendRender placement="bottom" />
</ChartContainer>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/radar-slots.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import type { DotVariant } from '../../ui/layerchart-dot/types.js';

const RADAR_SLOTS_KEY = Symbol('evilcharts.radar-slots');

/**
 * Registry for the `<Dot />` / `<ActiveDot />` children of one `<Radar />`.
 *
 * The reference resolves them with `React.Children.forEach`; Svelte cannot inspect a
 * snippet, so each slot registers itself here instead.
 */
export class RadarSlots {
	#dotToken: string | null = null;
	#activeDotToken: string | null = null;

	dot = $state<{ variant?: DotVariant } | null>(null);
	activeDot = $state<{ variant?: DotVariant } | null>(null);

	/** Token-keyed so a remount's stale teardown cannot clear the live slot. */
	registerDot(token: string, variant: DotVariant | undefined) {
		this.#dotToken = token;
		this.dot = { variant };
	}

	unregisterDot(token: string) {
		if (this.#dotToken !== token) return;
		this.#dotToken = null;
		this.dot = null;
	}

	registerActiveDot(token: string, variant: DotVariant | undefined) {
		this.#activeDotToken = token;
		this.activeDot = { variant };
	}

	unregisterActiveDot(token: string) {
		if (this.#activeDotToken !== token) return;
		this.#activeDotToken = null;
		this.activeDot = null;
	}
}

export function setRadarSlotsContext() {
	const slots = new RadarSlots();
	setContext(RADAR_SLOTS_KEY, slots);
	return slots;
}

export function useRadarSlots(): RadarSlots {
	const slots = getContext<RadarSlots | undefined>(RADAR_SLOTS_KEY);

	if (!slots) {
		throw new Error('<Dot /> and <ActiveDot /> must be composed inside a <Radar />');
	}

	return slots;
}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/radar.svelte`

```svelte
<script lang="ts">
	/**
	 * A single radar series. Each <Radar /> is fully self-contained: it generates its own
	 * stroke/fill gradients and glow filter under a unique id, so any number of radars — each with
	 * its own variant, opacity, and clickability — can live in one chart without style collisions.
	 * Compose <Dot /> and <ActiveDot /> inside it to add point markers.
	 */
	import { Highlight, Points, Spline, getChartContext } from 'layerchart';
	import { curveLinearClosed } from 'd3-shape';
	import type { Snippet } from 'svelte';
	import { animate, useMotionValue, useReducedMotion } from '@humanspeak/svelte-motion';
	import { polarIntroAction } from '../../ui/layerchart-chart/intros.js';
	import { ChartDot } from '../../ui/layerchart-dot/index.js';
	import type { DitherVariant } from '../../ui/layerchart-dither/index.js';
	import ColorGradient from './defs/color-gradient.svelte';
	import FillGradient from './defs/fill-gradient.svelte';
	import GlowFilter from './defs/glow-filter.svelte';
	import StrokeGradient from './defs/stroke-gradient.svelte';
	import { useRadarChart } from './radar-chart-context.svelte.js';
	import { setRadarSlotsContext } from './radar-slots.svelte.js';
	import { untrack } from 'svelte';
	import {
		DEFAULT_FILL_OPACITY,
		REVEAL_BEGIN,
		REVEAL_DURATION,
		REVEAL_EASE,
		STROKE_WIDTH,
		type RadarVariant
	} from './types.js';

	let {
		dataKey,
		variant = 'filled',
		fillOpacity = DEFAULT_FILL_OPACITY,
		isGlowing = false,
		isClickable = false,
		children,
		radarProps,
		ditherVariant
	}: {
		dataKey: string; // series key — must exist on the data and config
		variant?: RadarVariant; // fill style for this radar only
		fillOpacity?: number; // opacity of the filled area when `variant="filled"`
		isGlowing?: boolean; // adds a soft outer glow around this radar
		isClickable?: boolean; // lets this radar be selected by clicking it
		children?: Snippet; // optional <Dot /> and <ActiveDot /> composition
		radarProps?: Record<string, unknown>; // escape hatch for raw LayerChart Spline props
		ditherVariant?: DitherVariant; // ordered-dither texture override
	} = $props();

	const chart = useRadarChart();
	/** LayerChart's own context, for the plot span the dots' gradient rect needs. */
	const layer = getChartContext();
	const id = $props.id(); // unique id scopes this radar's style defs
	// Devices set to "reduce motion" skip the intro entirely
	const shouldReduceMotion = useReducedMotion();
	const isDither = $derived(chart.renderStyle === 'dither');
	const resolvedDitherVariant = $derived(ditherVariant ?? chart.ditherVariant);

	/**
	 * The intro, reproducing `<Radar>`'s own animation: every point travels from the centre to its
	 * final position over 1.5s with the CSS `ease` curve. For a polygon centred on the group's
	 * origin that is exactly a uniform scale, so one tween drives the whole shape and its dots.
	 */
	const reveal = useMotionValue(0);
	let previousLoading: boolean | undefined;

	// `untrack`: `animate` reads the motion value, and a tracked read would re-run this effect
	// on every frame — each run restarting the tween, so it crawled instead of playing once.
	$effect(() => {
		const loadingNow = chart.isLoading;
		const action = polarIntroAction(previousLoading, loadingNow, shouldReduceMotion.current);
		previousLoading = loadingNow;
		let controls: ReturnType<typeof animate> | undefined;

		untrack(() => {
			if (action === 'reset') reveal.set(0);
			if (action === 'finish') reveal.set(1);
			if (action === 'animate') {
				reveal.set(0);
				controls = animate(reveal, 1, {
					delay: REVEAL_BEGIN / 1000,
					duration: REVEAL_DURATION / 1000,
					ease: REVEAL_EASE
				});
			}
		});
		return () => controls?.stop();
	});

	const scale = $derived(shouldReduceMotion.current ? 1 : reveal.current);

	/**
	 * The dots' gradient rect, in this group's coordinates.
	 *
	 * `<ChartDot>` defaults to a rect spanning `x=0 → 100%`, which is the plot in a cartesian chart
	 * but starts at the *centre* here — every dot to the left of it vanished. Spanning the plot from
	 * `−width/2` restores the reference's behaviour.
	 */
	const gradientX = $derived(-layer.width / 2);
	const gradientWidth = $derived(layer.width);

	const slots = setRadarSlotsContext();

	$effect.pre(() => {
		chart.registerRadar(id, dataKey);
		return () => chart.registerRadar(id, undefined);
	});

	const isSelected = $derived(chart.selectedDataKey === null || chart.selectedDataKey === dataKey);
	const isDimmed = $derived(isClickable && !isSelected);

	/**
	 * Opacity when another radar is selected. The stroke stays full (1) on the selected/normal
	 * radar; when dimmed the fill recedes twice as far as the stroke and dots (fill 0.1 vs 0.2) so
	 * the picked radar reads clearly. The fill value multiplies the `fillOpacity` prop;
	 * stroke/dot are absolute.
	 */
	const opacity = $derived({
		stroke: isDimmed ? 0.2 : 1,
		fill: isDimmed ? 0.1 : 1,
		dot: isDimmed ? 0.2 : 1
	});

	const isFilled = $derived(variant === 'filled');

	const dotVariant = $derived(slots.dot?.variant);
	const activeDotVariant = $derived(slots.activeDot?.variant);

	function select() {
		if (!isClickable) return;
		// Clicking the selected radar clears the selection, otherwise selects it
		chart.selectDataKey(chart.selectedDataKey === dataKey ? null : dataKey);
	}
</script>

<!-- The root renders the skeleton radar while loading, so real radars step aside -->
{#if !chart.isLoading}
	{@render children?.()}

	<!-- The intro scales the whole radar out of the centre, which is where this group sits. -->
	<g style={`transform: scale(${scale}); transform-origin: 0 0`}>
		<!--
		One closed path carrying both the fill and the stroke, which is what Recharts' `<Radar>`
		renders. `curveLinearClosed` joins the last vertex back to the first.
	-->
		<Spline
			seriesKey={dataKey}
			curve={curveLinearClosed}
			stroke={isDither && !isFilled ? 'transparent' : `url(#${id}-radar-stroke-${dataKey})`}
			strokeOpacity={opacity.stroke}
			strokeWidth={STROKE_WIDTH}
			fill={isFilled && !isDither ? `url(#${id}-radar-fill-${dataKey})` : 'transparent'}
			fillOpacity={isFilled ? fillOpacity * opacity.fill : 0}
			filter={isGlowing ? `url(#${id}-radar-glow-${dataKey})` : undefined}
			data-evil-dither-mark={isDither ? (isFilled ? 'fill' : 'stroke') : undefined}
			data-evil-dither-key={isDither ? dataKey : undefined}
			data-evil-dither-variant={isDither ? resolvedDitherVariant : undefined}
			data-evil-dither-glow={isDither && isGlowing ? 'true' : undefined}
			class={['transition-opacity duration-200', isClickable && 'cursor-pointer']
				.filter(Boolean)
				.join(' ')}
			onclick={select}
			motion="none"
			{...radarProps}
		/>

		{#if slots.dot}
			<!-- Resting point markers, one per vertex. -->
			<Points seriesKey={dataKey} fill="none" stroke="none">
				{#snippet children({ points })}
					{#each points as point, index (index)}
						<ChartDot
							cx={point.x}
							cy={point.y}
							type={dotVariant}
							{dataKey}
							chartId={id}
							fillOpacity={opacity.dot}
							{gradientX}
							{gradientWidth}
						/>
					{/each}
				{/snippet}
			</Points>
		{/if}

		{#if slots.activeDot}
			<Highlight axis="none">
				{#snippet points({ points })}
					{#each points.filter((p) => (p as { seriesKey?: string }).seriesKey === dataKey) as point, index (index)}
						<ChartDot
							cx={point.x}
							cy={point.y}
							type={activeDotVariant}
							{dataKey}
							chartId={id}
							fillOpacity={opacity.dot}
							{gradientX}
							{gradientWidth}
						/>
					{/each}
				{/snippet}
			</Highlight>
		{/if}
	</g>

	<defs>
		<ColorGradient {id} {dataKey} config={chart.config} />
		<StrokeGradient {id} {dataKey} config={chart.config} />
		{#if isFilled}
			<FillGradient {id} {dataKey} config={chart.config} />
		{/if}
		{#if isGlowing}
			<GlowFilter {id} {dataKey} />
		{/if}
	</defs>
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/tooltip-render.svelte`

```svelte
<script lang="ts">
	/** Renders the registered `<Tooltip />` slot: the floating box, as a sibling of `<Svg>`. */
	import { getChartContext } from 'layerchart';
	import {
		ChartTooltip,
		ChartTooltipContent,
		type TooltipPayloadItem
	} from '../../ui/layerchart-tooltip/index.js';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	const chart = useRadarChart();
	/** LayerChart's own context, to tell a hovered row from the `defaultIndex` one. */
	const layer = getChartContext();

	const slot = $derived(chart.slots.tooltip);

	/**
	 * Row shown when nothing is hovered — the reference's `defaultIndex`.
	 *
	 * LayerChart resolves its tooltip data as `dataProp ?? ctx.tooltip.data`, so passing `data`
	 * unconditionally pins the tooltip to that row forever: with `defaultIndex` set, hovering any
	 * other category still reported the default one. The hovered row therefore takes precedence
	 * here and `defaultRow` only fills in when nothing is hovered.
	 */
	const defaultRow = $derived(
		slot?.defaultIndex === undefined ? undefined : chart.data[slot.defaultIndex]
	);

	function toPayload(row: Record<string, unknown>): TooltipPayloadItem[] {
		return chart.seriesKeys.map((key) => ({
			dataKey: key,
			name: key,
			value: row[key] as number | string | null,
			payload: row
		}));
	}
</script>

{#if slot && !chart.isLoading}
	<ChartTooltip data={layer.tooltip.data ?? defaultRow}>
		{#snippet children({ data })}
			<!-- Read inline so changes to the hovered row re-derive the tooltip content. -->
			<ChartTooltipContent
				active
				payload={toPayload(data as Record<string, unknown>)}
				label={chart.angleKey
					? ((data as Record<string, unknown>)[chart.angleKey] as string)
					: undefined}
				selected={chart.selectedDataKey}
				roundness={slot.roundness}
				variant={slot.variant}
			/>
		{/snippet}
	</ChartTooltip>
{/if}
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/tooltip.svelte`

```svelte
<script lang="ts">
	/**
	 * The hover tooltip. Reads the chart's selection from context so its content
	 * dims unselected series. Hidden automatically while the chart is loading.
	 *
	 * Config-only: the tooltip box and its cursor cannot render inside `<Svg>`, so this
	 * registers its props and the root renders them in the right place.
	 */
	import type { TooltipRoundness, TooltipVariant } from '../../ui/layerchart-tooltip/index.js';
	import { useRadarChart } from './radar-chart-context.svelte.js';

	let {
		variant,
		roundness,
		defaultIndex
	}: {
		variant?: TooltipVariant; // visual style of the tooltip surface
		roundness?: TooltipRoundness; // border-radius of the tooltip
		defaultIndex?: number; // data index shown by default with no hover
	} = $props();

	const chart = useRadarChart();
	const token = $props.id();

	$effect.pre(() => {
		// The reference hardcodes `cursor={false}` on the radar's tooltip.
		chart.slots.registerTooltip(token, { variant, roundness, defaultIndex, cursor: false });
		return () => chart.slots.unregisterTooltip(token);
	});
</script>
```

`$lib/components/evilcharts/charts/layerchart-radar-chart/types.ts`

```ts
// Constants
export const STROKE_WIDTH = 1;
export const DEFAULT_FILL_OPACITY = 0.3;
export const LOADING_POINTS = 6;
export const LOADING_ANIMATION_DURATION = 1500; // in milliseconds
export const LOADING_RADAR_DATA_KEY = 'value';

/**
 * Recharts' `<Radar>` animation defaults, which the reference leaves switched on — unlike the
 * cartesian charts, it never passes `isAnimationActive={false}`, so the radar grows out of the
 * centre on mount.
 *
 * `Radar.js` interpolates every point from `(cx, cy)` to its final position, which for a polygon
 * centred on the origin is a uniform scale.
 */
export const REVEAL_BEGIN = 0; // <Radar animationBegin>, in milliseconds
export const REVEAL_DURATION = 1500; // <Radar animationDuration>, in milliseconds
/** `animationEasing: "ease"` — the CSS `ease` curve. */
export const REVEAL_EASE: [number, number, number, number] = [0.25, 0.1, 0.25, 1];

/**
 * Recharts' `<RadarChart outerRadius>` defaults to 80% of the largest circle that fits the plot,
 * which is `min(width, height) / 2`. LayerChart's radial `yRange` defaults to the full
 * `height / 2`, so the root sets it explicitly.
 */
export const DEFAULT_OUTER_RADIUS_RATIO = 0.8;

export type RadarVariant = 'filled' | 'lines';

/** Categories the loading skeleton cycles through. */
export const LOADING_CATEGORIES = ['A', 'B', 'C', 'D', 'E', 'F'];
```
        
      
       
        ### Add the chart component.
        

The chart needs these components to render. Create a `ui` folder inside `evilcharts` and paste the code there.

Below is the main chart component.


        
          ### $lib/components/evilcharts/ui/layerchart-chart

`$lib/components/evilcharts/ui/layerchart-chart/accessibility.ts`

```ts
/**
 * Accessible name and description for a chart root.
 *
 * A chart may be named directly or by visible text elsewhere on the page. Descriptions can be
 * supplied directly, linked from existing content, or both. The container exposes this as a
 * `group`, rather than an image, so interactive legends and marks remain discoverable.
 */
type ChartAccessibleName =
	{ label: string; labelledBy?: never } | { label?: never; labelledBy: string };

export type ChartAccessibility = ChartAccessibleName & {
	description?: string;
	describedBy?: string;
};
```

`$lib/components/evilcharts/ui/layerchart-chart/animated-grow.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';

	type GrowAnimation = {
		initial: { scaleX: number } | { scaleY: number };
		animate: { scaleX: number } | { scaleY: number };
		transition: {
			duration: number;
			delay: number;
			ease: number[];
		};
		style: { originX: number } | { originY: number };
	};

	let { animation, children }: { animation: GrowAnimation; children: Snippet } = $props();

	function runGrow(value: GrowAnimation) {
		return (node: SVGGElement) => {
			node.style.transformBox = 'fill-box';
			const options: KeyframeAnimationOptions = {
				duration: value.transition.duration * 1000,
				delay: value.transition.delay * 1000,
				easing: `cubic-bezier(${value.transition.ease.join(',')})`,
				fill: 'both'
			};

			if ('scaleX' in value.initial) {
				const to = 'scaleX' in value.animate ? value.animate.scaleX : 1;
				node.style.transformOrigin = '0% 50%';
				const animation = node.animate(
					[{ transform: `scaleX(${value.initial.scaleX})` }, { transform: `scaleX(${to})` }],
					options
				);
				return () => animation.cancel();
			}

			const to = 'scaleY' in value.animate ? value.animate.scaleY : 1;
			node.style.transformOrigin = '50% 100%';
			const animation = node.animate(
				[{ transform: `scaleY(${value.initial.scaleY})` }, { transform: `scaleY(${to})` }],
				options
			);
			return () => animation.cancel();
		};
	}
</script>

<g {@attach runGrow(animation)}>
	{@render children()}
</g>
```

`$lib/components/evilcharts/ui/layerchart-chart/bar-geometry.ts`

```ts
/**
 * Bar sizing and placement within a category, ported from Recharts' `getBarPositions`.
 *
 * LayerChart divides a band with a nested `scaleBand`, which cannot reproduce Recharts' numbers:
 * Recharts subtracts the category gap and the inter-bar gaps from the band, divides what is left
 * between the bars, and **floors the result to a whole pixel** — so a 49px band holding two bars
 * with `barCategoryGap="10%"` and `barGap={4}` yields bars of exactly 17px, not 17.6px. It also
 * supports a fixed `barSize`, which centres a group of that width in the band.
 *
 * The port therefore computes each bar's offset and width here and applies them as `insets` on
 * LayerChart's `<Bar>`, leaving the band itself undivided.
 */

/** Leading/trailing insets along one axis, as LayerChart's `<Bar insets>` takes them. */
export type BarInsets = { left?: number; right?: number; top?: number; bottom?: number };

export type BarSlot = {
	/** Distance from the band's leading edge to this bar's leading edge, in pixels. */
	offset: number;
	/** Width of this bar along the category axis, in pixels. */
	size: number;
};

/**
 * Recharts' `getPercentValue` for a gap: a `"10%"` string resolves against the band, a number is
 * taken as pixels, and the result is clamped into `[0, bandSize]`.
 */
function resolveGap(
	value: number | string | undefined,
	bandSize: number,
	fallback: number | string
) {
	const raw = value ?? fallback;
	const resolved =
		typeof raw === 'string' && raw.trim().endsWith('%')
			? (Number.parseFloat(raw) / 100) * bandSize
			: Number(raw);

	if (!Number.isFinite(resolved)) return 0;
	return Math.max(0, Math.min(resolved, bandSize));
}

export function getBarPositions({
	bandSize,
	count,
	barGap,
	barCategoryGap,
	barSize,
	maxBarSize
}: {
	/** The category band's full size along the category axis. */
	bandSize: number;
	/** How many bars share the category. Stacked series count as one. */
	count: number;
	/** Gap between bars sharing a category. Recharts' default is `4`. */
	barGap?: number | string;
	/** Gap on each side of the category. Recharts' default is `"10%"`. */
	barCategoryGap?: number | string;
	/** Fixed bar width. When set, the group is centred in the band at that width. */
	barSize?: number;
	/** Upper bound on the derived width. */
	maxBarSize?: number;
}): BarSlot[] {
	if (count < 1 || !(bandSize > 0)) return [];

	let realBarGap = resolveGap(barGap, bandSize, 4);

	if (barSize != null && Number.isFinite(barSize)) {
		let useFull = false;
		let fullBarSize = bandSize / count;
		let sum = count * barSize + (count - 1) * realBarGap;

		// Too wide to fit: first drop the gaps, then fall back to 90% of an even share.
		if (sum >= bandSize) {
			sum -= (count - 1) * realBarGap;
			realBarGap = 0;
		}
		if (sum >= bandSize && fullBarSize > 0) {
			useFull = true;
			fullBarSize *= 0.9;
			sum = count * fullBarSize;
		}

		// Recharts truncates the centring offset to a whole pixel (`>> 0`).
		const offset = Math.trunc((bandSize - sum) / 2);
		const size = useFull ? fullBarSize : barSize;

		return Array.from({ length: count }, (_, index) => ({
			offset: offset + (size + realBarGap) * index,
			size
		}));
	}

	const categoryOffset = resolveGap(barCategoryGap, bandSize, '10%');
	// No room left for gaps once the category inset is taken out.
	if (bandSize - 2 * categoryOffset - (count - 1) * realBarGap <= 0) realBarGap = 0;

	let originalSize = (bandSize - 2 * categoryOffset - (count - 1) * realBarGap) / count;
	// Recharts floors anything above a pixel (`>>= 0`), which is why bars land on whole pixels.
	if (originalSize > 1) originalSize = Math.trunc(originalSize);

	const size =
		maxBarSize != null && Number.isFinite(maxBarSize)
			? Math.min(originalSize, maxBarSize)
			: originalSize;

	// The stride uses the unclamped size, so `maxBarSize` narrows a bar in place rather than
	// re-packing the group — again matching Recharts.
	return Array.from({ length: count }, (_, index) => ({
		offset: categoryOffset + (originalSize + realBarGap) * index + (originalSize - size) / 2,
		size
	}));
}
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-config.ts`

```ts
import type { Component, Snippet } from 'svelte';

// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: '', dark: '.dark' } as const;

export type ThemeKey = keyof typeof THEMES;

// All Keys are optional at first
type ThemeColorsBase = {
	[K in ThemeKey]?: string[];
};

// Require at least one theme key
type AtLeastOneThemeColor = {
	[K in ThemeKey]: Required<Pick<ThemeColorsBase, K>> & Partial<Omit<ThemeColorsBase, K>>;
}[ThemeKey];

export const VALID_THEME_KEYS = Object.keys(THEMES) as ThemeKey[];

export { THEMES };

// Validation for chart config colors at runtime
export function validateChartConfigColors(config: ChartConfig): void {
	for (const [key, value] of Object.entries(config)) {
		if (value.colors) {
			const hasValidThemeKey = VALID_THEME_KEYS.some(
				(themeKey) => value.colors?.[themeKey] !== undefined
			);

			if (!hasValidThemeKey) {
				throw new Error(
					`[EvilCharts] Invalid chart config for "${key}": colors object must have at least one theme key (${VALID_THEME_KEYS.join(', ')}). Received empty object or invalid keys.`
				);
			}
		}
	}
}

export type ChartConfig = Record<
	string,
	{
		label?: string | Snippet;
		icon?: Component<Record<string, never>>;
		colors?: AtLeastOneThemeColor;
	}
>;

/** Validates that every config key also exists on the data row type. */
export type ValidateConfigKeys<TData, TConfig> = {
	[K in keyof TConfig]: K extends keyof TData ? ChartConfig[string] : never;
};
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-container.svelte`

```svelte
<script lang="ts">
	import type { HTMLAttributes } from 'svelte/elements';
	import type { Snippet } from 'svelte';
	import { cn } from '$lib/utils.js';
	import { validateChartConfigColors, type ChartConfig } from './chart-config.js';
	import type { ChartAccessibility } from './accessibility.js';
	import { setChartContext } from './chart-context.svelte.js';
	import ChartStyle from './chart-style.svelte';

	type Props = Omit<HTMLAttributes<HTMLDivElement>, 'children'> & {
		config: ChartConfig;
		children?: Snippet;
		/** Size used before the container has been measured. */
		initialDimension?: { width: number; height: number };
		/** @internal Resolved fallback-or-measured size used by chart roots. */
		dimension?: { width: number; height: number };
		/** Optional content rendered below the chart (e.g. EvilBrush) */
		footer?: Snippet;
		/** Accessible name and optional description for the chart as an interactive group. */
		accessibility?: ChartAccessibility;
	};

	let {
		id,
		config,
		initialDimension = { width: 320, height: 200 },
		dimension = $bindable(),
		class: className,
		children,
		footer,
		accessibility,
		...restProps
	}: Props = $props();

	const uniqueId = $props.id();
	const chartId = $derived(`chart-${id ?? uniqueId}`);
	const descriptionId = $derived(`${chartId}-description`);
	const describedBy = $derived(
		[accessibility?.description ? descriptionId : undefined, accessibility?.describedBy]
			.filter(Boolean)
			.join(' ') || undefined
	);
	let measuredWidth = $state(0);
	let measuredHeight = $state(0);
	const resolvedDimension = $derived(
		measuredWidth > 0 && measuredHeight > 0
			? { width: measuredWidth, height: measuredHeight }
			: initialDimension
	);

	$effect(() => {
		dimension = resolvedDimension;
	});

	// Validate chart config at runtime
	$effect.pre(() => {
		validateChartConfigColors(config);
	});

	setChartContext({
		get config() {
			return config;
		},
		get chartId() {
			return chartId;
		},
		get initialDimension() {
			return resolvedDimension;
		}
	});
</script>

<div
	data-slot="chart"
	data-chart={chartId}
	role={accessibility ? 'group' : undefined}
	aria-label={accessibility?.label}
	aria-labelledby={accessibility?.labelledBy}
	aria-describedby={describedBy}
	class={cn(
		'min-h-0 w-full flex-1',
		// Reference equivalents, retargeted from Recharts' `.recharts-*` hooks onto
		// LayerChart's `.lc-*` hooks.
		/*
			The grid and rule overrides are gated on `:not([stroke])`, mirroring the reference's
			`[&_.recharts-cartesian-grid_line[stroke='#ccc']]` / `[&_.recharts-polar-grid_[stroke='#ccc']]`
			selectors: they restyle only marks still carrying the library's *default* stroke and leave
			an explicitly-set one alone. Without the gate they also repainted the radar's polar grid,
			which sets `stroke="currentColor"` itself, washing the web out to `border/50`.
		*/
		"relative flex flex-col justify-center text-xs [&_.lc-arc-track]:fill-muted [&_.lc-axis-label]:[stroke:none] [&_.lc-axis-label]:text-xs [&_.lc-axis-label]:font-normal [&_.lc-axis-tick-label]:fill-[#666] [&_.lc-axis-tick-label]:[stroke:none] [&_.lc-axis-tick-label]:text-xs [&_.lc-axis-tick-label]:font-normal [&_.lc-axis[data-evil-scale='point']_.lc-axis-tick-group:last-of-type_.lc-axis-tick-label]:translate-x-[5px] [&_.lc-axis[data-evil-scale='point']_.lc-axis-tick-group:last-of-type_.lc-axis-tick-label]:[text-anchor:end] [&_.lc-grid-x-line:not([stroke])]:stroke-border/50 [&_.lc-grid-x-radial-line:not([stroke])]:stroke-border [&_.lc-grid-y-line:not([stroke])]:stroke-border/50 [&_.lc-grid-y-radial-circle:not([stroke])]:stroke-border [&_.lc-highlight-bar]:fill-muted [&_.lc-highlight-line]:stroke-border [&_.lc-highlight-point[stroke='#fff']]:stroke-transparent [&_.lc-layer]:outline-hidden [&_.lc-layout-svg]:outline-hidden [&_.lc-pie-arc]:outline-hidden [&_.lc-pie-arc[stroke='#fff']]:stroke-transparent [&_.lc-rule-x-line:not([stroke])]:stroke-border [&_.lc-rule-y-line:not([stroke])]:stroke-border",
		!footer && 'aspect-video',
		className
	)}
	{...restProps}
>
	{#if accessibility?.description}
		<span id={descriptionId} class="sr-only">{accessibility.description}</span>
	{/if}
	<ChartStyle id={chartId} {config} />
	<div
		class="relative flex min-h-0 w-full flex-1 flex-col"
		bind:clientWidth={measuredWidth}
		bind:clientHeight={measuredHeight}
	>
		{@render children?.()}
	</div>
	{@render footer?.()}
</div>
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-context.svelte.ts`

```ts
import { getContext, setContext } from 'svelte';
import type { ChartConfig } from './chart-config.js';

const CHART_CONTEXT_KEY = Symbol('evilcharts.chart');

export type ChartContextValue = {
	readonly config: ChartConfig;
	readonly chartId: string;
	/**
	 * Size the chart falls back to before its container has been measured — the reference
	 * passes this to Recharts' `<ResponsiveContainer initialDimension>`.
	 */
	readonly initialDimension: { width: number; height: number };
};

export function setChartContext(value: ChartContextValue) {
	setContext(CHART_CONTEXT_KEY, value);
	return value;
}

/**
 * Reads the container context, throwing a helpful error when used outside <ChartContainer />.
 */
export function useChart(): ChartContextValue {
	const context = getContext<ChartContextValue | undefined>(CHART_CONTEXT_KEY);

	if (!context) {
		throw new Error('useChart must be used within a <ChartContainer />');
	}

	return context;
}
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-slots.svelte.ts`

```ts
export type TooltipSlot = {
	variant?: 'default' | 'frosted-glass';
	roundness?: 'sm' | 'md' | 'lg' | 'xl';
	defaultIndex?: number;
	cursor?: boolean;
};

export type LegendSlot = {
	variant?:
		| 'square'
		| 'circle'
		| 'circle-outline'
		| 'rounded-square'
		| 'rounded-square-outline'
		| 'vertical-bar'
		| 'horizontal-bar';
	align?: 'left' | 'center' | 'right';
	verticalAlign?: 'top' | 'middle' | 'bottom';
	isClickable?: boolean;
};

/**
 * Reactive registrations for chart parts that render outside the plot SVG.
 *
 * A chart child records its tooltip or legend props here, and the root renders the matching HTML
 * layer in the correct place. Registrations use per-instance tokens because a chart subtree can
 * remount before the previous instance finishes tearing down. A stale cleanup must not clear the
 * newer live registration.
 */
export class ChartSlots {
	#tooltipToken: string | null = null;
	#legendToken: string | null = null;

	tooltip = $state<TooltipSlot | null>(null);
	legend = $state<LegendSlot | null>(null);

	registerTooltip(token: string, slot: TooltipSlot) {
		this.#tooltipToken = token;
		this.tooltip = slot;
	}

	unregisterTooltip(token: string) {
		if (this.#tooltipToken !== token) return;
		this.#tooltipToken = null;
		this.tooltip = null;
	}

	registerLegend(token: string, slot: LegendSlot) {
		this.#legendToken = token;
		this.legend = slot;
	}

	unregisterLegend(token: string) {
		if (this.#legendToken !== token) return;
		this.#legendToken = null;
		this.legend = null;
	}
}
```

`$lib/components/evilcharts/ui/layerchart-chart/chart-style.svelte`

```svelte
<script lang="ts">
	import { THEMES, type ChartConfig, type ThemeKey } from './chart-config.js';
	import { distributeColors, getColorsCount } from './colors.js';

	let { id, config }: { id: string; config: ChartConfig } = $props();

	const colorConfig = $derived(
		Object.entries(config).filter(([, itemConfig]) => itemConfig.colors)
	);

	function generateCssVars(theme: ThemeKey) {
		return colorConfig
			.flatMap(([key, itemConfig]) => {
				const colorsArray = itemConfig.colors?.[theme];
				if (!colorsArray || !Array.isArray(colorsArray) || colorsArray.length === 0) {
					return [];
				}

				// Get max count across all themes for this key
				const maxCount = getColorsCount(itemConfig);

				// Distribute colors evenly across all required slots
				const distributedColors = distributeColors(colorsArray, maxCount);

				return distributedColors.map((color, index) => `  --color-${key}-${index}: ${color};`);
			})
			.filter(Boolean)
			.join('\n');
	}

	const css = $derived(
		Object.entries(THEMES)
			.map(
				([theme, prefix]) =>
					`${prefix} [data-chart=${id}] {\n${generateCssVars(theme as ThemeKey)}\n}`
			)
			.join('\n')
	);
</script>

{#if colorConfig.length}
	<!-- A plain <style> element in a Svelte template is scoped-compiled, so the tag is built
	     dynamically to emit global CSS — the equivalent of the reference's
	     `<style dangerouslySetInnerHTML>`. Building it as an element (rather than {@html})
	     means the CSS text can never be parsed as markup. -->
	<svelte:element this={"style"}>{css}</svelte:element>
{/if}
```

`$lib/components/evilcharts/ui/layerchart-chart/colors.ts`

```ts
import { VALID_THEME_KEYS, type ChartConfig } from './chart-config.js';

// Distribute colors evenly across slots, extra slots go to last color(s)
// Example: 2 colors for 4 slots → [red, red, pink, pink]
// Example: 3 colors for 4 slots → [red, pink, blue, blue]
export function distributeColors(colorsArray: string[], maxCount: number): string[] {
	const availableCount = colorsArray.length;
	if (availableCount >= maxCount) {
		return colorsArray.slice(0, maxCount);
	}

	const result: string[] = [];
	const baseSlots = Math.floor(maxCount / availableCount);
	const extraSlots = maxCount % availableCount;

	// First (availableCount - extraSlots) colors get baseSlots each
	// Last extraSlots colors get (baseSlots + 1) each
	for (let colorIdx = 0; colorIdx < availableCount; colorIdx++) {
		const isExtraColor = colorIdx >= availableCount - extraSlots;
		const slotsForThisColor = baseSlots + (isExtraColor ? 1 : 0);
		for (let j = 0; j < slotsForThisColor; j++) {
			result.push(colorsArray[colorIdx]);
		}
	}

	return result;
}

// Get max colors count across all themes for a config entry
export function getColorsCount(config: ChartConfig[string]): number {
	if (!config.colors) return 1;
	const counts = VALID_THEME_KEYS.map((theme) => config.colors?.[theme]?.length ?? 0);
	return Math.max(...counts, 1);
}
```

`$lib/components/evilcharts/ui/layerchart-chart/curves.ts`

```ts
import {
	curveBasis,
	curveBasisClosed,
	curveBasisOpen,
	curveBumpX,
	curveBumpY,
	curveLinear,
	curveLinearClosed,
	curveMonotoneX,
	curveMonotoneY,
	curveNatural,
	curveStep,
	curveStepAfter,
	curveStepBefore,
	type CurveFactory
} from 'd3-shape';

/**
 * The curve names Recharts accepts on `<Area type>` / `<Line type>`. Kept as the public
 * `curveType` union so every chart's API reads exactly as it does in the reference.
 */
export type CurveType =
	| 'basis'
	| 'basisClosed'
	| 'basisOpen'
	| 'bumpX'
	| 'bumpY'
	| 'bump'
	| 'linear'
	| 'linearClosed'
	| 'natural'
	| 'monotoneX'
	| 'monotoneY'
	| 'monotone'
	| 'step'
	| 'stepBefore'
	| 'stepAfter';

/**
 * Recharts resolves each `type` to the identically named d3-shape curve — `bump` and
 * `monotone` being the two aliases, which it maps to the X-oriented variants for the
 * default horizontal layout. LayerChart takes the d3 curve factory directly, so the
 * mapping is all that stands between the two APIs.
 */
const CURVES: Record<CurveType, CurveFactory> = {
	basis: curveBasis,
	basisClosed: curveBasisClosed as CurveFactory,
	basisOpen: curveBasisOpen as CurveFactory,
	bumpX: curveBumpX,
	bumpY: curveBumpY,
	bump: curveBumpX,
	linear: curveLinear,
	linearClosed: curveLinearClosed as CurveFactory,
	natural: curveNatural,
	monotoneX: curveMonotoneX,
	monotoneY: curveMonotoneY,
	monotone: curveMonotoneX,
	step: curveStep,
	stepBefore: curveStepBefore,
	stepAfter: curveStepAfter
};

/**
 * Resolve a Recharts `type` string to the d3-shape curve LayerChart marks expect.
 *
 * The three closed/open variants are line-only in d3's typings; they are cast to `CurveFactory`
 * because Recharts accepts them on `<Area type>` as well and behaves the same way — the closing
 * segment simply has no area counterpart.
 */
export function resolveCurve(type: CurveType | undefined): CurveFactory {
	return CURVES[type ?? 'linear'] ?? curveLinear;
}

export const CURVE_TYPES = Object.keys(CURVES) as CurveType[];
```

`$lib/components/evilcharts/ui/layerchart-chart/format.ts`

```ts
// Format values to percent for expanded charts
export function axisValueToPercentFormatter(value: number) {
	return `${Math.round(value * 100).toFixed(0)}%`;
}
```

`$lib/components/evilcharts/ui/layerchart-chart/index.ts`

```ts
export { default as ChartContainer } from './chart-container.svelte';
export { default as ChartStyle } from './chart-style.svelte';
export { default as LoadingIndicator } from './loading-indicator.svelte';
export type { ChartAccessibility } from './accessibility.js';

export {
	THEMES,
	VALID_THEME_KEYS,
	validateChartConfigColors,
	type ChartConfig,
	type ThemeKey,
	type ValidateConfigKeys
} from './chart-config.js';
export { setChartContext, useChart, type ChartContextValue } from './chart-context.svelte.js';
export { distributeColors, getColorsCount } from './colors.js';
export { getPayloadConfigFromPayload } from './payload.js';
export { axisValueToPercentFormatter } from './format.js';
export { getLoadingData, LOADING_CATEGORY_DATA_KEY } from './loading.js';
export { resolveCurve, CURVE_TYPES, type CurveType } from './curves.js';
export { getBarPositions, type BarSlot, type BarInsets } from './bar-geometry.js';
export { dropOverflowingLeadTick, rechartsValueAxisTicks, thinAxisTicks } from './ticks.js';
```

`$lib/components/evilcharts/ui/layerchart-chart/intros.ts`

```ts
export type IntroAction = 'reset' | 'animate' | 'finish' | 'none';

/** Decides how a polar mark responds to the chart loading lifecycle. */
export function polarIntroAction(
	wasLoading: boolean | undefined,
	isLoading: boolean,
	reduceMotion: boolean
): IntroAction {
	if (isLoading) return 'reset';
	if (reduceMotion) return 'finish';
	if (wasLoading === undefined || wasLoading) return 'animate';
	return 'none';
}

/**
 * Builds a one-shot wipe animation anchored to the chart root's mount timestamp.
 * Keyed LayerChart remounts therefore resume at elapsed progress and can never jump backwards.
 */
export function getRevealAnimation(
	durationSeconds: number,
	ease: [number, number, number, number],
	startedAt: number,
	now = Date.now()
) {
	const durationMs = durationSeconds * 1000;
	const elapsed = Math.max(0, now - startedAt);
	if (elapsed >= durationMs) return null;

	const progress = durationMs > 0 ? elapsed / durationMs : 1;
	return {
		initial: { scaleX: progress },
		animate: { scaleX: 1 },
		transition: {
			duration: Math.max(0, durationSeconds - elapsed / 1000),
			ease
		}
	};
}
```

`$lib/components/evilcharts/ui/layerchart-chart/loading-indicator.svelte`

```svelte
<script lang="ts">
	let { isLoading }: { isLoading: boolean } = $props();
</script>

{#if isLoading}
	<div class="pointer-events-none absolute inset-0 z-20 flex items-center justify-center">
		<div
			class="flex items-center justify-center gap-2 rounded-md border bg-background px-2 py-0.5 text-sm text-primary"
		>
			<div
				class="h-3 w-3 animate-spin rounded-full border border-border border-t-primary motion-reduce:animate-none"
			></div>
			<span>Loading</span>
		</div>
	</div>
{/if}
```

`$lib/components/evilcharts/ui/layerchart-chart/loading.ts`

```ts
// Generate random loading data for skeleton/loading state
// min/max represent percentage of the range (0-100), defaults to 20-80 for realistic look
/** Internal ordinal key that lets LayerChart spread generated loading rows across a category scale. */
export const LOADING_CATEGORY_DATA_KEY = '__loadingCategory';

export const getLoadingData = (points: number = 10, min: number = 0, max: number = 70) => {
	const range = max - min;
	return Array.from({ length: points }, (_, index) => ({
		[LOADING_CATEGORY_DATA_KEY]: index,
		loading: Math.floor(Math.random() * range) + min
	}));
};
```

`$lib/components/evilcharts/ui/layerchart-chart/payload.ts`

```ts
import type { ChartConfig } from './chart-config.js';

// Helper to extract item config from a payload.
export function getPayloadConfigFromPayload(config: ChartConfig, payload: unknown, key: string) {
	if (typeof payload !== 'object' || payload === null) {
		return undefined;
	}

	const payloadPayload =
		'payload' in payload && typeof payload.payload === 'object' && payload.payload !== null
			? payload.payload
			: undefined;

	let configLabelKey: string = key;

	if (key in payload && typeof payload[key as keyof typeof payload] === 'string') {
		configLabelKey = payload[key as keyof typeof payload] as string;
	} else if (
		payloadPayload &&
		key in payloadPayload &&
		typeof payloadPayload[key as keyof typeof payloadPayload] === 'string'
	) {
		configLabelKey = payloadPayload[key as keyof typeof payloadPayload] as string;
	}

	return configLabelKey in config ? config[configLabelKey] : config[key];
}
```

`$lib/components/evilcharts/ui/layerchart-chart/ticks.ts`

```ts
import type { AnyScale } from 'layerchart';

/** Converts LayerChart's x-axis tick origin to Recharts' `tickMargin` baseline. */
export const RECHARTS_X_AXIS_TICK_OFFSET = 5.5;

/**
 * Axis tick values with a leading tick dropped when its label cannot fit inside the plot.
 *
 * Recharts' `<XAxis>` runs `interval="preserveEnd"`: it keeps the last tick unconditionally and
 * discards any earlier one that will not fit. For a **point** scale with no outer padding that
 * always means the first tick — its label is centred on the plot's very left edge, so half of it
 * would spill outside. A **band** scale centres the label in the band instead, half a step in, so
 * nothing is dropped. LayerChart draws every tick it is given, so the same filter is applied here.
 *
 * Pass it straight to `<Axis ticks={dropOverflowingLeadTick}>`.
 */
export function dropOverflowingLeadTick(scale: AnyScale): unknown[] {
	const values = scale.domain() as unknown[];
	if (values.length < 2) return values;

	const range = scale.range() as number[];
	const start = Math.min(...range);

	// A band scale reports its band's leading edge, but the label sits at the band's centre.
	const bandOffset =
		typeof (scale as { bandwidth?: () => number }).bandwidth === 'function'
			? ((scale as { bandwidth: () => number }).bandwidth() ?? 0) / 2
			: 0;

	const first = (scale as (value: unknown) => number | undefined)(values[0]);
	if (typeof first !== 'number') return values;

	// Less than a pixel of room means the label is centred on the boundary itself.
	return first + bandOffset - start < 1 ? values.slice(1) : values;
}

/**
 * Axis tick values thinned so their labels do not collide, the way Recharts' `interval="preserveEnd"`
 * does: it keeps the last tick and walks backwards, dropping any tick whose label would come within
 * `minTickGap` of the one already kept. LayerChart's `tickSpacing` cannot do this — it only derives a
 * tick *count*, and it is disabled outright for band scales.
 *
 * Recharts measures real text; there is no rendered text to measure before the axis draws, so the
 * width is estimated from the label's length. `charWidth` defaults to a 12px monospace-ish advance,
 * which is what these axes use.
 *
 * Pass it to `<Axis ticks={…}>`; it also applies `dropOverflowingLeadTick`'s boundary rule.
 */
export function thinAxisTicks({
	format,
	minGap = 5,
	charWidth = 6.6,
	leadingInset = 0
}: {
	/** Renders a domain value the way the axis will, so its width can be estimated. */
	format: (value: unknown, index: number) => string;
	/** Recharts' `minTickGap`, which defaults to 5. */
	minGap?: number;
	/** Estimated advance per character, in pixels. */
	charWidth?: number;
	/** Space between the SVG edge and the scale range (for example a rendered Y axis). */
	leadingInset?: number;
}) {
	return (scale: AnyScale): unknown[] => {
		const domain = scale.domain() as unknown[];
		if (domain.length < 2) return domain;

		const bandOffset =
			typeof (scale as { bandwidth?: () => number }).bandwidth === 'function'
				? ((scale as { bandwidth: () => number }).bandwidth() ?? 0) / 2
				: 0;

		const centreOf = (value: unknown) =>
			Number((scale as (v: unknown) => number)(value)) + bandOffset;
		const halfWidthOf = (value: unknown) =>
			(format(
				value,
				domain.findIndex((candidate) => Object.is(candidate, value))
			).length *
				charWidth) /
			2;
		const range = scale.range() as number[];
		const endBoundary = Math.max(...range);

		// Recharts moves the final label just far enough inward for its trailing edge to stay inside
		// the axis view box. That shifted label then owns the collision boundary, which is why a
		// narrow Jan–Dec axis keeps Dec but drops Nov even though the unshifted labels would fit.
		const kept: unknown[] = [];
		let nextHeadEdge = Number.POSITIVE_INFINITY;

		for (let index = domain.length - 1; index >= 0; index -= 1) {
			const value = domain[index];
			const half = halfWidthOf(value);
			const centre = centreOf(value);

			// The first point-scale label may use space before the plot when a Y axis has reserved it.
			// Clip against the SVG's physical leading edge (0), not the scale range's first position.
			if (index === 0 && leadingInset + centre - half < 0) continue;

			const adjustedCentre =
				index === domain.length - 1 ? Math.min(centre, endBoundary - half) : centre;
			const tail = adjustedCentre + half;

			if (tail + minGap <= nextHeadEdge) {
				kept.push(value);
				nextHeadEdge = adjustedCentre - half;
			}
		}

		return kept.reverse();
	};
}

/**
 * Recharts' numeric axes default to five ticks and include both ends of the resolved domain.
 * D3's `scale.ticks(5)` instead chooses a rounded step and can omit the upper endpoint (for
 * example `[0, 500, 1000, 1500]` for a `[0, 1800]` domain), so LayerChart needs explicit values.
 */
export function rechartsValueAxisTicks(scale: AnyScale, count = 5): unknown[] {
	const domain = scale.domain() as unknown[];
	const start = Number(domain[0]);
	const end = Number(domain.at(-1));
	if (!Number.isFinite(start) || !Number.isFinite(end) || count < 2) return domain;

	const step = (end - start) / (count - 1);
	return Array.from({ length: count }, (_, index) =>
		Number((start + step * index).toPrecision(12))
	);
}

/** Recharts' hidden 6px tick length still contributes to an auto-sized Y-axis. */
export const RECHARTS_VALUE_AXIS_TICK_LENGTH = 6;

/**
 * Resolves Recharts' `width="auto"` gutter from already measured tick labels.
 *
 * Recharts rounds the widest label to the nearest pixel, then adds the configured tick margin and
 * the default 6px tick length (even when `tickLine={false}`). Keeping this as a pure helper makes
 * the browser-only canvas measurement easy to test independently.
 */
export function rechartsAutoYAxisWidth(
	labelWidths: number[],
	tickMargin = 8,
	tickLength = RECHARTS_VALUE_AXIS_TICK_LENGTH
): number {
	return Math.round(Math.max(0, ...labelWidths) + tickMargin + tickLength);
}

/** Measures value-axis labels in the same 12px inherited font used by the chart container. */
export function measureRechartsYAxisWidth(labels: string[], tickMargin = 8): number {
	if (typeof document === 'undefined') return 42;

	const canvas = document.createElement('canvas');
	const context = canvas.getContext('2d');
	if (!context) return 42;

	const family = getComputedStyle(document.body).fontFamily;
	context.font = `400 12px ${family}`;
	return rechartsAutoYAxisWidth(
		labels.map((label) => context.measureText(label).width),
		tickMargin
	);
}

/**
 * Keeps the second argument that LayerChart supplies to format functions at runtime.
 *
 * Its public `FormatType` currently describes a single-argument callback even though Axis invokes
 * it as `format(tick, index)`. Recharts exposes that index, so this adapter keeps the runtime value
 * while remaining assignable to LayerChart's narrower callback type.
 */
export function layerChartFormatter(
	formatter: (value: unknown, index: number) => string
): (value: unknown, index?: number) => string {
	return (value, index = 0) => formatter(value, index);
}
```
        
      
       
        ### Add the sub components.
        

Create `tooltip.svelte` inside `evilcharts/ui` and paste the code there.


        
          ### $lib/components/evilcharts/ui/layerchart-tooltip

`$lib/components/evilcharts/ui/layerchart-tooltip/index.ts`

```ts
export { default as ChartTooltip } from './tooltip.svelte';
export { default as ChartTooltipContent } from './tooltip-content.svelte';
export { getIndicatorColorStyle, roundnessMap, variantMap } from './styles.js';
export type {
	TooltipIndicator,
	TooltipPayloadItem,
	TooltipRoundness,
	TooltipVariant
} from './types.js';
```

`$lib/components/evilcharts/ui/layerchart-tooltip/styles.ts`

```ts
import { getColorsCount } from '../layerchart-chart/colors.js';
import type { ChartConfig } from '../layerchart-chart/chart-config.js';
import type { TooltipRoundness, TooltipVariant } from './types.js';

export const roundnessMap: Record<TooltipRoundness, string> = {
	sm: 'rounded-sm',
	md: 'rounded-md',
	lg: 'rounded-lg',
	xl: 'rounded-xl'
};

export const variantMap: Record<TooltipVariant, string> = {
	default: 'bg-background',
	'frosted-glass': 'bg-background/70 backdrop-blur-sm'
};

export function getIndicatorColorStyle(dataKey: string, colorsCount: number): string {
	if (colorsCount <= 1) {
		return `background: var(--color-${dataKey}-0)`;
	}

	// Multiple colors: create linear gradient with evenly distributed stops
	const stops = Array.from({ length: colorsCount }, (_, index) => {
		const offset = (index / (colorsCount - 1)) * 100;
		return `var(--color-${dataKey}-${index}) ${offset}%`;
	}).join(', ');

	return `background: linear-gradient(to right, ${stops})`;
}

export function colorsCountFor(itemConfig: ChartConfig[string] | undefined): number {
	return itemConfig ? getColorsCount(itemConfig) : 1;
}
```

`$lib/components/evilcharts/ui/layerchart-tooltip/tooltip-content.svelte`

```svelte
<script lang="ts">
	import type { Snippet } from 'svelte';
	import { cn } from '$lib/utils.js';
	import { getPayloadConfigFromPayload } from '../layerchart-chart/payload.js';
	import { useChart } from '../layerchart-chart/chart-context.svelte.js';
	import { colorsCountFor, getIndicatorColorStyle, roundnessMap, variantMap } from './styles.js';
	import type { ChartConfig } from '../layerchart-chart/chart-config.js';
	import type {
		TooltipIndicator,
		TooltipPayloadItem,
		TooltipRoundness,
		TooltipVariant
	} from './types.js';

	type Props = {
		active?: boolean;
		payload?: TooltipPayloadItem[];
		class?: string;
		indicator?: TooltipIndicator;
		hideLabel?: boolean;
		hideIndicator?: boolean;
		label?: unknown;
		labelFormatter?: Snippet<[unknown, TooltipPayloadItem[]]>;
		labelClassName?: string;
		formatter?: Snippet<[TooltipPayloadItem['value'], string, TooltipPayloadItem, number, unknown]>;
		nameKey?: string;
		labelKey?: string;
		selected?: string | null;
		roundness?: TooltipRoundness;
		variant?: TooltipVariant;
	};

	let {
		active,
		payload,
		class: className,
		indicator = 'dot',
		hideLabel = false,
		hideIndicator = false,
		label,
		labelFormatter,
		labelClassName,
		formatter,
		nameKey,
		labelKey,
		selected,
		roundness = 'lg',
		variant = 'default'
	}: Props = $props();

	const { config } = $derived(useChart());

	const labelValue = $derived.by(() => {
		if (hideLabel || !payload?.length) {
			return null;
		}

		const [item] = payload;
		const key = `${labelKey ?? item?.dataKey ?? item?.name ?? 'value'}`;
		const itemConfig = getPayloadConfigFromPayload(config, item, key);

		return !labelKey && typeof label === 'string'
			? (config[label]?.label ?? label)
			: itemConfig?.label;
	});

	const nestLabel = $derived(payload?.length === 1 && indicator !== 'dot');

	type Row = {
		item: TooltipPayloadItem;
		index: number;
		key: string;
		itemConfig: ChartConfig[string] | undefined;
		colorsCount: number;
		isDimmed: boolean;
		/** Resolved before rendering so changes to the row re-derive it. */
		icon: ChartConfig[string]['icon'];
	};

	/**
	 * Fully resolved rows.
	 *
	 * Derived as one list rather than with in-markup declarations so a later `selected` change
	 * re-derives every row — the reference recomputes these inline on each React render.
	 */
	const rows = $derived<Row[]>(
		(payload ?? [])
			.filter((item) => item.type !== 'none')
			.map((item, index) => {
				// For pie charts, item.name contains the sector name (e.g., "chrome")
				// For radial charts, the name is in item.payload[nameKey]
				// For other charts, item.name or item.dataKey contains the series name
				const payloadName =
					nameKey && item.payload ? (item.payload as Record<string, unknown>)[nameKey] : undefined;
				const key = `${payloadName ?? item.name ?? item.dataKey ?? 'value'}`;
				const itemConfig = getPayloadConfigFromPayload(config, item, key);

				return {
					item,
					index,
					key,
					itemConfig,
					// Get colors count for this item to determine gradient vs solid
					colorsCount: colorsCountFor(itemConfig),
					isDimmed: selected != null && selected !== item.dataKey,
					icon: itemConfig?.icon
				};
			})
	);
</script>

{#snippet tooltipLabel()}
	{#if !hideLabel && payload?.length}
		{#if labelFormatter}
			<div class={cn('font-medium', labelClassName)}>
				{@render labelFormatter(labelValue, payload)}
			</div>
		{:else if labelValue}
			<div class={cn('font-medium', labelClassName)}>
				{#if typeof labelValue === 'string'}{labelValue}{:else}{@render labelValue()}{/if}
			</div>
		{/if}
	{/if}
{/snippet}

{#if !active || !payload?.length}
	<!-- Empty tooltip - to prevent position getting 0.0 so it doesnt animate tooltip every time from 0.0 origin -->
	<span class="p-4"></span>
{:else}
	<div
		class={cn(
			'grid min-w-32 items-start gap-1.5 border border-border/50 px-2.5 py-1.5 text-xs shadow-xl',
			roundnessMap[roundness],
			variantMap[variant],
			className
		)}
	>
		{#if !nestLabel}{@render tooltipLabel()}{/if}
		<div class="grid gap-1.5">
			{#each rows as row (row.index)}
				<div
					class={cn(
						'flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground',
						indicator === 'dot' && 'items-center',
						row.isDimmed && 'opacity-30'
					)}
				>
					{#if formatter && row.item?.value !== undefined && row.item.name}
						{@render formatter(
							row.item.value,
							row.item.name,
							row.item,
							row.index,
							row.item.payload
						)}
					{:else}
						{#if row.icon}
							{const Icon = row.icon}
							<Icon />
						{:else if !hideIndicator}
							<div
								class={cn('shrink-0 rounded-[2px]', {
									'h-2.5 w-2.5': indicator === 'dot',
									'w-1': indicator === 'line',
									'w-0 border-[1.5px] border-dashed bg-transparent!': indicator === 'dashed',
									'my-0.5': nestLabel && indicator === 'dashed'
								})}
								style={getIndicatorColorStyle(row.key, row.colorsCount)}
							></div>
						{/if}
						<div
							class={cn(
								'flex flex-1 justify-between gap-4 leading-none',
								nestLabel ? 'items-end' : 'items-center'
							)}
						>
							<div class="grid gap-1.5">
								{#if nestLabel}{@render tooltipLabel()}{/if}
								<span class="text-muted-foreground">
									{#if row.itemConfig?.label}
										{#if typeof row.itemConfig.label === 'string'}
											{row.itemConfig.label}
										{:else}
											{@render row.itemConfig.label()}
										{/if}
									{:else}
										{row.item.name}
									{/if}
								</span>
							</div>
							{#if row.item.value != null}
								<span class="font-mono font-medium text-foreground tabular-nums">
									{typeof row.item.value === 'number'
										? row.item.value.toLocaleString()
										: String(row.item.value)}
								</span>
							{/if}
						</div>
					{/if}
				</div>
			{/each}
		</div>
	</div>
{/if}
```

`$lib/components/evilcharts/ui/layerchart-tooltip/tooltip.svelte`

```svelte
<script lang="ts">
	import { Tooltip } from 'layerchart';
	import type { ComponentProps, Snippet } from 'svelte';
	import { useChart } from '../layerchart-chart/index.js';

	type Props = Omit<ComponentProps<typeof Tooltip.Root>, 'children' | 'variant'> & {
		children: Snippet<[{ data: unknown }]>;
	};

	let { children: content, props, ...restProps }: Props = $props();

	const { chartId } = useChart();
</script>

<!-- `variant="none"` drops LayerChart's own tooltip chrome so <ChartTooltipContent> owns the
     entire look, matching the reference where Recharts' `<Tooltip content>` replaces the
     default box. Position motion is left at LayerChart's default spring.

     LayerChart's default body portal escapes overflow boundaries. Repeating the chart's
     `data-chart` value on the portaled root keeps the scoped `--color-*` variables available. -->
<Tooltip.Root
	variant="none"
	{...restProps}
	props={{ ...props, root: { ...props?.root, 'data-chart': chartId } }}
>
	{#snippet children({ data })}
		{@render content({ data })}
	{/snippet}
</Tooltip.Root>
```

`$lib/components/evilcharts/ui/layerchart-tooltip/types.ts`

```ts
export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl';
export type TooltipVariant = 'default' | 'frosted-glass';

export type TooltipIndicator = 'line' | 'dot' | 'dashed';

/**
 * One row of tooltip data.
 *
 * Mirrors the shape Recharts hands `<Tooltip content>` in the reference, so the content
 * component stays renderer-agnostic exactly as the reference's is. Each chart's `Tooltip`
 * part builds this list from LayerChart's `TooltipContext`.
 */
export type TooltipPayloadItem = {
	dataKey?: string;
	name?: string;
	value?: number | string | null;
	payload?: unknown;
	/** Rows with `type: 'none'` are hidden, as in Recharts. */
	type?: string;
	color?: string;
};
```
        
        

Then create `legend.svelte` in the same folder and paste the code there.


        
          ### $lib/components/evilcharts/ui/layerchart-legend

`$lib/components/evilcharts/ui/layerchart-legend/index.ts`

```ts
export { default as ChartLegendContent } from './legend-content.svelte';
export { default as LegendIndicator } from './legend-indicator.svelte';
export { getLegendFillStyle, getLegendOutlineStyle } from './styles.js';
export { resolveLegendPlacement } from './types.js';
export type {
	ChartLegendVariant,
	LegendAlign,
	LegendPayloadItem,
	LegendVerticalAlign
} from './types.js';
```

`$lib/components/evilcharts/ui/layerchart-legend/legend-content.svelte`

```svelte
<script lang="ts">
	import { cn } from '$lib/utils.js';
	import { getColorsCount } from '../layerchart-chart/colors.js';
	import { getPayloadConfigFromPayload } from '../layerchart-chart/payload.js';
	import { useChart } from '../layerchart-chart/chart-context.svelte.js';
	import LegendIndicator from './legend-indicator.svelte';
	import type { ChartConfig } from '../layerchart-chart/chart-config.js';
	import type {
		ChartLegendVariant,
		LegendAlign,
		LegendPayloadItem,
		LegendVerticalAlign
	} from './types.js';

	type Props = {
		class?: string;
		hideIcon?: boolean;
		nameKey?: string;
		payload?: LegendPayloadItem[];
		verticalAlign?: LegendVerticalAlign;
		align?: LegendAlign;
		selected?: string | null;
		isClickable?: boolean;
		onSelectChange?: (selected: string | null) => void;
		variant?: ChartLegendVariant;
	};

	let {
		class: className,
		hideIcon = false,
		nameKey,
		payload,
		verticalAlign,
		align = 'right',
		selected,
		isClickable,
		onSelectChange,
		variant = 'rounded-square'
	}: Props = $props();

	const { config } = $derived(useChart());

	type Entry = {
		key: string;
		itemConfig: ChartConfig[string] | undefined;
		colorsCount: number;
		isSelected: boolean;
		/** Resolved before rendering so changes to the entry re-derive it. */
		icon: ChartConfig[string]['icon'];
	};

	/**
	 * Fully resolved entries.
	 *
	 * Everything each row needs is derived here rather than with in-markup declarations, so a later
	 * `selected` change re-derives the whole list — the reference recomputes these inline on every
	 * React render.
	 */
	/**
	 * Recharts' `<Legend>` defaults to `itemSorter="value"`, so its entries come out ordered by
	 * series name rather than in config order — a composed chart of `revenue` + `profit` lists
	 * "Profit" first, and a pie of browsers lists them alphabetically. The comparison matches
	 * lodash's `compareAscending`, which is what `sortBy` uses: plain `<` on the raw value, with
	 * anything missing sorted last. (Its `<Tooltip itemSorter>` only reaches the *default* tooltip
	 * content, so tooltip rows stay in data order — the reference behaves the same way.)
	 */
	function byValueAscending(a: LegendPayloadItem, b: LegendPayloadItem) {
		const left = a.value;
		const right = b.value;
		if (left === right) return 0;
		if (left === undefined) return 1;
		if (right === undefined) return -1;
		return left < right ? -1 : 1;
	}

	const entries = $derived<Entry[]>(
		(payload ?? [])
			.filter((item) => item.type !== 'none')
			.toSorted(byValueAscending)
			.map((item) => {
				// For pie charts, item.value contains the sector name (e.g., "chrome")
				// For radial charts, the name is in item.payload[nameKey]
				// For other charts, item.dataKey contains the series name (e.g., "desktop")
				const payloadName =
					nameKey && item.payload ? (item.payload as Record<string, unknown>)[nameKey] : undefined;
				const key = `${payloadName ?? item.value ?? item.dataKey ?? 'value'}`;
				const itemConfig = getPayloadConfigFromPayload(config, item, key);

				return {
					key,
					itemConfig,
					// Get colors count for this item to determine gradient vs solid
					colorsCount: itemConfig ? getColorsCount(itemConfig) : 1,
					isSelected: selected === null || selected === undefined || selected === key,
					icon: itemConfig?.icon
				};
			})
	);

	function entryClass(isSelected: boolean) {
		return cn(
			'[&>svg]:text-muted-foreground flex items-center gap-1.5 transition-opacity [&>svg]:h-3 [&>svg]:w-3',
			!isSelected && 'opacity-30',
			isClickable && 'cursor-pointer'
		);
	}

	function select(key: string) {
		if (!isClickable) return;

		onSelectChange?.(selected === key ? null : key);
	}
</script>

{#snippet indicator(entry: Entry)}
	{#if entry.icon && !hideIcon}
		{const Icon = entry.icon}
		<Icon />
	{:else}
		<LegendIndicator {variant} dataKey={entry.key} colorsCount={entry.colorsCount} />
	{/if}
	{#if entry.itemConfig?.label}
		{#if typeof entry.itemConfig.label === 'string'}
			{entry.itemConfig.label}
		{:else}
			{@render entry.itemConfig.label()}
		{/if}
	{/if}
{/snippet}

{#if entries.length}
	<div
		class={cn(
			'relative z-10 flex items-center gap-4 select-none',
			align === 'left' && 'justify-start',
			align === 'center' && 'justify-center',
			align === 'right' && 'justify-end',
			verticalAlign === 'top' && 'pb-4',
			verticalAlign === 'bottom' && 'pt-4',
			className
		)}
	>
		{#each entries as entry (entry.key)}
			{#if isClickable}
				<!-- The reference makes the entry clickable with a bare onClick. Keyboard support is
				     added here without changing the rendered class list. -->
				<button
					type="button"
					class={entryClass(entry.isSelected)}
					aria-pressed={selected === entry.key}
					onclick={() => select(entry.key)}
				>
					{@render indicator(entry)}
				</button>
			{:else}
				<div class={entryClass(entry.isSelected)}>
					{@render indicator(entry)}
				</div>
			{/if}
		{/each}
	</div>
{/if}
```

`$lib/components/evilcharts/ui/layerchart-legend/legend-indicator.svelte`

```svelte
<script lang="ts">
	// ---------------------------------------------------------------------------
	// Legend indicator — each variant gets its own branch so future variants
	// can diverge freely in markup & style.
	// ---------------------------------------------------------------------------
	import { getLegendFillStyle, getLegendOutlineStyle } from './styles.js';
	import type { ChartLegendVariant } from './types.js';

	let {
		variant,
		dataKey,
		colorsCount
	}: { variant: ChartLegendVariant; dataKey: string; colorsCount: number } = $props();

	const fillStyle = $derived(getLegendFillStyle(dataKey, colorsCount));
	const outlineStyle = $derived(getLegendOutlineStyle(dataKey, colorsCount));
</script>

{#if variant === 'square'}
	<div class="h-2 w-2 shrink-0" style={fillStyle}></div>
{:else if variant === 'circle'}
	<div class="h-2 w-2 shrink-0 rounded-full" style={fillStyle}></div>
{:else if variant === 'circle-outline'}
	<div class="h-2.5 w-2.5 shrink-0 rounded-full p-[1.5px]" style={outlineStyle}></div>
{:else if variant === 'vertical-bar'}
	<div class="h-3 w-1 shrink-0 rounded-[2px]" style={fillStyle}></div>
{:else if variant === 'horizontal-bar'}
	<div class="h-1 w-3 shrink-0 rounded-[2px]" style={fillStyle}></div>
{:else if variant === 'rounded-square-outline'}
	<div class="h-2.5 w-2.5 shrink-0 rounded-[3px] p-[1.5px]" style={outlineStyle}></div>
{:else}
	<div class="h-2 w-2 shrink-0 rounded-[2px]" style={fillStyle}></div>
{/if}
```

`$lib/components/evilcharts/ui/layerchart-legend/styles.ts`

```ts
/** Solid fill / gradient background for filled variants. */
export function getLegendFillStyle(dataKey: string, colorsCount: number): string {
	if (colorsCount <= 1) {
		return `background-color: var(--color-${dataKey}-0)`;
	}

	const stops = Array.from({ length: colorsCount }, (_, i) => {
		const offset = (i / (colorsCount - 1)) * 100;
		return `var(--color-${dataKey}-${i}) ${offset}%`;
	}).join(', ');

	return `background: linear-gradient(to right, ${stops})`;
}

/**
 * Outline style for stroke variants.
 * Uses background + mask-composite to punch out the center, leaving only the
 * "border" visible. Works with both solid colors and gradients, and respects
 * border-radius — unlike plain `border-color`.
 */
export function getLegendOutlineStyle(dataKey: string, colorsCount: number): string {
	const maskStyle = [
		'-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
		'-webkit-mask-composite: xor',
		'mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0)',
		'mask-composite: exclude'
	].join('; ');

	if (colorsCount <= 1) {
		return `background-color: var(--color-${dataKey}-0); ${maskStyle}`;
	}

	const stops = Array.from({ length: colorsCount }, (_, i) => {
		const offset = (i / (colorsCount - 1)) * 100;
		return `var(--color-${dataKey}-${i}) ${offset}%`;
	}).join(', ');

	return `background: linear-gradient(to right, ${stops}); ${maskStyle}`;
}
```

`$lib/components/evilcharts/ui/layerchart-legend/types.ts`

```ts
export type ChartLegendVariant =
	| 'square'
	| 'circle'
	| 'circle-outline'
	| 'rounded-square'
	| 'rounded-square-outline'
	| 'vertical-bar'
	| 'horizontal-bar';

export type LegendAlign = 'left' | 'center' | 'right';
export type LegendVerticalAlign = 'top' | 'middle' | 'bottom';

export function resolveLegendPlacement(
	requested: LegendVerticalAlign | undefined,
	fallback: 'top' | 'bottom'
): LegendVerticalAlign {
	return requested ?? fallback;
}

/**
 * One legend entry.
 *
 * Mirrors the shape Recharts hands `<Legend content>` in the reference: `value` carries the
 * series name for pie charts, `dataKey` for cartesian charts, and `payload` the raw row so
 * `nameKey` can reach into it.
 */
export type LegendPayloadItem = {
	value?: string;
	dataKey?: string;
	payload?: unknown;
	/** Entries typed `'none'` are hidden, as in Recharts. */
	type?: string;
	color?: string;
};
```
        
      
    
  


## Usage

`<EvilRadarChart />` is the root of a composable compound component. Every visual part (`<EvilRadarChart.PolarGrid />`, `<EvilRadarChart.PolarAngleAxis />`, `<EvilRadarChart.Tooltip />`, `<EvilRadarChart.Legend />`, and the `<EvilRadarChart.Radar />` series) composes as a child — render only what you need.

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart';
</script>
```

```svelte
<script lang="ts">
	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: { light: ['#3b82f6'], dark: ['#60a5fa'] }
		},
		mobile: {
			label: 'Mobile',
			colors: { light: ['#10b981'], dark: ['#34d399'] }
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig}>
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="filled" />
</EvilRadarChart>
```

### Interactive Selection

Set `isClickable` on a `<Radar />` or `<Legend />` to toggle selection by clicking. The root's `onSelectionChange` callback fires on every selection change:

```svelte
<EvilRadarChart
	{data}
	config={chartConfig}
	onSelectionChange={(selectedDataKey) => {
		if (selectedDataKey) {
			console.log('Selected:', selectedDataKey);
		} else {
			console.log('Deselected');
		}
	}}
>
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend isClickable />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled" isClickable />
	<EvilRadarChart.Radar dataKey="mobile" variant="filled" isClickable />
</EvilRadarChart>
```

### Loading State

### isLoading='true'

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<!-- [!code highlight] -->
<EvilRadarChart data={[]} config={chartConfig} class="h-full w-full p-4" isLoading={true}>
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled" />
	<EvilRadarChart.Radar dataKey="mobile" variant="filled" />
</EvilRadarChart>
```
>  
  

Pass `isLoading` to the root to show an animated loading skeleton while your data is being fetched.




```svelte
<EvilRadarChart data={[]} config={chartConfig} isLoading>
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled" />
	<EvilRadarChart.Radar dataKey="mobile" variant="filled" />
</EvilRadarChart>
```

## Examples

Radar charts in different configurations.

### Lines Variant

### variant='lines'

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EvilRadarChart.Radar dataKey="desktop" variant="lines">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="lines">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
</EvilRadarChart>
```
>  
  

Set `variant="lines"` to show only the outline without fill — clearer for comparing multiple datasets.




### Circle Grid

### gridType='circle'

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<!-- [!code highlight:2] -->
	<EvilRadarChart.PolarGrid gridType="circle" />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
</EvilRadarChart>
```
>  
  

Set `gridType="circle"` to use circular grid lines instead of the default polygon grid.




### Gradient Colors

### gradient colors

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				// [!code highlight:2]
				light: ['#6366f1', '#a855f7', '#ec4899'], // Indigo -> Purple -> Pink
				dark: ['red', 'orange', 'pink']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				// [!code highlight:2]
				light: ['#14b8a6', '#06b6d4', '#3b82f6'], // Teal -> Cyan -> Blue
				dark: ['#2dd4bf', '#22d3ee', '#60a5fa']
			}
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
</EvilRadarChart>
```

### Glowing Radars

### <Radar isGlowing />

```svelte
<script lang="ts">
	import { EvilRadarChart } from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	import { type ChartConfig } from '$lib/components/evilcharts/ui/layerchart-chart/index.js';

	const data = [
		{ skill: 'JavaScript', desktop: 186, mobile: 80 },
		{ skill: 'TypeScript', desktop: 305, mobile: 200 },
		{ skill: 'React', desktop: 237, mobile: 120 },
		{ skill: 'Node.js', desktop: 173, mobile: 190 },
		{ skill: 'CSS', desktop: 209, mobile: 130 },
		{ skill: 'Python', desktop: 214, mobile: 140 }
	];

	const chartConfig = {
		desktop: {
			label: 'Desktop',
			colors: {
				light: ['#3b82f6'],
				dark: ['#60a5fa']
			}
		},
		mobile: {
			label: 'Mobile',
			colors: {
				light: ['#10b981'],
				dark: ['#34d399']
			}
		}
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} config={chartConfig} class="h-full w-full p-4">
	<EvilRadarChart.PolarGrid />
	<EvilRadarChart.PolarAngleAxis dataKey="skill" />
	<EvilRadarChart.Legend />
	<EvilRadarChart.Tooltip />
	<!-- [!code highlight:2] -->
	<EvilRadarChart.Radar dataKey="desktop" variant="filled" isGlowing>
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
	<EvilRadarChart.Radar dataKey="mobile" variant="filled">
		<EvilRadarChart.Dot variant="colored-border" />
		<EvilRadarChart.ActiveDot variant="default" />
	</EvilRadarChart.Radar>
</EvilRadarChart>
```
>  
  

Set `isGlowing` on a `<Radar />` for a soft glow. Each radar controls its own glow independently.




### Dither rendering

Set `renderStyle="dither"` on the existing radar root for ordered-dither polygons while the SVG grid, axes, dots, tooltip, and selection targets remain authoritative. A radar-level `ditherVariant` overrides the root texture.

### renderStyle='dither'

```svelte
<script lang="ts">
	import {
		EvilRadarChart,
		type ChartConfig
	} from '$lib/components/evilcharts/charts/layerchart-radar-chart/index.js';
	const data = [
		{ skill: 'JS', desktop: 80, mobile: 48 },
		{ skill: 'TS', desktop: 96, mobile: 70 },
		{ skill: 'Svelte', desktop: 88, mobile: 76 },
		{ skill: 'CSS', desktop: 72, mobile: 84 },
		{ skill: 'Node', desktop: 82, mobile: 58 },
		{ skill: 'A11y', desktop: 68, mobile: 74 }
	];
	const config = {
		desktop: { label: 'Desktop', colors: { light: ['#047857'], dark: ['#10b981'] } },
		mobile: { label: 'Mobile', colors: { light: ['#be123c'], dark: ['#f43f5e'] } }
	} satisfies ChartConfig;
</script>

<EvilRadarChart {data} {config} renderStyle="dither" bloom="low" class="h-full w-full p-4">
	<EvilRadarChart.PolarGrid /><EvilRadarChart.PolarAngleAxis
		dataKey="skill"
	/><EvilRadarChart.Legend isClickable /><EvilRadarChart.Tooltip />
	<EvilRadarChart.Radar dataKey="desktop" ditherVariant="gradient" isClickable />
	<EvilRadarChart.Radar dataKey="mobile" ditherVariant="hatched" isClickable />
</EvilRadarChart>
```

The renderer is independently implemented for EvilCharts SV and inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit) by Boring Software.

## API Reference

A root container plus a set of composable parts, each documented below.

### EvilRadarChart

The root container. Owns the data, shared context, and loading skeleton. All other parts render as its children.


  ### `data` (required)

type: `TData[]`

The chart data — an array of objects, one per radar data point (`TData extends Record<string, unknown>`).
  ### `config` (required)

type: `Record<string, ChartConfig[string]>`

Defines the radar series. Each key matches a numeric data key and sets its colors and label.
  ### `children` (required)

type: `Snippet`

The composed parts of the chart — `<PolarGrid />`, `<PolarAngleAxis />`, `<PolarRadiusAxis />`, `<Tooltip />`, `<Legend />`, and one or more `<Radar />` series.
  ### `accessibility`

type: `ChartAccessibility`

Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology.
  ### `className`

type: `string`

Extra CSS classes for the chart container.
  ### `backgroundVariant`

type: `BackgroundVariant`

Background pattern shown behind the chart.
  ### `defaultSelectedDataKey`

type: `string | null` · default: `null`

The radar series selected on first render.
  ### `onSelectionChange`

type: `(selectedDataKey: string | null) => void`

Fires when a radar is selected or deselected. Receives the data key, or `null` when deselected.
  ### `isLoading`

type: `boolean` · default: `false`

Shows an animated loading skeleton while data is being fetched.
  ### `loadingPoints`

type: `number` · default: `6`

Number of points rendered in the loading skeleton radar.
  ### `renderStyle`

type: `"svg" | "dither"` · default: `"svg"`

Selects the SVG or ordered-dither renderer.
  ### `ditherVariant`

type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"`

Default texture for dithered radars.
  ### `ditherCellSize`

type: `number` · default: `2`

Dither cell size in CSS pixels.
  ### `bloom`

type: `"off" | "low" | "high" | "aura"` · default: `"off"`

Optional bounded glow around dither pixels.
  ### `chartProps`

type: `ComponentProps<typeof RadarChart>`

Extra props forwarded to the underlying LayerChart Chart. See the [LayerChart Chart documentation](https://www.layerchart.com/docs/components/Chart).


### Radar

A single radar series. Each `<Radar />` generates its own gradients and glow filter under a unique id, so radars never collide on styles. Compose `<Dot />` and `<ActiveDot />` inside for point markers.


  ### `dataKey` (required)

type: `string`

The series key to render. Must exist on both the data and the config.
  ### `variant`

type: `"filled" | "lines"` · default: `"filled"`

The visual style for this radar. `"filled"` shows a filled area, `"lines"` shows only the outline.
  ### `fillOpacity`

type: `number` · default: `0.3`

Opacity of the filled area when `variant="filled"`.
  ### `isGlowing`

type: `boolean` · default: `false`

Adds a soft outer glow. Each radar controls its own glow independently.
  ### `isClickable`

type: `boolean` · default: `false`

Lets clicking this radar select or deselect it. Unselected radars dim while one is selected.
  ### `children`

type: `Snippet`

Optional `<Dot />` and `<ActiveDot />` for point markers on this radar.
  ### `radarProps`

type: `Omit<ComponentProps<typeof Radar>, "dataKey">`

Extra props forwarded to the underlying LayerChart Spline. See the [LayerChart Spline documentation](https://www.layerchart.com/docs/components/Spline).


### Dot / ActiveDot

Configuration slots inside a `<Radar />`. `<Dot />` styles the resting markers; `<ActiveDot />` styles the active marker. Neither renders anything on its own.


  ### `variant`

type: `"default" | "border" | "colored-border"`

The visual style for the point marker.


### PolarGrid

The polar grid lines. Defaults to a dashed polygon grid and forwards every LayerChart PolarGrid prop.


  ### `gridType`

type: `"polygon" | "circle"` · default: `"polygon"`

Shape of the grid lines. `"polygon"` for angular, `"circle"` for circular.
  ### `...props`

type: `ComponentProps<typeof PolarGrid>`

Forwarded to the underlying LayerChart radial Grid. See the [LayerChart Grid documentation](https://www.layerchart.com/docs/components/Grid).


### PolarAngleAxis

The angular category axis — the labels around the chart's perimeter. Hidden while loading.


  ### `dataKey`

type: `string`

The data key for the angle axis labels (e.g. categories, skills, months).
  ### `...props`

type: `ComponentProps<typeof PolarAngleAxis>`

Forwarded to the underlying LayerChart radial Axis. See the [LayerChart Axis documentation](https://www.layerchart.com/docs/components/Axis).


### PolarRadiusAxis

The radial value axis — the scale from center outward. Hidden while loading.


  ### `...props`

type: `ComponentProps<typeof PolarRadiusAxis>`

Forwarded to the underlying LayerChart radial Axis. See the [LayerChart Axis documentation](https://www.layerchart.com/docs/components/Axis).


### Tooltip

The hover tooltip. Dims unselected series based on the chart's selection. Hidden while loading.


  ### `variant`

type: `"default" | "frosted-glass"` · default: `"default"`

Visual style of the tooltip.
  ### `roundness`

type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"`

Border-radius of the tooltip.
  ### `defaultIndex`

type: `number`

Shows the tooltip by default at this data point index.


### Legend

The series legend. With `isClickable`, each entry toggles selection of its series. Hidden while loading.


  ### `variant`

type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | …`

Visual style of the legend indicators.
  ### `align`

type: `"left" | "center" | "right"` · default: `"center"`

Horizontal placement of the legend.
  ### `verticalAlign`

type: `"top" | "middle" | "bottom"` · default: `"bottom"`

Vertical placement of the legend.
  ### `isClickable`

type: `boolean` · default: `false`

Lets each entry toggle its series' selection, driving the shared state read by every `<Radar />`.

