# EvilCharts Full Documentation (Svelte port) > Full markdown snapshot generated from the same source as the site. > Svelte 5 port by miel: https://github.com/mielsense/evilcharts-sv > Original EvilCharts by Gurbinder: https://github.com/legions-developer/evilcharts ## Introduction > Open-source Svelte 5 chart components built with LayerChart or Apache ECharts. Source: https://evilcharts-sv.vercel.app/docs Markdown: https://evilcharts-sv.vercel.app/docs.md >

This is a Svelte 5 port of EvilCharts with ordered-dither chart variants adapted from Dither Kit.

EvilCharts is a set of installable chart components, not a separate charting runtime. Choose [LayerChart](https://www.layerchart.com/) for Svelte-rendered SVG or [Apache ECharts](https://echarts.apache.org) for Canvas with optional SVG rendering. Both providers use the same chart config and [shadcn-svelte](https://shadcn-svelte.com) registry flow. ## The Problem Gurbinder created EvilCharts after seeing the same default chart styles repeated across products. The original project set out to make charts more distinctive without hiding their source code. > The words below are adapted from Gurbinder's introduction to the original project. This port > brings that work to Svelte. There are tons of chart libraries, but none are beautiful. They allow customization, yet everyone just ships the defaults from the docs. The result is an open-source chart component collection whose code belongs in your project. ## Why is it called EvilCharts? "Evil" doesn't mean malicious. It means: - Unapologetically opinionated UI choices - Design-first, animated, unique designs - Handcrafted SVG and motion that give each chart a distinct look ## Two providers, one chart API Every chart is a compound component: the root owns the data and config, and the visible parts are children. LayerChart components use the `Evil*Chart` prefix; ECharts components use `ECharts*Chart`. Both providers keep the same composition model, data, variants, and interactions. ```svelte ``` You install one chart at a time from this port's registry, so you only ship what you choose. Use `layerchart-area-chart` or `echarts-area-chart` in the registry URL to select the provider. ## Accessibility Give each chart an accessible name with the root `accessibility` prop. Add a description when the chart needs context that its name does not provide. ```svelte ``` If visible text already names or describes the chart, pass its element IDs as `labelledBy` and `describedBy`. The chart wrapper keeps `role="group"`, so interactive legends and chart marks remain available to assistive technology. ### LayerChart **SVG, and all of it is in the DOM.** LayerChart renders through Svelte to SVG, so every gradient, pattern, dot and glow is a real element. - **Themeable without JavaScript.** Series colors compile to per-chart CSS variables, so light and dark modes swap with a class and do not need a re-render. - **Inspectable.** Open devtools and the whole chart is right there, stylable with the CSS you already use. - **Composable.** Charts are built from parts, not props. Drop in a ``, a ``, or a second ``. Reach for it by default: product dashboards, marketing pages, anything where the chart is design surface first and data volume second. Start with [Installation](/docs/layerchart/installation). Labels, colors, and icons come from one object, which is covered in [Chart Config](/docs/chart-config). ### Apache ECharts **Canvas by default, with SVG available.** ECharts is a strong fit for dense dashboards and teams that already use its option model. EvilCharts keeps the compound Svelte API and compiles those parts into a typed ECharts option. - **Renderer choice.** Use the Canvas default or pass `renderer="svg"` on any chart root. - **Native updates.** Data changes interpolate through ECharts rather than replacing the chart. - **Same source-registry workflow.** Install only the ECharts chart and UI modules you need. Start with [ECharts installation](/docs/echarts/installation), then browse the matching chart pages under the ECharts provider. ### Which provider? Provider selection is a per-chart choice. Start with LayerChart for inspectable, themeable Svelte SVG charts and design-led dashboards. Move an individual chart to ECharts for dense data, frequent updates, or an existing ECharts option workflow. One app or dashboard may mix both providers by rendering separate provider-specific chart roots. Each compound chart root must use parts from that same provider. Install only one implementation of a chart unless the app intentionally renders both. ## Use with a coding agent The official skill teaches coding agents how to choose a provider, install the correct registry item, compose chart parts, and start from the right example or block. See the [Agent Skill guide](/docs/agent-skill) for installation, example prompts, and the Context7 alternative. ## Credits and licence | | | | ---------------- | -------------------------------------------------------------------------------------------------------------- | | Original project | [legions-developer/evilcharts](https://github.com/legions-developer/evilcharts) | | Original site | [evilcharts.com](https://evilcharts.com) | | Original author | [Gurbinder](https://x.com/legionsdev) | | LayerChart | [LayerChart](https://www.layerchart.com/) | | Apache ECharts | [Apache ECharts](https://echarts.apache.org/) | | Licence | MIT, for both the original and this port | | Svelte port | [mielsense/evilcharts-sv](https://github.com/mielsense/evilcharts-sv) | | Port author | [miel](https://github.com/mielsense) | Substantially all of the design, component API, documentation prose and example data in this port derive from the original project. The Svelte translation uses LayerChart, `@humanspeak/svelte-motion`, Apache ECharts, and Svelte 5 in place of the original React stack. --- ## Agent Skill > Teach coding agents how to install, compose, and debug EvilCharts for Svelte. Source: https://evilcharts-sv.vercel.app/docs/agent-skill Markdown: https://evilcharts-sv.vercel.app/docs/agent-skill.md The official `evilcharts-svelte` skill gives a coding agent the library-specific context it needs before it changes a chart. Install it once, then ask the agent to use it whenever it adds, customizes, migrates, or debugs EvilCharts components. ## Install the agent skill Install the skill from this repository with the skills CLI: ```bash npx skills add mielsense/evilcharts-sv --skill evilcharts-svelte ``` The installed skill includes a chart catalog and an implementation guide. It directs the agent to the current Markdown documentation before it chooses a registry item, import, component, or prop. [Read the skills CLI documentation](https://skills.sh/docs), or [review the published SKILL.md](/skill.md) before installing it. Public skills are added to the skills.sh directory from anonymous CLI installation telemetry; the GitHub install command above works directly and does not depend on a directory listing. ## What the skill covers - All eight chart families: Area, Line, Bar, Composed, Radar, Pie, Radial, and Sankey - Choosing between LayerChart and Apache ECharts for each chart - Compound roots and their provider-specific child components - Registry installation, focused examples, and complete dashboard blocks - Chart config, accessibility, selection, loading states, and reduced-motion behavior - Ordered-dither variants adapted from Dither Kit - The boundary for mixing providers safely in one application Provider choice is per chart. An application may render LayerChart and ECharts charts side by side, but every child inside a compound chart root must come from that root's provider. ## Ask an agent to use it Name the skill and the outcome you want. The agent can then select the provider, registry item, and closest documented example without guessing from the original React library. ```text Use the evilcharts-svelte skill to add an accessible LayerChart area chart with a clickable legend and reduced-motion support. Start from the closest documented example. ``` For a complete dashboard composition, ask for a block rather than a basic example: ```text Use the evilcharts-svelte skill to find an ECharts bar block with supporting metrics, then adapt it to monthly revenue data. ``` ## Context7 alternative If your agent cannot install repository skills, Context7 MCP is the alternative. Context7 retrieves the current documentation on demand; the installed skill additionally provides the workflow, catalog, and guardrails described above. EvilCharts for Svelte is indexed under the exact library ID /mielsense/evilcharts-sv. Give that ID to the agent so it reads this Svelte port rather than the original React implementation: ```text Use Context7 library /mielsense/evilcharts-sv for EvilCharts Svelte docs. ``` [Open the EvilCharts for Svelte Context7 page](https://context7.com/mielsense/evilcharts-sv). Agents without either integration can read [the documentation index](/llms.txt), [the full documentation snapshot](/llms-full.txt), or connect to the [documentation MCP endpoint](/mcp). --- ## Changelog > Changes to the installable EvilCharts for Svelte library and source registry. Source: https://evilcharts-sv.vercel.app/docs/changelog Markdown: https://evilcharts-sv.vercel.app/docs/changelog.md This page records changes to the installable Svelte port, its source registry, and its agent skill. Docs-only, test-only, CI, and contributor-workflow changes are recorded in their pull requests. ## 2026-08-30 - Added ECharts counterparts for every LayerChart dashboard block and aligned shared block data, palettes, supporting metrics, and responsive compositions across providers. The ECharts Bar API now includes isometric bars, reference lines, and row-hover callbacks; ECharts Radar series add dashed strokes and optional glow for those compositions. - Preserved null and non-finite data as real gaps across ECharts Area, Line, and Composed charts, including their brushes, reveal layers, buffer lines, hit targets, and tooltips. - Gave both Sankey providers the same guarded data contract and expanded ECharts keyboard and controlled-selection coverage across chart families. - Unified registry source rewriting and consumer dependency versions across generated installs. - Added the installable `evilcharts-svelte` agent skill, with skills.sh-compatible and HTTP discovery, a complete chart and compound-component catalog, implementation workflows, example and block guidance, plus links to the Markdown docs, Context7 library, and MCP endpoint. - Added controlled selection to LayerChart Area and Pie roots without remounting chart state, and made ECharts resolve chart-local color changes after mount. - Hardened LayerChart Sankey layout for invalid endpoints, cycles, zero-flow graphs, and numeric edge cases while preserving valid Recharts-compatible geometry. - Added Apache ECharts as a complete second rendering provider for all eight chart families, with Canvas rendering by default and optional SVG output. - Added provider-specific ECharts tooltips, legends, dots, brushes, loading states, examples, dashboard blocks, registry items, documentation, and agent-readable pages. - Added ordered-dither treatments for the ECharts area, line, bar, composed, pie, and radar charts, preserving the Dither Kit attribution and licence boundary. - Added a provider switcher so readers can move between matching LayerChart and ECharts pages. - Fixed production renderer registration so Canvas and SVG charts still initialize after tree-shaking. - Aligned ECharts legends, brushes, basic Area geometry, loading states, and radial chart clearance with the original provider layouts. - Kept Area loading geometry fixed while its Line-style top stroke and translucent fill use the same travelling shimmer mask; loading data changes only after that mask leaves the plot. - Matched the data and chart configuration of all 113 upstream LayerChart examples and mirrored the same examples in the ECharts provider, apart from the six documented dither additions. - Preserved chart-owned animation lifecycles when consumer ECharts options are merged and made loading, dashed-stroke, hover, and landing motion respect reduced-motion preferences. - Restored the original ECharts Bar, Pie, and Radial entrances by initializing each renderer only after its primary compound series is registered; Composed bars now use the same grow lifecycle. - Made LayerChart Cartesian entrances use one uninterrupted chart-owned timeline, preventing scale remounts from shortening or restarting reveals, and matched Area and Line wipe speed to the ECharts provider. - Added keyboard controls for clickable series and ECharts brush ranges without duplicating an existing clickable legend. - Aligned shared theme tokens and legend spacing with the original, and made generated chart CSS safe for arbitrary chart IDs and data keys. ## 2026-08-27 - Loading states now match the original chart geometry, including cartesian category spacing, Area chart domain headroom, Radial chart ring placement, and Pie chart legend clearance. - Area loading curves now remain static while the shimmer crosses the plot, matching the original. - Area loading now renders its moving top stroke with the same masked spline as Line loading, while the shimmer continues through the gradient fill beneath it. - Radial loading bars now tween between values with the original 1.5-second ease-in-out motion. - Radial plots now reserve the full edge-legend band so outer rings cannot overlap the legend. ## 2026-08-25 - Tooltips now escape cards with `overflow: hidden` while keeping chart-scoped series colors. --- ## Chart Config > Define labels, colors, and icons for each data series Source: https://evilcharts-sv.vercel.app/docs/chart-config Markdown: https://evilcharts-sv.vercel.app/docs/chart-config.md Every EvilCharts component uses a chart config object. It maps each data key to its label, theme colors, and optional icon for tooltips and legends. ## Structure ```svelte ``` Each key (e.g. `desktop`, `mobile`) must match a data key in your dataset. Its type: ```ts type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component; colors?: { light?: string[]; dark?: string[]; }; } >; ``` ## Properties ### label The name shown in tooltips and legends. A string or any `Snippet`. ```ts const chartConfig = { desktop: { label: 'Desktop Users' // [!code highlight] // ... } } satisfies ChartConfig; ``` ### colors Theme-aware color arrays. Provide at least one theme key (`light` or `dark`); each holds an array of CSS color strings. One color per theme creates a solid fill: ```ts colors: { light: ["#047857"], dark: ["#10b981"], } ``` Multiple colors create gradient fills across bars, areas, and other elements. The chart distributes them evenly across the series: ```ts colors: { light: ["#a855f7", "#6366f1", "#3b82f6"], dark: ["#f43f5e", "#ec4899", "#a855f7", "#6366f1", "#3b82f6"], } ``` Themes can define different color counts. The chart uses the largest count across all themes. ### icon An optional Svelte component that replaces the default color indicator in the tooltip and legend. Use icons when color alone is not enough to distinguish each series. ```svelte ``` The `icon` renders in place of the color dot/square in tooltips and the color indicator in legends. It's styled with `text-muted-foreground` and sized `h-2.5 w-2.5` in tooltips, `h-3 w-3` in legends. ## How colors work The chart config generates CSS custom properties scoped to each chart instance. A key `desktop` with colors `["#a855f7", "#6366f1"]` produces: ```css --color-desktop-0: #a855f7; --color-desktop-1: #6366f1; ``` Chart components, tooltips, and legends read these variables for consistent theming. Switching between light and dark mode swaps in the correct set automatically. ### Color distribution When you provide fewer colors than segments need, they're **evenly distributed** across slots: - 2 colors for 4 slots: `[red, red, pink, pink]` - 3 colors for 4 slots: `[red, pink, blue, blue]` So 2-3 gradient stops smoothly span any number of data points. ## Runtime validation The config is validated at runtime. An empty `colors` object, or one without a valid theme key, throws a clear error: ```plaintext [EvilCharts] Invalid chart config for "desktop": colors object must have at least one theme key (light, dark). Received empty object or invalid keys. ``` ## Examples ### Default (labels and colors) Labels and theme-aware colors. The label shows in the tooltip and legend; the colors control the fill. ### Basic chart config ```svelte String(value).substring(0, 3)} /> ``` ### With icons Pass an `icon` component per entry to replace the default color indicator in the tooltip and legend. ### Chart config with icons ```svelte String(value).substring(0, 3)} /> ``` ### Gradient colors Pass multiple colors per theme for gradient fills. Each array value is a stop distributed across the chart elements. ### Gradient colors ```svelte String(value).substring(0, 3)} /> ``` ## API reference ### `label` type: `Snippet` Name for the data series, shown in tooltips and legends. ### `colors` type: `{ light?: string[]; dark?: string[] }` Theme-aware colors. Include at least one theme key (`light` or `dark`), each mapping to an array of CSS color strings. One color is a solid fill; multiple create a gradient. ### `icon` type: `Component` Optional Svelte component rendered in place of the default color indicator in tooltips and legends. --- ## Area Chart > Static, beautifully designed area charts powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/area-chart Markdown: https://evilcharts-sv.vercel.app/docs/echarts/area-chart.md ### Basic Chart ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ## Installation ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-area-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-area-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-area-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-area-chart ``` ### Install the following dependencies: ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Copy and paste the following code snippets into your project. In your `components` directory, create `evilcharts`, then `charts` nested inside it, and paste the area-chart code into a new `echarts-area-chart` file there. ### $lib/components/evilcharts/charts/echarts-area-chart `$lib/components/evilcharts/charts/echarts-area-chart/active-dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-area-chart/area-chart-context.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { AreaRegistration, AxisRegistration } from './types.js'; const CONTEXT = Symbol('evilcharts.echarts-area-chart'); export class EChartsAreaChartContext { areas = new RegistrationSet(); xAxes = new RegistrationSet(); yAxes = new RegistrationSet(); grids = new RegistrationSet>(); } export function setEChartsAreaChartContext(): EChartsAreaChartContext { const context = new EChartsAreaChartContext(); setContext(CONTEXT, context); return context; } export function useEChartsAreaChart(): EChartsAreaChartContext { const context = getContext(CONTEXT); if (!context) throw new Error('[EvilCharts] ECharts area parts must be children of EChartsAreaChart.'); return context; } ``` `$lib/components/evilcharts/charts/echarts-area-chart/area-chart.svelte` ```svelte {#snippet overlay()} {#if legend && !isLoading} {/if} {/snippet} {@render children?.()} {#if !legend?.isClickable} {/if} {#if brush && !isLoading && data.length > 0} brush.formatLabel?.(categoryValues[index] ?? '', index) ?? String(categoryValues[index] ?? index)} onChange={(range) => { const last = Math.max(0, data.length - 1); brushRange = { start: last === 0 ? 0 : (range.startIndex / last) * 100, end: last === 0 ? 100 : (range.endIndex / last) * 100 }; instance?.dispatchAction( { type: 'dataZoom', start: brushRange.start, end: brushRange.end }, { silent: true } ); brush.onChange?.(range); }} /> {/if} ``` `$lib/components/evilcharts/charts/echarts-area-chart/area-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; type DotRegistration = { variant: DotVariant }; const CONTEXT = Symbol('evilcharts.echarts-area-slots'); export class EChartsAreaSlots { dots = new RegistrationSet(); activeDots = new RegistrationSet(); } export function setEChartsAreaSlots(): EChartsAreaSlots { const context = new EChartsAreaSlots(); setContext(CONTEXT, context); return context; } export function useEChartsAreaSlots(): EChartsAreaSlots { const context = getContext(CONTEXT); if (!context) throw new Error('[EvilCharts] ECharts Dot must be nested inside Area.'); return context; } ``` `$lib/components/evilcharts/charts/echarts-area-chart/area.svelte` ```svelte {@render children?.()} ``` `$lib/components/evilcharts/charts/echarts-area-chart/dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-area-chart/grid.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-area-chart/index.ts` ```ts import Root from './area-chart.svelte'; import Area from './area.svelte'; import Dot from './dot.svelte'; import ActiveDot from './active-dot.svelte'; import XAxis from './x-axis.svelte'; import YAxis from './y-axis.svelte'; import Grid from './grid.svelte'; import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { Legend } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import { Brush } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; type RootComponent = typeof Root; export const EChartsAreaChart: RootComponent & { Area: typeof Area; Dot: typeof Dot; ActiveDot: typeof ActiveDot; XAxis: typeof XAxis; YAxis: typeof YAxis; Grid: typeof Grid; Tooltip: typeof Tooltip; Legend: typeof Legend; Brush: typeof Brush; } = Object.assign(Root, { Area, Dot, ActiveDot, XAxis, YAxis, Grid, Tooltip, Legend, Brush }); export type { ChartAccessibility, ChartConfig, EChartsRenderer, EChartsRenderStyle } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; export type { DitherBloom, DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; export type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export type { AreaAnimationType, AreaVariant, CurveType, StackType, StrokeVariant } from './types.js'; ``` `$lib/components/evilcharts/charts/echarts-area-chart/option.ts` ```ts import type { LineSeriesOption } from 'echarts/charts'; import type { DataZoomComponentOption, GridComponentOption, TooltipComponentOption } from 'echarts/components'; import type { ComposeOption, EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { flattenColor, getColorsCount, seriesPaint, withAlpha, type ChartConfig, type ResolvedColors } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import { dotItemStyle, dotStyle, sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import { buildBrushDataZoom, type BrushRange } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; import { createDitherPattern, type DitherBloom, type DitherVariant, type RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import { tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { BUFFER_DASH, type AreaRegistration, type AxisRegistration, type BrushRegistration, type CurveType, type LegendRegistration, type StackType, type TooltipRegistration } from './types.js'; export type EChartsAreaOption = ComposeOption< LineSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption >; type ArrayItem = T extends readonly (infer Item)[] ? Item : T; type XAxisOption = ArrayItem>; type YAxisOption = ArrayItem>; export type AreaOptionContext = { data: Record[]; config: ChartConfig; areas: AreaRegistration[]; xDataKey?: string; curveType: CurveType; stackType: StackType; selectedDataKey: string | null; enableHoverHighlight: boolean; enableHoverReveal: boolean; hoverRevealIndex: number | null; xAxis?: AxisRegistration; yAxis?: AxisRegistration; showGrid: boolean; tooltip?: TooltipRegistration; legend?: LegendRegistration; brush?: BrushRegistration; brushRange: BrushRange; isLoading: boolean; loadingData: number[]; resolved: ResolvedColors; animation: boolean; animationType: string; reducedMotion: boolean; rendererSize: { width: number; height: number }; renderStyle: RenderStyle; ditherVariant: DitherVariant; ditherCellSize: number; bloom?: DitherBloom; }; const ditherBloomBlur = (style: RenderStyle, bloom?: DitherBloom) => style !== 'dither' || bloom === 'off' || bloom === undefined ? 0 : bloom === 'aura' ? 14 : bloom === 'high' ? 8 : 4; const labelFor = (config: ChartConfig, key: string) => typeof config[key]?.label === 'string' ? (config[key].label as string) : key; const opacityFor = (selected: string | null, key: string) => selected === null || selected === key ? 1 : 0.3; function curveConfig(curve: CurveType) { return curve === 'step' ? { smooth: false, step: 'middle' as const } : { smooth: curve !== 'linear', step: false as const }; } function categoryKey(c: AreaOptionContext) { if (c.xDataKey) return c.xDataKey; if (c.xAxis?.dataKey) return c.xAxis.dataKey; const keys = new Set(c.areas.map((area) => area.dataKey)); return Object.keys(c.data[0] ?? {}).find((key) => !keys.has(key)); } function categories(c: AreaOptionContext) { const key = categoryKey(c); return c.data.map((row, i) => String((key ? row[key] : undefined) ?? i)); } function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function rawValues(c: AreaOptionContext, key: string): (number | null)[] { return c.data.map((row) => finiteNumber(row[key])); } function ditherPlotBounds(c: AreaOptionContext, reverse = false) { const top = c.legend?.verticalAlign === 'top' ? 42 : 16; const showBrush = Boolean(c.brush) && !c.isLoading; const bottom = 8 + (showBrush ? (c.brush?.height ?? 56) + 30 + (c.xAxis?.label ? 22 : 0) : 0) + (c.legend?.verticalAlign === 'bottom' ? 34 : 0); return { height: Math.max(c.ditherCellSize, c.rendererSize.height - top - bottom), offsetY: top, reverse }; } function values(c: AreaOptionContext, key: string) { const raw = rawValues(c, key); if (c.stackType !== 'expanded') return raw; return raw.map((value, index) => { if (value === null) return null; const total = c.areas.reduce((sum, area) => sum + (rawValues(c, area.dataKey)[index] ?? 0), 0); return total ? value / total : 0; }); } function axes(c: AreaOptionContext): { xAxis: XAxisOption; yAxis: YAxisOption } { const { mutedForeground, border, background } = c.resolved.tokens; const dotColor = flattenColor(border, background); return { xAxis: { type: 'category', boundaryGap: false, data: c.isLoading ? c.loadingData.map((_, i) => i) : categories(c), name: c.isLoading ? undefined : c.xAxis?.label, nameLocation: 'middle', nameGap: 30, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !c.isLoading && Boolean(c.xAxis) && !c.xAxis?.hideDots, alignWithLabel: true, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, axisLabel: { show: !c.isLoading && Boolean(c.xAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: c.xAxis?.tickFormatter }, splitLine: { show: false } }, yAxis: { type: 'value', max: c.stackType === 'expanded' ? 1 : undefined, name: c.isLoading ? undefined : c.yAxis?.label, nameLocation: 'middle', nameGap: 38, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !c.isLoading && Boolean(c.yAxis) && !c.yAxis?.hideDots, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, axisLabel: { show: !c.isLoading && Boolean(c.yAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: c.stackType === 'expanded' ? (value: number) => `${Math.round(value * 100)}%` : c.yAxis?.tickFormatter }, splitLine: { show: c.showGrid && !c.isLoading, lineStyle: { color: border, type: [3, 3], width: 1 } } } }; } type ImagePattern = { image: HTMLCanvasElement; repeat: 'repeat' | 'no-repeat'; rotation?: number; scaleX?: number; scaleY?: number; }; function nativePatternFill( kind: 'dotted' | 'lines' | 'hatched' | 'stripe', color: string ): ImagePattern | null { if (typeof document === 'undefined') return null; const dpr = Math.max(window.devicePixelRatio || 1, 1); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return null; const size = (width: number, height: number) => { canvas.width = width * dpr; canvas.height = height * dpr; context.scale(dpr, dpr); }; const pattern = (rotation = 0): ImagePattern => ({ image: canvas, repeat: 'repeat', rotation, scaleX: 1 / dpr, scaleY: 1 / dpr }); if (kind === 'dotted') { size(6, 6); context.fillStyle = withAlpha(color, 0.7); context.beginPath(); context.arc(3, 3, 0.85, 0, Math.PI * 2); context.fill(); return pattern(); } if (kind === 'lines' || kind === 'stripe') { size(5, 5); context.strokeStyle = withAlpha(color, 0.3); context.lineWidth = 1; context.beginPath(); context.moveTo(2.5, -1); context.lineTo(2.5, 6); context.stroke(); return pattern(-Math.PI / 4); } size(20, 20); context.fillStyle = withAlpha(color, 0.06); context.fillRect(0, 0, 10, 20); context.fillStyle = withAlpha(color, 0.22); context.fillRect(10, 0, 10, 20); return pattern((20 * Math.PI) / 180); } function gradientFillTexture(slots: string[], width: number, height: number, reverse: boolean) { if (typeof document === 'undefined' || width < 1 || height < 1) return null; const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width); canvas.height = Math.ceil(height); const context = canvas.getContext('2d'); if (!context) return null; const colors = context.createLinearGradient(0, 0, canvas.width, 0); for (const [index, color] of slots.entries()) { colors.addColorStop(index / (slots.length - 1), color); } context.fillStyle = colors; context.fillRect(0, 0, canvas.width, canvas.height); const fade = context.createLinearGradient(0, 0, 0, canvas.height); fade.addColorStop(0, `rgba(0, 0, 0, ${reverse ? 0 : 0.1})`); fade.addColorStop(1, `rgba(0, 0, 0, ${reverse ? 0.1 : 0})`); context.globalCompositeOperation = 'destination-in'; context.fillStyle = fade; context.fillRect(0, 0, canvas.width, canvas.height); return canvas; } function patternFadeTexture( kind: 'dotted' | 'lines' | 'hatched', color: string, width: number, height: number ) { const source = nativePatternFill(kind, color); if (!source || typeof document === 'undefined' || width < 1 || height < 1) return null; const tile = source.image; const canvas = document.createElement('canvas'); canvas.width = Math.ceil(width); canvas.height = Math.ceil(height); const context = canvas.getContext('2d'); if (!context) return null; const pattern = context.createPattern(tile, 'repeat'); if (!pattern) return null; if (typeof pattern.setTransform === 'function') { const transform = new DOMMatrix(); transform.rotateSelf(((source.rotation ?? 0) * 180) / Math.PI); transform.scaleSelf(source.scaleX ?? 1, source.scaleY ?? 1); pattern.setTransform(transform); } context.fillStyle = pattern; context.fillRect(0, 0, canvas.width, canvas.height); const fade = context.createLinearGradient(0, 0, 0, canvas.height); fade.addColorStop(0, 'rgba(0, 0, 0, 1)'); fade.addColorStop(1, 'rgba(0, 0, 0, 0)'); context.globalCompositeOperation = 'destination-in'; context.fillStyle = fade; context.fillRect(0, 0, canvas.width, canvas.height); return canvas; } function areaPaint( c: AreaOptionContext, area: AreaRegistration, slots: string[], showUnselected = false ) { const color = slots[0] ?? c.resolved.tokens.foreground; if (area.variant === 'none') return 'transparent'; if (c.renderStyle === 'dither') { return createDitherPattern( slots, area.ditherVariant ?? c.ditherVariant, c.ditherCellSize, 0.8, ditherPlotBounds(c, area.variant === 'gradient-reverse') ); } if (showUnselected) return nativePatternFill('stripe', color) ?? withAlpha(color, 0.1); if (area.variant === 'solid') { if (slots.length > 1) return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((slot, index) => ({ offset: index / (slots.length - 1), color: withAlpha(slot, 0.1) })) ); return withAlpha(color, 0.1); } if (area.variant === 'dotted' || area.variant === 'hatched' || area.variant === 'lines') { const texture = patternFadeTexture( area.variant, color, c.rendererSize.width, c.rendererSize.height ); return texture ? ({ image: texture, repeat: 'no-repeat' } as ImagePattern) : (nativePatternFill(area.variant, color) ?? withAlpha(color, 0.1)); } const reverse = area.variant === 'gradient-reverse'; if (slots.length > 1) { const texture = gradientFillTexture( slots, c.rendererSize.width, c.rendererSize.height, reverse ); if (texture) return { image: texture, repeat: 'no-repeat' } as ImagePattern; } const transparent = withAlpha(color, 0); return new echarts.graphic.LinearGradient(0, reverse ? 1 : 0, 0, reverse ? 0 : 1, [ { offset: 0, color: withAlpha(color, 0.1) }, { offset: 1, color: transparent } ]); } function tooltip(c: AreaOptionContext): TooltipComponentOption { const slot = c.tooltip; return { ...tooltipBaseOption({ present: Boolean(slot) && !c.isLoading, cursor: slot?.cursor ?? true, position: slot?.position ?? 'variable', axisPointerColor: c.resolved.tokens.border, strokeWidth: 0.8 }), formatter: (raw) => { const params = (Array.isArray(raw) ? raw : [raw]) as Array<{ seriesId?: string; seriesName?: string; axisValueLabel?: string; value?: unknown; data?: unknown; }>; const seen = new Set(); const body = params .map((p) => { const rawId = p.seriesId ?? ''; const key = rawId.startsWith('__buffer-') ? rawId.slice('__buffer-'.length) : rawId.startsWith('__') ? '' : rawId || p.seriesName || ''; if (!key || seen.has(key)) return ''; const value = typeof p.data === 'object' && p.data && 'value' in p.data ? (p.data as { value: unknown }).value : p.value; const numericValue = finiteNumber(value); if (numericValue === null) return ''; seen.add(key); return tooltipRow({ indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(c.config[key] ?? {})), labelText: labelFor(c.config, key), valueText: c.stackType === 'expanded' ? `${Math.round(numericValue * 100)}%` : numericValue.toLocaleString(), dimmed: opacityFor(c.selectedDataKey, key) < 1 ? ' opacity-30' : '' }); }) .join(''); return tooltipShell({ label: params[0]?.axisValueLabel ?? '', body, roundness: slot?.roundness ?? 'lg', variant: slot?.variant ?? 'default' }); } }; } function series(c: AreaOptionContext): LineSeriesOption[] { if (c.isLoading) return [ { id: '__loading', type: 'line', data: c.loadingData, ...curveConfig(c.curveType), showSymbol: false, silent: true, // The rAF shimmer reveals the fixed stroke and fill together. Keeping the // resting paint transparent prevents a solid line between sweeps. lineStyle: { color: withAlpha(c.resolved.tokens.foreground, 0), width: 1 }, areaStyle: { color: withAlpha(c.resolved.tokens.foreground, 0) }, z: 1, animation: false } ]; return c.areas.flatMap((area, index) => { const vals = values(c, area.dataKey); const slots = c.resolved.series[area.dataKey] ?? [c.resolved.tokens.foreground]; const showUnselected = c.selectedDataKey !== null && c.selectedDataKey !== area.dataKey; const bloomBlur = ditherBloomBlur(c.renderStyle, c.bloom); const bloomColor = bloomBlur > 0 ? withAlpha(slots[0] ?? c.resolved.tokens.foreground, 0.55) : undefined; const ditherStroke = c.renderStyle === 'dither' && area.strokeVariant !== 'animated-dashed'; const paint = ditherStroke ? createDitherPattern( slots, area.ditherVariant ?? c.ditherVariant, c.ditherCellSize, 1, ditherPlotBounds(c) ) : seriesPaint(slots); const strokePaint = c.enableHoverReveal && slots.length > 1 && c.renderStyle === 'native' ? new echarts.graphic.LinearGradient( 8, 0, Math.max(c.rendererSize.width - 8, 9), 0, slots.map((color, slotIndex) => ({ offset: slotIndex / (slots.length - 1), color })), true ) : paint; const dotPaint = seriesPaint(slots); const curve = curveConfig(area.curveType ?? c.curveType); const opacity = opacityFor(c.selectedDataKey, area.dataKey); const dot = dotStyle(area.dotVariant, dotPaint, c.resolved.tokens.background); const activeDot = dotStyle(area.activeDotVariant, dotPaint, c.resolved.tokens.background); const hasBuffer = !c.enableHoverReveal && area.enableBufferLine && vals.length > 1; const revealActive = c.enableHoverReveal && c.hoverRevealIndex !== null && c.selectedDataKey === null; const visibleValues = revealActive ? vals.map((value, valueIndex) => (valueIndex <= c.hoverRevealIndex! ? value : null)) : hasBuffer ? vals.map((v, i) => (i === vals.length - 1 ? null : v)) : vals; const data = visibleValues.map((v, i) => v === null || (area.dotVariant === 'none' && area.activeDotVariant === 'none') ? v : { value: v, itemStyle: dotItemStyle( area.dotVariant, sampleGradient(slots, vals.length > 1 ? i / (vals.length - 1) : 0), c.resolved.tokens.background ) } ); const bufferData = vals.map((v, i) => { if (i < vals.length - 2 || v === null) return null; if (area.dotVariant === 'none' && area.activeDotVariant === 'none') return v; return { value: v, itemStyle: dotItemStyle( area.dotVariant, sampleGradient(slots, vals.length > 1 ? i / (vals.length - 1) : 0), c.resolved.tokens.background ) }; }); const result: LineSeriesOption[] = []; if (c.enableHoverReveal) result.push({ id: `__reveal-${area.dataKey}`, type: 'line', data: revealActive ? vals.map((value, valueIndex) => (valueIndex < c.hoverRevealIndex! ? null : value)) : vals, stack: c.stackType === 'default' ? undefined : '__area-reveal-stack', smooth: curve.smooth, step: curve.step, connectNulls: area.connectNulls, showSymbol: false, silent: true, z: index, lineStyle: { color: c.resolved.tokens.mutedForeground, width: area.strokeWidth, type: hasBuffer || area.strokeVariant === 'solid' ? 'solid' : [3, 3], opacity: revealActive ? 0.3 : 0 }, emphasis: { disabled: true }, blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } }, tooltip: { show: false }, animation: false }); result.push({ id: area.dataKey, name: labelFor(c.config, area.dataKey), type: 'line', data, stack: c.stackType === 'default' ? undefined : '__area-stack', smooth: curve.smooth, step: curve.step, connectNulls: area.connectNulls, showSymbol: dot.size > 0, symbol: 'circle', symbolSize: dot.size > 0 ? dot.size : activeDot.size, cursor: area.isClickable ? 'pointer' : 'default', triggerEvent: area.isClickable, z: c.selectedDataKey === area.dataKey ? 3 : c.selectedDataKey === null ? 2 : 1, lineStyle: { color: strokePaint, width: ditherStroke ? Math.max(area.strokeWidth, c.ditherCellSize) : area.strokeWidth, type: ditherStroke ? [c.ditherCellSize, c.ditherCellSize] : hasBuffer || area.strokeVariant === 'solid' ? 'solid' : [3, 3], opacity, shadowBlur: bloomBlur, shadowColor: bloomColor }, itemStyle: { ...dot.itemStyle, opacity }, areaStyle: { color: areaPaint(c, area, slots, showUnselected), opacity: c.selectedDataKey === null || c.selectedDataKey === area.dataKey ? 0.8 : 0.1, shadowBlur: bloomBlur, shadowColor: bloomColor }, emphasis: { focus: c.enableHoverHighlight && !c.enableHoverReveal && c.selectedDataKey === null ? 'series' : 'none', scale: dot.size > 0 ? activeDot.size / Math.max(dot.size, 1) : 1, itemStyle: { ...activeDot.itemStyle, opacity: 1 } }, blur: { lineStyle: { opacity: 0.3 }, areaStyle: { opacity: 0.1 }, itemStyle: { opacity: 0.3 } }, animation: c.animation && (area.animationType ?? c.animationType) !== 'none' && !c.reducedMotion, animationDuration: 1000, animationDurationUpdate: 0 }); if (hasBuffer) result.push({ id: `__buffer-${area.dataKey}`, type: 'line', data: bufferData, stack: c.stackType === 'default' ? undefined : '__area-buffer-stack', smooth: curve.smooth, step: curve.step, connectNulls: true, showSymbol: dot.size > 0, symbol: 'circle', symbolSize: dot.size > 0 ? dot.size : activeDot.size, silent: true, z: c.selectedDataKey === area.dataKey ? 3 : c.selectedDataKey === null ? 2 : 1, lineStyle: { color: paint, width: area.strokeWidth, type: BUFFER_DASH, opacity, shadowBlur: bloomBlur, shadowColor: bloomColor }, itemStyle: { ...dot.itemStyle, opacity }, emphasis: { disabled: false, scale: dot.size > 0 ? activeDot.size / Math.max(dot.size, 1) : 1, itemStyle: { ...activeDot.itemStyle, opacity: 1 } }, blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } }, areaStyle: { opacity: 0 }, animation: false }); if (hasBuffer) result.push({ id: `__bufferfill-${area.dataKey}`, type: 'line', data: vals.map((v, i) => (i >= vals.length - 2 ? v : null)), stack: c.stackType === 'default' ? undefined : '__area-buffer-fill-stack', smooth: curve.smooth, step: curve.step, connectNulls: true, showSymbol: false, silent: true, z: index, lineStyle: { opacity: 0 }, areaStyle: { color: areaPaint(c, area, slots, showUnselected), opacity: c.selectedDataKey === null || c.selectedDataKey === area.dataKey ? 0.8 : 0.1, shadowBlur: bloomBlur, shadowColor: bloomColor }, animation: false, tooltip: { show: false } }); return result; }); } export function buildAreaOption(c: AreaOptionContext): EChartsAreaOption { const showBrush = Boolean(c.brush) && !c.isLoading; const height = c.brush?.height ?? 56; const bottom = c.legend?.verticalAlign === 'bottom' ? 34 : 6; const main: GridComponentOption = { left: 8, right: 8, top: c.legend?.verticalAlign === 'top' ? 42 : 16, bottom: 8 + (showBrush ? height + 30 + (c.xAxis?.label ? 22 : 0) : 0) + (c.legend?.verticalAlign === 'bottom' ? 34 : 0) }; const builtAxes = axes(c); const mainSeries = series(c); if (!showBrush) return { animation: false, aria: { enabled: true }, grid: main, xAxis: builtAxes.xAxis, yAxis: builtAxes.yAxis, tooltip: tooltip(c), series: mainSeries }; const mini = c.areas.map((area) => { const key = area.dataKey; const base = c.resolved.series[key]?.[0] ?? c.resolved.tokens.foreground; const curve = curveConfig(area.curveType ?? c.curveType); const selected = c.selectedDataKey === null || c.selectedDataKey === key; return { id: `__mini-${key}`, type: 'line' as const, xAxisIndex: 1, yAxisIndex: 1, data: rawValues(c, key), stack: c.stackType === 'default' ? undefined : '__mini-total', smooth: curve.smooth, step: curve.step, connectNulls: area.connectNulls, showSymbol: false, silent: true, emphasis: { disabled: true }, tooltip: { show: false }, lineStyle: { color: base, width: 1, opacity: 0.5 * (selected ? 1 : 0.3) }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: withAlpha(base, 0.15 * (selected ? 1 : 0.125)) }, { offset: 1, color: withAlpha(base, 0) } ]) }, z: 0, animation: false }; }); return { animation: false, aria: { enabled: true }, grid: [main, { left: 8, right: 8, bottom, height, outerBoundsMode: 'none' }], xAxis: [ builtAxes.xAxis, { type: 'category', gridIndex: 1, boundaryGap: false, data: categories(c), show: false, axisPointer: { show: false } } ], yAxis: [builtAxes.yAxis, { type: 'value', gridIndex: 1, show: false }], tooltip: tooltip(c), dataZoom: buildBrushDataZoom({ brushBottom: bottom, brushHeight: height, brushRange: c.brushRange, fillerColor: 'transparent' }), series: [...mainSeries, ...mini] }; } export function createAreaLoadingData(points: number): number[] { const rows: number[] = []; let value = 30 + Math.random() * 20; for (let index = 0; index < Math.max(0, points); index += 1) { value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16)); rows.push(Math.round(value)); } return rows; } export function computeAreaPlottedTops(c: AreaOptionContext): Record { const running = new Array(c.data.length).fill(0); const tops: Record = {}; for (const area of c.areas) { const plotted = values(c, area.dataKey); tops[area.dataKey] = plotted.map((value, index) => { if (value === null) return null; return c.stackType === 'default' ? value : (running[index] += value); }); } return tops; } export function resolveAreaAtPixel( chart: EChartsType, tops: Record, keys: string[], x: number, y: number ): string | null { if (keys.length < 2 || !chart.containPixel({ gridIndex: 0 }, [x, y])) return null; const converted = chart.convertFromPixel({ gridIndex: 0 }, [x, y]); const rawIndex = Array.isArray(converted) ? converted[0] : converted; if (typeof rawIndex !== 'number') return null; const index = Math.round(rawIndex); let nearest: string | null = null; let nearestDistance = Number.POSITIVE_INFINITY; let above: string | null = null; let abovePixelY = Number.NEGATIVE_INFINITY; for (const key of keys) { const value = tops[key]?.[index]; if (value === null || value === undefined) continue; const point = chart.convertToPixel({ gridIndex: 0 }, [index, value]); const pixelY = Array.isArray(point) ? point[1] : undefined; if (typeof pixelY !== 'number') continue; const distance = Math.abs(pixelY - y); if (distance < nearestDistance) { nearestDistance = distance; nearest = key; } if (pixelY <= y && pixelY > abovePixelY) { abovePixelY = pixelY; above = key; } } return nearestDistance <= 10 ? nearest : above; } ``` `$lib/components/evilcharts/charts/echarts-area-chart/types.ts` ```ts import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export const STROKE_WIDTH = 0.8; export const BUFFER_DASH: [number, number] = [4, 3]; export type AreaVariant = 'gradient' | 'gradient-reverse' | 'solid' | 'dotted' | 'lines' | 'hatched' | 'none'; export type StrokeVariant = 'solid' | 'dashed' | 'animated-dashed'; export type StackType = 'default' | 'stacked' | 'expanded'; export type AreaAnimationType = 'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in'; export type CurveType = 'linear' | 'smooth' | 'bump' | 'monotone' | 'monotoneX' | 'monotoneY' | 'natural' | 'step'; export type AreaRegistration = { dataKey: string; variant: AreaVariant; strokeVariant: StrokeVariant; strokeWidth: number; curveType?: CurveType; animationType?: AreaAnimationType; connectNulls: boolean; isClickable: boolean; enableBufferLine: boolean; dotVariant: DotVariant; activeDotVariant: DotVariant; ditherVariant?: DitherVariant; }; export type AxisRegistration = { dataKey?: string; tickFormatter?: (value: string | number, index: number) => string; label?: string; hideDots: boolean; }; export type TooltipRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipVariant; roundness: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipRoundness; cursor?: boolean; defaultIndex?: number; position: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipPosition; }; export type LegendRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-legend/index.js').LegendVariant; align: 'left' | 'center' | 'right'; verticalAlign: 'top' | 'middle' | 'bottom'; isClickable: boolean; }; export type BrushRegistration = { height?: number; formatLabel?: (value: string, index: number) => string; onChange?: (range: { startIndex: number; endIndex: number }) => void; }; ``` `$lib/components/evilcharts/charts/echarts-area-chart/x-axis.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-area-chart/y-axis.svelte` ```svelte ``` ### Add the shared chart module. Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it. ### $lib/components/evilcharts/ui/echarts-chart `$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte` ```svelte
{#if accessibility?.description} {accessibility.description} {/if}
{@render children?.()}
{@render overlay?.()} {@render footer?.()}
``` `$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte` ```svelte {#if css} {css} {/if} ``` `$lib/components/evilcharts/ui/echarts-chart/colors.ts` ```ts import * as echarts from 'echarts/core'; import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js'; const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/; export function chartColorToken(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key; return `u-${Array.from(key, (character) => (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0') ).join('')}`; } export function chartColorVariableName(key: string, index: number): string { return `--color-${chartColorToken(key)}-${index}`; } export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string { const name = chartColorVariableName(key, index); return fallbackIndex === undefined ? `var(${name})` : `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`; } export function quoteCssString(value: string): string { return `"${Array.from(value, (character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === '"' || character === '\\') return `\\${character}`; if (codePoint === 0) return '\uFFFD'; if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `; return character; }).join('')}"`; } export function getColorsCount(item: ChartConfig[string]): number { if (!item.colors) return 1; return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1); } export function distributeColors(colors: string[], maxCount: number): string[] { if (colors.length === 0) return []; if (colors.length >= maxCount) return colors.slice(0, maxCount); const result: string[] = []; const baseSlots = Math.floor(maxCount / colors.length); const extraSlots = maxCount % colors.length; for (let index = 0; index < colors.length; index += 1) { const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0); for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]); } return result; } export function buildChartCss(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter(([, item]) => item.colors); if (colorConfig.length === 0) return ''; const variablesFor = (theme: ThemeKey) => colorConfig .flatMap(([key, item]) => { const authored = item.colors?.[theme]; if (!authored?.length) return []; return distributeColors(authored, getColorsCount(item)).map( (color, index) => ` ${chartColorVariableName(key, index)}: ${color};` ); }) .join('\n'); return Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}` ) .join('\n'); } let normalizerContext: CanvasRenderingContext2D | null = null; export function normalizeColor(value: string): string { const raw = value.trim(); if (!raw || typeof document === 'undefined') return raw; if (!normalizerContext) { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; normalizerContext = canvas.getContext('2d', { willReadFrequently: true }); } if (!normalizerContext) return raw; normalizerContext.clearRect(0, 0, 1, 1); normalizerContext.fillStyle = '#000'; normalizerContext.fillStyle = raw; normalizerContext.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data; return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`; } export function withAlpha(color: string, alpha: number): string { const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim()); const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0; return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`; } export type ResolvedColors = { series: Record; tokens: { mutedForeground: string; border: string; foreground: string; background: string; }; }; export function resolveColors( container: HTMLElement, config: ChartConfig, seriesKeys: string[] ): ResolvedColors { const computed = getComputedStyle(container); const series: Record = {}; for (const key of seriesKeys) { const count = getColorsCount(config[key] ?? {}); series[key] = Array.from({ length: count }, (_, index) => { const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim(); return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)'; }); } const probe = document.createElement('span'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;'; container.appendChild(probe); const readToken = (className: string) => { probe.className = className; return normalizeColor(getComputedStyle(probe).color); }; const tokens = { mutedForeground: readToken('text-muted-foreground'), border: readToken('text-border'), foreground: readToken('text-foreground'), background: readToken('text-background') }; probe.remove(); return { series, tokens }; } export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } export function indicatorBackground(key: string, colorsCount: number): string { if (colorsCount <= 1) return chartColorVariable(key, 0); const stops = Array.from({ length: colorsCount }, (_, index) => { const offset = (index / (colorsCount - 1)) * 100; return `${chartColorVariable(key, index)} ${offset}%`; }).join(', '); return `linear-gradient(to right, ${stops})`; } export function flattenColor(color: string, base: string): string { const parse = (value: string) => value .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1]; const [red, green, blue, alpha = 1] = parse(color); const [baseRed, baseGreen, baseBlue] = parse(base); const mix = (channel: number, baseChannel: number) => Math.round(channel * alpha + baseChannel * (1 - alpha)); return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`; } ``` `$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte` ```svelte
``` `$lib/components/evilcharts/ui/echarts-chart/index.ts` ```ts export { default as ChartContainer } from './chart-container.svelte'; export { default as ChartStyle } from './chart-style.svelte'; export { default as EChartsHost } from './echarts-host.svelte'; export { default as LoadingIndicator } from './loading-indicator.svelte'; export { default as SelectableSeriesControls } from './selectable-series-controls.svelte'; export { mergeLifecycleOptions } from './merge-options.js'; export type { EChartsEventHandler } from './echarts-host.svelte'; export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js'; export { getEChartsSharedSlotContext, setEChartsSharedSlotContext, type EChartsSharedSlotName } from './shared-slots.svelte.js'; export { buildChartCss, chartColorToken, chartColorVariable, chartColorVariableName, distributeColors, flattenColor, getColorsCount, indicatorBackground, normalizeColor, resolveColors, seriesPaint, withAlpha, quoteCssString, type ResolvedColors } from './colors.js'; export { DEFAULT_ECHARTS_RENDERER, ECHARTS_RENDERERS, THEMES, THEME_KEYS, validateChartConfigColors, type AtLeastOneThemeColor, type ChartAccessibility, type ChartConfig, type EChartsRenderer, type EChartsRenderStyle, type ThemeKey } from './types.js'; ``` `$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte` ```svelte {#if isLoading}
Loading
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/merge-options.ts` ```ts type LifecycleOptions = { animation?: unknown; animationDuration?: unknown; animationDurationUpdate?: unknown; }; /** Merge consumer options without letting them break chart-owned animation lifecycles. */ export function mergeLifecycleOptions(built: T, overrides?: object): T { const lifecycle = built as T & LifecycleOptions; return { ...built, ...overrides, animation: lifecycle.animation, animationDuration: lifecycle.animationDuration, animationDurationUpdate: lifecycle.animationDurationUpdate } as T; } ``` `$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts` ```ts import { SvelteMap } from 'svelte/reactivity'; export type RegistrationGetter = () => T; /** Ordered, reactive storage for DOM-free compound-component registrations. */ export class RegistrationSet { #entries = new SvelteMap>(); register(token: string, getter: RegistrationGetter): () => void { this.#entries.set(token, getter); return () => { if (this.#entries.get(token) === getter) this.#entries.delete(token); }; } get values(): T[] { return Array.from(this.#entries.values(), (getter) => getter()); } get first(): T | undefined { return this.#entries.values().next().value?.(); } get size(): number { return this.#entries.size; } } ``` `$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte` ```svelte {#if items.length > 0}
{#each items as item (item.key)} {/each}
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush'; type SharedSlotContext = { register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void; }; const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots'); export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext { setContext(ECHARTS_SHARED_SLOT_CONTEXT, context); return context; } export function getEChartsSharedSlotContext(): SharedSlotContext { const context = getContext(ECHARTS_SHARED_SLOT_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.'); } return context; } ``` `$lib/components/evilcharts/ui/echarts-chart/types.ts` ```ts import type { Component, Snippet } from 'svelte'; export const ECHARTS_RENDERERS = { canvas: 'canvas', svg: 'svg' } as const; export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS]; export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas; export const THEMES = { light: '', dark: '.dark' } as const; export type ThemeKey = keyof typeof THEMES; export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[]; type ThemeColorsBase = { [K in ThemeKey]?: string[]; }; export type AtLeastOneThemeColor = { [K in ThemeKey]: Required> & Partial>; }[ThemeKey]; export type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component>; colors?: AtLeastOneThemeColor; } >; export function validateChartConfigColors(config: ChartConfig): void { for (const [key, item] of Object.entries(config)) { if (!item.colors) continue; if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue; throw new Error( `[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.` ); } } export type ChartAccessibility = | { label: string; labelledBy?: never; description?: string; describedBy?: string; } | { label?: never; labelledBy: string; description?: string; describedBy?: string; }; export type EChartsRenderStyle = 'native' | 'dither'; ``` ### Add the sub-components. Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there. ### $lib/components/evilcharts/ui/echarts-tooltip `$lib/components/evilcharts/ui/echarts-tooltip/index.ts` ```ts export { default as Tooltip } from './tooltip.svelte'; export type { TooltipProps } from './tooltip.svelte'; export { escapeTooltipHtml, resolveTooltipPosition, roundnessClass, tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell, tooltipVariantClass, type TooltipPosition, type TooltipRoundness, type TooltipVariant } from './tooltip.js'; ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts` ```ts import type { TooltipComponentOption } from 'echarts/components'; import { indicatorBackground } from '../echarts-chart/index.js'; export type TooltipVariant = 'default' | 'frosted-glass'; export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl'; export type TooltipPosition = 'fixed' | 'variable'; export const roundnessClass: Record = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }; export const tooltipVariantClass: Record = { default: 'bg-background', 'frosted-glass': 'bg-background/50 backdrop-blur-md' }; export function escapeTooltipHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function tooltipIndicatorHtml(key: string, colorsCount: number): string { return `
`; } export function tooltipRow({ indicatorHtml, labelText, valueText, dimmed }: { indicatorHtml: string; labelText: string; valueText: string; dimmed: string; }): string { return `
${indicatorHtml}
${escapeTooltipHtml(labelText)} ${escapeTooltipHtml(valueText)}
`; } export function tooltipShell({ label, body, roundness, variant }: { label: string; body: string; roundness: TooltipRoundness; variant: TooltipVariant; }): string { return `
${escapeTooltipHtml(label)}
${body}
`; } export function resolveTooltipPosition( position: TooltipPosition ): TooltipComponentOption['position'] { if (position === 'variable') return undefined; return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8]; } export function tooltipBaseOption(params: { present: boolean; cursor: boolean; position: TooltipPosition; axisPointerColor: string; strokeWidth: number; }): TooltipComponentOption { const { present, cursor, position, axisPointerColor, strokeWidth } = params; return { show: present, trigger: 'axis', confine: true, displayTransition: false, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', axisPointer: cursor ? { type: 'line', lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] } } : { type: 'none' }, position: resolveTooltipPosition(position) }; } ``` Next, create `echarts-legend` in the same `ui` folder and paste the legend overlay there. ### $lib/components/evilcharts/ui/echarts-legend `$lib/components/evilcharts/ui/echarts-legend/index.ts` ```ts export { default as Legend } from './legend.svelte'; export { default as LegendIndicator } from './legend-indicator.svelte'; export { default as LegendOverlay } from './legend-overlay.svelte'; export type { LegendProps, LegendVariant } from './legend.svelte'; ``` `$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte` ```svelte {#if variant === 'square'} {:else if variant === 'circle'} {:else if variant === 'circle-outline'} {:else if variant === 'vertical-bar'} {:else if variant === 'horizontal-bar'} {:else if variant === 'rounded-square-outline'} {:else} {/if} ``` `$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte` ```svelte
{#each entries as entry (entry.key)} {#if isClickable} {:else}
{#if typeof entry.item?.label === 'function'} {@render entry.item.label()} {:else} {entry.item?.label ?? entry.key} {/if}
{/if} {/each}
``` `$lib/components/evilcharts/ui/echarts-legend/legend.svelte` ```svelte ``` Next, create `echarts-dot` in the same `ui` folder and paste the dot styles the series markers draw with there. ### $lib/components/evilcharts/ui/echarts-dot `$lib/components/evilcharts/ui/echarts-dot/dot.ts` ```ts import type * as echarts from 'echarts/core'; export type DotVariant = 'none' | 'default' | 'border' | 'colored-border' | 'ping'; export type DotItemStyleOption = { color?: string | echarts.graphic.LinearGradient; borderColor?: string | echarts.graphic.LinearGradient; borderWidth?: number; opacity?: number; }; export type DotStyle = { size: number; itemStyle: DotItemStyleOption }; function colorWithAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) hex = hex.replace(/./g, (character) => character.repeat(2)); const numeric = Number.parseInt(hex, 16); return `rgba(${(numeric >> 16) & 255}, ${(numeric >> 8) & 255}, ${numeric & 255}, ${alpha})`; } const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue] = match[1].split(',').map((part) => Number.parseFloat(part)); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } export function dotItemStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotItemStyleOption { switch (variant) { case 'border': return { color: paint, borderColor: background, borderWidth: 2 }; case 'colored-border': return { color: background, borderColor: paint, borderWidth: 1 }; case 'ping': return { color: paint, borderColor: typeof paint === 'string' ? colorWithAlpha(paint, 0.28) : paint, borderWidth: 10 }; case 'default': return { color: paint, borderWidth: 0 }; default: return {}; } } export const DOT_SIZES: Record = { none: 0, default: 6, border: 8, 'colored-border': 6, ping: 8 }; export function dotStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotStyle { return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) }; } export function sampleGradient(slots: string[], position: number): string { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; const parse = (color: string) => color .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map(Number) ?? [120, 120, 120, 1]; const scaled = position * (slots.length - 1); const index = Math.min(Math.floor(scaled), slots.length - 2); const fraction = scaled - index; const [redFrom, greenFrom, blueFrom, alphaFrom = 1] = parse(slots[index]); const [redTo, greenTo, blueTo, alphaTo = 1] = parse(slots[index + 1]); const lerp = (from: number, to: number) => from + (to - from) * fraction; return `rgba(${Math.round(lerp(redFrom, redTo))}, ${Math.round(lerp(greenFrom, greenTo))}, ${Math.round(lerp(blueFrom, blueTo))}, ${lerp(alphaFrom, alphaTo).toFixed(3)})`; } ``` `$lib/components/evilcharts/ui/echarts-dot/index.ts` ```ts export { DOT_SIZES, dotItemStyle, dotStyle, sampleGradient, type DotItemStyleOption, type DotStyle, type DotVariant } from './dot.js'; ``` Finally, create `echarts-brush` in the same `ui` folder and paste the zoom brush there. ### $lib/components/evilcharts/ui/echarts-brush `$lib/components/evilcharts/ui/echarts-brush/brush-controls.svelte` ```svelte {#if totalPoints > 0}
handleKey(event, 'range')} > Selected range: {formatLabel(startIndex)} to {formatLabel(endIndex)}
handleKey(event, 'start')} > Range start: {formatLabel(startIndex)}
handleKey(event, 'end')} > Range end: {formatLabel(endIndex)}
{/if} ``` `$lib/components/evilcharts/ui/echarts-brush/brush.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-brush/brush.ts` ```ts import type { DataZoomComponentOption } from 'echarts/components'; import type { EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { withAlpha, type ResolvedColors } from '../echarts-chart/index.js'; export const BRUSH_BORDER_OPACITY = 1; export type BrushRange = { start: number; end: number }; export type BrushGeometry = { bottom: number; height: number }; export type BrushOverlayParams = { range: BrushRange; geom: BrushGeometry; size: { width: number; height: number }; tokens: ResolvedColors['tokens']; labels: { start: string; end: string } | null; showLabels: boolean; hover: { left: boolean; right: boolean }; }; type ZrRect = InstanceType; type ZrCircle = InstanceType; type ZrText = InstanceType; export type BrushOverlayElements = { dimLeft: ZrRect; dimRight: ZrRect; frame: ZrRect; pillLeft: ZrRect; pillRight: ZrRect; grips: ZrCircle[]; labelStart: ZrText; labelEnd: ZrText; }; export function syncBrushOverlay( chart: EChartsType, store: { brushOverlay: BrushOverlayElements | null }, params: BrushOverlayParams | null ): void { const renderer = chart.getZr(); if (!renderer) return; if (!params) { if (store.brushOverlay) { const { grips, ...rest } = store.brushOverlay; for (const element of [...Object.values(rest), ...grips]) renderer.remove(element); store.brushOverlay = null; } return; } if (!store.brushOverlay) { const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} }); const elements: BrushOverlayElements = { dimLeft: rect(100), dimRight: rect(100), frame: rect(101), pillLeft: rect(102), pillRight: rect(102), grips: Array.from( { length: 6 }, () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }) ), labelStart: new echarts.graphic.Text({ silent: true, z: 104 }), labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }) }; const { grips, ...rest } = elements; for (const element of [...Object.values(rest), ...grips]) renderer.add(element); store.brushOverlay = elements; } const elements = store.brushOverlay; const { range, geom, size, tokens, labels, showLabels, hover } = params; const trackLeft = 8; const trackRight = Math.max(size.width - 8, trackLeft); const trackWidth = trackRight - trackLeft; const top = size.height - geom.bottom - geom.height; const centerY = top + geom.height / 2; const selectionLeft = trackLeft + (trackWidth * range.start) / 100; const selectionRight = trackLeft + (trackWidth * range.end) / 100; const dimFill = withAlpha(tokens.background, 0.7); elements.dimLeft.setShape({ x: trackLeft, y: top, width: Math.max(selectionLeft - trackLeft, 0), height: geom.height }); elements.dimLeft.setStyle({ fill: dimFill }); elements.dimRight.setShape({ x: selectionRight, y: top, width: Math.max(trackRight - selectionRight, 0), height: geom.height }); elements.dimRight.setStyle({ fill: dimFill }); elements.frame.setShape({ x: selectionLeft, y: top, width: Math.max(selectionRight - selectionLeft, 0), height: geom.height, r: 6 }); elements.frame.setStyle({ fill: 'none', stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY), lineWidth: 1 }); const updatePill = (element: ZrRect, x: number, hovered: boolean) => { element.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 }); element.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground }); }; updatePill(elements.pillLeft, selectionLeft, hover.left); updatePill(elements.pillRight, selectionRight, hover.right); const gripFill = withAlpha(tokens.background, 0.7); for (const [index, offset] of [-4, 0, 4].entries()) { elements.grips[index].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 }); elements.grips[index].setStyle({ fill: gripFill }); elements.grips[index + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 }); elements.grips[index + 3].setStyle({ fill: gripFill }); } const updateLabel = (element: ZrText, text: string, x: number, align: 'left' | 'right') => { element.setStyle({ text, x: align === 'left' ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2), y: top + geom.height, align, verticalAlign: 'middle', fill: tokens.background, backgroundColor: tokens.foreground, padding: [2, 5], borderRadius: 4, font: '500 9px system-ui, sans-serif' }); element.attr('invisible', !showLabels || !text); }; updateLabel(elements.labelStart, labels?.start ?? '', selectionLeft, 'left'); updateLabel(elements.labelEnd, labels?.end ?? '', selectionRight, 'right'); } export function buildBrushDataZoom(params: { brushBottom: number; brushHeight: number; brushRange: BrushRange; fillerColor: string; }): DataZoomComponentOption[] { const { brushBottom, brushHeight, brushRange, fillerColor } = params; return [ { type: 'slider', show: true, xAxisIndex: [0], left: 8, right: 8, bottom: brushBottom, height: brushHeight, start: brushRange.start, end: brushRange.end, brushSelect: false, showDetail: false, backgroundColor: 'transparent', borderColor: 'transparent', fillerColor, dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, handleIcon: 'path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z', handleSize: '35%', handleStyle: { opacity: 0 }, moveHandleSize: 0, emphasis: { handleStyle: { opacity: 0 } } }, { type: 'inside', xAxisIndex: [0] } ]; } ``` `$lib/components/evilcharts/ui/echarts-brush/index.ts` ```ts export { default as Brush } from './brush.svelte'; export { default as BrushControls } from './brush-controls.svelte'; export type { BrushProps } from './brush.svelte'; export { BRUSH_BORDER_OPACITY, buildBrushDataZoom, syncBrushOverlay, type BrushGeometry, type BrushOverlayElements, type BrushOverlayParams, type BrushRange } from './brush.js'; ``` ## Usage The ECharts area chart is composable. `` is the container, and every part hangs off it as a compound member — ``, ``, ``, ``, ``, ``, and one or more `` — so a single import gives you the whole chart. Each `` sets its own `variant`, `strokeVariant`, and `isClickable`, so one chart can mix fills, strokes, and selective interactivity. ```svelte ``` ```svelte ``` The difference is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`. The `config` is the same contract as every EvilCharts chart — each key maps a data key to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from CSS variables at runtime, so dark mode just works. > The ECharts implementation brings two small departures from the LayerChart sibling: multi-color gradient areas and the `dotted`, `lines`, and `hatched` fills use offscreen canvas textures sized to the plot. With `renderer="svg"`, ECharts may embed those texture tiles as raster images inside the SVG. The zoom brush is a themed mini chart driven by ECharts' native `dataZoom` instead of the custom `EvilBrush`. ### Loading State ### isLoading='true' ```svelte String(value).substring(0, 3)} /> ``` > Pass `isLoading` to show an animated skeleton; use `loadingPoints` to set how many points it draws. ## Examples Change `variant` and `strokeVariant` on an ``, or `curveType` and `stackType` on the chart, to restyle it. ### SVG Renderer Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer. ### renderer="svg" ```svelte String(value).substring(0, 3)} /> ``` ### Hover Highlight ### enableHoverHighlight='true' ```svelte String(value).substring(0, 3)} /> ``` ### Buffer Line ### enableBufferLine='true' ```svelte String(value).substring(0, 3)} /> ``` > With `enableBufferLine`, each area's last segment renders as a dashed tail while the rest stays solid — useful for marking projected, estimated, or incomplete data at the end of a series, as in financial charts and forecasting dashboards. ### Hover Reveal ### enableHoverReveal='true' ```svelte String(value).substring(0, 3)} /> ``` > With `enableHoverReveal`, hovering colors each area's line and fill only up to the pointer's position and mutes everything past it to a neutral gray, with the active dot riding the cursor — a scrubbing effect for reading a series left-to-right. When not hovering, the chart looks completely normal. ### Gradient Colors ### gradient colors ```svelte String(value).substring(0, 3)} /> ``` ### gradient colors - bump ```svelte String(value).substring(0, 3)} /> ``` ### Curve Types ### curveType='bump' ```svelte String(value).substring(0, 3)} /> ``` ### curveType='step' ```svelte String(value).substring(0, 3)} /> ``` ### curveType='monotoneY' ```svelte String(value).substring(0, 3)} /> ``` ### Stack Types ### stackType='default' ```svelte String(value).substring(0, 3)} /> ``` ### stackType='stacked' ```svelte String(value).substring(0, 3)} /> ``` ### stackType='expanded' ```svelte String(value).substring(0, 3)} /> ``` ### Stroke Variants ### strokeVariant='solid' ```svelte String(value).substring(0, 3)} /> ``` ### strokeVariant='dashed' ```svelte String(value).substring(0, 3)} /> ``` ### strokeVariant='animated-dashed' ```svelte String(value).substring(0, 3)} /> ``` ### Area Variants ### variant='gradient' ```svelte String(value).substring(0, 3)} /> ``` ### variant='gradient-reverse' ```svelte String(value).substring(0, 3)} /> ``` ### variant='solid' ```svelte String(value).substring(0, 3)} /> ``` ### variant='dotted' ```svelte String(value).substring(0, 3)} /> ``` ### variant='lines' ```svelte String(value).substring(0, 3)} /> ``` ### variant='hatched' ```svelte String(value).substring(0, 3)} /> ``` ### Ordered dither Set `renderStyle="dither"` to use the independent ordered-dither treatment inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit). Axes, tooltips, selection, and the ECharts renderer stay intact. ### renderStyle="dither" ```svelte ``` ## API Reference Props are grouped by the part they belong to. Regardless of renderer, each part is declarative config the root compiles, but the API mirrors the LayerChart sibling one-to-one. ### EChartsAreaChart The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual is composed as its children and compiled into the ECharts option. ### `data` (required) type: `TData[]` Chart data — an array of objects, one per data point (`TData extends Record`). ### `config` (required) type: `ChartConfig` Defines the chart's series. Each key matches a data key, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config). ### `children` (required) type: `Snippet` The composed chart parts — ``, ``, ``, ``, ``, and one or more ``. ### `class` type: `string` Additional CSS classes for the chart container. ### `renderer` type: `"canvas" | "svg"` · default: `"canvas"` Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default. ### `renderStyle` type: `"native" | "dither"` · default: `"native"` Selects native ECharts paint or EvilCharts' ordered-dither rendering. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"` Default ordered-dither pattern used by the chart's series. ### `ditherCellSize` type: `number` · default: `2` Dither cell size in CSS pixels. ### `bloom` type: `"off" | "low" | "high" | "aura"` · default: `"off"` Optional glow applied to dithered marks. It has no effect in native rendering mode. ### `xDataKey` type: `keyof TData & string` Data key for the x-axis categories. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` · default: `"linear"` Default curve interpolation inherited by every ``. ### `animation` type: `boolean` · default: `true` Master switch for the intro draw-in. Pass `false` to render instantly, regardless of `animationType`. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"` The intro animation inherited by every ``. Any value but `"none"` plays ECharts' native progressive draw-in — the line traces in and dots pop as its front passes (direction values exist for parity with the LayerChart sibling). `"none"` disables it; OS reduce-motion falls back to `"none"` automatically. ### `enableHoverHighlight` type: `boolean` · default: `false` Highlights the hovered series by dimming the others — the hover twin of click selection, using the same dim levels. ### `enableHoverReveal` type: `boolean` · default: `false` On hover, colors each area up to the pointer's x-position and mutes the rest to a neutral gray, with the active dot at the cursor. A standalone hover mode that takes visual precedence over `enableHoverHighlight`; idle, the chart renders normally. ### `stackType` type: `"default" | "stacked" | "expanded"` · default: `"default"` How multiple areas combine — independent, stacked, or normalized to 100%. ### `defaultSelectedDataKey` type: `string | null` · default: `null` The series selected on first render. ### `selectedDataKey` type: `string | null` Controls the selected series. Leave it undefined to let the chart manage selection; pass `null` to clear a controlled selection. ### `onSelectionChange` type: `(key: string | null) => void` Fires when a series is selected or deselected via a clickable `` or ``. ### `isLoading` type: `boolean` · default: `false` Shows the animated loading skeleton. ### `loadingPoints` type: `number` · default: `14` Number of points in the loading skeleton. ### `chartOptions` type: `Record` Escape hatch deep-merged into the underlying ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html). ### `accessibility` type: `ChartAccessibility` Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology. ### Area A single area series. Each `` carries its own fill and stroke config, so a chart can hold any number — each with its own variant, stroke, and clickability. ### `dataKey` (required) type: `string` The series key. Must exist on both the data rows and the chart `config`. ### `variant` type: `"gradient" | "gradient-reverse" | "solid" | "dotted" | "lines" | "hatched"` · default: `"gradient"` Visual style of the area fill, for this area only. Multi-color configs render the full horizontal gradient faded vertically, matching the LayerChart sibling. ### `strokeVariant` type: `"solid" | "dashed" | "animated-dashed"` · default: `"dashed"` The stroke style for this area. ### `strokeWidth` type: `number` · default: `0.8` Stroke thickness for this area, in pixels. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` The curve interpolation for this area. Falls back to the chart's `curveType` when omitted. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` The intro draw-in for this area (the first ``'s value drives the chart). Falls back to the chart's `animationType` when omitted. ### `connectNulls` type: `boolean` · default: `false` Whether to connect line segments across null or missing values. ### `isClickable` type: `boolean` · default: `false` Makes this area selectable on click. When any area is selected, the rest turn semi-transparent. ### `enableBufferLine` type: `boolean` · default: `false` Renders this area's last segment as a dashed buffer tail while the rest stays solid — useful for projected or incomplete data at the end of a series. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` Overrides the root ordered-dither pattern for this area only. ### `children` type: `Snippet` Optional `` and `` config that adds point markers to this area. ### Dot and ActiveDot Point markers composed inside an ``. `` is the resting marker; `` is the hovered marker. They render nothing on their own — the parent `` reads their `variant`. ### `variant` type: `"default" | "border" | "colored-border"` · default: `"default"` The visual style of the point marker. ### XAxis and YAxis The category and value axes. Include `` or `` to show each; omit either to hide it. Both hide automatically while loading, and `` formats ticks as percentages when `stackType="expanded"`. ### `dataKey` type: `string` The data key for the axis values. ### `tickFormatter` type: `(value: string | number, index: number) => string` Formats the axis tick labels. ### `hideDots` type: `boolean` · default: `false` Hides the small tick dots that sit beside this axis's labels. ### Grid The background grid lines. Include it to draw the dashed horizontal split lines; omit it and they don't. Takes no props. ### Tooltip The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads selection state, dimming unselected series in its content. ### `variant` type: `"default" | "frosted-glass"` · default: `"default"` The visual style of the tooltip surface. ### `roundness` type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"` Controls the border-radius of the tooltip. ### `cursor` type: `boolean` · default: `true` Whether the vertical cursor line follows the pointer on hover. ### Legend The series legend, rendered as HTML above the chart surface. Include it to show the legend; omit it and none shows. When `isClickable` is set, each entry toggles selection of its series. ### `variant` type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"` The visual style of the legend indicators. ### `align` type: `"left" | "center" | "right"` · default: `"right"` Horizontal placement of the legend. ### `verticalAlign` type: `"top" | "middle" | "bottom"` · default: `"top"` Vertical placement of the legend. ### `isClickable` type: `boolean` · default: `false` Lets each legend entry toggle selection of its series. ### Brush An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `` to render it; dragging the range filters the main chart. ### `height` type: `number` · default: `56` Height of the brush preview strip in pixels. ### `formatLabel` type: `(value: string | number, index: number) => string` Formats the range-handle labels below the brush. ### `onChange` Fires when the brush selection range changes. --- ## Area Blocks > Ready-made area chart blocks, powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/area-chart/blocks Markdown: https://evilcharts-sv.vercel.app/docs/echarts/area-chart/blocks.md ## Latency Percentiles ### Latency Percentiles ```svelte
{#each series as item (item.key)} {/each}
(selected = key)} > String(value).replace('Today ', '')} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/latency-echarts-area-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/latency-echarts-area-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/latency-echarts-area-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/latency-echarts-area-chart ``` ## Portfolio Comparison ### Portfolio Comparison ```svelte
{#each series as item (item.key)}
{item.label}
{item.pct > 0 ? '+' : '−'}{Math.abs(item.pct).toFixed(2)}%
{item.delta < 0 ? '−' : '+'}${money(item.delta)}
{/each}
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/portfolio-echarts-area-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/portfolio-echarts-area-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/portfolio-echarts-area-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/portfolio-echarts-area-chart ``` ## Growth Benchmark ### Growth Benchmark ```svelte
Weekly signups
{latest.actual} +{delta.toFixed(1)}% vs target
Actual
Target
String(value).split(' ')[1] === '1' ? String(value).split(' ')[0] : ''} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/benchmark-echarts-area-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/benchmark-echarts-area-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/benchmark-echarts-area-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/benchmark-echarts-area-chart ``` ## Audience Growth ### Audience Growth ```svelte
Listeners Monthly reach across every show and episode
{total.toLocaleString('en-US')} Total listeners
{#each data as row (row.month)}{row.month}{/each}
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/audience-echarts-area-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/audience-echarts-area-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/audience-echarts-area-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/audience-echarts-area-chart ``` --- ## Bar Chart > Static, beautifully designed bar charts powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/bar-chart Markdown: https://evilcharts-sv.vercel.app/docs/echarts/bar-chart.md ### Basic Chart ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ## Installation ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-bar-chart ``` ### Install the following dependencies: ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Copy the following code into your project. In your `components` directory, create the nested folders `evilcharts` → `charts`, then paste the bar-chart code into a new `echarts-bar-chart` file inside. ### $lib/components/evilcharts/charts/echarts-bar-chart `$lib/components/evilcharts/charts/echarts-bar-chart/bar-chart-context.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { AxisRegistration, BarRegistration } from './types.js'; const CONTEXT = Symbol('echarts-bar-chart'); export class EChartsBarChartContext { bars = new RegistrationSet(); xAxes = new RegistrationSet(); yAxes = new RegistrationSet(); grids = new RegistrationSet>(); } export function setEChartsBarChartContext() { const value = new EChartsBarChartContext(); setContext(CONTEXT, value); return value; } export function useEChartsBarChart() { const value = getContext(CONTEXT); if (!value) throw new Error('[EvilCharts] ECharts bar parts must be children of EChartsBarChart.'); return value; } ``` `$lib/components/evilcharts/charts/echarts-bar-chart/bar-chart.svelte` ```svelte {#snippet overlay()} {#if legend && !isLoading} {/if} {/snippet} {@render children?.()} {#if bars.length > 0} {/if} {#if !legend?.isClickable} {/if} {#if brush && !isLoading && data.length > 0 && layout === 'vertical'} brush.formatLabel?.(categoryValues[index] ?? '', index) ?? String(categoryValues[index] ?? index)} onChange={(range) => { const last = Math.max(0, data.length - 1); brushRange = { start: last === 0 ? 0 : (range.startIndex / last) * 100, end: last === 0 ? 100 : (range.endIndex / last) * 100 }; instance?.dispatchAction( { type: 'dataZoom', start: brushRange.start, end: brushRange.end }, { silent: true } ); brush.onChange?.(range); }} /> {/if} ``` `$lib/components/evilcharts/charts/echarts-bar-chart/bar.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-bar-chart/grid.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-bar-chart/index.ts` ```ts import Root from './bar-chart.svelte'; import Bar from './bar.svelte'; import XAxis from './x-axis.svelte'; import YAxis from './y-axis.svelte'; import Grid from './grid.svelte'; import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { Legend } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import { Brush } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; type RootComponent = typeof Root; export const EChartsBarChart: RootComponent & { Bar: typeof Bar; XAxis: typeof XAxis; YAxis: typeof YAxis; Grid: typeof Grid; Tooltip: typeof Tooltip; Legend: typeof Legend; Brush: typeof Brush; } = Object.assign(Root, { Bar, XAxis, YAxis, Grid, Tooltip, Legend, Brush }); export type { ChartAccessibility, ChartConfig, EChartsRenderer, EChartsRenderStyle } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; export type { DitherBloom, DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; export type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export type { BarAnimationType, BarHoverDatum, BarLayout, BarVariant, StackType } from './types.js'; ``` `$lib/components/evilcharts/charts/echarts-bar-chart/option.ts` ```ts import type { BarSeriesOption, CustomSeriesOption } from 'echarts/charts'; import type { DataZoomComponentOption, GridComponentOption, MarkLineComponentOption, TooltipComponentOption } from 'echarts/components'; import type { ComposeOption, EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { flattenColor, getColorsCount, withAlpha, type ChartConfig, type ResolvedColors } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import { buildBrushDataZoom, type BrushRange } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; import { createDitherPattern, type DitherBloom, type DitherVariant, type RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import { sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import { tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import type { AxisRegistration, BarLayout, BarRegistration, BrushRegistration, LegendRegistration, StackType, TooltipRegistration } from './types.js'; export type EChartsBarOption = ComposeOption< | BarSeriesOption | CustomSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption | MarkLineComponentOption >; type ArrayItem = T extends readonly (infer Item)[] ? Item : T; type XAxisOption = ArrayItem>; type YAxisOption = ArrayItem>; export type BarOptionContext = { data: Record[]; config: ChartConfig; bars: BarRegistration[]; xDataKey?: string; stackType: StackType; layout: BarLayout; barRadius: number; barGap?: number; barCategoryGap?: number; selectedDataKey: string | null; enableMaxValueHighlight: boolean; referenceLine?: number | null; referenceLineFormatter?: (value: number) => string; xAxis?: AxisRegistration; yAxis?: AxisRegistration; showGrid: boolean; tooltip?: TooltipRegistration; legend?: LegendRegistration; brush?: BrushRegistration; brushRange: BrushRange; isLoading: boolean; loadingData: number[]; resolved: ResolvedColors; animation: boolean; animationType: string; reducedMotion: boolean; rendererSize: { width: number; height: number }; renderStyle: RenderStyle; ditherVariant: DitherVariant; ditherCellSize: number; bloom?: DitherBloom; valuePxPerUnit?: number | null; barWidthPx?: number | null; expand?: { key: string | null; hovered: number | null; progress: ReadonlyMap }; }; const ditherBloomBlur = (style: RenderStyle, bloom?: DitherBloom) => style !== 'dither' || bloom === 'off' || bloom === undefined ? 0 : bloom === 'aura' ? 14 : bloom === 'high' ? 8 : 4; const GRAY = 'rgba(120, 120, 120, 1)'; const BLOCK_SIZE = 8; const BLOCK_GAP = 4; const BLOCK_TRACK_OPACITY = 0.22; const STACK_SEGMENT_GAP = 4; const STRIPPED_CAP_HEIGHT = 4; const STRIPPED_BODY_ALPHA = 0.2; const STRIPPED_CAP_MAX_FRACTION = 0.85; const STRIPPED_FALLBACK_FRACTION = 0.12; const EXPAND_COLLAPSED = 0.12; const MAX_HIGHLIGHT_DIM = 0.16; const labelFor = (config: ChartConfig, key: string) => typeof config[key]?.label === 'string' ? (config[key].label as string) : key; function ditherPlotBounds(c: BarOptionContext) { const top = c.legend?.verticalAlign === 'top' ? 42 : 16; const showBrush = c.layout === 'vertical' && Boolean(c.brush) && !c.isLoading; const bottom = 8 + (showBrush ? (c.brush?.height ?? 56) + 30 + (c.xAxis?.label ? 22 : 0) : 0) + (c.legend?.verticalAlign === 'bottom' ? 34 : 0); return { height: Math.max(c.ditherCellSize, c.rendererSize.height - top - bottom), offsetY: top }; } function categoryKey(c: BarOptionContext) { if (c.xDataKey) return c.xDataKey; const axis = c.layout === 'vertical' ? c.xAxis : c.yAxis; if (axis?.dataKey) return axis.dataKey; const keys = new Set(c.bars.map((bar) => bar.dataKey)); return Object.keys(c.data[0] ?? {}).find((key) => !keys.has(key)); } function categories(c: BarOptionContext) { const key = categoryKey(c); return c.data.map((row, i) => String((key ? row[key] : undefined) ?? i)); } function rawValues(c: BarOptionContext, key: string): number[] { return c.data.map((row) => typeof row[key] === 'number' && Number.isFinite(row[key]) ? (row[key] as number) : 0 ); } function values(c: BarOptionContext, key: string) { const raw = rawValues(c, key); if (c.stackType !== 'percent') return raw; return raw.map((value, i) => { const total = c.bars.reduce((sum, bar) => sum + rawValues(c, bar.dataKey)[i], 0); return total ? value / total : 0; }); } function axisBase(c: BarOptionContext, axis: AxisRegistration | undefined, isCategory: boolean) { const { mutedForeground, border, background } = c.resolved.tokens; return { type: isCategory ? ('category' as const) : ('value' as const), data: isCategory ? (c.isLoading ? c.loadingData.map((_, i) => i) : categories(c)) : undefined, axisLine: { show: false }, axisTick: { show: !c.isLoading && Boolean(axis) && !axis?.hideDots, length: 0.5, lineStyle: { color: flattenColor(border, background), width: 3, cap: 'round' } }, axisLabel: { show: !c.isLoading && Boolean(axis), color: mutedForeground, fontSize: 10, margin: 8, formatter: !isCategory && c.stackType === 'percent' ? (value: number) => `${Math.round(value * 100)}%` : axis?.tickFormatter }, splitLine: { show: !isCategory && c.showGrid && !c.isLoading, lineStyle: { color: border, type: [3, 3], width: 1 } }, name: c.isLoading ? undefined : axis?.label, nameLocation: 'middle' as const, nameGap: isCategory ? c.layout === 'horizontal' ? 38 : 30 : c.layout === 'horizontal' ? 30 : 38, nameTextStyle: { color: mutedForeground, fontSize: 10 }, max: !isCategory && c.stackType === 'percent' ? 1 : undefined }; } function axes(c: BarOptionContext): { xAxis: XAxisOption; yAxis: YAxisOption } { if (c.layout === 'horizontal') return { xAxis: axisBase(c, c.xAxis, false) as XAxisOption, yAxis: { ...axisBase(c, c.yAxis, true), inverse: true } as YAxisOption }; return { xAxis: axisBase(c, c.xAxis, true) as XAxisOption, yAxis: axisBase(c, c.yAxis, false) as YAxisOption }; } type ImagePattern = { image: HTMLCanvasElement; repeat: 'repeat'; rotation?: number; scaleX?: number; scaleY?: number; }; function solidVerticalPaint(slots: string[], alpha = 1) { if (slots.length <= 1) { const base = slots[0] ?? GRAY; return alpha === 1 ? base : withAlpha(base, alpha); } return new echarts.graphic.LinearGradient( 0, 0, 0, 1, slots.map((color, index) => ({ offset: index / (slots.length - 1), color: withAlpha(color, alpha) })) ); } function verticalFadePaint(slots: string[]) { const offsets = [0, 0.2, 0.45, 0.7, 0.9, 1]; return new echarts.graphic.LinearGradient( 0, 0, 0, 1, offsets.map((offset) => ({ offset, color: withAlpha( sampleGradient(slots, offset), offset <= 0.2 ? 1 : offset >= 0.9 ? 0 : 1 - (offset - 0.2) / 0.7 ) })) ); } function duotoneSplitPaint( base: string, firstAlpha: number, secondAlpha: number, horizontal: boolean ) { const stops = [ { offset: 0, color: withAlpha(base, firstAlpha) }, { offset: 0.5, color: withAlpha(base, firstAlpha) }, { offset: 0.5, color: withAlpha(base, secondAlpha) }, { offset: 1, color: withAlpha(base, secondAlpha) } ]; return horizontal ? new echarts.graphic.LinearGradient(0, 0, 0, 1, stops) : new echarts.graphic.LinearGradient(1, 0, 0, 0, stops); } function strippedDatumPaint(slots: string[], horizontal: boolean, capFraction: number) { const fraction = Math.min(Math.max(capFraction, 0), 1); const cap = withAlpha(sampleGradient(slots, 0), 1); const stops = [ { offset: 0, color: cap }, { offset: fraction, color: cap }, { offset: fraction, color: withAlpha(sampleGradient(slots, fraction), STRIPPED_BODY_ALPHA) }, { offset: 1, color: withAlpha(sampleGradient(slots, 1), STRIPPED_BODY_ALPHA) } ]; return horizontal ? new echarts.graphic.LinearGradient(1, 0, 0, 0, stops) : new echarts.graphic.LinearGradient(0, 0, 0, 1, stops); } export function strippedCapFraction(value: number, valuePxPerUnit: number | null): number { if (valuePxPerUnit === null) return STRIPPED_FALLBACK_FRACTION; const barPixels = Math.abs(value) * valuePxPerUnit; if (!(barPixels > 0)) return STRIPPED_FALLBACK_FRACTION; return Math.min(STRIPPED_CAP_HEIGHT / barPixels, STRIPPED_CAP_MAX_FRACTION); } export function measureBarValueScale(chart: EChartsType, horizontal: boolean): number | null { try { const finder = horizontal ? { xAxisIndex: 0 } : { yAxisIndex: 0 }; const start = chart.convertToPixel(finder, 0); const end = chart.convertToPixel(finder, 1); if (typeof start !== 'number' || typeof end !== 'number') return null; const delta = Math.abs(end - start); return Number.isFinite(delta) && delta > 0 ? delta : null; } catch { return null; } } export function measureBarWidth( chart: EChartsType, horizontal: boolean, barCategoryGap?: number ): number | null { try { const finder = horizontal ? { yAxisIndex: 0 } : { xAxisIndex: 0 }; const start = chart.convertToPixel(finder, 0); const end = chart.convertToPixel(finder, 1); if (typeof start !== 'number' || typeof end !== 'number') return null; const pitch = Math.abs(end - start); if (!Number.isFinite(pitch) || pitch <= 0) return null; const width = barCategoryGap === undefined ? pitch * 0.8 : pitch - barCategoryGap; return width > 1 ? width : null; } catch { return null; } } function patternFill( kind: 'hatched' | 'buffer' | 'blocks', color: string, blockSize = BLOCK_SIZE ): ImagePattern | null { if (typeof document === 'undefined') return null; const dpr = Math.max(window.devicePixelRatio || 1, 1); const canvas = document.createElement('canvas'); const context = canvas.getContext('2d'); if (!context) return null; const size = (width: number, height: number) => { canvas.width = width * dpr; canvas.height = height * dpr; context.scale(dpr, dpr); }; const pattern = (rotation = 0): ImagePattern => ({ image: canvas, repeat: 'repeat', rotation, scaleX: 1 / dpr, scaleY: 1 / dpr }); if (kind === 'blocks') { size(1, blockSize + BLOCK_GAP); context.fillStyle = color; context.fillRect(0, 0, 1, blockSize); return pattern(); } if (kind === 'hatched') { size(5, 5); context.fillStyle = withAlpha(color, 0.3); context.fillRect(0, 0, 5, 5); context.fillStyle = color; context.fillRect(0, 0, 1.5, 5); return pattern(-Math.PI / 4); } size(5, 5); context.fillStyle = color; context.fillRect(0, 0, 1, 5); return pattern(-Math.PI / 4); } export function createExpandablePaint(slots: string[], openness: number) { const base = slots[0] ?? GRAY; const half = Math.max(0, Math.min(1, openness)) / 2; const left = 0.5 - half; const right = 0.5 + half; const clear = withAlpha(base, 0); return new echarts.graphic.LinearGradient(0, 0, 1, 0, [ { offset: 0, color: clear }, { offset: left, color: clear }, { offset: left, color: base }, { offset: right, color: base }, { offset: right, color: clear }, { offset: 1, color: clear } ]); } function barBorderRadius(radius: number, variant: BarRegistration['variant'], horizontal: boolean) { if (variant === 'blocks' || variant === 'expandable') return 0; if (variant !== 'stripped') return radius; return horizontal ? [0, radius, radius, 0] : [radius, radius, 0, 0]; } function fill(c: BarOptionContext, bar: BarRegistration) { const slots = c.resolved.series[bar.dataKey] ?? [c.resolved.tokens.foreground]; const color = slots[0] ?? c.resolved.tokens.foreground; if (c.renderStyle === 'dither') return createDitherPattern( slots, bar.ditherVariant ?? c.ditherVariant, c.ditherCellSize, 1, ditherPlotBounds(c) ); if (bar.variant === 'gradient') return verticalFadePaint(slots); if (bar.variant === 'duotone') return duotoneSplitPaint(color, 0.4, 1, c.layout === 'horizontal'); if (bar.variant === 'duotone-reverse') return duotoneSplitPaint(color, 1, 0.4, c.layout === 'horizontal'); if (bar.variant === 'hatched') return patternFill('hatched', color) ?? solidVerticalPaint(slots); if (bar.variant === 'blocks') return patternFill('blocks', color, c.barWidthPx ?? BLOCK_SIZE) ?? solidVerticalPaint(slots); if (bar.variant === 'stripped') return strippedDatumPaint(slots, c.layout === 'horizontal', STRIPPED_FALLBACK_FRACTION); if (bar.variant === 'expandable') return createExpandablePaint(slots, EXPAND_COLLAPSED); return solidVerticalPaint(slots); } function tooltip(c: BarOptionContext): TooltipComponentOption { const slot = c.tooltip; return { ...tooltipBaseOption({ present: Boolean(slot) && !c.isLoading, cursor: false, position: slot?.position ?? 'variable', axisPointerColor: c.resolved.tokens.border, strokeWidth: 0.8 }), formatter: (raw) => { const params = (Array.isArray(raw) ? raw : [raw]) as Array<{ seriesId?: string; axisValueLabel?: string; value?: unknown; data?: unknown; }>; const body = params .filter((p) => p.seriesId && !p.seriesId.startsWith('__')) .map((p) => { const key = p.seriesId as string; const rawValue = typeof p.data === 'object' && p.data && 'value' in p.data ? (p.data as { value: unknown }).value : p.value; const value = Array.isArray(rawValue) ? rawValue.at(-1) : rawValue; return tooltipRow({ indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(c.config[key] ?? {})), labelText: labelFor(c.config, key), valueText: Number(value).toLocaleString(), dimmed: c.selectedDataKey && c.selectedDataKey !== key ? ' opacity-30' : '' }); }) .join(''); return tooltipShell({ label: params[0]?.axisValueLabel ?? '', body, roundness: slot?.roundness ?? 'lg', variant: slot?.variant ?? 'default' }); } }; } function isDarkBackground(color: string): boolean { const channels = color .match(/[\d.]+/g) ?.slice(0, 3) .map(Number); if (!channels || channels.length < 3) return false; return channels[0] * 0.2126 + channels[1] * 0.7152 + channels[2] * 0.0722 < 128; } function isometricSeries( c: BarOptionContext, bar: BarRegistration, maxIndex: number | null ): CustomSeriesOption { const slots = c.resolved.series[bar.dataKey] ?? [c.resolved.tokens.foreground]; const base = slots[0] ?? c.resolved.tokens.foreground; const dark = isDarkBackground(c.resolved.tokens.background); const accent = dark ? '#15803d' : '#22c55e'; const valuesForBar = values(c, bar.dataKey); return { id: bar.dataKey, name: labelFor(c.config, bar.dataKey), type: 'custom', coordinateSystem: 'cartesian2d', encode: { x: 0, y: 1 }, data: valuesForBar.map((value, index) => [index, value]), cursor: bar.isClickable ? 'pointer' : 'default', renderItem(params, api) { const index = params.dataIndex; const value = Number(api.value(1)); const top = api.coord([api.value(0), value]); const baseline = api.coord([api.value(0), 0]); const categorySize = api.size?.([1, 0]) ?? [0, 0]; const width = Math.max( 4, Number(Array.isArray(categorySize) ? categorySize[0] : categorySize) * 0.55 ); const depth = Math.min(10, Math.max(5, width * 0.24)); const left = Number(top[0]) - width / 2; const right = Number(top[0]) + width / 2; const topY = Number(top[1]); const baseY = Number(baseline[1]); const height = Math.max(0, baseY - topY); const highlight = index === maxIndex; const color = highlight ? accent : base; const front = patternFill('hatched', color) ?? color; return { type: 'group', children: [ { type: 'polygon', shape: { points: [ [right, topY], [right + depth, topY - depth], [right + depth, baseY - depth], [right, baseY] ] }, style: { fill: withAlpha(color, 0.55) } }, { type: 'polygon', shape: { points: [ [left, topY], [right, topY], [right + depth, topY - depth], [left + depth, topY - depth] ] }, style: { fill: withAlpha(color, 0.78) } }, { type: 'rect', shape: { x: left, y: topY, width, height }, style: { fill: front } } ] }; }, animation: c.animation && !c.reducedMotion, animationDuration: 700, animationDelay: (index) => index * 80, animationEasing: 'cubicOut' }; } function referenceMarkLine(c: BarOptionContext) { if (c.referenceLine === null || c.referenceLine === undefined) return undefined; return { symbol: 'none', silent: true, lineStyle: { color: withAlpha(c.resolved.tokens.mutedForeground, 0.6), type: 'dashed' as const, width: 1 }, label: { show: true, position: c.layout === 'horizontal' ? ('end' as const) : ('insideEndTop' as const), formatter: c.referenceLineFormatter?.(c.referenceLine) ?? String(c.referenceLine), color: c.resolved.tokens.foreground, backgroundColor: c.resolved.tokens.background, borderColor: c.resolved.tokens.border, borderWidth: 1, borderRadius: 4, padding: [2, 5], fontFamily: 'var(--font-mono, monospace)', fontSize: 10 }, data: [c.layout === 'horizontal' ? { xAxis: c.referenceLine } : { yAxis: c.referenceLine }] }; } function series(c: BarOptionContext): Array { if (c.isLoading) { return [ { id: '__loading', type: 'bar', data: c.loadingData, barCategoryGap: '30%', silent: true, itemStyle: { color: withAlpha(c.resolved.tokens.foreground, 0), borderRadius: barBorderRadius(c.barRadius, 'default', c.layout === 'horizontal') }, z: 1, animation: false } ]; } const totals = c.data.map((_, index) => c.bars.reduce((sum, bar) => sum + rawValues(c, bar.dataKey)[index], 0) ); const maxIndex = c.enableMaxValueHighlight ? totals.reduce((best, total, index) => (total > (totals[best] ?? -Infinity) ? index : best), 0) : null; const horizontal = c.layout === 'horizontal'; const built = c.bars.map((bar, seriesIndex): BarSeriesOption | CustomSeriesOption => { if (bar.variant === 'isometric') return isometricSeries(c, bar, maxIndex); const slots = c.resolved.series[bar.dataKey] ?? [GRAY]; const baseColor = slots[0] ?? GRAY; const opacity = c.selectedDataKey === null || c.selectedDataKey === bar.dataKey ? 1 : 0.3; const paint = fill(c, bar); const bloomBlur = ditherBloomBlur(c.renderStyle, c.bloom); const bloomColor = bloomBlur ? withAlpha(baseColor, 0.55) : undefined; const vals = values(c, bar.dataKey); const radius = barBorderRadius(bar.radius ?? c.barRadius, bar.variant, horizontal); const expandedIndex = c.expand?.key === bar.dataKey ? c.expand.hovered : null; const openness = (index: number) => c.expand?.key === bar.dataKey ? (c.expand.progress.get(index) ?? EXPAND_COLLAPSED) : EXPAND_COLLAPSED; const datumStyle = bar.variant === 'stripped' || bar.variant === 'expandable' || bar.bufferBar || bar.glowing || maxIndex !== null || opacity < 1; const dataPoints = datumStyle ? vals.map((value, index) => { const buffer = bar.bufferBar && index === vals.length - 1; const muted = maxIndex !== null && index !== maxIndex; let color = paint; if (bar.variant === 'stripped') { color = strippedDatumPaint( slots, horizontal, strippedCapFraction(value, c.valuePxPerUnit ?? null) ); } if (bar.variant === 'expandable') color = createExpandablePaint(slots, openness(index)); if (buffer) color = patternFill('buffer', baseColor) ?? 'transparent'; if (muted) color = withAlpha(c.resolved.tokens.mutedForeground, MAX_HIGHLIGHT_DIM); const glow = bar.glowing || index === maxIndex; return { value, ...(bar.variant === 'expandable' ? { label: { show: index === expandedIndex } } : {}), itemStyle: { color, borderColor: buffer ? baseColor : undefined, borderWidth: buffer ? 1 : 0, borderRadius: radius, opacity, shadowBlur: glow ? 18 : bloomBlur, shadowColor: glow ? withAlpha( sampleGradient(slots, vals.length > 1 ? index / (vals.length - 1) : 0), 0.65 ) : bloomColor } }; }) : vals; return { id: bar.dataKey, name: labelFor(c.config, bar.dataKey), type: 'bar', data: dataPoints, stack: c.stackType === 'default' ? undefined : '__bar-stack', barGap: c.barGap, barCategoryGap: c.barCategoryGap, cursor: bar.isClickable ? 'pointer' : 'default', z: c.selectedDataKey === bar.dataKey ? 3 : c.selectedDataKey === null ? 2 : 1, label: bar.variant === 'expandable' ? { show: false, position: 'top', color: c.resolved.tokens.foreground, fontFamily: 'var(--font-mono, monospace)', fontSize: 11 } : undefined, showBackground: bar.variant === 'blocks', backgroundStyle: bar.variant === 'blocks' ? { color: patternFill( 'blocks', withAlpha(c.resolved.tokens.mutedForeground, BLOCK_TRACK_OPACITY), c.barWidthPx ?? BLOCK_SIZE ) ?? withAlpha(c.resolved.tokens.mutedForeground, BLOCK_TRACK_OPACITY), borderRadius: radius } : undefined, itemStyle: { color: paint, borderRadius: radius, opacity, shadowBlur: bloomBlur, shadowColor: bloomColor }, emphasis: bar.enableHoverHighlight && c.selectedDataKey === null ? { focus: 'self', blurScope: 'coordinateSystem' } : { disabled: true }, blur: bar.enableHoverHighlight && c.selectedDataKey === null ? { itemStyle: { opacity: 0.3 } } : undefined, animation: c.animation && (bar.animationType ?? c.animationType) !== 'none' && !c.reducedMotion, animationDuration: 500, animationEasing: 'cubicOut', markLine: seriesIndex === 0 ? referenceMarkLine(c) : undefined, animationDelay: (index) => { const type = bar.animationType ?? c.animationType; const last = Math.max(0, c.data.length - 1); const center = last / 2; const rank = type === 'right-to-left' ? last - index : type === 'center-out' ? Math.abs(index - center) : type === 'edges-in' ? center - Math.abs(index - center) : index; return rank * 50; } }; }); if (built.some((entry) => entry.type === 'custom')) return built; const gapUnits = c.stackType !== 'default' && built.length > 1 && c.valuePxPerUnit ? STACK_SEGMENT_GAP / c.valuePxPerUnit : 0; if (!gapUnits) return built; return (built as BarSeriesOption[]).flatMap((entry, index) => index === built.length - 1 ? [entry] : [ entry, { id: `__stackgap-${index}`, type: 'bar' as const, stack: '__bar-stack', data: c.data.map(() => gapUnits), itemStyle: { color: 'transparent' }, silent: true, tooltip: { show: false }, legendHoverLink: false, emphasis: { disabled: true }, animation: false, z: 1 } ] ); } export function buildBarOption(c: BarOptionContext): EChartsBarOption { const allowBrush = c.layout === 'vertical' && Boolean(c.brush) && !c.isLoading; const height = c.brush?.height ?? 56; const bottom = c.legend?.verticalAlign === 'bottom' ? 34 : 6; const main: GridComponentOption = { left: 8, right: 8, top: c.legend?.verticalAlign === 'top' ? 42 : 16, bottom: 8 + (allowBrush ? height + 30 + (c.xAxis?.label ? 22 : 0) : 0) + (c.legend?.verticalAlign === 'bottom' ? 34 : 0) }; const builtAxes = axes(c); const mainSeries = series(c); if (!allowBrush) return { animation: c.animation && !c.reducedMotion, animationDuration: 500, animationDurationUpdate: 0, aria: { enabled: true }, grid: main, xAxis: builtAxes.xAxis, yAxis: builtAxes.yAxis, tooltip: tooltip(c), series: mainSeries }; const mini = c.bars.map((bar) => ({ id: `__mini-${bar.dataKey}`, type: 'bar' as const, xAxisIndex: 1, yAxisIndex: 1, data: rawValues(c, bar.dataKey), stack: c.stackType === 'default' ? undefined : '__mini-total', silent: true, barCategoryGap: '20%', emphasis: { disabled: true }, tooltip: { show: false }, itemStyle: { color: c.resolved.series[bar.dataKey]?.[0] ?? c.resolved.tokens.foreground, opacity: 0.5 * (c.selectedDataKey !== null && c.selectedDataKey !== bar.dataKey ? 0.3 : 1), borderRadius: 1 }, z: 0, animation: false })); return { animation: c.animation && !c.reducedMotion, animationDuration: 500, animationDurationUpdate: 0, aria: { enabled: true }, grid: [main, { left: 8, right: 8, bottom, height, outerBoundsMode: 'none' }], xAxis: [builtAxes.xAxis, { type: 'category', gridIndex: 1, data: categories(c), show: false }], yAxis: [builtAxes.yAxis, { type: 'value', gridIndex: 1, show: false }], tooltip: tooltip(c), dataZoom: buildBrushDataZoom({ brushBottom: bottom, brushHeight: height, brushRange: c.brushRange, fillerColor: 'transparent' }), series: [...mainSeries, ...mini] }; } export function createBarLoadingData(count: number): number[] { const rows: number[] = []; let value = 40 + Math.random() * 25; for (let index = 0; index < Math.max(0, count); index += 1) { value = Math.min(85, Math.max(20, value + (Math.random() - 0.5) * 30)); rows.push(Math.round(value)); } return rows; } ``` `$lib/components/evilcharts/charts/echarts-bar-chart/types.ts` ```ts import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export const DEFAULT_BAR_RADIUS = 2; export type BarVariant = | 'default' | 'hatched' | 'duotone' | 'duotone-reverse' | 'gradient' | 'stripped' | 'blocks' | 'expandable' | 'isometric'; export type StackType = 'default' | 'stacked' | 'percent'; export type BarLayout = 'vertical' | 'horizontal'; export type BarAnimationType = 'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in'; export type BarHoverDatum = { index: number; row: Record; }; export type BarRegistration = { dataKey: string; variant: BarVariant; radius?: number; animationType?: BarAnimationType; isClickable: boolean; enableHoverHighlight: boolean; glowing: boolean; bufferBar: boolean; ditherVariant?: DitherVariant; }; export type AxisRegistration = { dataKey?: string; tickFormatter?: (value: string | number, index: number) => string; label?: string; hideDots: boolean; }; export type TooltipRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipVariant; roundness: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipRoundness; cursor?: boolean; defaultIndex?: number; position: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipPosition; }; export type LegendRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-legend/index.js').LegendVariant; align: 'left' | 'center' | 'right'; verticalAlign: 'top' | 'middle' | 'bottom'; isClickable: boolean; }; export type BrushRegistration = { height?: number; formatLabel?: (value: string, index: number) => string; onChange?: (range: { startIndex: number; endIndex: number }) => void; }; ``` `$lib/components/evilcharts/charts/echarts-bar-chart/x-axis.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-bar-chart/y-axis.svelte` ```svelte ``` ### Add the shared chart module. Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it. ### $lib/components/evilcharts/ui/echarts-chart `$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte` ```svelte
{#if accessibility?.description} {accessibility.description} {/if}
{@render children?.()}
{@render overlay?.()} {@render footer?.()}
``` `$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte` ```svelte {#if css} {css} {/if} ``` `$lib/components/evilcharts/ui/echarts-chart/colors.ts` ```ts import * as echarts from 'echarts/core'; import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js'; const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/; export function chartColorToken(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key; return `u-${Array.from(key, (character) => (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0') ).join('')}`; } export function chartColorVariableName(key: string, index: number): string { return `--color-${chartColorToken(key)}-${index}`; } export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string { const name = chartColorVariableName(key, index); return fallbackIndex === undefined ? `var(${name})` : `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`; } export function quoteCssString(value: string): string { return `"${Array.from(value, (character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === '"' || character === '\\') return `\\${character}`; if (codePoint === 0) return '\uFFFD'; if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `; return character; }).join('')}"`; } export function getColorsCount(item: ChartConfig[string]): number { if (!item.colors) return 1; return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1); } export function distributeColors(colors: string[], maxCount: number): string[] { if (colors.length === 0) return []; if (colors.length >= maxCount) return colors.slice(0, maxCount); const result: string[] = []; const baseSlots = Math.floor(maxCount / colors.length); const extraSlots = maxCount % colors.length; for (let index = 0; index < colors.length; index += 1) { const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0); for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]); } return result; } export function buildChartCss(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter(([, item]) => item.colors); if (colorConfig.length === 0) return ''; const variablesFor = (theme: ThemeKey) => colorConfig .flatMap(([key, item]) => { const authored = item.colors?.[theme]; if (!authored?.length) return []; return distributeColors(authored, getColorsCount(item)).map( (color, index) => ` ${chartColorVariableName(key, index)}: ${color};` ); }) .join('\n'); return Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}` ) .join('\n'); } let normalizerContext: CanvasRenderingContext2D | null = null; export function normalizeColor(value: string): string { const raw = value.trim(); if (!raw || typeof document === 'undefined') return raw; if (!normalizerContext) { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; normalizerContext = canvas.getContext('2d', { willReadFrequently: true }); } if (!normalizerContext) return raw; normalizerContext.clearRect(0, 0, 1, 1); normalizerContext.fillStyle = '#000'; normalizerContext.fillStyle = raw; normalizerContext.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data; return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`; } export function withAlpha(color: string, alpha: number): string { const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim()); const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0; return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`; } export type ResolvedColors = { series: Record; tokens: { mutedForeground: string; border: string; foreground: string; background: string; }; }; export function resolveColors( container: HTMLElement, config: ChartConfig, seriesKeys: string[] ): ResolvedColors { const computed = getComputedStyle(container); const series: Record = {}; for (const key of seriesKeys) { const count = getColorsCount(config[key] ?? {}); series[key] = Array.from({ length: count }, (_, index) => { const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim(); return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)'; }); } const probe = document.createElement('span'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;'; container.appendChild(probe); const readToken = (className: string) => { probe.className = className; return normalizeColor(getComputedStyle(probe).color); }; const tokens = { mutedForeground: readToken('text-muted-foreground'), border: readToken('text-border'), foreground: readToken('text-foreground'), background: readToken('text-background') }; probe.remove(); return { series, tokens }; } export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } export function indicatorBackground(key: string, colorsCount: number): string { if (colorsCount <= 1) return chartColorVariable(key, 0); const stops = Array.from({ length: colorsCount }, (_, index) => { const offset = (index / (colorsCount - 1)) * 100; return `${chartColorVariable(key, index)} ${offset}%`; }).join(', '); return `linear-gradient(to right, ${stops})`; } export function flattenColor(color: string, base: string): string { const parse = (value: string) => value .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1]; const [red, green, blue, alpha = 1] = parse(color); const [baseRed, baseGreen, baseBlue] = parse(base); const mix = (channel: number, baseChannel: number) => Math.round(channel * alpha + baseChannel * (1 - alpha)); return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`; } ``` `$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte` ```svelte
``` `$lib/components/evilcharts/ui/echarts-chart/index.ts` ```ts export { default as ChartContainer } from './chart-container.svelte'; export { default as ChartStyle } from './chart-style.svelte'; export { default as EChartsHost } from './echarts-host.svelte'; export { default as LoadingIndicator } from './loading-indicator.svelte'; export { default as SelectableSeriesControls } from './selectable-series-controls.svelte'; export { mergeLifecycleOptions } from './merge-options.js'; export type { EChartsEventHandler } from './echarts-host.svelte'; export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js'; export { getEChartsSharedSlotContext, setEChartsSharedSlotContext, type EChartsSharedSlotName } from './shared-slots.svelte.js'; export { buildChartCss, chartColorToken, chartColorVariable, chartColorVariableName, distributeColors, flattenColor, getColorsCount, indicatorBackground, normalizeColor, resolveColors, seriesPaint, withAlpha, quoteCssString, type ResolvedColors } from './colors.js'; export { DEFAULT_ECHARTS_RENDERER, ECHARTS_RENDERERS, THEMES, THEME_KEYS, validateChartConfigColors, type AtLeastOneThemeColor, type ChartAccessibility, type ChartConfig, type EChartsRenderer, type EChartsRenderStyle, type ThemeKey } from './types.js'; ``` `$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte` ```svelte {#if isLoading}
Loading
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/merge-options.ts` ```ts type LifecycleOptions = { animation?: unknown; animationDuration?: unknown; animationDurationUpdate?: unknown; }; /** Merge consumer options without letting them break chart-owned animation lifecycles. */ export function mergeLifecycleOptions(built: T, overrides?: object): T { const lifecycle = built as T & LifecycleOptions; return { ...built, ...overrides, animation: lifecycle.animation, animationDuration: lifecycle.animationDuration, animationDurationUpdate: lifecycle.animationDurationUpdate } as T; } ``` `$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts` ```ts import { SvelteMap } from 'svelte/reactivity'; export type RegistrationGetter = () => T; /** Ordered, reactive storage for DOM-free compound-component registrations. */ export class RegistrationSet { #entries = new SvelteMap>(); register(token: string, getter: RegistrationGetter): () => void { this.#entries.set(token, getter); return () => { if (this.#entries.get(token) === getter) this.#entries.delete(token); }; } get values(): T[] { return Array.from(this.#entries.values(), (getter) => getter()); } get first(): T | undefined { return this.#entries.values().next().value?.(); } get size(): number { return this.#entries.size; } } ``` `$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte` ```svelte {#if items.length > 0}
{#each items as item (item.key)} {/each}
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush'; type SharedSlotContext = { register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void; }; const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots'); export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext { setContext(ECHARTS_SHARED_SLOT_CONTEXT, context); return context; } export function getEChartsSharedSlotContext(): SharedSlotContext { const context = getContext(ECHARTS_SHARED_SLOT_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.'); } return context; } ``` `$lib/components/evilcharts/ui/echarts-chart/types.ts` ```ts import type { Component, Snippet } from 'svelte'; export const ECHARTS_RENDERERS = { canvas: 'canvas', svg: 'svg' } as const; export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS]; export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas; export const THEMES = { light: '', dark: '.dark' } as const; export type ThemeKey = keyof typeof THEMES; export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[]; type ThemeColorsBase = { [K in ThemeKey]?: string[]; }; export type AtLeastOneThemeColor = { [K in ThemeKey]: Required> & Partial>; }[ThemeKey]; export type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component>; colors?: AtLeastOneThemeColor; } >; export function validateChartConfigColors(config: ChartConfig): void { for (const [key, item] of Object.entries(config)) { if (!item.colors) continue; if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue; throw new Error( `[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.` ); } } export type ChartAccessibility = | { label: string; labelledBy?: never; description?: string; describedBy?: string; } | { label?: never; labelledBy: string; description?: string; describedBy?: string; }; export type EChartsRenderStyle = 'native' | 'dither'; ``` ### Add the sub-components. Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there. ### $lib/components/evilcharts/ui/echarts-tooltip `$lib/components/evilcharts/ui/echarts-tooltip/index.ts` ```ts export { default as Tooltip } from './tooltip.svelte'; export type { TooltipProps } from './tooltip.svelte'; export { escapeTooltipHtml, resolveTooltipPosition, roundnessClass, tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell, tooltipVariantClass, type TooltipPosition, type TooltipRoundness, type TooltipVariant } from './tooltip.js'; ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts` ```ts import type { TooltipComponentOption } from 'echarts/components'; import { indicatorBackground } from '../echarts-chart/index.js'; export type TooltipVariant = 'default' | 'frosted-glass'; export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl'; export type TooltipPosition = 'fixed' | 'variable'; export const roundnessClass: Record = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }; export const tooltipVariantClass: Record = { default: 'bg-background', 'frosted-glass': 'bg-background/50 backdrop-blur-md' }; export function escapeTooltipHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function tooltipIndicatorHtml(key: string, colorsCount: number): string { return `
`; } export function tooltipRow({ indicatorHtml, labelText, valueText, dimmed }: { indicatorHtml: string; labelText: string; valueText: string; dimmed: string; }): string { return `
${indicatorHtml}
${escapeTooltipHtml(labelText)} ${escapeTooltipHtml(valueText)}
`; } export function tooltipShell({ label, body, roundness, variant }: { label: string; body: string; roundness: TooltipRoundness; variant: TooltipVariant; }): string { return `
${escapeTooltipHtml(label)}
${body}
`; } export function resolveTooltipPosition( position: TooltipPosition ): TooltipComponentOption['position'] { if (position === 'variable') return undefined; return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8]; } export function tooltipBaseOption(params: { present: boolean; cursor: boolean; position: TooltipPosition; axisPointerColor: string; strokeWidth: number; }): TooltipComponentOption { const { present, cursor, position, axisPointerColor, strokeWidth } = params; return { show: present, trigger: 'axis', confine: true, displayTransition: false, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', axisPointer: cursor ? { type: 'line', lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] } } : { type: 'none' }, position: resolveTooltipPosition(position) }; } ``` Next, create `echarts-legend` in the same `ui` folder and paste the legend overlay there. ### $lib/components/evilcharts/ui/echarts-legend `$lib/components/evilcharts/ui/echarts-legend/index.ts` ```ts export { default as Legend } from './legend.svelte'; export { default as LegendIndicator } from './legend-indicator.svelte'; export { default as LegendOverlay } from './legend-overlay.svelte'; export type { LegendProps, LegendVariant } from './legend.svelte'; ``` `$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte` ```svelte {#if variant === 'square'} {:else if variant === 'circle'} {:else if variant === 'circle-outline'} {:else if variant === 'vertical-bar'} {:else if variant === 'horizontal-bar'} {:else if variant === 'rounded-square-outline'} {:else} {/if} ``` `$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte` ```svelte
{#each entries as entry (entry.key)} {#if isClickable} {:else}
{#if typeof entry.item?.label === 'function'} {@render entry.item.label()} {:else} {entry.item?.label ?? entry.key} {/if}
{/if} {/each}
``` `$lib/components/evilcharts/ui/echarts-legend/legend.svelte` ```svelte ``` Next, create `echarts-dot` in the same `ui` folder and paste the dot styles the series markers draw with there. ### $lib/components/evilcharts/ui/echarts-dot `$lib/components/evilcharts/ui/echarts-dot/dot.ts` ```ts import type * as echarts from 'echarts/core'; export type DotVariant = 'none' | 'default' | 'border' | 'colored-border' | 'ping'; export type DotItemStyleOption = { color?: string | echarts.graphic.LinearGradient; borderColor?: string | echarts.graphic.LinearGradient; borderWidth?: number; opacity?: number; }; export type DotStyle = { size: number; itemStyle: DotItemStyleOption }; function colorWithAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) hex = hex.replace(/./g, (character) => character.repeat(2)); const numeric = Number.parseInt(hex, 16); return `rgba(${(numeric >> 16) & 255}, ${(numeric >> 8) & 255}, ${numeric & 255}, ${alpha})`; } const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue] = match[1].split(',').map((part) => Number.parseFloat(part)); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } export function dotItemStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotItemStyleOption { switch (variant) { case 'border': return { color: paint, borderColor: background, borderWidth: 2 }; case 'colored-border': return { color: background, borderColor: paint, borderWidth: 1 }; case 'ping': return { color: paint, borderColor: typeof paint === 'string' ? colorWithAlpha(paint, 0.28) : paint, borderWidth: 10 }; case 'default': return { color: paint, borderWidth: 0 }; default: return {}; } } export const DOT_SIZES: Record = { none: 0, default: 6, border: 8, 'colored-border': 6, ping: 8 }; export function dotStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotStyle { return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) }; } export function sampleGradient(slots: string[], position: number): string { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; const parse = (color: string) => color .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map(Number) ?? [120, 120, 120, 1]; const scaled = position * (slots.length - 1); const index = Math.min(Math.floor(scaled), slots.length - 2); const fraction = scaled - index; const [redFrom, greenFrom, blueFrom, alphaFrom = 1] = parse(slots[index]); const [redTo, greenTo, blueTo, alphaTo = 1] = parse(slots[index + 1]); const lerp = (from: number, to: number) => from + (to - from) * fraction; return `rgba(${Math.round(lerp(redFrom, redTo))}, ${Math.round(lerp(greenFrom, greenTo))}, ${Math.round(lerp(blueFrom, blueTo))}, ${lerp(alphaFrom, alphaTo).toFixed(3)})`; } ``` `$lib/components/evilcharts/ui/echarts-dot/index.ts` ```ts export { DOT_SIZES, dotItemStyle, dotStyle, sampleGradient, type DotItemStyleOption, type DotStyle, type DotVariant } from './dot.js'; ``` Finally, create `echarts-brush` in the same `ui` folder and paste the zoom brush there. ### $lib/components/evilcharts/ui/echarts-brush `$lib/components/evilcharts/ui/echarts-brush/brush-controls.svelte` ```svelte {#if totalPoints > 0}
handleKey(event, 'range')} > Selected range: {formatLabel(startIndex)} to {formatLabel(endIndex)}
handleKey(event, 'start')} > Range start: {formatLabel(startIndex)}
handleKey(event, 'end')} > Range end: {formatLabel(endIndex)}
{/if} ``` `$lib/components/evilcharts/ui/echarts-brush/brush.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-brush/brush.ts` ```ts import type { DataZoomComponentOption } from 'echarts/components'; import type { EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { withAlpha, type ResolvedColors } from '../echarts-chart/index.js'; export const BRUSH_BORDER_OPACITY = 1; export type BrushRange = { start: number; end: number }; export type BrushGeometry = { bottom: number; height: number }; export type BrushOverlayParams = { range: BrushRange; geom: BrushGeometry; size: { width: number; height: number }; tokens: ResolvedColors['tokens']; labels: { start: string; end: string } | null; showLabels: boolean; hover: { left: boolean; right: boolean }; }; type ZrRect = InstanceType; type ZrCircle = InstanceType; type ZrText = InstanceType; export type BrushOverlayElements = { dimLeft: ZrRect; dimRight: ZrRect; frame: ZrRect; pillLeft: ZrRect; pillRight: ZrRect; grips: ZrCircle[]; labelStart: ZrText; labelEnd: ZrText; }; export function syncBrushOverlay( chart: EChartsType, store: { brushOverlay: BrushOverlayElements | null }, params: BrushOverlayParams | null ): void { const renderer = chart.getZr(); if (!renderer) return; if (!params) { if (store.brushOverlay) { const { grips, ...rest } = store.brushOverlay; for (const element of [...Object.values(rest), ...grips]) renderer.remove(element); store.brushOverlay = null; } return; } if (!store.brushOverlay) { const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} }); const elements: BrushOverlayElements = { dimLeft: rect(100), dimRight: rect(100), frame: rect(101), pillLeft: rect(102), pillRight: rect(102), grips: Array.from( { length: 6 }, () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }) ), labelStart: new echarts.graphic.Text({ silent: true, z: 104 }), labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }) }; const { grips, ...rest } = elements; for (const element of [...Object.values(rest), ...grips]) renderer.add(element); store.brushOverlay = elements; } const elements = store.brushOverlay; const { range, geom, size, tokens, labels, showLabels, hover } = params; const trackLeft = 8; const trackRight = Math.max(size.width - 8, trackLeft); const trackWidth = trackRight - trackLeft; const top = size.height - geom.bottom - geom.height; const centerY = top + geom.height / 2; const selectionLeft = trackLeft + (trackWidth * range.start) / 100; const selectionRight = trackLeft + (trackWidth * range.end) / 100; const dimFill = withAlpha(tokens.background, 0.7); elements.dimLeft.setShape({ x: trackLeft, y: top, width: Math.max(selectionLeft - trackLeft, 0), height: geom.height }); elements.dimLeft.setStyle({ fill: dimFill }); elements.dimRight.setShape({ x: selectionRight, y: top, width: Math.max(trackRight - selectionRight, 0), height: geom.height }); elements.dimRight.setStyle({ fill: dimFill }); elements.frame.setShape({ x: selectionLeft, y: top, width: Math.max(selectionRight - selectionLeft, 0), height: geom.height, r: 6 }); elements.frame.setStyle({ fill: 'none', stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY), lineWidth: 1 }); const updatePill = (element: ZrRect, x: number, hovered: boolean) => { element.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 }); element.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground }); }; updatePill(elements.pillLeft, selectionLeft, hover.left); updatePill(elements.pillRight, selectionRight, hover.right); const gripFill = withAlpha(tokens.background, 0.7); for (const [index, offset] of [-4, 0, 4].entries()) { elements.grips[index].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 }); elements.grips[index].setStyle({ fill: gripFill }); elements.grips[index + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 }); elements.grips[index + 3].setStyle({ fill: gripFill }); } const updateLabel = (element: ZrText, text: string, x: number, align: 'left' | 'right') => { element.setStyle({ text, x: align === 'left' ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2), y: top + geom.height, align, verticalAlign: 'middle', fill: tokens.background, backgroundColor: tokens.foreground, padding: [2, 5], borderRadius: 4, font: '500 9px system-ui, sans-serif' }); element.attr('invisible', !showLabels || !text); }; updateLabel(elements.labelStart, labels?.start ?? '', selectionLeft, 'left'); updateLabel(elements.labelEnd, labels?.end ?? '', selectionRight, 'right'); } export function buildBrushDataZoom(params: { brushBottom: number; brushHeight: number; brushRange: BrushRange; fillerColor: string; }): DataZoomComponentOption[] { const { brushBottom, brushHeight, brushRange, fillerColor } = params; return [ { type: 'slider', show: true, xAxisIndex: [0], left: 8, right: 8, bottom: brushBottom, height: brushHeight, start: brushRange.start, end: brushRange.end, brushSelect: false, showDetail: false, backgroundColor: 'transparent', borderColor: 'transparent', fillerColor, dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, handleIcon: 'path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z', handleSize: '35%', handleStyle: { opacity: 0 }, moveHandleSize: 0, emphasis: { handleStyle: { opacity: 0 } } }, { type: 'inside', xAxisIndex: [0] } ]; } ``` `$lib/components/evilcharts/ui/echarts-brush/index.ts` ```ts export { default as Brush } from './brush.svelte'; export { default as BrushControls } from './brush-controls.svelte'; export type { BrushProps } from './brush.svelte'; export { BRUSH_BORDER_OPACITY, buildBrushDataZoom, syncBrushOverlay, type BrushGeometry, type BrushOverlayElements, type BrushOverlayParams, type BrushRange } from './brush.js'; ``` ## Usage The ECharts bar chart is composable, sharing the LayerChart sibling's API shape. `` is the container, and every part hangs off it as a compound member — ``, ``, ``, ``, ``, and one or more `` — so a single import gives you the whole chart. Each `` carries its own `variant`, `radius`, `glowing`, `bufferBar`, and `isClickable`, so one chart can mix fill styles and make only some series interactive. ```svelte ``` ```svelte ``` The difference is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`. The `config` is the same contract as every EvilCharts chart — each key maps a data key to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from CSS variables at runtime, so dark mode just works. > The ECharts implementation brings a few small departures from the LayerChart sibling: the `duotone` split is a single ECharts gradient, the `stripped` cap is a fixed-height band derived from the measured axis scale, and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom`. The `hatched`, `blocks`, and buffer fills use offscreen canvas tiles; with `renderer="svg"`, ECharts may embed those tiles as raster images inside the SVG. ### SVG Renderer Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer. ### renderer="svg" ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ### Interactive Selection Add `isClickable` to any `` (and to ``) to make those series selectable. Use the `onSelectionChange` callback on `` to handle selection events: ```svelte { if (selectedDataKey) { console.log('Selected:', selectedDataKey); } else { console.log('Deselected'); } }} > ``` ### Loading State ### isLoading='true' ```svelte String(value).substring(0, 3)} /> ``` > Pass `isLoading` to show an animated skeleton of gray bars with a shimmer while data loads, and `loadingBars` to set how many bars the skeleton draws. ### Buffer Bar ### ```svelte String(value).substring(0, 3)} /> ``` > With `bufferBar` set, a <Bar>'s last data point renders with a hatched (diagonal lines) pattern and a series-colored outline while the rest stay solid — handy for flagging projected, estimated, or incomplete data at the end of a series. ## Examples Examples of the bar chart with different `variants`. Each `` sets its own `variant`; the chart-wide `stackType` and `layout` shape the rest. ### Hover Highlight ### ```svelte String(value).substring(0, 3)} /> ``` > Set `enableHoverHighlight` on a <Bar> to dim every other bar on hover, keeping focus on one series. It uses ECharts' native emphasis/blur, so nothing re-renders mid-hover. ### Max Value Highlight ### ```svelte String(value).substring(0, 3)} /> ``` > Set `enableMaxValueHighlight` on the chart to color only its tallest column and mute the rest. With several series the comparison is per column — the totals across every series at that category — so a whole stack or group lights up together rather than one bar inside it. ### Gradient Colors ### gradient colors ```svelte String(value).substring(0, 3)} /> ``` ### Bar Variants ### variant='default' ```svelte String(value).substring(0, 3)} /> ``` ### variant='hatched' ```svelte String(value).substring(0, 3)} /> ``` ### variant='duotone' ```svelte String(value).substring(0, 3)} /> ``` ### variant='duotone-reverse' ```svelte String(value).substring(0, 3)} /> ``` ### variant='gradient' ```svelte String(value).substring(0, 3)} /> ``` ### variant='stripped' ```svelte String(value).substring(0, 3)} /> ``` ### variant='blocks' ```svelte String(value).substring(0, 3)} /> ``` ### variant='expandable' ```svelte String(value).substring(0, 3)} /> ``` > `variant="blocks"` renders each bar as a stack of segments rather than a solid column, and fills the rest of the column with the same segments in a muted tone — so every bar reads against a dim grid of its own blocks. `variant="expandable"` rests as a thin line and grows out from its own middle to the full bar width on hover, naming its value above itself. ### Stack Types ### stackType='stacked' ```svelte String(value).substring(0, 3)} /> ``` ### stackType='percent' ```svelte String(value).substring(0, 3)} /> ``` ### Horizontal Layout ### layout='horizontal' ```svelte String(value).substring(0, 3)} /> ``` > Set `layout="horizontal"` on <EChartsBarChart> to render bars horizontally. The <YAxis> then shows categories and the <XAxis> shows values — pass a `tickFormatter` to <YAxis> for category formatting. ### Glowing Bars ### - desktop ```svelte String(value).substring(0, 3)} /> ``` ### - mobile ```svelte String(value).substring(0, 3)} /> ``` ### Ordered dither Set `renderStyle="dither"` to use the independent ordered-dither treatment inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit). Axes, tooltips, selection, and the ECharts renderer stay intact. ### renderStyle="dither" ```svelte ``` ## API Reference The props below are grouped by the part they belong to. Regardless of renderer, each part is declarative config the root compiles, but the API mirrors the LayerChart sibling one-to-one. ### EChartsBarChart The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual composes as children and compiles into the ECharts option. ### `data` (required) type: `TData[]` The chart data — an array of objects, one per data point (`TData extends Record`). ### `config` (required) type: `ChartConfig` Defines the chart's series. Each key matches a data key, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config). ### `children` (required) type: `Snippet` The composed chart parts — ``, ``, ``, ``, ``, and one or more ``. ### `class` type: `string` Additional CSS classes for the chart container. ### `renderer` type: `"canvas" | "svg"` · default: `"canvas"` Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default. ### `renderStyle` type: `"native" | "dither"` · default: `"native"` Selects native ECharts paint or EvilCharts' ordered-dither rendering. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"` Default ordered-dither pattern used by the chart's series. ### `ditherCellSize` type: `number` · default: `2` Dither cell size in CSS pixels. ### `bloom` type: `"off" | "low" | "high" | "aura"` · default: `"off"` Optional glow applied to dithered marks. It has no effect in native rendering mode. ### `stackType` type: `"default" | "stacked" | "percent"` · default: `"default"` How multiple bars combine. `"default"` renders them side by side, `"stacked"` stacks them, and `"percent"` normalizes them to a percentage distribution. ### `layout` type: `"vertical" | "horizontal"` · default: `"vertical"` Bar orientation. With `"horizontal"`, bars lay sideways and the axes swap — the `` shows categories and the `` shows values. ### `barRadius` type: `number` · default: `2` Default corner radius for every ``, in pixels. Each `` can override it with its own `radius` prop. ### `animation` type: `boolean` · default: `true` Master switch for the intro grow-in. Pass `false` to render the bars instantly, regardless of `animationType`. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"` Order in which bars grow into view, inherited by every ``. Bars rise from their baseline with a per-datum stagger. `"none"` disables it — devices set to OS reduce-motion fall back to `"none"` automatically. ### `barGap` type: `number` Gap between bars in the same category (with multiple series), in pixels. ### `barCategoryGap` type: `number` Gap between bar categories, in pixels. ### `defaultSelectedDataKey` type: `string | null` · default: `null` The series selected on first render. ### `onSelectionChange` type: `(key: string | null) => void` Fires when a series is selected or deselected via a clickable `` or ``. Receives the selected data key, or `null` when deselected. ### `enableMaxValueHighlight` type: `boolean` · default: `false` Colors only the tallest column and mutes every other. With several series the comparison is per column (totals across all series), so a whole stack or group highlights together. ### `referenceLine` type: `number | null` · default: `null` Draws a dashed reference line across the value axis. Useful for medians, targets, and thresholds. ### `referenceLineFormatter` type: `(value: number) => string` Formats the label attached to `referenceLine`. ### `onDataHover` type: `(datum: BarHoverDatum | null) => void` Reports the category row under the pointer, and `null` when the pointer leaves the plot. This powers block-level readouts without coupling the chart to their layout. ### `isLoading` type: `boolean` · default: `false` Shows the animated shimmer skeleton while data loads. ### `loadingBars` type: `number` · default: `12` Number of bars in the loading skeleton. ### `xDataKey` type: `keyof TData & string` The data key used for the category axis. Falls back to the axis `dataKey`; also read by the brush footer. ### `chartOptions` type: `Record` Escape hatch merged over the underlying ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html). ### `accessibility` type: `ChartAccessibility` Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology. ### Bar A single bar series. Each `` carries its own fill variant, radius, glow, buffer, and clickability, so a chart can hold any number of bars with mixed styles. ### `dataKey` (required) type: `string` The series key. Must exist on both the data rows and the chart `config`. ### `variant` type: `"default" | "hatched" | "duotone" | "duotone-reverse" | "gradient" | "stripped" | "blocks" | "expandable" | "isometric"` · default: `"default"` The bar's fill style, applied to this bar only. The `default` variant renders the full vertical color gradient for multi-color configs; `blocks` renders the bar as a stack of segments over a muted grid of the same segments; `isometric` draws dimensional front, top, and side faces. ### `radius` type: `number` The corner radius of this bar, in pixels. Falls back to the chart's `barRadius` when omitted. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` The grow-in order for this bar series. Falls back to the chart's `animationType` when omitted. ### `isClickable` type: `boolean` · default: `false` Lets this bar be selected by clicking it. When any bar is selected, unselected bars become semi-transparent. ### `enableHoverHighlight` type: `boolean` · default: `false` Hovering over a bar dims every other bar, keeping focus on one series. ### `glowing` type: `boolean` · default: `false` Applies a soft outer glow to this bar series. ### `bufferBar` type: `boolean` · default: `false` Renders this series' last data point with a hatched (diagonal lines) pattern and a series-colored outline while the rest stay solid. Useful for flagging projected or incomplete data at the end of a series. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` Overrides the root ordered-dither pattern for this bar series only. ### XAxis and YAxis The two axes. In the default (vertical) layout `` is the category axis and `` the value axis; `layout="horizontal"` swaps the roles. Include an axis to show its tick labels, omit it to hide them. Both hide automatically while loading, and the value axis formats ticks as percentages when `stackType="percent"`. ### `dataKey` type: `string` The category key for the axis. Overrides the root `xDataKey`. ### `tickFormatter` type: `(value: string, index: number) => string` Formats the axis tick labels. Category values arrive as strings. ### `label` type: `string` An axis title centered outside the tick labels — below the ``, alongside the ``. Hidden while loading. ### `hideDots` type: `boolean` · default: `false` Hides the small tick dots that sit beside this axis's labels. ### Grid The background grid lines. Include it to draw the dashed split lines on the value axis; omit it and they don't render. Takes no props. ### Tooltip The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads selection state, so its content dims unselected series. ### `variant` type: `"default" | "frosted-glass"` · default: `"default"` The visual style of the tooltip surface. ### `roundness` type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"` Controls the border-radius of the tooltip. ### `defaultIndex` type: `number` Shows the tooltip by default at the given data point index, with no hover. ### `position` type: `"fixed" | "variable"` · default: `"variable"` How the tooltip is anchored. `"variable"` lets it follow the pointer, and `"fixed"` pins it near the top of the chart while only tracking the pointer's X. ### Legend The series legend, rendered as HTML above the chart surface. Include it to show the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its series. ### `variant` type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"` The visual style of the legend indicators. ### `align` type: `"left" | "center" | "right"` · default: `"right"` Horizontal placement of the legend. ### `verticalAlign` type: `"top" | "middle" | "bottom"` · default: `"top"` Vertical placement of the legend. ### `isClickable` type: `boolean` · default: `false` Lets each legend entry toggle selection of its series. ### Brush An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `` to render it; dragging the range filters the main chart. ### `height` type: `number` · default: `56` Height of the brush preview strip in pixels. ### `formatLabel` type: `(value: string | number, index: number) => string` Formats the range-handle labels below the brush. ### `onChange` Fires when the brush selection range changes. --- ## Bar Blocks > Ready-made bar chart blocks, powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/bar-chart/blocks Markdown: https://evilcharts-sv.vercel.app/docs/echarts/bar-chart/blocks.md ## Peak Week ### Peak Week ```svelte
Best week
{peak.organic + peak.paid}signups in {peak.week}
OrganicPaid
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/peak-echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/peak-echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/peak-echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/peak-echarts-bar-chart ``` ## Grid Bar Chart ### Grid Bar Chart ```svelte
[Σ] Total {total.toLocaleString()}

[⬆] Peak {maxData.month.slice(0, 3)}
// CELL: 10x10px // TYPE: GRID

String(value).slice(0, 3)} hideDots />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/grid-echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/grid-echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/grid-echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/grid-echarts-bar-chart ``` ## Monospace Bar Chart ### Monospace Bar Chart ```svelte
[$] Total Sales $ 14,340

[⬆] Top Month June
// X-AXIS: MONTHS // Y-AXIS: SALES

String(value).slice(0, 3)} hideDots />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/monospace-echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/monospace-echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/monospace-echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/monospace-echarts-bar-chart ``` ## Hover Trace Bar Chart ### Hover Trace Bar Chart ```svelte

[desktop] Value

[month]

{selectedData.month}

`$${value}`} onDataHover={(datum) => (hovered = datum)} > String(value).slice(0, 3)} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/hover-trace-echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/hover-trace-echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/hover-trace-echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/hover-trace-echarts-bar-chart ``` ## Isometric Bar Chart ### Isometric Bar Chart ```svelte
[$] Total $ {total}K

[⬆] Peak {peak.month.slice(0, 3)}
// PROJECTION: ISOMETRIC // HIGHLIGHT: MAX

String(value).slice(0, 3)} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/isometric-echarts-bar-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/isometric-echarts-bar-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/isometric-echarts-bar-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/isometric-echarts-bar-chart ``` --- ## Components > All ECharts components in EvilCharts. Source: https://evilcharts-sv.vercel.app/docs/echarts/components Markdown: https://evilcharts-sv.vercel.app/docs/echarts/components.md ## Charts - [Area Chart · LayerChart](/docs/layerchart/area-chart) - Highlight trends with filled area ranges. - [Area Chart · ECharts](/docs/echarts/area-chart) - Highlight trends with filled area ranges. - [Line Chart · LayerChart](/docs/layerchart/line-chart) - Track change over time with lines. - [Line Chart · ECharts](/docs/echarts/line-chart) - Track change over time with lines. - [Bar Chart · LayerChart](/docs/layerchart/bar-chart) - Compare categories quickly with bold bars. - [Bar Chart · ECharts](/docs/echarts/bar-chart) - Compare categories quickly with bold bars. - [Composed Chart · LayerChart](/docs/layerchart/composed-chart) - Mix lines, bars, areas in one. - [Composed Chart · ECharts](/docs/echarts/composed-chart) - Mix lines, bars, areas in one. - [Radar Chart · LayerChart](/docs/layerchart/radar-chart) - Compare multi-metric profiles on radial axes. - [Radar Chart · ECharts](/docs/echarts/radar-chart) - Compare multi-metric profiles on radial axes. - [Pie Chart · LayerChart](/docs/layerchart/pie-chart) - Show parts of a whole, clearly. - [Pie Chart · ECharts](/docs/echarts/pie-chart) - Show parts of a whole, clearly. - [Radial Chart · LayerChart](/docs/layerchart/radial-chart) - Visualize totals in a circular layout. - [Radial Chart · ECharts](/docs/echarts/radial-chart) - Visualize totals in a circular layout. - [Sankey Chart · LayerChart](/docs/layerchart/sankey-chart) - Show flows between stages with weighted links. - [Sankey Chart · ECharts](/docs/echarts/sankey-chart) - Show flows between stages with weighted links. --- ## Composed Chart > Beautifully designed composed charts combining bars and lines, powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/composed-chart Markdown: https://evilcharts-sv.vercel.app/docs/echarts/composed-chart.md ### Basic Chart ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ## Installation ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-composed-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-composed-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-composed-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-composed-chart ``` ### Install the following dependencies: ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Copy and paste the following code snippets into your project. Create the folder `evilcharts` with a `charts` subfolder in your `components` directory, then copy the code below into a new `echarts-composed-chart` file there. ### $lib/components/evilcharts/charts/echarts-composed-chart `$lib/components/evilcharts/charts/echarts-composed-chart/active-dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-composed-chart/bar.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-composed-chart/composed-chart-context.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { AxisRegistration, BarRegistration, LineRegistration } from './types.js'; const CONTEXT = Symbol('echarts-composed-chart'); export class EChartsComposedChartContext { bars = new RegistrationSet(); lines = new RegistrationSet(); xAxes = new RegistrationSet(); yAxes = new RegistrationSet(); grids = new RegistrationSet>(); } export function setEChartsComposedChartContext() { const value = new EChartsComposedChartContext(); setContext(CONTEXT, value); return value; } export function useEChartsComposedChart() { const value = getContext(CONTEXT); if (!value) throw new Error( '[EvilCharts] ECharts composed parts must be children of EChartsComposedChart.' ); return value; } ``` `$lib/components/evilcharts/charts/echarts-composed-chart/composed-chart.svelte` ```svelte {#snippet overlay()} {#if legend && !isLoading} {/if} {/snippet} {@render children?.()} {#if bars.length + lines.length > 0} {/if} {#if !legend?.isClickable} {/if} {#if brush && !isLoading && data.length > 0} brush.formatLabel?.(categoryValues[index] ?? '', index) ?? String(categoryValues[index] ?? index)} onChange={(range) => { const last = Math.max(0, data.length - 1); brushRange = { start: last === 0 ? 0 : (range.startIndex / last) * 100, end: last === 0 ? 100 : (range.endIndex / last) * 100 }; instance?.dispatchAction( { type: 'dataZoom', start: brushRange.start, end: brushRange.end }, { silent: true } ); brush.onChange?.(range); }} /> {/if} ``` `$lib/components/evilcharts/charts/echarts-composed-chart/dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-composed-chart/grid.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-composed-chart/index.ts` ```ts import Root from './composed-chart.svelte'; import Bar from './bar.svelte'; import Line from './line.svelte'; import Dot from './dot.svelte'; import ActiveDot from './active-dot.svelte'; import XAxis from './x-axis.svelte'; import YAxis from './y-axis.svelte'; import Grid from './grid.svelte'; import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { Legend } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import { Brush } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; type RootComponent = typeof Root; export const EChartsComposedChart: RootComponent & { Bar: typeof Bar; Line: typeof Line; Dot: typeof Dot; ActiveDot: typeof ActiveDot; XAxis: typeof XAxis; YAxis: typeof YAxis; Grid: typeof Grid; Tooltip: typeof Tooltip; Legend: typeof Legend; Brush: typeof Brush; } = Object.assign(Root, { Bar, Line, Dot, ActiveDot, XAxis, YAxis, Grid, Tooltip, Legend, Brush }); export type { ChartAccessibility, ChartConfig, EChartsRenderer, EChartsRenderStyle } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; export type { DitherBloom, DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; export type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export type { BarVariant, ComposedAnimationType, CurveType, StrokeVariant } from './types.js'; ``` `$lib/components/evilcharts/charts/echarts-composed-chart/line-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; type DotRegistration = { variant: DotVariant }; const CONTEXT = Symbol('echarts-composed-line-slots'); class Slots { dots = new RegistrationSet(); activeDots = new RegistrationSet(); } export function setEChartsComposedLineSlots() { const value = new Slots(); setContext(CONTEXT, value); return value; } export function useEChartsComposedLineSlots() { const value = getContext(CONTEXT); if (!value) throw new Error('[EvilCharts] Dot parts must be children of EChartsComposedChart.Line.'); return value; } ``` `$lib/components/evilcharts/charts/echarts-composed-chart/line.svelte` ```svelte {@render children?.()} ``` `$lib/components/evilcharts/charts/echarts-composed-chart/option.ts` ```ts import type { DataZoomComponentOption, GridComponentOption, TooltipComponentOption } from 'echarts/components'; import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts'; import type { ComposeOption } from 'echarts/core'; import * as echarts from 'echarts/core'; import { flattenColor, getColorsCount, seriesPaint, withAlpha, type ChartConfig, type ResolvedColors } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import { dotItemStyle, dotStyle, sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import { tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { buildBrushDataZoom, type BrushRange } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; import { createDitherPattern, type DitherBloom, type DitherVariant, type RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import type { AxisRegistration, BarRegistration, BrushRegistration, CurveType, LegendRegistration, LineRegistration, TooltipRegistration } from './types.js'; export type EChartsComposedOption = ComposeOption< | LineSeriesOption | BarSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption >; type ArrayItem = T extends readonly (infer Item)[] ? Item : T; type XAxisOption = ArrayItem>; type YAxisOption = ArrayItem>; export type ComposedOptionContext = { data: Record[]; config: ChartConfig; lines: LineRegistration[]; bars: BarRegistration[]; xDataKey?: string; curveType: CurveType; selectedDataKey: string | null; barGap?: number | string; barCategoryGap?: number | string; xAxis?: AxisRegistration; yAxis?: AxisRegistration; showGrid: boolean; tooltip?: TooltipRegistration; legend?: LegendRegistration; brush?: BrushRegistration; brushRange: BrushRange; isLoading: boolean; loadingData: number[]; loadingLineData: number[]; resolved: ResolvedColors; animation: boolean; animationType: string; reducedMotion: boolean; rendererSize: { width: number; height: number }; renderStyle: RenderStyle; ditherVariant: DitherVariant; ditherCellSize: number; bloom?: DitherBloom; }; const ditherBloomBlur = (style: RenderStyle, bloom?: DitherBloom) => style !== 'dither' || bloom === 'off' || bloom === undefined ? 0 : bloom === 'aura' ? 14 : bloom === 'high' ? 8 : 4; function labelFor(config: ChartConfig, key: string): string { const label = config[key]?.label; return typeof label === 'string' ? label : key; } function curveConfig(curveType: CurveType): { smooth: boolean; step: 'middle' | false } { if (curveType === 'step') return { smooth: false, step: 'middle' }; if (curveType === 'linear') return { smooth: false, step: false }; return { smooth: true, step: false }; } function opacityFor(selected: string | null, key: string): number { return selected === null || selected === key ? 1 : 0.3; } function ditherPlotBounds(context: ComposedOptionContext) { const top = context.legend?.verticalAlign === 'top' ? 42 : 16; const showBrush = Boolean(context.brush) && !context.isLoading; const bottom = 8 + (showBrush ? (context.brush?.height ?? 56) + 30 + (context.xAxis?.label ? 22 : 0) : 0) + (context.legend?.verticalAlign === 'bottom' ? 34 : 0); return { height: Math.max(context.ditherCellSize, context.rendererSize.height - top - bottom), offsetY: top }; } function categoryKey(context: ComposedOptionContext): string | undefined { if (context.xDataKey) return context.xDataKey; if (context.xAxis?.dataKey) return context.xAxis.dataKey; const series = new Set([ ...context.lines.map((line) => line.dataKey), ...context.bars.map((bar) => bar.dataKey) ]); return Object.keys(context.data[0] ?? {}).find((key) => !series.has(key)); } function categories(context: ComposedOptionContext): string[] { const key = categoryKey(context); return context.data.map((row, index) => String((key ? row[key] : undefined) ?? index)); } function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function seriesValues(context: ComposedOptionContext, key: string): (number | null)[] { return context.data.map((row) => finiteNumber(row[key])); } function buildAxes(context: ComposedOptionContext): { xAxis: XAxisOption; yAxis: YAxisOption } { const { mutedForeground, border, background } = context.resolved.tokens; const splitLineColor = withAlpha(border, 1); const dotColor = flattenColor(splitLineColor, background); const xAxis: XAxisOption = { type: 'category', boundaryGap: context.bars.length > 0 || context.isLoading, show: true, data: context.isLoading ? context.loadingData.map((_, index) => index) : categories(context), name: context.isLoading ? undefined : context.xAxis?.label, nameLocation: 'middle', nameGap: 30, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !context.isLoading && Boolean(context.xAxis) && !context.xAxis?.hideDots, alignWithLabel: true, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, splitLine: { show: false }, axisLabel: { show: !context.isLoading && Boolean(context.xAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: context.xAxis?.tickFormatter } }; const yAxis: YAxisOption = { type: 'value', show: Boolean(context.yAxis) || context.showGrid, min: context.bars.length > 0 ? 0 : undefined, name: context.isLoading ? undefined : context.yAxis?.label, nameLocation: 'middle', nameGap: 38, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !context.isLoading && Boolean(context.yAxis) && !context.yAxis?.hideDots, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, axisLabel: { show: !context.isLoading && Boolean(context.yAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: context.yAxis?.tickFormatter }, splitLine: { show: context.showGrid && !context.isLoading, lineStyle: { color: splitLineColor, type: [3, 3], width: 1 } } }; return { xAxis, yAxis }; } function tooltip(context: ComposedOptionContext): TooltipComponentOption { const slot = context.tooltip; const base = tooltipBaseOption({ present: Boolean(slot) && !context.isLoading, cursor: slot?.cursor ?? true, position: slot?.position ?? 'variable', axisPointerColor: context.resolved.tokens.border, strokeWidth: 0.8 }); return { ...base, formatter: (rawParams) => { const params = (Array.isArray(rawParams) ? rawParams : [rawParams]) as Array<{ seriesId?: string; seriesName?: string; axisValueLabel?: string; value?: unknown; data?: unknown; }>; const rows = params .filter((item) => item.seriesId && !item.seriesId.startsWith('__')) .map((item) => { const key = item.seriesId as string; const value = typeof item.data === 'object' && item.data && 'value' in item.data ? (item.data as { value: unknown }).value : item.value; const numericValue = finiteNumber(value); if (numericValue === null) return ''; return tooltipRow({ indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(context.config[key] ?? {})), labelText: labelFor(context.config, key), valueText: numericValue.toLocaleString(), dimmed: opacityFor(context.selectedDataKey, key) < 1 ? ' opacity-30' : '' }); }) .join(''); return tooltipShell({ label: params[0]?.axisValueLabel ?? '', body: rows, roundness: slot?.roundness ?? 'lg', variant: slot?.variant ?? 'default' }); } }; } function lineData( values: (number | null)[], line: LineRegistration, slots: string[], background: string, active = false ): LineSeriesOption['data'] { const variant = active ? line.activeDotVariant : line.dotVariant; return values.map((value, index) => { if (value === null) return null; const paint = sampleGradient(slots, values.length > 1 ? index / (values.length - 1) : 0); return { value, itemStyle: dotItemStyle(variant, paint, background), emphasis: { itemStyle: dotItemStyle(line.activeDotVariant, paint, background) } }; }); } function verticalBarPaint(slots: string[]) { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 0, 1, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } type ImagePattern = { image: HTMLCanvasElement; repeat: 'repeat'; rotation: number; scaleX: number; scaleY: number; }; function barHatchPattern(color: string): ImagePattern | null { if (typeof document === 'undefined') return null; const dpr = Math.max(window.devicePixelRatio || 1, 1); const canvas = document.createElement('canvas'); const drawing = canvas.getContext('2d'); if (!drawing) return null; canvas.width = 5 * dpr; canvas.height = 5 * dpr; drawing.scale(dpr, dpr); drawing.fillStyle = withAlpha(color, 0.3); drawing.fillRect(0, 0, 5, 5); drawing.fillStyle = color; drawing.fillRect(0, 0, 1.5, 5); return { image: canvas, repeat: 'repeat', rotation: -Math.PI / 4, scaleX: 1 / dpr, scaleY: 1 / dpr }; } function composedBarPaint(context: ComposedOptionContext, bar: BarRegistration, slots: string[]) { const color = slots[0] ?? context.resolved.tokens.foreground; if (context.renderStyle === 'dither') return createDitherPattern( slots, bar.ditherVariant ?? context.ditherVariant, context.ditherCellSize, 1, ditherPlotBounds(context) ); if (bar.variant === 'hatched') return barHatchPattern(color) ?? color; if (bar.variant === 'gradient') return new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: sampleGradient(slots, 0) }, { offset: 0.2, color: sampleGradient(slots, 0.2) }, { offset: 0.9, color: withAlpha(sampleGradient(slots, 0.9), 0) }, { offset: 1, color: withAlpha(sampleGradient(slots, 1), 0) } ]); if (bar.variant === 'duotone' || bar.variant === 'duotone-reverse') { const left = withAlpha(color, bar.variant === 'duotone-reverse' ? 1 : 0.4); const right = withAlpha(color, bar.variant === 'duotone-reverse' ? 0.4 : 1); return new echarts.graphic.LinearGradient(0, 0, 1, 0, [ { offset: 0, color: left }, { offset: 0.5, color: left }, { offset: 0.5, color: right }, { offset: 1, color: right } ]); } if (bar.variant === 'stripped') return new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color }, { offset: 0.05, color: withAlpha(color, 0.4) }, { offset: 1, color: withAlpha(color, 0.1) } ]); return verticalBarPaint(slots); } function staggerDelay(type: string, index: number, count: number) { const last = Math.max(0, count - 1); const center = last / 2; const step = type === 'right-to-left' ? last - index : type === 'center-out' ? Math.abs(index - center) : type === 'edges-in' ? center - Math.abs(index - center) : index; return step * 50; } function buildSeries(context: ComposedOptionContext): (LineSeriesOption | BarSeriesOption)[] { if (context.isLoading) { return [ { id: '__loading', type: 'bar', data: context.loadingData, silent: true, barCategoryGap: '30%', itemStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), borderRadius: [4, 4, 0, 0] }, animation: false }, { id: '__loading-line', type: 'line', data: context.loadingLineData, smooth: true, showSymbol: false, silent: true, lineStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), width: 2 }, animation: false, tooltip: { show: false } } ]; } const bars: BarSeriesOption[] = context.bars.map((bar) => { const slots = context.resolved.series[bar.dataKey] ?? [context.resolved.tokens.foreground]; const color = slots[0] ?? context.resolved.tokens.foreground; const bloomBlur = ditherBloomBlur(context.renderStyle, context.bloom); const bloomColor = bloomBlur > 0 ? withAlpha(color, 0.55) : undefined; const values = seriesValues(context, bar.dataKey); const opacity = context.selectedDataKey === null || context.selectedDataKey === bar.dataKey ? 1 : 0.15; const variantColor = composedBarPaint(context, bar, slots); const base: BarSeriesOption = { id: bar.dataKey, name: labelFor(context.config, bar.dataKey), type: 'bar', data: bar.glow && slots.length > 1 ? values.map((value, index) => ({ value, itemStyle: { shadowBlur: 16, shadowColor: withAlpha( sampleGradient(slots, values.length > 1 ? index / (values.length - 1) : 0), 0.6 ) } })) : values, barGap: context.barGap, barCategoryGap: context.barCategoryGap, cursor: bar.isClickable ? 'pointer' : 'default', z: 2, itemStyle: { color: variantColor, opacity, borderRadius: bar.variant === 'stripped' ? 0 : bar.radius, shadowBlur: bar.glow && slots.length === 1 ? 16 : bloomBlur, shadowColor: bar.glow && slots.length === 1 ? withAlpha(color, 0.6) : bloomColor }, emphasis: { focus: bar.enableHoverHighlight && context.selectedDataKey === null ? 'self' : 'none', blurScope: 'series' }, blur: bar.enableHoverHighlight && context.selectedDataKey === null ? { itemStyle: { opacity: 0.15 } } : undefined, animation: context.animation && (bar.animationType ?? context.animationType) !== 'none' && !context.reducedMotion, animationDuration: 500, animationEasing: 'cubicOut', animationDelay: (i) => staggerDelay(bar.animationType ?? context.animationType, i, context.data.length) }; return bar.barProps ? { ...base, ...bar.barProps } : base; }); const lines = context.lines.flatMap((line, lineIndex) => { const values = seriesValues(context, line.dataKey); const slots = context.resolved.series[line.dataKey] ?? ['rgba(120, 120, 120, 1)']; const bloomBlur = ditherBloomBlur(context.renderStyle, context.bloom); const bloomColor = bloomBlur > 0 ? withAlpha(slots[0] ?? context.resolved.tokens.foreground, 0.55) : undefined; const ditherStroke = context.renderStyle === 'dither' && line.strokeVariant !== 'animated-dashed'; const paint = ditherStroke ? createDitherPattern( slots, line.ditherVariant ?? context.ditherVariant, context.ditherCellSize, 1, ditherPlotBounds(context) ) : seriesPaint(slots); const dotPaint = seriesPaint(slots); const curve = curveConfig(line.curveType ?? context.curveType); const opacity = opacityFor(context.selectedDataKey, line.dataKey); const dot = dotStyle(line.dotVariant, dotPaint, context.resolved.tokens.background); const activeDot = dotStyle(line.activeDotVariant, dotPaint, context.resolved.tokens.background); const series: LineSeriesOption[] = []; if (line.glow) { for (const [glowIndex, layer] of [ { opacity: 0.22, blur: 42 }, { opacity: 0.38, blur: 24 }, { opacity: 0.6, blur: 12 }, { opacity: 0.9, blur: 5 } ].entries()) { series.push({ id: `__glow-${line.dataKey}-${glowIndex}`, type: 'line', data: values, smooth: curve.smooth, step: curve.step, connectNulls: line.connectNulls, showSymbol: false, silent: true, z: 2, lineStyle: { color: paint, width: 2, opacity: layer.opacity * opacity, shadowBlur: layer.blur, shadowColor: sampleGradient(slots, 0.5), cap: 'round', join: 'round' }, animation: false, tooltip: { show: false }, emphasis: { disabled: true } }); } } const base: LineSeriesOption = { id: line.dataKey, name: labelFor(context.config, line.dataKey), type: 'line', data: lineData(values, line, slots, context.resolved.tokens.background), smooth: curve.smooth, step: curve.step, connectNulls: line.connectNulls, showSymbol: dot.size > 0, symbol: 'circle', symbolSize: dot.size > 0 ? dot.size : activeDot.size, cursor: line.isClickable ? 'pointer' : 'default', triggerEvent: line.isClickable, silent: false, z: 3 + lineIndex, lineStyle: { color: paint, width: ditherStroke ? Math.max(2, context.ditherCellSize) : 2, type: ditherStroke ? [context.ditherCellSize, context.ditherCellSize] : line.strokeVariant === 'solid' ? 'solid' : [5, 5], opacity, cap: 'round', join: 'round', shadowBlur: bloomBlur, shadowColor: bloomColor }, itemStyle: { ...dot.itemStyle, opacity }, emphasis: { focus: 'none', scale: activeDot.size > dot.size ? activeDot.size / Math.max(1, dot.size) : false, lineStyle: { opacity }, itemStyle: { ...activeDot.itemStyle, opacity } }, blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } }, animation: context.animation && context.animationType !== 'none' && !context.reducedMotion, animationDuration: 1000, animationDurationUpdate: 0 }; series.push(line.lineProps ? { ...base, ...line.lineProps } : base); return series; }); return [...bars, ...lines]; } export function buildComposedOption(context: ComposedOptionContext): EChartsComposedOption { const legendTop = context.legend?.verticalAlign === 'top'; const legendBottom = context.legend?.verticalAlign === 'bottom'; const brushHeight = context.brush?.height ?? 56; const showBrush = Boolean(context.brush) && !context.isLoading; const brushGap = showBrush ? brushHeight + 30 + (context.xAxis?.label ? 22 : 0) : 0; const mainGrid: GridComponentOption = { left: 8, right: 8, top: legendTop ? 42 : 16, bottom: 8 + brushGap + (legendBottom ? 34 : 0) }; const { xAxis, yAxis } = buildAxes(context); const series = buildSeries(context); if (!showBrush) { return { animation: context.animation && !context.reducedMotion, animationDurationUpdate: 0, aria: { enabled: true }, grid: mainGrid, xAxis, yAxis, tooltip: tooltip(context), series }; } const brushBottom = legendBottom ? 34 : 6; const miniGrid: GridComponentOption = { left: 8, right: 8, bottom: brushBottom, height: brushHeight }; const miniInputs = [ ...context.bars.map((bar) => ({ dataKey: bar.dataKey, curveType: undefined, connectNulls: false })), ...context.lines.map((line) => ({ dataKey: line.dataKey, curveType: line.curveType, connectNulls: line.connectNulls })) ]; const miniLines = miniInputs.map((line) => { const curve = curveConfig(line.curveType ?? context.curveType); const opacity = opacityFor(context.selectedDataKey, line.dataKey); return { id: `__mini-${line.dataKey}`, type: 'line' as const, xAxisIndex: 1, yAxisIndex: 1, data: seriesValues(context, line.dataKey), smooth: curve.smooth, step: curve.step, connectNulls: line.connectNulls, showSymbol: false, silent: true, lineStyle: { color: seriesPaint(context.resolved.series[line.dataKey] ?? []), width: 1, opacity: 0.5 * opacity }, areaStyle: { color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ { offset: 0, color: withAlpha( context.resolved.series[line.dataKey]?.[0] ?? context.resolved.tokens.foreground, 0.15 * (context.selectedDataKey === null || context.selectedDataKey === line.dataKey ? 1 : 0.15) ) }, { offset: 1, color: withAlpha( context.resolved.series[line.dataKey]?.[0] ?? context.resolved.tokens.foreground, 0 ) } ]) }, emphasis: { disabled: true }, tooltip: { show: false }, z: 0, animation: false }; }); return { animation: context.animation && !context.reducedMotion, animationDurationUpdate: 0, aria: { enabled: true }, grid: [mainGrid, { ...miniGrid, outerBoundsMode: 'none' }], xAxis: [ xAxis, { type: 'category', gridIndex: 1, boundaryGap: false, data: categories(context), show: false } ], yAxis: [yAxis, { type: 'value', gridIndex: 1, show: false }], tooltip: tooltip(context), dataZoom: buildBrushDataZoom({ brushBottom, brushHeight, brushRange: context.brushRange, fillerColor: 'transparent' }), series: [...series, ...miniLines] }; } export function createComposedLoadingData(points: number): number[] { const values: number[] = []; let value = 30 + Math.random() * 20; for (let index = 0; index < points; index += 1) { value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16)); values.push(Math.round(value)); } return values; } ``` `$lib/components/evilcharts/charts/echarts-composed-chart/types.ts` ```ts import type { BarSeriesOption, LineSeriesOption } from 'echarts/charts'; import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type BarVariant = 'default' | 'hatched' | 'duotone' | 'duotone-reverse' | 'gradient' | 'stripped'; export type StrokeVariant = 'solid' | 'dashed' | 'animated-dashed'; export type ComposedAnimationType = 'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in'; export type CurveType = 'linear' | 'smooth' | 'bump' | 'monotone' | 'monotoneX' | 'monotoneY' | 'natural' | 'step'; export type BarRegistration = { dataKey: string; variant: BarVariant; radius: number; glow: boolean; animationType?: ComposedAnimationType; isClickable: boolean; enableHoverHighlight: boolean; barProps?: Partial; ditherVariant?: DitherVariant; }; export type LineRegistration = { dataKey: string; strokeVariant: StrokeVariant; curveType?: CurveType; animationType?: ComposedAnimationType; connectNulls: boolean; glow: boolean; isClickable: boolean; dotVariant: DotVariant; activeDotVariant: DotVariant; lineProps?: Partial; ditherVariant?: DitherVariant; }; export type AxisRegistration = { dataKey?: string; tickFormatter?: (value: string | number, index: number) => string; label?: string; hideDots: boolean; }; export type TooltipRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipVariant; roundness: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipRoundness; cursor?: boolean; defaultIndex?: number; position: import('$lib/components/evilcharts/ui/echarts-tooltip/index.js').TooltipPosition; }; export type LegendRegistration = { variant: import('$lib/components/evilcharts/ui/echarts-legend/index.js').LegendVariant; align: 'left' | 'center' | 'right'; verticalAlign: 'top' | 'middle' | 'bottom'; isClickable: boolean; }; export type BrushRegistration = { height?: number; formatLabel?: (value: string, index: number) => string; onChange?: (range: { startIndex: number; endIndex: number }) => void; }; ``` `$lib/components/evilcharts/charts/echarts-composed-chart/x-axis.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-composed-chart/y-axis.svelte` ```svelte ``` ### Add the shared chart module. Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it. ### $lib/components/evilcharts/ui/echarts-chart `$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte` ```svelte
{#if accessibility?.description} {accessibility.description} {/if}
{@render children?.()}
{@render overlay?.()} {@render footer?.()}
``` `$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte` ```svelte {#if css} {css} {/if} ``` `$lib/components/evilcharts/ui/echarts-chart/colors.ts` ```ts import * as echarts from 'echarts/core'; import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js'; const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/; export function chartColorToken(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key; return `u-${Array.from(key, (character) => (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0') ).join('')}`; } export function chartColorVariableName(key: string, index: number): string { return `--color-${chartColorToken(key)}-${index}`; } export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string { const name = chartColorVariableName(key, index); return fallbackIndex === undefined ? `var(${name})` : `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`; } export function quoteCssString(value: string): string { return `"${Array.from(value, (character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === '"' || character === '\\') return `\\${character}`; if (codePoint === 0) return '\uFFFD'; if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `; return character; }).join('')}"`; } export function getColorsCount(item: ChartConfig[string]): number { if (!item.colors) return 1; return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1); } export function distributeColors(colors: string[], maxCount: number): string[] { if (colors.length === 0) return []; if (colors.length >= maxCount) return colors.slice(0, maxCount); const result: string[] = []; const baseSlots = Math.floor(maxCount / colors.length); const extraSlots = maxCount % colors.length; for (let index = 0; index < colors.length; index += 1) { const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0); for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]); } return result; } export function buildChartCss(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter(([, item]) => item.colors); if (colorConfig.length === 0) return ''; const variablesFor = (theme: ThemeKey) => colorConfig .flatMap(([key, item]) => { const authored = item.colors?.[theme]; if (!authored?.length) return []; return distributeColors(authored, getColorsCount(item)).map( (color, index) => ` ${chartColorVariableName(key, index)}: ${color};` ); }) .join('\n'); return Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}` ) .join('\n'); } let normalizerContext: CanvasRenderingContext2D | null = null; export function normalizeColor(value: string): string { const raw = value.trim(); if (!raw || typeof document === 'undefined') return raw; if (!normalizerContext) { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; normalizerContext = canvas.getContext('2d', { willReadFrequently: true }); } if (!normalizerContext) return raw; normalizerContext.clearRect(0, 0, 1, 1); normalizerContext.fillStyle = '#000'; normalizerContext.fillStyle = raw; normalizerContext.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data; return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`; } export function withAlpha(color: string, alpha: number): string { const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim()); const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0; return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`; } export type ResolvedColors = { series: Record; tokens: { mutedForeground: string; border: string; foreground: string; background: string; }; }; export function resolveColors( container: HTMLElement, config: ChartConfig, seriesKeys: string[] ): ResolvedColors { const computed = getComputedStyle(container); const series: Record = {}; for (const key of seriesKeys) { const count = getColorsCount(config[key] ?? {}); series[key] = Array.from({ length: count }, (_, index) => { const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim(); return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)'; }); } const probe = document.createElement('span'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;'; container.appendChild(probe); const readToken = (className: string) => { probe.className = className; return normalizeColor(getComputedStyle(probe).color); }; const tokens = { mutedForeground: readToken('text-muted-foreground'), border: readToken('text-border'), foreground: readToken('text-foreground'), background: readToken('text-background') }; probe.remove(); return { series, tokens }; } export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } export function indicatorBackground(key: string, colorsCount: number): string { if (colorsCount <= 1) return chartColorVariable(key, 0); const stops = Array.from({ length: colorsCount }, (_, index) => { const offset = (index / (colorsCount - 1)) * 100; return `${chartColorVariable(key, index)} ${offset}%`; }).join(', '); return `linear-gradient(to right, ${stops})`; } export function flattenColor(color: string, base: string): string { const parse = (value: string) => value .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1]; const [red, green, blue, alpha = 1] = parse(color); const [baseRed, baseGreen, baseBlue] = parse(base); const mix = (channel: number, baseChannel: number) => Math.round(channel * alpha + baseChannel * (1 - alpha)); return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`; } ``` `$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte` ```svelte
``` `$lib/components/evilcharts/ui/echarts-chart/index.ts` ```ts export { default as ChartContainer } from './chart-container.svelte'; export { default as ChartStyle } from './chart-style.svelte'; export { default as EChartsHost } from './echarts-host.svelte'; export { default as LoadingIndicator } from './loading-indicator.svelte'; export { default as SelectableSeriesControls } from './selectable-series-controls.svelte'; export { mergeLifecycleOptions } from './merge-options.js'; export type { EChartsEventHandler } from './echarts-host.svelte'; export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js'; export { getEChartsSharedSlotContext, setEChartsSharedSlotContext, type EChartsSharedSlotName } from './shared-slots.svelte.js'; export { buildChartCss, chartColorToken, chartColorVariable, chartColorVariableName, distributeColors, flattenColor, getColorsCount, indicatorBackground, normalizeColor, resolveColors, seriesPaint, withAlpha, quoteCssString, type ResolvedColors } from './colors.js'; export { DEFAULT_ECHARTS_RENDERER, ECHARTS_RENDERERS, THEMES, THEME_KEYS, validateChartConfigColors, type AtLeastOneThemeColor, type ChartAccessibility, type ChartConfig, type EChartsRenderer, type EChartsRenderStyle, type ThemeKey } from './types.js'; ``` `$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte` ```svelte {#if isLoading}
Loading
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/merge-options.ts` ```ts type LifecycleOptions = { animation?: unknown; animationDuration?: unknown; animationDurationUpdate?: unknown; }; /** Merge consumer options without letting them break chart-owned animation lifecycles. */ export function mergeLifecycleOptions(built: T, overrides?: object): T { const lifecycle = built as T & LifecycleOptions; return { ...built, ...overrides, animation: lifecycle.animation, animationDuration: lifecycle.animationDuration, animationDurationUpdate: lifecycle.animationDurationUpdate } as T; } ``` `$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts` ```ts import { SvelteMap } from 'svelte/reactivity'; export type RegistrationGetter = () => T; /** Ordered, reactive storage for DOM-free compound-component registrations. */ export class RegistrationSet { #entries = new SvelteMap>(); register(token: string, getter: RegistrationGetter): () => void { this.#entries.set(token, getter); return () => { if (this.#entries.get(token) === getter) this.#entries.delete(token); }; } get values(): T[] { return Array.from(this.#entries.values(), (getter) => getter()); } get first(): T | undefined { return this.#entries.values().next().value?.(); } get size(): number { return this.#entries.size; } } ``` `$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte` ```svelte {#if items.length > 0}
{#each items as item (item.key)} {/each}
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush'; type SharedSlotContext = { register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void; }; const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots'); export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext { setContext(ECHARTS_SHARED_SLOT_CONTEXT, context); return context; } export function getEChartsSharedSlotContext(): SharedSlotContext { const context = getContext(ECHARTS_SHARED_SLOT_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.'); } return context; } ``` `$lib/components/evilcharts/ui/echarts-chart/types.ts` ```ts import type { Component, Snippet } from 'svelte'; export const ECHARTS_RENDERERS = { canvas: 'canvas', svg: 'svg' } as const; export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS]; export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas; export const THEMES = { light: '', dark: '.dark' } as const; export type ThemeKey = keyof typeof THEMES; export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[]; type ThemeColorsBase = { [K in ThemeKey]?: string[]; }; export type AtLeastOneThemeColor = { [K in ThemeKey]: Required> & Partial>; }[ThemeKey]; export type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component>; colors?: AtLeastOneThemeColor; } >; export function validateChartConfigColors(config: ChartConfig): void { for (const [key, item] of Object.entries(config)) { if (!item.colors) continue; if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue; throw new Error( `[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.` ); } } export type ChartAccessibility = | { label: string; labelledBy?: never; description?: string; describedBy?: string; } | { label?: never; labelledBy: string; description?: string; describedBy?: string; }; export type EChartsRenderStyle = 'native' | 'dither'; ``` ### Add the sub-components. Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there. ### $lib/components/evilcharts/ui/echarts-tooltip `$lib/components/evilcharts/ui/echarts-tooltip/index.ts` ```ts export { default as Tooltip } from './tooltip.svelte'; export type { TooltipProps } from './tooltip.svelte'; export { escapeTooltipHtml, resolveTooltipPosition, roundnessClass, tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell, tooltipVariantClass, type TooltipPosition, type TooltipRoundness, type TooltipVariant } from './tooltip.js'; ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts` ```ts import type { TooltipComponentOption } from 'echarts/components'; import { indicatorBackground } from '../echarts-chart/index.js'; export type TooltipVariant = 'default' | 'frosted-glass'; export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl'; export type TooltipPosition = 'fixed' | 'variable'; export const roundnessClass: Record = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }; export const tooltipVariantClass: Record = { default: 'bg-background', 'frosted-glass': 'bg-background/50 backdrop-blur-md' }; export function escapeTooltipHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function tooltipIndicatorHtml(key: string, colorsCount: number): string { return `
`; } export function tooltipRow({ indicatorHtml, labelText, valueText, dimmed }: { indicatorHtml: string; labelText: string; valueText: string; dimmed: string; }): string { return `
${indicatorHtml}
${escapeTooltipHtml(labelText)} ${escapeTooltipHtml(valueText)}
`; } export function tooltipShell({ label, body, roundness, variant }: { label: string; body: string; roundness: TooltipRoundness; variant: TooltipVariant; }): string { return `
${escapeTooltipHtml(label)}
${body}
`; } export function resolveTooltipPosition( position: TooltipPosition ): TooltipComponentOption['position'] { if (position === 'variable') return undefined; return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8]; } export function tooltipBaseOption(params: { present: boolean; cursor: boolean; position: TooltipPosition; axisPointerColor: string; strokeWidth: number; }): TooltipComponentOption { const { present, cursor, position, axisPointerColor, strokeWidth } = params; return { show: present, trigger: 'axis', confine: true, displayTransition: false, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', axisPointer: cursor ? { type: 'line', lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] } } : { type: 'none' }, position: resolveTooltipPosition(position) }; } ``` Next, create `echarts-legend` in the same `ui` folder and paste the legend overlay there. ### $lib/components/evilcharts/ui/echarts-legend `$lib/components/evilcharts/ui/echarts-legend/index.ts` ```ts export { default as Legend } from './legend.svelte'; export { default as LegendIndicator } from './legend-indicator.svelte'; export { default as LegendOverlay } from './legend-overlay.svelte'; export type { LegendProps, LegendVariant } from './legend.svelte'; ``` `$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte` ```svelte {#if variant === 'square'} {:else if variant === 'circle'} {:else if variant === 'circle-outline'} {:else if variant === 'vertical-bar'} {:else if variant === 'horizontal-bar'} {:else if variant === 'rounded-square-outline'} {:else} {/if} ``` `$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte` ```svelte
{#each entries as entry (entry.key)} {#if isClickable} {:else}
{#if typeof entry.item?.label === 'function'} {@render entry.item.label()} {:else} {entry.item?.label ?? entry.key} {/if}
{/if} {/each}
``` `$lib/components/evilcharts/ui/echarts-legend/legend.svelte` ```svelte ``` Next, create `echarts-dot` in the same `ui` folder and paste the dot styles the series markers draw with there. ### $lib/components/evilcharts/ui/echarts-dot `$lib/components/evilcharts/ui/echarts-dot/dot.ts` ```ts import type * as echarts from 'echarts/core'; export type DotVariant = 'none' | 'default' | 'border' | 'colored-border' | 'ping'; export type DotItemStyleOption = { color?: string | echarts.graphic.LinearGradient; borderColor?: string | echarts.graphic.LinearGradient; borderWidth?: number; opacity?: number; }; export type DotStyle = { size: number; itemStyle: DotItemStyleOption }; function colorWithAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) hex = hex.replace(/./g, (character) => character.repeat(2)); const numeric = Number.parseInt(hex, 16); return `rgba(${(numeric >> 16) & 255}, ${(numeric >> 8) & 255}, ${numeric & 255}, ${alpha})`; } const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue] = match[1].split(',').map((part) => Number.parseFloat(part)); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } export function dotItemStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotItemStyleOption { switch (variant) { case 'border': return { color: paint, borderColor: background, borderWidth: 2 }; case 'colored-border': return { color: background, borderColor: paint, borderWidth: 1 }; case 'ping': return { color: paint, borderColor: typeof paint === 'string' ? colorWithAlpha(paint, 0.28) : paint, borderWidth: 10 }; case 'default': return { color: paint, borderWidth: 0 }; default: return {}; } } export const DOT_SIZES: Record = { none: 0, default: 6, border: 8, 'colored-border': 6, ping: 8 }; export function dotStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotStyle { return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) }; } export function sampleGradient(slots: string[], position: number): string { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; const parse = (color: string) => color .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map(Number) ?? [120, 120, 120, 1]; const scaled = position * (slots.length - 1); const index = Math.min(Math.floor(scaled), slots.length - 2); const fraction = scaled - index; const [redFrom, greenFrom, blueFrom, alphaFrom = 1] = parse(slots[index]); const [redTo, greenTo, blueTo, alphaTo = 1] = parse(slots[index + 1]); const lerp = (from: number, to: number) => from + (to - from) * fraction; return `rgba(${Math.round(lerp(redFrom, redTo))}, ${Math.round(lerp(greenFrom, greenTo))}, ${Math.round(lerp(blueFrom, blueTo))}, ${lerp(alphaFrom, alphaTo).toFixed(3)})`; } ``` `$lib/components/evilcharts/ui/echarts-dot/index.ts` ```ts export { DOT_SIZES, dotItemStyle, dotStyle, sampleGradient, type DotItemStyleOption, type DotStyle, type DotVariant } from './dot.js'; ``` Finally, create `echarts-brush` in the same `ui` folder and paste the zoom brush there. ### $lib/components/evilcharts/ui/echarts-brush `$lib/components/evilcharts/ui/echarts-brush/brush-controls.svelte` ```svelte {#if totalPoints > 0}
handleKey(event, 'range')} > Selected range: {formatLabel(startIndex)} to {formatLabel(endIndex)}
handleKey(event, 'start')} > Range start: {formatLabel(startIndex)}
handleKey(event, 'end')} > Range end: {formatLabel(endIndex)}
{/if} ``` `$lib/components/evilcharts/ui/echarts-brush/brush.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-brush/brush.ts` ```ts import type { DataZoomComponentOption } from 'echarts/components'; import type { EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { withAlpha, type ResolvedColors } from '../echarts-chart/index.js'; export const BRUSH_BORDER_OPACITY = 1; export type BrushRange = { start: number; end: number }; export type BrushGeometry = { bottom: number; height: number }; export type BrushOverlayParams = { range: BrushRange; geom: BrushGeometry; size: { width: number; height: number }; tokens: ResolvedColors['tokens']; labels: { start: string; end: string } | null; showLabels: boolean; hover: { left: boolean; right: boolean }; }; type ZrRect = InstanceType; type ZrCircle = InstanceType; type ZrText = InstanceType; export type BrushOverlayElements = { dimLeft: ZrRect; dimRight: ZrRect; frame: ZrRect; pillLeft: ZrRect; pillRight: ZrRect; grips: ZrCircle[]; labelStart: ZrText; labelEnd: ZrText; }; export function syncBrushOverlay( chart: EChartsType, store: { brushOverlay: BrushOverlayElements | null }, params: BrushOverlayParams | null ): void { const renderer = chart.getZr(); if (!renderer) return; if (!params) { if (store.brushOverlay) { const { grips, ...rest } = store.brushOverlay; for (const element of [...Object.values(rest), ...grips]) renderer.remove(element); store.brushOverlay = null; } return; } if (!store.brushOverlay) { const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} }); const elements: BrushOverlayElements = { dimLeft: rect(100), dimRight: rect(100), frame: rect(101), pillLeft: rect(102), pillRight: rect(102), grips: Array.from( { length: 6 }, () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }) ), labelStart: new echarts.graphic.Text({ silent: true, z: 104 }), labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }) }; const { grips, ...rest } = elements; for (const element of [...Object.values(rest), ...grips]) renderer.add(element); store.brushOverlay = elements; } const elements = store.brushOverlay; const { range, geom, size, tokens, labels, showLabels, hover } = params; const trackLeft = 8; const trackRight = Math.max(size.width - 8, trackLeft); const trackWidth = trackRight - trackLeft; const top = size.height - geom.bottom - geom.height; const centerY = top + geom.height / 2; const selectionLeft = trackLeft + (trackWidth * range.start) / 100; const selectionRight = trackLeft + (trackWidth * range.end) / 100; const dimFill = withAlpha(tokens.background, 0.7); elements.dimLeft.setShape({ x: trackLeft, y: top, width: Math.max(selectionLeft - trackLeft, 0), height: geom.height }); elements.dimLeft.setStyle({ fill: dimFill }); elements.dimRight.setShape({ x: selectionRight, y: top, width: Math.max(trackRight - selectionRight, 0), height: geom.height }); elements.dimRight.setStyle({ fill: dimFill }); elements.frame.setShape({ x: selectionLeft, y: top, width: Math.max(selectionRight - selectionLeft, 0), height: geom.height, r: 6 }); elements.frame.setStyle({ fill: 'none', stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY), lineWidth: 1 }); const updatePill = (element: ZrRect, x: number, hovered: boolean) => { element.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 }); element.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground }); }; updatePill(elements.pillLeft, selectionLeft, hover.left); updatePill(elements.pillRight, selectionRight, hover.right); const gripFill = withAlpha(tokens.background, 0.7); for (const [index, offset] of [-4, 0, 4].entries()) { elements.grips[index].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 }); elements.grips[index].setStyle({ fill: gripFill }); elements.grips[index + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 }); elements.grips[index + 3].setStyle({ fill: gripFill }); } const updateLabel = (element: ZrText, text: string, x: number, align: 'left' | 'right') => { element.setStyle({ text, x: align === 'left' ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2), y: top + geom.height, align, verticalAlign: 'middle', fill: tokens.background, backgroundColor: tokens.foreground, padding: [2, 5], borderRadius: 4, font: '500 9px system-ui, sans-serif' }); element.attr('invisible', !showLabels || !text); }; updateLabel(elements.labelStart, labels?.start ?? '', selectionLeft, 'left'); updateLabel(elements.labelEnd, labels?.end ?? '', selectionRight, 'right'); } export function buildBrushDataZoom(params: { brushBottom: number; brushHeight: number; brushRange: BrushRange; fillerColor: string; }): DataZoomComponentOption[] { const { brushBottom, brushHeight, brushRange, fillerColor } = params; return [ { type: 'slider', show: true, xAxisIndex: [0], left: 8, right: 8, bottom: brushBottom, height: brushHeight, start: brushRange.start, end: brushRange.end, brushSelect: false, showDetail: false, backgroundColor: 'transparent', borderColor: 'transparent', fillerColor, dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, handleIcon: 'path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z', handleSize: '35%', handleStyle: { opacity: 0 }, moveHandleSize: 0, emphasis: { handleStyle: { opacity: 0 } } }, { type: 'inside', xAxisIndex: [0] } ]; } ``` `$lib/components/evilcharts/ui/echarts-brush/index.ts` ```ts export { default as Brush } from './brush.svelte'; export { default as BrushControls } from './brush-controls.svelte'; export type { BrushProps } from './brush.svelte'; export { BRUSH_BORDER_OPACITY, buildBrushDataZoom, syncBrushOverlay, type BrushGeometry, type BrushOverlayElements, type BrushOverlayParams, type BrushRange } from './brush.js'; ``` ## Usage The ECharts composed chart is composable, sharing the LayerChart sibling's API shape. `` is the container, and every part hangs off it as a compound member — ``, ``, ``, ``, ``, and one or more `` and `` — so a single import gives you the whole chart. Each `` carries its own `variant`, `glow`, and `isClickable`, and each `` its own `strokeVariant`, `curveType`, `glow`, and `isClickable`, so one chart can freely mix bar and line styles. ```svelte ``` ```svelte const chartConfig = { revenue: { label: "Revenue", colors: { light: ["#3b82f6"], dark: ["#6A5ACD"] }, }, profit: { label: "Profit", colors: { light: ["#10b981"], dark: ["#34d399"] }, }, } satisfies ChartConfig; ``` The difference from the LayerChart sibling is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`. The `config` is the same contract as every EvilCharts chart — each key maps a data key to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from your CSS variables at runtime, so dark mode just works. > The ECharts implementation brings a few small departures from the LayerChart sibling: multi-color bars run a vertical gradient through their color slots, the glow becomes a soft colored blur rather than the LayerChart SVG filter, and the zoom brush is a themed mini chart driven by native `dataZoom`. The textured `hatched` bar fill uses an offscreen canvas tile; with `renderer="svg"`, ECharts may embed that tile as a raster image inside the SVG. ### SVG Renderer Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer. ### renderer="svg" ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ### Interactive Selection Add `isClickable` to any ``, ``, or `` to make its series selectable, and handle events with the `onSelectionChange` callback on ``: ```svelte { if (selectedDataKey) { console.log('Selected:', selectedDataKey); } else { console.log('Deselected'); } }} > ``` ### Loading State ### isLoading='true' ```svelte String(value).substring(0, 3)} /> ``` > Pass `isLoading` to show an animated skeleton of shimmering bars and a line, both revealed by one diagonal shimmer sweep. Use `loadingBars` to set how many bars it draws. ## Examples Examples of the composed chart with different `variants`. Customize each `` with a `variant`, and each `` with a `strokeVariant`, `curveType`, and more. ### Gradient Colors ### gradient colors ```svelte String(value).substring(0, 3)} /> ``` ### Bar Variants ### ```svelte String(value).substring(0, 3)} /> ``` ### ```svelte String(value).substring(0, 3)} /> ``` ### ```svelte String(value).substring(0, 3)} /> ``` ### ```svelte String(value).substring(0, 3)} /> ``` ### Line Stroke Variants ### ```svelte String(value).substring(0, 3)} /> ``` ### ```svelte String(value).substring(0, 3)} /> ``` ### Curve Types ### ```svelte String(value).substring(0, 3)} /> ``` ### Line Dots ### and ```svelte String(value).substring(0, 3)} /> ``` > Inside a <Line>, compose a <Dot> for the resting marker and an <ActiveDot> for the one shown on hover. Available variants: `default`, `border`, `colored-border`. ### Hover Highlight ### ```svelte String(value).substring(0, 3)} /> ``` > Set `enableHoverHighlight` on a <Bar> to dim its other columns when you hover one, making a single data point easier to focus on. It uses ECharts' native emphasis, so nothing re-renders mid-hover. ### Glowing Effects ### and ```svelte String(value).substring(0, 3)} /> ``` > Add the `glow` prop to a <Bar> or <Line> for a subtle glow. ECharts renders it as a soft colored blur that follows the series' own color along its length, staying faithful even on multi-stop gradients. ### Ordered dither Set `renderStyle="dither"` to use the independent ordered-dither treatment inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit). Axes, tooltips, selection, and the ECharts renderer stay intact. ### renderStyle="dither" ```svelte ``` ## API Reference The chart is composed of several parts; the props below are grouped by part. Regardless of renderer, each part is config the root compiles, but the API mirrors the LayerChart sibling one-to-one. ### EChartsComposedChart The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual is composed as its children and compiled into the ECharts option. ### `data` (required) type: `TData[]` The chart data — an array of objects, one per data point (`TData extends Record`). ### `config` (required) type: `ChartConfig` Defines every bar and line series. Each key matches a data key, with a `label` and a per-theme `colors` array. Same contract as every EvilCharts chart — see [Chart Config](/docs/chart-config). ### `children` (required) type: `Snippet` The composed chart parts — ``, ``, ``, ``, ``, and one or more `` and ``. ### `class` type: `string` Additional CSS classes for the chart container. ### `renderer` type: `"canvas" | "svg"` · default: `"canvas"` Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default. ### `renderStyle` type: `"native" | "dither"` · default: `"native"` Selects native ECharts paint or EvilCharts' ordered-dither rendering. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"` Default ordered-dither pattern used by the chart's series. ### `ditherCellSize` type: `number` · default: `2` Dither cell size in CSS pixels. ### `bloom` type: `"off" | "low" | "high" | "aura"` · default: `"off"` Optional glow applied to dithered marks. It has no effect in native rendering mode. ### `xDataKey` type: `keyof TData & string` The data key for the x-axis categories. Falls back to the `` value, then to the first data column no series claims. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` · default: `"linear"` Default curve interpolation inherited by every ``; each may override it locally. ### `animation` type: `boolean` · default: `true` Master switch for the intro draw-in. Pass `false` to render the chart instantly, regardless of `animationType`. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"` The intro animation inherited by every `` and ``. Any value but `"none"` plays the draw-in: lines trace in left-to-right while bars grow from their baseline, staggered per-column in the chosen direction (`left-to-right`, `right-to-left`, `center-out`, `edges-in`). `"none"` disables it; the OS reduce-motion preference falls back to `"none"` automatically. ### `barGap` type: `number | string` The gap between bars in the same category (ECharts accepts a percentage like `"30%"` or a pixel number). ### `barCategoryGap` type: `number | string` The gap between bar categories. ### `defaultSelectedDataKey` type: `string | null` · default: `null` The series selected on first render. ### `onSelectionChange` type: `(key: string | null) => void` Fires when a series is selected or deselected — by clicking a clickable ``, ``, or `` entry. Receives the selected data key, or `null` when deselected. ### `isLoading` type: `boolean` · default: `false` Shows the animated loading skeleton. ### `loadingBars` type: `number` · default: `12` Number of bars in the loading skeleton. ### `chartOptions` type: `Record` Escape hatch merged over the built ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html). ### `accessibility` type: `ChartAccessibility` Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology. ### Bar A single bar series. Each `` carries its own fill variant, radius, glow, and clickability, so a chart can hold any number of bars. ### `dataKey` (required) type: `string` The series key. Must exist on both the data rows and the chart `config`. ### `variant` type: `"default" | "hatched" | "duotone" | "duotone-reverse" | "gradient" | "stripped"` · default: `"default"` The bar fill style, for this bar only. ### `radius` type: `number` · default: `4` The corner radius of the bar in pixels. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` The grow-in order for this bar (the first declared series' value drives the chart). Falls back to the chart's `animationType` when omitted. ### `glow` type: `boolean` · default: `false` Applies a soft neon glow to this bar. ### `isClickable` type: `boolean` · default: `false` Lets this bar be selected by clicking it. When any series is selected, unselected series become semi-transparent. ### `enableHoverHighlight` type: `boolean` · default: `false` Hovering a column dims this bar's other columns, easing focus on a single data point. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` Overrides the root ordered-dither pattern for this bar only. ### `barProps` type: `Partial` Escape hatch merged into the raw ECharts bar series. ### Line A single line series. Each `` carries its own stroke, curve, glow, and clickability, so a chart can hold any number of lines. ### `dataKey` (required) type: `string` The series key. Must exist on both the data rows and the chart `config`. ### `strokeVariant` type: `"solid" | "dashed" | "animated-dashed"` · default: `"solid"` The stroke style for this line. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` The curve interpolation for this line. Falls back to the chart's `curveType` when omitted. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` The intro reveal for this line (the first declared series' value drives the chart). Falls back to the chart's `animationType` when omitted. ### `connectNulls` type: `boolean` · default: `false` Whether to connect line segments across null or missing values. ### `glow` type: `boolean` · default: `false` A soft neon glow for this line — a colored blur that follows its color along its length. ### `isClickable` type: `boolean` · default: `false` Lets this line be selected by clicking it. When any series is selected, unselected series become semi-transparent. ### `children` type: `Snippet` Optional `` and `` that add point markers to this line. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` Overrides the root ordered-dither pattern for this line only. ### `lineProps` type: `Partial` Escape hatch merged into the raw ECharts line series. ### Dot and ActiveDot Point markers composed inside a ``. `` is the resting marker; `` is the hovered marker. They render nothing on their own — the parent `` reads their `variant`. ### `variant` type: `"default" | "border" | "colored-border"` · default: `"default"` The visual style of the point marker. ### XAxis and YAxis The category and value axes. Include `` for the x-axis labels and `` for the y-axis; omit either to hide it. Both hide automatically while loading. ### `dataKey` type: `string` The data key for the axis values. ### `tickFormatter` type: `(value: string | number, index: number) => string` Formats the axis tick labels. ### `label` type: `string` An axis title. Rendered centered below the x-axis tick labels, or rotated beside the y-axis ones. ### `hideDots` type: `boolean` · default: `false` Hides the small tick dots that sit beside this axis's labels. ### Grid The background grid lines. Include it to render the dashed horizontal split lines; omit it and they don't draw. Takes no props. ### Tooltip The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads the selection state and dims unselected series. ### `variant` type: `"default" | "frosted-glass"` · default: `"default"` The visual style of the tooltip surface. ### `roundness` type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"` Controls the border-radius of the tooltip. ### `defaultIndex` type: `number` Shows the tooltip by default at this data point index. ### `cursor` type: `boolean` · default: `true` Whether the vertical cursor line follows the pointer on hover. ### `position` type: `"fixed" | "variable"` · default: `"variable"` How the tooltip is anchored. `variable` follows both axes (default); `fixed` pins the tooltip near the top and only tracks the pointer's X. ### Legend The series legend, rendered as HTML above the chart surface. Include it to show the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its series. ### `variant` type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"` The visual style of the legend indicators. ### `align` type: `"left" | "center" | "right"` · default: `"right"` Horizontal placement of the legend. ### `verticalAlign` type: `"top" | "middle" | "bottom"` · default: `"top"` Vertical placement of the legend. ### `isClickable` type: `boolean` · default: `false` Lets each legend entry toggle selection of its series. ### Brush An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `` to render it; dragging the range filters the main chart. ### `height` type: `number` · default: `56` Height of the brush preview strip in pixels. ### `formatLabel` type: `(value: string | number, index: number) => string` Formats the range-handle labels below the brush. ### `onChange` Fires when the brush selection range changes. --- ## Composed Blocks > Complete dashboard blocks that combine bars and lines with Apache ECharts. Source: https://evilcharts-sv.vercel.app/docs/echarts/composed-chart/blocks Markdown: https://evilcharts-sv.vercel.app/docs/echarts/composed-chart/blocks.md The ECharts and LayerChart providers use the same data, colors, supporting metrics, and responsive card layouts. Choose the renderer that fits the surrounding page; the blocks can coexist. ## Revenue and profit ### Revenue and profit ```svelte
Revenue and profit {compactMoney(annualRevenue)} this year
December revenue {compactMoney(latest.revenue)}
December profit {compactMoney(latest.profit)}
String(value).slice(0, 3)} />
Annual profit {compactMoney(annualProfit)} Margin {((annualProfit / annualRevenue) * 100).toFixed(1)}%
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/revenue-echarts-composed-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/revenue-echarts-composed-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/revenue-echarts-composed-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/revenue-echarts-composed-chart ``` ## Signups against target ### Signups against target ```svelte
Weekly signups {latest.actual} +{delta.toFixed(1)}% vs target
Target {latest.target}
(String(value).endsWith(' 1') ? String(value).split(' ')[0] : '')} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/signups-echarts-composed-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/signups-echarts-composed-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/signups-echarts-composed-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/signups-echarts-composed-chart ``` --- ## Installation > install the evilcharts echarts components in your project Source: https://evilcharts-sv.vercel.app/docs/echarts/installation Markdown: https://evilcharts-sv.vercel.app/docs/echarts/installation.md The ECharts provider uses Apache ECharts with Canvas by default and optional SVG rendering — same config contract, same install flow, and a renderer choice on every chart root. Omit `renderer` to keep the Canvas default, or pass `renderer="svg"` to select ECharts' SVG renderer: ```svelte ``` ## Steps ### Install ECharts Apache ECharts powers the provider and is required by every ECharts component. Learn more at the [ECharts import guide](https://echarts.apache.org/handbook/en/basics/import). ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Setup shadcn-svelte Initialize shadcn-svelte to set up Tailwind CSS, theming, and component structure. Skip this step if you already have it. Check out the [shadcn-svelte installation docs](https://www.shadcn-svelte.com/docs/installation) for more details. ### npm ```bash npx shadcn-svelte@latest add init ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add init ``` ### bun ```bash bunx --bun shadcn-svelte@latest add init ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add init ``` ### Add EvilCharts ECharts Components Add components with the CLI. ECharts components are prefixed with `echarts-` — replace `{chart-name}` with one of `area-chart`, `line-chart`, `bar-chart`, `composed-chart`, `radar-chart`, `pie-chart`, `radial-chart`, or `sankey-chart`. Installing one pulls that chart plus the shared `ui` modules it imports. ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-{chart-name} ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-{chart-name} ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-{chart-name} ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-{chart-name} ``` --- ## Line Chart > Static, beautifully designed line charts powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/line-chart Markdown: https://evilcharts-sv.vercel.app/docs/echarts/line-chart.md ### Basic Chart ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ## Installation ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-line-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-line-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-line-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-line-chart ``` ### Install the following dependencies: ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Copy and paste the following code snippets into your project. Create the folder `evilcharts` with a `charts` subfolder in your `components` directory, then paste the line-chart code into a new `echarts-line-chart` file there. ### $lib/components/evilcharts/charts/echarts-line-chart `$lib/components/evilcharts/charts/echarts-line-chart/active-dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-line-chart/dot.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-line-chart/grid.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-line-chart/index.ts` ```ts import Root from './line-chart.svelte'; import Line from './line.svelte'; import Dot from './dot.svelte'; import ActiveDot from './active-dot.svelte'; import XAxis from './x-axis.svelte'; import YAxis from './y-axis.svelte'; import Grid from './grid.svelte'; import { Tooltip } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { Legend } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import { Brush } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; type RootComponent = typeof Root; export const EChartsLineChart: RootComponent & { Line: typeof Line; Dot: typeof Dot; ActiveDot: typeof ActiveDot; XAxis: typeof XAxis; YAxis: typeof YAxis; Grid: typeof Grid; Tooltip: typeof Tooltip; Legend: typeof Legend; Brush: typeof Brush; } = Object.assign(Root, { Line, Dot, ActiveDot, XAxis, YAxis, Grid, Tooltip, Legend, Brush }); export type { ChartAccessibility, ChartConfig, EChartsRenderer, EChartsRenderStyle } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; export type { DitherBloom, DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; export type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export type { CurveType, LineAnimationType, StrokeVariant } from './types.js'; ``` `$lib/components/evilcharts/charts/echarts-line-chart/interactions.ts` ```ts export function resolveEventSeriesKey( params: unknown, seriesKeyByIndex: readonly (string | undefined)[] ): string | null { if (!params || typeof params !== 'object') return null; const event = params as { seriesId?: unknown; seriesIndex?: unknown }; const key = typeof event.seriesId === 'string' ? event.seriesId : typeof event.seriesIndex === 'number' ? seriesKeyByIndex[event.seriesIndex] : undefined; return typeof key === 'string' && !key.startsWith('__') ? key : null; } export function companionSeriesIds( line: { dataKey: string; glowing: boolean; enableBufferLine: boolean }, enableHoverReveal: boolean, dataLength: number ): string[] { if (enableHoverReveal) return [`__reveal-base-${line.dataKey}`]; const ids: string[] = []; if (line.glowing) { for (let index = 0; index < 4; index += 1) ids.push(`__glow-${index}-${line.dataKey}`); } if (line.enableBufferLine && dataLength >= 2) ids.push(`__buffer-${line.dataKey}`); return ids; } export function sliceToIndex(values: readonly T[], index: number): (T | null)[] { return values.map((value, valueIndex) => (valueIndex > index ? null : value)); } export function sliceFromIndex(values: readonly T[], index: number): (T | null)[] { return values.map((value, valueIndex) => (valueIndex < index ? null : value)); } ``` `$lib/components/evilcharts/charts/echarts-line-chart/line-chart-context.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { AxisRegistration, LineRegistration } from './types.js'; const LINE_CHART_CONTEXT = Symbol('evilcharts.echarts-line-chart'); export class EChartsLineChartContext { lines = new RegistrationSet(); xAxes = new RegistrationSet(); yAxes = new RegistrationSet(); grids = new RegistrationSet>(); } export function setEChartsLineChartContext(): EChartsLineChartContext { const context = new EChartsLineChartContext(); setContext(LINE_CHART_CONTEXT, context); return context; } export function useEChartsLineChart(): EChartsLineChartContext { const context = getContext(LINE_CHART_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts line parts must be children of EChartsLineChart.'); } return context; } ``` `$lib/components/evilcharts/charts/echarts-line-chart/line-chart.svelte` ```svelte {#snippet overlay()} {#if legend && !isLoading} {/if} {/snippet} {@render children?.()} {#if !legend?.isClickable} {/if} {#if brush && !isLoading && data.length > 0} brush.formatLabel?.(categoryValues[index] ?? '', index) ?? String(categoryValues[index] ?? index)} onChange={(range) => { const last = Math.max(0, data.length - 1); brushRange = { start: last === 0 ? 0 : (range.startIndex / last) * 100, end: last === 0 ? 100 : (range.endIndex / last) * 100 }; instance?.dispatchAction( { type: 'dataZoom', start: brushRange.start, end: brushRange.end }, { silent: true } ); brush.onChange?.(range); }} /> {/if} ``` `$lib/components/evilcharts/charts/echarts-line-chart/line-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; type DotRegistration = { variant: DotVariant }; export class EChartsLineSlots { dots = new RegistrationSet(); activeDots = new RegistrationSet(); } const LINE_SLOTS_CONTEXT = Symbol('evilcharts.echarts-line-slots'); export function setEChartsLineSlots(): EChartsLineSlots { const context = new EChartsLineSlots(); setContext(LINE_SLOTS_CONTEXT, context); return context; } export function useEChartsLineSlots(): EChartsLineSlots { const context = getContext(LINE_SLOTS_CONTEXT); if (!context) throw new Error('[EvilCharts] ECharts Dot must be nested inside Line.'); return context; } ``` `$lib/components/evilcharts/charts/echarts-line-chart/line.svelte` ```svelte {@render children?.()} ``` `$lib/components/evilcharts/charts/echarts-line-chart/option.ts` ```ts import type { DataZoomComponentOption, GridComponentOption, TooltipComponentOption } from 'echarts/components'; import type { LineSeriesOption } from 'echarts/charts'; import type { ComposeOption } from 'echarts/core'; import * as echarts from 'echarts/core'; import { flattenColor, getColorsCount, seriesPaint, withAlpha, type ChartConfig, type ResolvedColors } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import { dotItemStyle, dotStyle, sampleGradient } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import { tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { buildBrushDataZoom, type BrushRange } from '$lib/components/evilcharts/ui/echarts-brush/index.js'; import { createDitherPattern, type DitherBloom, type DitherVariant, type RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import { BUFFER_DASH, type AxisRegistration, type BrushRegistration, type CurveType, type LegendRegistration, type LineRegistration, type TooltipRegistration } from './types.js'; import { sliceFromIndex, sliceToIndex } from './interactions.js'; export type EChartsLineOption = ComposeOption< LineSeriesOption | GridComponentOption | TooltipComponentOption | DataZoomComponentOption >; type ArrayItem = T extends readonly (infer Item)[] ? Item : T; type XAxisOption = ArrayItem>; type YAxisOption = ArrayItem>; export type LineOptionContext = { data: Record[]; config: ChartConfig; lines: LineRegistration[]; xDataKey?: string; curveType: CurveType; selectedDataKey: string | null; enableHoverHighlight: boolean; enableHoverReveal: boolean; hoverRevealIndex: number | null; xAxis?: AxisRegistration; yAxis?: AxisRegistration; showGrid: boolean; tooltip?: TooltipRegistration; legend?: LegendRegistration; brush?: BrushRegistration; brushRange: BrushRange; isLoading: boolean; loadingData: number[]; resolved: ResolvedColors; animation: boolean; animationType: string; reducedMotion: boolean; renderStyle: RenderStyle; ditherVariant: DitherVariant; ditherCellSize: number; bloom?: DitherBloom; rendererSize?: { width: number; height: number }; getHoveredDataKey?: () => string | null; revealSink?: Record; }; const GLOW_LAYERS = [ { width: 2, opacity: 0.9, blur: 5, symbolPad: 2 }, { width: 2, opacity: 0.6, blur: 12, symbolPad: 6 }, { width: 2, opacity: 0.38, blur: 24, symbolPad: 11 }, { width: 2, opacity: 0.22, blur: 42, symbolPad: 16 } ] as const; function labelFor(config: ChartConfig, key: string): string { const label = config[key]?.label; return typeof label === 'string' ? label : key; } function curveConfig(curveType: CurveType): { smooth: boolean; step: 'middle' | false } { if (curveType === 'step') return { smooth: false, step: 'middle' }; if (curveType === 'linear') return { smooth: false, step: false }; return { smooth: true, step: false }; } function opacityFor(selected: string | null, key: string): number { return selected === null || selected === key ? 1 : 0.3; } function categoryKey(context: LineOptionContext): string | undefined { if (context.xAxis?.dataKey) return context.xAxis.dataKey; if (context.xDataKey) return context.xDataKey; const series = new Set(context.lines.map((line) => line.dataKey)); return Object.keys(context.data[0] ?? {}).find((key) => !series.has(key)); } function categories(context: LineOptionContext): string[] { const key = categoryKey(context); return context.data.map((row, index) => String((key ? row[key] : undefined) ?? index)); } function finiteNumber(value: unknown): number | null { return typeof value === 'number' && Number.isFinite(value) ? value : null; } function seriesValues(context: LineOptionContext, key: string): (number | null)[] { return context.data.map((row) => finiteNumber(row[key])); } function buildAxes(context: LineOptionContext): { xAxis: XAxisOption; yAxis: YAxisOption } { const { mutedForeground, border, background } = context.resolved.tokens; const splitLineColor = withAlpha(border, 1); const dotColor = flattenColor(splitLineColor, background); const xAxis: XAxisOption = { type: 'category', boundaryGap: false, show: true, data: context.isLoading ? context.loadingData.map((_, index) => index) : categories(context), name: context.isLoading ? undefined : context.xAxis?.label, nameLocation: 'middle', nameGap: 30, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !context.isLoading && Boolean(context.xAxis) && !context.xAxis?.hideDots, alignWithLabel: true, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, splitLine: { show: false }, axisLabel: { show: !context.isLoading && Boolean(context.xAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: context.xAxis?.tickFormatter } }; const yAxis: YAxisOption = { type: 'value', show: Boolean(context.yAxis) || context.showGrid, name: context.isLoading ? undefined : context.yAxis?.label, nameLocation: 'middle', nameGap: 38, nameTextStyle: { color: mutedForeground, fontSize: 10 }, axisLine: { show: false }, axisTick: { show: !context.isLoading && Boolean(context.yAxis) && !context.yAxis?.hideDots, length: 0.5, lineStyle: { color: dotColor, width: 3, cap: 'round' } }, axisLabel: { show: !context.isLoading && Boolean(context.yAxis), color: mutedForeground, fontSize: 10, margin: 8, formatter: context.yAxis?.tickFormatter }, splitLine: { show: context.showGrid && !context.isLoading, lineStyle: { color: splitLineColor, type: [3, 3], width: 1 } } }; return { xAxis, yAxis }; } function tooltip(context: LineOptionContext): TooltipComponentOption { const slot = context.tooltip; const base = tooltipBaseOption({ present: Boolean(slot) && !context.isLoading, cursor: slot?.cursor ?? true, position: slot?.position ?? 'variable', axisPointerColor: context.resolved.tokens.border, strokeWidth: 0.8 }); return { ...base, formatter: (rawParams) => { const params = (Array.isArray(rawParams) ? rawParams : [rawParams]) as Array<{ seriesId?: string; seriesName?: string; axisValue?: string | number; axisValueLabel?: string; name?: string; value?: unknown; data?: unknown; }>; const seen = new Set(); const rows = params .map((item) => { const rawId = item.seriesId ?? ''; const key = rawId.startsWith('__buffer-') ? rawId.slice('__buffer-'.length) : rawId.startsWith('__') ? '' : rawId; if (!key) return ''; const value = typeof item.data === 'object' && item.data && 'value' in item.data ? (item.data as { value: unknown }).value : item.value; const numericValue = finiteNumber(value); if (numericValue === null) return ''; if (seen.has(key)) return ''; seen.add(key); const hovered = context.getHoveredDataKey?.() ?? null; return tooltipRow({ indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(context.config[key] ?? {})), labelText: labelFor(context.config, key), valueText: numericValue.toLocaleString(), dimmed: opacityFor(context.selectedDataKey, key) < 1 || (hovered !== null && hovered !== key) ? ' opacity-30' : '' }); }) .join(''); return tooltipShell({ label: String(params[0]?.axisValue ?? params[0]?.name ?? ''), body: rows, roundness: slot?.roundness ?? 'lg', variant: slot?.variant ?? 'default' }); } }; } function lineData( values: (number | null)[], line: LineRegistration, slots: string[], background: string, dotOpacity = 1 ): LineSeriesOption['data'] { if (slots.length <= 1) return values; const restingVariant = line.dotVariant === 'none' ? line.activeDotVariant : line.dotVariant; const activeVariant = line.activeDotVariant === 'none' ? ('default' as const) : line.activeDotVariant; return values.map((value, index) => { if (value === null) return null; const paint = sampleGradient(slots, values.length > 1 ? index / (values.length - 1) : 0); return { value, itemStyle: { ...dotItemStyle(restingVariant, paint, background), opacity: dotOpacity }, emphasis: { itemStyle: { ...dotItemStyle(activeVariant, paint, background), opacity: 1 } } }; }); } function bloomPixels(bloom: DitherBloom | undefined): number { if (bloom === 'aura') return 14; if (bloom === 'high') return 8; if (bloom === 'low') return 4; return 0; } function buildGlowSeries({ line, values, curve, paint, slots, z, opacity, dotSize }: { line: LineRegistration; values: (number | null)[]; curve: { smooth: boolean; step: 'middle' | false }; paint: ReturnType | ReturnType; slots: string[]; z: number; opacity: number; dotSize: number; }): LineSeriesOption[] { const showDots = dotSize > 0; return GLOW_LAYERS.map((layer, index) => { const glowOpacity = layer.opacity * opacity; const data = slots.length <= 1 || !showDots ? values : values.map((value, valueIndex) => { if (value === null) return null; return { value, itemStyle: { color: sampleGradient( slots, values.length > 1 ? valueIndex / (values.length - 1) : 0 ), opacity: glowOpacity } }; }); return { id: `__glow-${index}-${line.dataKey}`, type: 'line', data, smooth: curve.smooth, step: curve.step, connectNulls: line.connectNulls, silent: true, showSymbol: showDots, symbol: 'circle', symbolSize: showDots ? dotSize + layer.symbolPad : 0, tooltip: { show: false }, z, lineStyle: { color: paint, width: layer.width, opacity: glowOpacity, shadowBlur: layer.blur, shadowColor: sampleGradient(slots, 0.5), cap: 'round', join: 'round' }, itemStyle: { color: slots[0], opacity: glowOpacity }, emphasis: { focus: 'none', scale: false, lineStyle: { opacity: glowOpacity }, itemStyle: { opacity: glowOpacity } }, blur: { lineStyle: { opacity: glowOpacity * 0.3 }, itemStyle: { opacity: glowOpacity * 0.3 } } }; }); } function buildSeries(context: LineOptionContext): LineSeriesOption[] { if (context.isLoading) { const curve = curveConfig(context.curveType); return [ { id: '__loading', type: 'line', data: context.loadingData, smooth: curve.smooth, step: curve.step, showSymbol: false, silent: true, lineStyle: { color: withAlpha(context.resolved.tokens.foreground, 0), width: 1 }, z: 1, animation: false, tooltip: { show: false } } ]; } return context.lines.flatMap((line) => { const values = seriesValues(context, line.dataKey); const slots = context.resolved.series[line.dataKey] ?? ['rgba(120, 120, 120, 1)']; const isDither = context.renderStyle === 'dither' && line.strokeVariant !== 'animated-dashed'; const paint = isDither ? createDitherPattern( slots, line.ditherVariant ?? context.ditherVariant, context.ditherCellSize, 1, { height: context.rendererSize?.height } ) : seriesPaint(slots); const dotPaint = seriesPaint(slots); const curve = curveConfig(line.curveType ?? context.curveType); const opacity = opacityFor(context.selectedDataKey, line.dataKey); const dot = dotStyle(line.dotVariant, dotPaint, context.resolved.tokens.background); const activeDot = dotStyle(line.activeDotVariant, dotPaint, context.resolved.tokens.background); const restingVisible = line.dotVariant !== 'none'; const reveal = context.enableHoverReveal; const hasBuffer = !reveal && line.enableBufferLine && values.length >= 2; const revealActive = reveal && context.hoverRevealIndex !== null; const bodyValues = hasBuffer ? values.map((value, index) => (index === values.length - 1 ? null : value)) : values; const fullPoints = lineData( values, line, slots, context.resolved.tokens.background, opacity ) as unknown[]; if (reveal && context.revealSink) context.revealSink[line.dataKey] = fullPoints; const revealedPoints = revealActive ? sliceToIndex(fullPoints, context.hoverRevealIndex as number) : hasBuffer ? lineData(bodyValues, line, slots, context.resolved.tokens.background, opacity) : fullPoints; const hasSelection = context.selectedDataKey !== null; const z = context.selectedDataKey === line.dataKey ? 3 : hasSelection ? 1 : 2; const mainDash = hasBuffer || line.strokeVariant === 'solid' ? 'solid' : ([3, 3] as [number, number]); const rendererWidth = context.rendererSize?.width ?? 0; const strokePaint = reveal && slots.length > 1 && !isDither ? new echarts.graphic.LinearGradient( 8, 0, Math.max(rendererWidth - 8, 9), 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })), true ) : paint; const series: LineSeriesOption[] = []; if (line.glowing && !reveal) { series.push( ...buildGlowSeries({ line, values, curve, paint, slots, z, opacity, dotSize: restingVisible ? dot.size : 0 }) ); } const main: LineSeriesOption = { id: line.dataKey, name: labelFor(context.config, line.dataKey), type: 'line', data: revealedPoints as LineSeriesOption['data'], smooth: curve.smooth, step: curve.step, connectNulls: line.connectNulls, showSymbol: restingVisible, symbol: 'circle', symbolSize: restingVisible ? dot.size : activeDot.size, cursor: line.isClickable ? 'pointer' : 'default', triggerEvent: line.isClickable, silent: false, z, lineStyle: { color: strokePaint, width: isDither ? Math.max(line.strokeWidth, context.ditherCellSize) : line.strokeWidth, type: isDither ? [context.ditherCellSize, context.ditherCellSize] : mainDash, opacity, shadowBlur: isDither ? bloomPixels(context.bloom) : undefined, shadowColor: isDither && bloomPixels(context.bloom) > 0 ? sampleGradient(slots, 0.5) : undefined, cap: isDither && (line.ditherVariant ?? context.ditherVariant) === 'hatched' ? 'butt' : 'round', join: 'round' }, itemStyle: slots.length > 1 ? { opacity } : { ...(restingVisible ? dot.itemStyle : activeDot.itemStyle), opacity }, emphasis: { focus: context.enableHoverHighlight && !context.enableHoverReveal && !hasSelection ? 'series' : 'none', scale: restingVisible ? activeDot.size / Math.max(dot.size, 1) : 1, ...(slots.length > 1 ? {} : { itemStyle: { ...activeDot.itemStyle, opacity: 1 } }) }, blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } } }; series.push(line.lineProps ? { ...main, ...line.lineProps } : main); if (reveal) { series.unshift({ id: `__reveal-base-${line.dataKey}`, type: 'line', data: revealActive ? (sliceFromIndex( fullPoints, context.hoverRevealIndex as number ) as LineSeriesOption['data']) : (fullPoints as LineSeriesOption['data']), smooth: curve.smooth, step: curve.step, connectNulls: false, showSymbol: false, silent: true, z: z - 1, lineStyle: { color: context.resolved.tokens.mutedForeground, width: line.strokeWidth, type: mainDash, opacity: revealActive ? 0.3 : 0 }, emphasis: { disabled: true }, blur: { lineStyle: { opacity: revealActive ? 0.3 : 0 } }, tooltip: { show: false } }); return series; } if (hasBuffer) { const bufferValues = values.map((value, index) => index >= values.length - 2 ? value : null ); series.push({ id: `__buffer-${line.dataKey}`, name: labelFor(context.config, line.dataKey), type: 'line', data: lineData(bufferValues, line, slots, context.resolved.tokens.background, opacity), smooth: curve.smooth, step: curve.step, connectNulls: true, showSymbol: restingVisible, symbol: 'circle', symbolSize: restingVisible ? dot.size : activeDot.size, silent: true, z, lineStyle: { color: paint, width: line.strokeWidth, type: BUFFER_DASH, opacity }, itemStyle: slots.length > 1 ? { opacity } : { ...(restingVisible ? dot.itemStyle : activeDot.itemStyle), opacity }, emphasis: { focus: 'none', scale: false, lineStyle: { opacity }, itemStyle: { opacity } }, blur: { lineStyle: { opacity: 0.3 }, itemStyle: { opacity: 0.3 } } }); } return series; }); } export function buildLineOption(context: LineOptionContext): EChartsLineOption { const legendTop = context.legend?.verticalAlign === 'top'; const legendBottom = context.legend?.verticalAlign === 'bottom'; const brushHeight = context.brush?.height ?? 56; const showBrush = Boolean(context.brush) && !context.isLoading; const brushGap = showBrush ? brushHeight + 30 + (context.xAxis?.label ? 22 : 0) : 0; const mainGrid: GridComponentOption = { left: 8, right: 8, top: legendTop ? 42 : 16, bottom: 8 + brushGap + (legendBottom ? 34 : 0) }; const { xAxis, yAxis } = buildAxes(context); const series = buildSeries(context); const effectiveAnimation = context.lines[0]?.animationType ?? context.animationType; const animation = !context.isLoading && context.animation && effectiveAnimation !== 'none' && !context.reducedMotion; const animationOption = { animation, animationDuration: 1000, animationDurationUpdate: 0 }; if (!showBrush) { return { ...animationOption, aria: { enabled: true }, grid: mainGrid, xAxis, yAxis, tooltip: tooltip(context), series }; } const brushBottom = legendBottom ? 34 : 6; const miniGrid: GridComponentOption = { left: 8, right: 8, bottom: brushBottom, height: brushHeight, outerBoundsMode: 'none' }; const miniSeries = context.lines.map((line) => ({ id: `__mini-${line.dataKey}`, type: 'line' as const, xAxisIndex: 1, yAxisIndex: 1, data: seriesValues(context, line.dataKey), smooth: curveConfig(line.curveType ?? context.curveType).smooth, step: curveConfig(line.curveType ?? context.curveType).step, connectNulls: line.connectNulls, showSymbol: false, silent: true, emphasis: { disabled: true }, tooltip: { show: false }, z: 0, lineStyle: { color: context.resolved.series[line.dataKey]?.[0] ?? 'rgba(120, 120, 120, 1)', width: 1, opacity: opacityFor(context.selectedDataKey, line.dataKey) * 0.5 }, animation: false })); return { ...animationOption, aria: { enabled: true }, grid: [mainGrid, miniGrid], xAxis: [ xAxis, { type: 'category', gridIndex: 1, boundaryGap: false, data: categories(context), show: false, axisPointer: { show: false } } ], yAxis: [yAxis, { type: 'value', gridIndex: 1, show: false }], tooltip: tooltip(context), dataZoom: buildBrushDataZoom({ brushBottom, brushHeight, brushRange: context.brushRange, fillerColor: 'transparent' }), series: [...series, ...miniSeries] }; } export function createLineLoadingData(points: number): number[] { const values: number[] = []; let value = 30 + Math.random() * 20; for (let index = 0; index < points; index += 1) { value = Math.min(58, Math.max(16, value + (Math.random() - 0.5) * 16)); values.push(Math.round(value)); } return values; } ``` `$lib/components/evilcharts/charts/echarts-line-chart/types.ts` ```ts import type { LineSeriesOption } from 'echarts/charts'; import type { DotVariant } from '$lib/components/evilcharts/ui/echarts-dot/index.js'; import type { DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export const STROKE_WIDTH = 0.8; export const LOADING_ANIMATION_DURATION = 2000; export const REVEAL_DURATION = 1000; export const BUFFER_DASH: [number, number] = [4, 3]; export type StrokeVariant = 'solid' | 'dashed' | 'animated-dashed'; export type LineAnimationType = 'none' | 'left-to-right' | 'right-to-left' | 'center-out' | 'edges-in'; export type CurveType = 'linear' | 'smooth' | 'bump' | 'monotone' | 'monotoneX' | 'monotoneY' | 'natural' | 'step'; export type LineRegistration = { dataKey: string; strokeVariant: StrokeVariant; strokeWidth: number; curveType?: CurveType; animationType?: LineAnimationType; connectNulls: boolean; isClickable: boolean; glowing: boolean; enableBufferLine: boolean; dotVariant: DotVariant; activeDotVariant: DotVariant; lineProps?: Partial; ditherVariant?: DitherVariant; }; export type AxisRegistration = { dataKey?: string; tickFormatter?: (value: string | number, index: number) => string; label?: string; hideDots: boolean; }; export type TooltipRegistration = { variant: TooltipVariant; roundness: TooltipRoundness; cursor?: boolean; defaultIndex?: number; position: TooltipPosition; }; export type LegendRegistration = { variant: LegendVariant; align: 'left' | 'center' | 'right'; verticalAlign: 'top' | 'middle' | 'bottom'; isClickable: boolean; }; export type BrushRegistration = { height?: number; formatLabel?: (value: string, index: number) => string; onChange?: (range: { startIndex: number; endIndex: number }) => void; }; ``` `$lib/components/evilcharts/charts/echarts-line-chart/x-axis.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-line-chart/y-axis.svelte` ```svelte ``` ### Add the shared chart module. Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it. ### $lib/components/evilcharts/ui/echarts-chart `$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte` ```svelte
{#if accessibility?.description} {accessibility.description} {/if}
{@render children?.()}
{@render overlay?.()} {@render footer?.()}
``` `$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte` ```svelte {#if css} {css} {/if} ``` `$lib/components/evilcharts/ui/echarts-chart/colors.ts` ```ts import * as echarts from 'echarts/core'; import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js'; const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/; export function chartColorToken(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key; return `u-${Array.from(key, (character) => (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0') ).join('')}`; } export function chartColorVariableName(key: string, index: number): string { return `--color-${chartColorToken(key)}-${index}`; } export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string { const name = chartColorVariableName(key, index); return fallbackIndex === undefined ? `var(${name})` : `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`; } export function quoteCssString(value: string): string { return `"${Array.from(value, (character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === '"' || character === '\\') return `\\${character}`; if (codePoint === 0) return '\uFFFD'; if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `; return character; }).join('')}"`; } export function getColorsCount(item: ChartConfig[string]): number { if (!item.colors) return 1; return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1); } export function distributeColors(colors: string[], maxCount: number): string[] { if (colors.length === 0) return []; if (colors.length >= maxCount) return colors.slice(0, maxCount); const result: string[] = []; const baseSlots = Math.floor(maxCount / colors.length); const extraSlots = maxCount % colors.length; for (let index = 0; index < colors.length; index += 1) { const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0); for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]); } return result; } export function buildChartCss(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter(([, item]) => item.colors); if (colorConfig.length === 0) return ''; const variablesFor = (theme: ThemeKey) => colorConfig .flatMap(([key, item]) => { const authored = item.colors?.[theme]; if (!authored?.length) return []; return distributeColors(authored, getColorsCount(item)).map( (color, index) => ` ${chartColorVariableName(key, index)}: ${color};` ); }) .join('\n'); return Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}` ) .join('\n'); } let normalizerContext: CanvasRenderingContext2D | null = null; export function normalizeColor(value: string): string { const raw = value.trim(); if (!raw || typeof document === 'undefined') return raw; if (!normalizerContext) { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; normalizerContext = canvas.getContext('2d', { willReadFrequently: true }); } if (!normalizerContext) return raw; normalizerContext.clearRect(0, 0, 1, 1); normalizerContext.fillStyle = '#000'; normalizerContext.fillStyle = raw; normalizerContext.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data; return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`; } export function withAlpha(color: string, alpha: number): string { const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim()); const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0; return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`; } export type ResolvedColors = { series: Record; tokens: { mutedForeground: string; border: string; foreground: string; background: string; }; }; export function resolveColors( container: HTMLElement, config: ChartConfig, seriesKeys: string[] ): ResolvedColors { const computed = getComputedStyle(container); const series: Record = {}; for (const key of seriesKeys) { const count = getColorsCount(config[key] ?? {}); series[key] = Array.from({ length: count }, (_, index) => { const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim(); return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)'; }); } const probe = document.createElement('span'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;'; container.appendChild(probe); const readToken = (className: string) => { probe.className = className; return normalizeColor(getComputedStyle(probe).color); }; const tokens = { mutedForeground: readToken('text-muted-foreground'), border: readToken('text-border'), foreground: readToken('text-foreground'), background: readToken('text-background') }; probe.remove(); return { series, tokens }; } export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } export function indicatorBackground(key: string, colorsCount: number): string { if (colorsCount <= 1) return chartColorVariable(key, 0); const stops = Array.from({ length: colorsCount }, (_, index) => { const offset = (index / (colorsCount - 1)) * 100; return `${chartColorVariable(key, index)} ${offset}%`; }).join(', '); return `linear-gradient(to right, ${stops})`; } export function flattenColor(color: string, base: string): string { const parse = (value: string) => value .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1]; const [red, green, blue, alpha = 1] = parse(color); const [baseRed, baseGreen, baseBlue] = parse(base); const mix = (channel: number, baseChannel: number) => Math.round(channel * alpha + baseChannel * (1 - alpha)); return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`; } ``` `$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte` ```svelte
``` `$lib/components/evilcharts/ui/echarts-chart/index.ts` ```ts export { default as ChartContainer } from './chart-container.svelte'; export { default as ChartStyle } from './chart-style.svelte'; export { default as EChartsHost } from './echarts-host.svelte'; export { default as LoadingIndicator } from './loading-indicator.svelte'; export { default as SelectableSeriesControls } from './selectable-series-controls.svelte'; export { mergeLifecycleOptions } from './merge-options.js'; export type { EChartsEventHandler } from './echarts-host.svelte'; export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js'; export { getEChartsSharedSlotContext, setEChartsSharedSlotContext, type EChartsSharedSlotName } from './shared-slots.svelte.js'; export { buildChartCss, chartColorToken, chartColorVariable, chartColorVariableName, distributeColors, flattenColor, getColorsCount, indicatorBackground, normalizeColor, resolveColors, seriesPaint, withAlpha, quoteCssString, type ResolvedColors } from './colors.js'; export { DEFAULT_ECHARTS_RENDERER, ECHARTS_RENDERERS, THEMES, THEME_KEYS, validateChartConfigColors, type AtLeastOneThemeColor, type ChartAccessibility, type ChartConfig, type EChartsRenderer, type EChartsRenderStyle, type ThemeKey } from './types.js'; ``` `$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte` ```svelte {#if isLoading}
Loading
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/merge-options.ts` ```ts type LifecycleOptions = { animation?: unknown; animationDuration?: unknown; animationDurationUpdate?: unknown; }; /** Merge consumer options without letting them break chart-owned animation lifecycles. */ export function mergeLifecycleOptions(built: T, overrides?: object): T { const lifecycle = built as T & LifecycleOptions; return { ...built, ...overrides, animation: lifecycle.animation, animationDuration: lifecycle.animationDuration, animationDurationUpdate: lifecycle.animationDurationUpdate } as T; } ``` `$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts` ```ts import { SvelteMap } from 'svelte/reactivity'; export type RegistrationGetter = () => T; /** Ordered, reactive storage for DOM-free compound-component registrations. */ export class RegistrationSet { #entries = new SvelteMap>(); register(token: string, getter: RegistrationGetter): () => void { this.#entries.set(token, getter); return () => { if (this.#entries.get(token) === getter) this.#entries.delete(token); }; } get values(): T[] { return Array.from(this.#entries.values(), (getter) => getter()); } get first(): T | undefined { return this.#entries.values().next().value?.(); } get size(): number { return this.#entries.size; } } ``` `$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte` ```svelte {#if items.length > 0}
{#each items as item (item.key)} {/each}
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush'; type SharedSlotContext = { register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void; }; const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots'); export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext { setContext(ECHARTS_SHARED_SLOT_CONTEXT, context); return context; } export function getEChartsSharedSlotContext(): SharedSlotContext { const context = getContext(ECHARTS_SHARED_SLOT_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.'); } return context; } ``` `$lib/components/evilcharts/ui/echarts-chart/types.ts` ```ts import type { Component, Snippet } from 'svelte'; export const ECHARTS_RENDERERS = { canvas: 'canvas', svg: 'svg' } as const; export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS]; export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas; export const THEMES = { light: '', dark: '.dark' } as const; export type ThemeKey = keyof typeof THEMES; export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[]; type ThemeColorsBase = { [K in ThemeKey]?: string[]; }; export type AtLeastOneThemeColor = { [K in ThemeKey]: Required> & Partial>; }[ThemeKey]; export type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component>; colors?: AtLeastOneThemeColor; } >; export function validateChartConfigColors(config: ChartConfig): void { for (const [key, item] of Object.entries(config)) { if (!item.colors) continue; if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue; throw new Error( `[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.` ); } } export type ChartAccessibility = | { label: string; labelledBy?: never; description?: string; describedBy?: string; } | { label?: never; labelledBy: string; description?: string; describedBy?: string; }; export type EChartsRenderStyle = 'native' | 'dither'; ``` ### Add the sub-components. Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there. ### $lib/components/evilcharts/ui/echarts-tooltip `$lib/components/evilcharts/ui/echarts-tooltip/index.ts` ```ts export { default as Tooltip } from './tooltip.svelte'; export type { TooltipProps } from './tooltip.svelte'; export { escapeTooltipHtml, resolveTooltipPosition, roundnessClass, tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell, tooltipVariantClass, type TooltipPosition, type TooltipRoundness, type TooltipVariant } from './tooltip.js'; ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts` ```ts import type { TooltipComponentOption } from 'echarts/components'; import { indicatorBackground } from '../echarts-chart/index.js'; export type TooltipVariant = 'default' | 'frosted-glass'; export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl'; export type TooltipPosition = 'fixed' | 'variable'; export const roundnessClass: Record = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }; export const tooltipVariantClass: Record = { default: 'bg-background', 'frosted-glass': 'bg-background/50 backdrop-blur-md' }; export function escapeTooltipHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function tooltipIndicatorHtml(key: string, colorsCount: number): string { return `
`; } export function tooltipRow({ indicatorHtml, labelText, valueText, dimmed }: { indicatorHtml: string; labelText: string; valueText: string; dimmed: string; }): string { return `
${indicatorHtml}
${escapeTooltipHtml(labelText)} ${escapeTooltipHtml(valueText)}
`; } export function tooltipShell({ label, body, roundness, variant }: { label: string; body: string; roundness: TooltipRoundness; variant: TooltipVariant; }): string { return `
${escapeTooltipHtml(label)}
${body}
`; } export function resolveTooltipPosition( position: TooltipPosition ): TooltipComponentOption['position'] { if (position === 'variable') return undefined; return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8]; } export function tooltipBaseOption(params: { present: boolean; cursor: boolean; position: TooltipPosition; axisPointerColor: string; strokeWidth: number; }): TooltipComponentOption { const { present, cursor, position, axisPointerColor, strokeWidth } = params; return { show: present, trigger: 'axis', confine: true, displayTransition: false, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', axisPointer: cursor ? { type: 'line', lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] } } : { type: 'none' }, position: resolveTooltipPosition(position) }; } ``` Next, create `echarts-legend` in the same `ui` folder and paste the legend overlay there. ### $lib/components/evilcharts/ui/echarts-legend `$lib/components/evilcharts/ui/echarts-legend/index.ts` ```ts export { default as Legend } from './legend.svelte'; export { default as LegendIndicator } from './legend-indicator.svelte'; export { default as LegendOverlay } from './legend-overlay.svelte'; export type { LegendProps, LegendVariant } from './legend.svelte'; ``` `$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte` ```svelte {#if variant === 'square'} {:else if variant === 'circle'} {:else if variant === 'circle-outline'} {:else if variant === 'vertical-bar'} {:else if variant === 'horizontal-bar'} {:else if variant === 'rounded-square-outline'} {:else} {/if} ``` `$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte` ```svelte
{#each entries as entry (entry.key)} {#if isClickable} {:else}
{#if typeof entry.item?.label === 'function'} {@render entry.item.label()} {:else} {entry.item?.label ?? entry.key} {/if}
{/if} {/each}
``` `$lib/components/evilcharts/ui/echarts-legend/legend.svelte` ```svelte ``` Next, create `echarts-dot` in the same `ui` folder and paste the dot styles the series markers draw with there. ### $lib/components/evilcharts/ui/echarts-dot `$lib/components/evilcharts/ui/echarts-dot/dot.ts` ```ts import type * as echarts from 'echarts/core'; export type DotVariant = 'none' | 'default' | 'border' | 'colored-border' | 'ping'; export type DotItemStyleOption = { color?: string | echarts.graphic.LinearGradient; borderColor?: string | echarts.graphic.LinearGradient; borderWidth?: number; opacity?: number; }; export type DotStyle = { size: number; itemStyle: DotItemStyleOption }; function colorWithAlpha(color: string, alpha: number): string { if (color.startsWith('#')) { let hex = color.slice(1); if (hex.length === 3) hex = hex.replace(/./g, (character) => character.repeat(2)); const numeric = Number.parseInt(hex, 16); return `rgba(${(numeric >> 16) & 255}, ${(numeric >> 8) & 255}, ${numeric & 255}, ${alpha})`; } const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue] = match[1].split(',').map((part) => Number.parseFloat(part)); return `rgba(${red}, ${green}, ${blue}, ${alpha})`; } export function dotItemStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotItemStyleOption { switch (variant) { case 'border': return { color: paint, borderColor: background, borderWidth: 2 }; case 'colored-border': return { color: background, borderColor: paint, borderWidth: 1 }; case 'ping': return { color: paint, borderColor: typeof paint === 'string' ? colorWithAlpha(paint, 0.28) : paint, borderWidth: 10 }; case 'default': return { color: paint, borderWidth: 0 }; default: return {}; } } export const DOT_SIZES: Record = { none: 0, default: 6, border: 8, 'colored-border': 6, ping: 8 }; export function dotStyle( variant: DotVariant, paint: string | echarts.graphic.LinearGradient, background: string ): DotStyle { return { size: DOT_SIZES[variant], itemStyle: dotItemStyle(variant, paint, background) }; } export function sampleGradient(slots: string[], position: number): string { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; const parse = (color: string) => color .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map(Number) ?? [120, 120, 120, 1]; const scaled = position * (slots.length - 1); const index = Math.min(Math.floor(scaled), slots.length - 2); const fraction = scaled - index; const [redFrom, greenFrom, blueFrom, alphaFrom = 1] = parse(slots[index]); const [redTo, greenTo, blueTo, alphaTo = 1] = parse(slots[index + 1]); const lerp = (from: number, to: number) => from + (to - from) * fraction; return `rgba(${Math.round(lerp(redFrom, redTo))}, ${Math.round(lerp(greenFrom, greenTo))}, ${Math.round(lerp(blueFrom, blueTo))}, ${lerp(alphaFrom, alphaTo).toFixed(3)})`; } ``` `$lib/components/evilcharts/ui/echarts-dot/index.ts` ```ts export { DOT_SIZES, dotItemStyle, dotStyle, sampleGradient, type DotItemStyleOption, type DotStyle, type DotVariant } from './dot.js'; ``` Finally, create `echarts-brush` in the same `ui` folder and paste the zoom brush there. ### $lib/components/evilcharts/ui/echarts-brush `$lib/components/evilcharts/ui/echarts-brush/brush-controls.svelte` ```svelte {#if totalPoints > 0}
handleKey(event, 'range')} > Selected range: {formatLabel(startIndex)} to {formatLabel(endIndex)}
handleKey(event, 'start')} > Range start: {formatLabel(startIndex)}
handleKey(event, 'end')} > Range end: {formatLabel(endIndex)}
{/if} ``` `$lib/components/evilcharts/ui/echarts-brush/brush.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-brush/brush.ts` ```ts import type { DataZoomComponentOption } from 'echarts/components'; import type { EChartsType } from 'echarts/core'; import * as echarts from 'echarts/core'; import { withAlpha, type ResolvedColors } from '../echarts-chart/index.js'; export const BRUSH_BORDER_OPACITY = 1; export type BrushRange = { start: number; end: number }; export type BrushGeometry = { bottom: number; height: number }; export type BrushOverlayParams = { range: BrushRange; geom: BrushGeometry; size: { width: number; height: number }; tokens: ResolvedColors['tokens']; labels: { start: string; end: string } | null; showLabels: boolean; hover: { left: boolean; right: boolean }; }; type ZrRect = InstanceType; type ZrCircle = InstanceType; type ZrText = InstanceType; export type BrushOverlayElements = { dimLeft: ZrRect; dimRight: ZrRect; frame: ZrRect; pillLeft: ZrRect; pillRight: ZrRect; grips: ZrCircle[]; labelStart: ZrText; labelEnd: ZrText; }; export function syncBrushOverlay( chart: EChartsType, store: { brushOverlay: BrushOverlayElements | null }, params: BrushOverlayParams | null ): void { const renderer = chart.getZr(); if (!renderer) return; if (!params) { if (store.brushOverlay) { const { grips, ...rest } = store.brushOverlay; for (const element of [...Object.values(rest), ...grips]) renderer.remove(element); store.brushOverlay = null; } return; } if (!store.brushOverlay) { const rect = (z: number) => new echarts.graphic.Rect({ silent: true, z, shape: {} }); const elements: BrushOverlayElements = { dimLeft: rect(100), dimRight: rect(100), frame: rect(101), pillLeft: rect(102), pillRight: rect(102), grips: Array.from( { length: 6 }, () => new echarts.graphic.Circle({ silent: true, z: 103, shape: {} }) ), labelStart: new echarts.graphic.Text({ silent: true, z: 104 }), labelEnd: new echarts.graphic.Text({ silent: true, z: 104 }) }; const { grips, ...rest } = elements; for (const element of [...Object.values(rest), ...grips]) renderer.add(element); store.brushOverlay = elements; } const elements = store.brushOverlay; const { range, geom, size, tokens, labels, showLabels, hover } = params; const trackLeft = 8; const trackRight = Math.max(size.width - 8, trackLeft); const trackWidth = trackRight - trackLeft; const top = size.height - geom.bottom - geom.height; const centerY = top + geom.height / 2; const selectionLeft = trackLeft + (trackWidth * range.start) / 100; const selectionRight = trackLeft + (trackWidth * range.end) / 100; const dimFill = withAlpha(tokens.background, 0.7); elements.dimLeft.setShape({ x: trackLeft, y: top, width: Math.max(selectionLeft - trackLeft, 0), height: geom.height }); elements.dimLeft.setStyle({ fill: dimFill }); elements.dimRight.setShape({ x: selectionRight, y: top, width: Math.max(trackRight - selectionRight, 0), height: geom.height }); elements.dimRight.setStyle({ fill: dimFill }); elements.frame.setShape({ x: selectionLeft, y: top, width: Math.max(selectionRight - selectionLeft, 0), height: geom.height, r: 6 }); elements.frame.setStyle({ fill: 'none', stroke: withAlpha(tokens.border, BRUSH_BORDER_OPACITY), lineWidth: 1 }); const updatePill = (element: ZrRect, x: number, hovered: boolean) => { element.setShape({ x: x - 3, y: centerY - 8, width: 6, height: 16, r: 3 }); element.setStyle({ fill: hovered ? tokens.foreground : tokens.mutedForeground }); }; updatePill(elements.pillLeft, selectionLeft, hover.left); updatePill(elements.pillRight, selectionRight, hover.right); const gripFill = withAlpha(tokens.background, 0.7); for (const [index, offset] of [-4, 0, 4].entries()) { elements.grips[index].setShape({ cx: selectionLeft, cy: centerY + offset, r: 1 }); elements.grips[index].setStyle({ fill: gripFill }); elements.grips[index + 3].setShape({ cx: selectionRight, cy: centerY + offset, r: 1 }); elements.grips[index + 3].setStyle({ fill: gripFill }); } const updateLabel = (element: ZrText, text: string, x: number, align: 'left' | 'right') => { element.setStyle({ text, x: align === 'left' ? Math.max(x + 6, trackLeft + 2) : Math.min(x - 6, trackRight - 2), y: top + geom.height, align, verticalAlign: 'middle', fill: tokens.background, backgroundColor: tokens.foreground, padding: [2, 5], borderRadius: 4, font: '500 9px system-ui, sans-serif' }); element.attr('invisible', !showLabels || !text); }; updateLabel(elements.labelStart, labels?.start ?? '', selectionLeft, 'left'); updateLabel(elements.labelEnd, labels?.end ?? '', selectionRight, 'right'); } export function buildBrushDataZoom(params: { brushBottom: number; brushHeight: number; brushRange: BrushRange; fillerColor: string; }): DataZoomComponentOption[] { const { brushBottom, brushHeight, brushRange, fillerColor } = params; return [ { type: 'slider', show: true, xAxisIndex: [0], left: 8, right: 8, bottom: brushBottom, height: brushHeight, start: brushRange.start, end: brushRange.end, brushSelect: false, showDetail: false, backgroundColor: 'transparent', borderColor: 'transparent', fillerColor, dataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, selectedDataBackground: { lineStyle: { opacity: 0 }, areaStyle: { opacity: 0 } }, handleIcon: 'path://M -3 -5 L -3 5 A 3 3 0 0 0 3 5 L 3 -5 A 3 3 0 0 0 -3 -5 Z', handleSize: '35%', handleStyle: { opacity: 0 }, moveHandleSize: 0, emphasis: { handleStyle: { opacity: 0 } } }, { type: 'inside', xAxisIndex: [0] } ]; } ``` `$lib/components/evilcharts/ui/echarts-brush/index.ts` ```ts export { default as Brush } from './brush.svelte'; export { default as BrushControls } from './brush-controls.svelte'; export type { BrushProps } from './brush.svelte'; export { BRUSH_BORDER_OPACITY, buildBrushDataZoom, syncBrushOverlay, type BrushGeometry, type BrushOverlayElements, type BrushOverlayParams, type BrushRange } from './brush.js'; ``` ## Usage The ECharts line chart is composable, sharing the LayerChart sibling's API shape. `` is the container, and every part hangs off it as a compound member — ``, ``, ``, ``, ``, and one or more `` — so a single import gives you the whole chart. Each `` carries its own `strokeVariant`, `curveType`, `glowing`, `enableBufferLine`, and `isClickable`, so one chart can mix stroke styles and make only some series interactive. ```svelte ``` ```svelte ``` The difference from the LayerChart sibling is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`. The `config` is the same contract as every EvilCharts chart — each key maps a data key to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from your CSS variables at runtime, so dark mode just works. > The ECharts implementation brings a few small departures from the LayerChart sibling: multi-color gradients tint each dot with the color at its x-position; the glow is layered gradient strokes stacked under the line, following the series' color in place of the LayerChart SVG blur filter; and the zoom brush is a themed mini chart driven by ECharts' native `dataZoom` rather than the custom `EvilBrush`. ### SVG Renderer Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer. ### renderer="svg" ```svelte String(value).substring(0, 3)} /> String(value).substring(0, 3)} /> ``` ### Interactive Selection Add `isClickable` to any `` (and to ``) to make those series selectable, then handle events via the `onSelectionChange` callback on ``: ```svelte { if (selectedDataKey) { console.log('Selected:', selectedDataKey); } else { console.log('Deselected'); } }} > ``` ### Loading State ### isLoading='true' ```svelte String(value).substring(0, 3)} /> ``` > Pass `isLoading` to show an animated skeleton, `loadingPoints` to set how many points it draws, and `curveType` to match the real chart's curve. ### Buffer Line ### enableBufferLine='true' ```svelte String(value).substring(0, 3)} /> ``` > With `enableBufferLine`, each line's last segment renders dashed while the rest stays solid — useful for marking projected, estimated, or incomplete data at the end of a series, as in financial charts and forecasting dashboards. ### Hover Reveal ### enableHoverReveal='true' ```svelte String(value).substring(0, 3)} /> ``` > With `enableHoverReveal`, hovering colors each line only up to the pointer's position and mutes everything past it to a neutral gray, with the active dot riding the cursor — a scrubbing effect for reading a series left-to-right. When not hovering, the chart looks completely normal. ## Examples Examples with different settings. Change `strokeVariant` on a `` or `curveType` on the chart to restyle it. ### Gradient Colors ### gradient colors ```svelte String(value).substring(0, 3)} /> ``` ### gradient colors - bump ```svelte String(value).substring(0, 3)} /> ``` ### Curve Types ### curveType='bump' ```svelte String(value).substring(0, 3)} /> ``` ### curveType='step' ```svelte String(value).substring(0, 3)} /> ``` ### curveType='monotoneY' ```svelte String(value).substring(0, 3)} /> ``` ### Stroke Variants ### strokeVariant='solid' ```svelte String(value).substring(0, 3)} /> ``` ### strokeVariant='dashed' ```svelte String(value).substring(0, 3)} /> ``` ### strokeVariant='animated-dashed' ```svelte String(value).substring(0, 3)} /> ``` ### Glowing Lines ### glowing - gradient colors ```svelte String(value).substring(0, 3)} /> ``` ### glowing - solid colors ```svelte String(value).substring(0, 3)} /> ``` ### Ordered dither Set `renderStyle="dither"` to use the independent ordered-dither treatment inspired by [Dither Kit](https://github.com/Boring-Software-Inc/dither-kit). Axes, tooltips, selection, and the ECharts renderer stay intact. ### renderStyle="dither" ```svelte ``` ## API Reference The chart is composed of several parts; the props below are grouped by part. Regardless of renderer, each part is declarative config the root compiles, but the API mirrors the LayerChart sibling one-to-one. ### EChartsLineChart The root container. It owns the data, shared selection state, loading skeleton, and optional native `dataZoom` brush. Everything visual is composed as its children and compiled into the ECharts option. ### `data` (required) type: `TData[]` The chart data — an array of objects, one per data point (`TData extends Record`). ### `config` (required) type: `ChartConfig` Defines the chart's series — each key matches a data key with a `label` and per-theme `colors` array. Same contract as every EvilCharts chart; see [Chart Config](/docs/chart-config). ### `children` (required) type: `Snippet` The composed chart parts — ``, ``, ``, ``, ``, and one or more ``. ### `class` type: `string` Additional CSS classes for the chart container. ### `renderer` type: `"canvas" | "svg"` · default: `"canvas"` Rendering engine used by ECharts. Use `"svg"` for an SVG-backed chart surface; omit the prop to keep the Canvas default. ### `renderStyle` type: `"native" | "dither"` · default: `"native"` Selects native ECharts paint or EvilCharts' ordered-dither rendering. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` · default: `"gradient"` Default ordered-dither pattern used by the chart's series. ### `ditherCellSize` type: `number` · default: `2` Dither cell size in CSS pixels. ### `bloom` type: `"off" | "low" | "high" | "aura"` · default: `"off"` Optional glow applied to dithered marks. It has no effect in native rendering mode. ### `xDataKey` type: `keyof TData & string` The data key for the x-axis categories. Falls back to the `` value, then to the first data column no `` claims. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` · default: `"linear"` Default curve interpolation inherited by every ``. Each `` may override it locally. ### `animation` type: `boolean` · default: `true` Master switch for the intro draw-in. Pass `false` to render the chart instantly, regardless of `animationType`. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` · default: `"left-to-right"` The intro animation inherited by every ``. Any value but `"none"` plays ECharts' native progressive draw-in (the line traces in and dots pop up as its front passes; direction values exist for API parity with the LayerChart sibling). `"none"` disables it; devices set to OS reduce-motion fall back to `"none"` automatically. ### `enableHoverHighlight` type: `boolean` · default: `false` Highlights the hovered line by dimming the rest — the hover twin of click selection. Dim levels match the selection styling; a glowing line's glow and a buffer line's dashed tail dim and brighten with their parent. ### `enableHoverReveal` type: `boolean` · default: `false` On hover, colors each line up to the pointer's x-position and mutes the rest to a neutral gray, with the active dot at the cursor. A standalone hover mode that takes visual precedence over `enableHoverHighlight`; idle, the chart renders normally. ### `defaultSelectedDataKey` type: `string | null` · default: `null` The series selected on first render. ### `onSelectionChange` type: `(key: string | null) => void` Fires when a series is selected or deselected via a clickable `` or ``. Receives the selected data key, or `null` on deselect. ### `isLoading` type: `boolean` · default: `false` Shows the animated loading skeleton. ### `loadingPoints` type: `number` · default: `14` Number of points in the loading skeleton. ### `chartOptions` type: `Record` Escape hatch merged over the built ECharts option object. See the [ECharts option documentation](https://echarts.apache.org/en/option.html). ### `accessibility` type: `ChartAccessibility` Names and optionally describes the chart wrapper. It remains a group, so interactive legends and marks stay available to assistive technology. ### Line A single line series. Each `` is self-contained — its own stroke, glow, and clickability — so a chart can hold any number of independently styled lines. ### `dataKey` (required) type: `string` The series key. Must exist on both the data rows and the chart `config`. ### `strokeVariant` type: `"solid" | "dashed" | "animated-dashed"` · default: `"solid"` The stroke style for this line. ### `strokeWidth` type: `number` · default: `0.8` Stroke thickness for this line, in pixels. ### `curveType` type: `"linear" | "smooth" | "bump" | "monotone" | "monotoneX" | "monotoneY" | "natural" | "step"` The curve interpolation for this line. Falls back to the chart's `curveType` when omitted. ### `animationType` type: `"none" | "left-to-right" | "right-to-left" | "center-out" | "edges-in"` The intro draw-in for this line (the first ``'s value drives the chart). Falls back to the chart's `animationType` when omitted. ### `connectNulls` type: `boolean` · default: `false` Whether to connect line segments across null or missing values. ### `isClickable` type: `boolean` · default: `false` Lets this line be selected by clicking it. When any line is selected, the rest become semi-transparent. ### `glowing` type: `boolean` · default: `false` Applies a soft outer glow to this line, tinted with its series color. ### `enableBufferLine` type: `boolean` · default: `false` Renders this line's last segment as a dashed buffer while the rest stays solid — useful for projected or incomplete data at the end of a series. ### `ditherVariant` type: `"gradient" | "dotted" | "hatched" | "solid"` Overrides the root ordered-dither pattern for this line only. ### `children` type: `Snippet` Optional `` and `` config that adds point markers to this line. ### Dot and ActiveDot Point markers composed inside a ``. `` is the resting marker; `` is the hovered marker. They render nothing on their own — the parent `` reads their `variant`. ### `variant` type: `"default" | "border" | "colored-border"` · default: `"default"` The visual style of the point marker. ### XAxis and YAxis The category and value axes. Include `` for x-axis labels and `` for the y-axis; omit either to hide it. Both hide automatically while the chart loads. ### `dataKey` type: `string` The data key for the axis values. ### `tickFormatter` type: `(value: string | number, index: number) => string` Formats the axis tick labels. ### `label` type: `string` An axis title rendered clear of the tick labels — centered below the x-axis labels, or rotated alongside the y-axis ones. ### `hideDots` type: `boolean` · default: `false` Hides the small tick dots that sit beside this axis's labels. ### Grid The background grid lines. Include it to render the dashed horizontal split lines; omit it and they don't draw. Takes no props. ### Tooltip The hover tooltip. Include it to enable the tooltip; omit it and none shows. It reads the chart's selection state, dimming unselected series in its content. ### `variant` type: `"default" | "frosted-glass"` · default: `"default"` The visual style of the tooltip surface. ### `roundness` type: `"sm" | "md" | "lg" | "xl"` · default: `"lg"` Controls the border-radius of the tooltip. ### `cursor` type: `boolean` · default: `true` Whether the vertical cursor line follows the pointer on hover. ### `position` type: `"fixed" | "variable"` · default: `"variable"` How the tooltip is anchored. `"variable"` follows both axes (the default). `"fixed"` pins the tooltip near the top of the chart and only tracks the pointer's X. ### Legend The series legend, rendered as HTML above the chart surface. Include it to show the legend; omit it and none shows. With `isClickable`, each entry toggles selection of its series. ### `variant` type: `"square" | "circle" | "circle-outline" | "rounded-square" | "rounded-square-outline" | "vertical-bar" | "horizontal-bar"` The visual style of the legend indicators. ### `align` type: `"left" | "center" | "right"` · default: `"right"` Horizontal placement of the legend. ### `verticalAlign` type: `"top" | "middle" | "bottom"` · default: `"top"` Vertical placement of the legend. ### `isClickable` type: `boolean` · default: `false` Lets each legend entry toggle selection of its series. ### Brush An optional zoom brush below the chart — a themed mini chart driven by ECharts' native `dataZoom`. Include `` to render it; dragging the range filters the main chart. ### `height` type: `number` · default: `56` Height of the brush preview strip in pixels. ### `formatLabel` type: `(value: string | number, index: number) => string` Formats the range-handle labels below the brush. ### `onChange` Fires when the brush selection range changes. --- ## Line Blocks > Ready-made line chart blocks, powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/line-chart/blocks Markdown: https://evilcharts-sv.vercel.app/docs/echarts/line-chart/blocks.md ## Payout Trend ### Payout Trend ```svelte
{#each stats as stat (stat.label)}
{stat.label} {stat.value} {stat.delta} {stat.sub}
{/each}
{#each cities as row, index (row.city)}
0} > {row.city}{row.amount}
{/each}
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/payouts-echarts-line-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/payouts-echarts-line-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/payouts-echarts-line-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/payouts-echarts-line-chart ``` ## Shipments ### Shipments ```svelte
Orders shipped
{total} +4.2% vs last week
{#each legend as item (item.label)} {item.label} {/each}
(String(value).endsWith(' 1') ? String(value).slice(0, 3) : '')} />
``` ### npm ```bash npx shadcn-svelte@latest add @evilcharts/shipments-echarts-line-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/shipments-echarts-line-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/shipments-echarts-line-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/shipments-echarts-line-chart ``` --- ## Pie Chart > Static, beautifully designed pie charts with donut, gradient, and pop-out selection, powered by Apache ECharts Source: https://evilcharts-sv.vercel.app/docs/echarts/pie-chart Markdown: https://evilcharts-sv.vercel.app/docs/echarts/pie-chart.md ### Basic Chart ```svelte ``` ## Installation ### npm ```bash npx shadcn-svelte@latest add @evilcharts/echarts-pie-chart ``` ### yarn ```bash yarn dlx shadcn-svelte@latest add @evilcharts/echarts-pie-chart ``` ### bun ```bash bunx --bun shadcn-svelte@latest add @evilcharts/echarts-pie-chart ``` ### pnpm ```bash pnpm dlx shadcn-svelte@latest add @evilcharts/echarts-pie-chart ``` ### Install the following dependencies: ### npm ```bash npm install echarts ``` ### yarn ```bash yarn add echarts ``` ### bun ```bash bun add echarts ``` ### pnpm ```bash pnpm add echarts ``` ### Copy and paste the following code snippets into your project. In your `components` directory, create an `evilcharts` folder with a `charts` subfolder inside. Paste the code below into a new `echarts-pie-chart` file there. ### $lib/components/evilcharts/charts/echarts-pie-chart `$lib/components/evilcharts/charts/echarts-pie-chart/background-overlay.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-pie-chart/background.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-pie-chart/index.ts` ```ts import Root from './pie-chart.svelte'; import Pie from './pie.svelte'; import Label from './label.svelte'; import Background from './background.svelte'; import Legend from './legend.svelte'; import Tooltip from './tooltip.svelte'; type RootComponent = typeof Root; export const EChartsPieChart: RootComponent & { Pie: typeof Pie; Label: typeof Label; Tooltip: typeof Tooltip; Legend: typeof Legend; Background: typeof Background; } = Object.assign(Root, { Pie, Label, Tooltip, Legend, Background }); export type { ChartAccessibility, ChartConfig, EChartsRenderer } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; export type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; export type { DitherVariant, RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; export type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export type { BackgroundVariant, DitherBloom, LabelPosition, PieVariant } from './types.js'; ``` `$lib/components/evilcharts/charts/echarts-pie-chart/label.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-pie-chart/legend.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-pie-chart/option.ts` ```ts import type { PieSeriesOption } from 'echarts/charts'; import type { TooltipComponentOption } from 'echarts/components'; import type { ComposeOption } from 'echarts/core'; import * as echarts from 'echarts/core'; import { getColorsCount, withAlpha, type ChartConfig, type ResolvedColors } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import { createDitherPattern, type DitherVariant, type RenderStyle } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import { resolveTooltipPosition, roundnessClass, tooltipIndicatorHtml, tooltipRow, tooltipVariantClass } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; import { DEFAULT_CORNER_RADIUS, DEFAULT_END_ANGLE, DEFAULT_INNER_RADIUS, DEFAULT_OUTER_RADIUS, DEFAULT_PADDING_ANGLE, DEFAULT_START_ANGLE, LOADING_SECTORS, REVEAL_DURATION, type DitherBloom, type LegendRegistration, type PieRegistration, type TooltipRegistration } from './types.js'; export type EChartsPieOption = ComposeOption; export type PieOptionContext = { data: Record[]; config: ChartConfig; dataKey: string; nameKey: string; pie?: PieRegistration; selectedSector: string | null; tooltip?: TooltipRegistration; legend?: LegendRegistration; isLoading: boolean; resolved: ResolvedColors; animation: boolean; reducedMotion: boolean; renderStyle?: RenderStyle; ditherVariant?: DitherVariant; ditherCellSize?: number; bloom?: DitherBloom; rendererSize?: { width: number; height: number }; }; const FALLBACK_COLOR = 'rgba(120, 120, 120, 1)'; const OVERLAP_BORDER_WIDTH = 5; const SELECTED_OFFSET = 12; const DIMMED_OPACITY = 0.15; const LOADING_BASE_OPACITY = 0.15; const LOADING_PEAK_OPACITY = 0.5; const LOADING_SHIMMER_BAND = 0.28; const LOADING_SHIMMER_FEATHER = 0.22; function percent(value: string): number | null { const match = /^(-?(?:\d+\.?\d*|\.\d+))%$/.exec(value.trim()); return match ? Number(match[1]) / 100 : null; } function pieDitherBounds(context: PieOptionContext) { const size = context.rendererSize; if (!size || size.width <= 0 || size.height <= 0) return undefined; const outer = context.pie?.outerRadius ?? DEFAULT_OUTER_RADIUS; const radius = typeof outer === 'number' ? outer : (percent(outer) ?? 0.8) * (Math.min(size.width, size.height) / 2); const center = Number.parseFloat(centerY(context.legend)) / 100; return { height: radius * 2, offsetY: size.height * center - radius }; } function rendererDitherPattern( slots: string[], variant: DitherVariant, cellSize: number, context: PieOptionContext ) { return createDitherPattern(slots, variant, cellSize, 1, pieDitherBounds(context)); } function sectorPaint( slots: string[], renderStyle: RenderStyle, ditherVariant: DitherVariant, ditherCellSize: number, context: PieOptionContext ): string | echarts.graphic.LinearGradient | ReturnType { if (renderStyle === 'dither') { return rendererDitherPattern(slots, ditherVariant, ditherCellSize, context); } if (slots.length <= 1) return slots[0] ?? FALLBACK_COLOR; return new echarts.graphic.LinearGradient( 0, 0, 1, 1, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } function bloomPixels(bloom: DitherBloom | undefined): number { if (bloom === 'aura') return 14; if (bloom === 'high') return 8; if (bloom === 'low') return 4; return 0; } function sectorBorder(paddingAngle: number, background: string) { if (paddingAngle < 0) return { borderColor: background, borderWidth: OVERLAP_BORDER_WIDTH }; if (paddingAngle > 0) return { borderColor: background, borderWidth: paddingAngle }; return {}; } function loadingSectorAlpha(position: number, center: number): number { const rawDistance = Math.abs(position - center); const distance = Math.min(rawDistance, 1 - rawDistance); if (distance >= LOADING_SHIMMER_BAND) return LOADING_BASE_OPACITY; if (distance <= LOADING_SHIMMER_BAND - LOADING_SHIMMER_FEATHER) { return LOADING_PEAK_OPACITY; } const progress = 1 - (distance - (LOADING_SHIMMER_BAND - LOADING_SHIMMER_FEATHER)) / LOADING_SHIMMER_FEATHER; const eased = Math.sin((progress * Math.PI) / 2); return LOADING_BASE_OPACITY + (LOADING_PEAK_OPACITY - LOADING_BASE_OPACITY) * eased; } export function createPieLoadingFrame({ center, foreground, background, cornerRadius, paddingAngle }: { center?: number; foreground: string; background: string; cornerRadius: number; paddingAngle: number; }) { return Array.from({ length: LOADING_SECTORS }, (_, index) => ({ name: `__loading-${index}`, value: 1, itemStyle: { color: withAlpha( foreground, center === undefined ? LOADING_BASE_OPACITY : loadingSectorAlpha((index + 0.5) / LOADING_SECTORS, center) ), opacity: 1, borderRadius: cornerRadius, ...sectorBorder(paddingAngle, background) } })); } function centerY(legend?: LegendRegistration): string { if (!legend) return '50%'; if (legend.verticalAlign === 'bottom') return '45%'; if (legend.verticalAlign === 'top') return '55%'; return '50%'; } function tooltipOption(context: PieOptionContext): TooltipComponentOption { const slot = context.tooltip; return { show: Boolean(slot) && !context.isLoading, trigger: 'item', confine: true, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', displayTransition: false, position: resolveTooltipPosition(slot?.position ?? 'variable'), formatter: (params: unknown) => { const item = (Array.isArray(params) ? params[0] : params) as { name?: unknown; value?: unknown; seriesId?: unknown; } | null; if (!item || String(item.seriesId ?? '').startsWith('__')) return ''; const key = String(item.name ?? ''); const configItem = context.config[key]; const label = typeof configItem?.label === 'string' ? configItem.label : key; const value = typeof item.value === 'number' ? item.value.toLocaleString() : String(item.value ?? ''); const row = tooltipRow({ indicatorHtml: tooltipIndicatorHtml(key, getColorsCount(configItem ?? {})), labelText: label, valueText: value, dimmed: context.selectedSector !== null && context.selectedSector !== key ? ' opacity-30' : '' }); return `
${row}
`; } }; } function realSeries(context: PieOptionContext): PieSeriesOption[] { const pie = context.pie; if (!pie) return []; const selected = context.selectedSector; const outside = pie.labelPosition === 'outside'; const explicitLabelKey = pie.labelDataKey || null; const shadowBlur = context.renderStyle === 'dither' ? bloomPixels(context.bloom) : 0; const data = context.data.map((row) => { const name = String(row[context.nameKey]); const isSelected = pie.isClickable && selected === name; const dimmed = pie.isClickable && selected !== null && !isSelected; return { name, value: Number(row[context.dataKey]) || 0, selected: isSelected, itemStyle: { color: sectorPaint( context.resolved.series[name] ?? [FALLBACK_COLOR], context.renderStyle ?? 'native', pie.ditherVariant ?? context.ditherVariant ?? 'gradient', context.ditherCellSize ?? 2, context ), opacity: dimmed ? DIMMED_OPACITY : 1, borderRadius: pie.cornerRadius, shadowBlur, shadowColor: shadowBlur > 0 ? (context.resolved.series[name]?.[0] ?? context.resolved.tokens.foreground) : undefined, ...sectorBorder(pie.paddingAngle, context.resolved.tokens.background) } }; }); return [ { id: 'pie', type: 'pie', center: ['50%', centerY(context.legend)], radius: [pie.innerRadius, pie.outerRadius], startAngle: pie.startAngle, endAngle: pie.endAngle, clockwise: false, padAngle: Math.min(pie.paddingAngle, 0), cursor: pie.isClickable ? 'pointer' : 'default', emphasis: { scale: false }, selectedMode: pie.isClickable ? 'single' : false, selectedOffset: SELECTED_OFFSET, select: { itemStyle: {} }, label: { show: pie.labelDataKey !== null, position: outside ? 'outside' : 'inner', color: outside ? context.resolved.tokens.mutedForeground : context.resolved.tokens.background, fontSize: 12, fontWeight: 500, formatter: (params: { dataIndex: number; name?: string; value?: unknown }) => { const row = context.data[params.dataIndex]; if (explicitLabelKey) return String(row?.[explicitLabelKey] ?? ''); if (outside) { const label = context.config[String(params.name ?? '')]?.label; return typeof label === 'string' ? label : String(params.name ?? ''); } return String(row?.[context.dataKey] ?? params.value ?? ''); } }, labelLine: outside ? { show: true, length: 14, length2: 14, smooth: false, lineStyle: { color: withAlpha(context.resolved.tokens.mutedForeground, 0.45), width: 1 } } : { show: false }, data, animation: context.animation && !context.reducedMotion, animationType: 'expansion', animationDuration: REVEAL_DURATION, animationDurationUpdate: 0 } ]; } function loadingSeries(context: PieOptionContext): PieSeriesOption[] { const pie = context.pie; const innerRadius = pie?.innerRadius ?? DEFAULT_INNER_RADIUS; const outerRadius = pie?.outerRadius ?? DEFAULT_OUTER_RADIUS; const cornerRadius = pie?.cornerRadius ?? DEFAULT_CORNER_RADIUS; const paddingAngle = pie?.paddingAngle ?? DEFAULT_PADDING_ANGLE; const startAngle = pie?.startAngle ?? DEFAULT_START_ANGLE; const endAngle = pie?.endAngle ?? DEFAULT_END_ANGLE; return [ { id: '__loading', type: 'pie', center: ['50%', centerY(context.legend)], radius: [innerRadius, outerRadius], startAngle, endAngle, clockwise: false, padAngle: Math.min(paddingAngle, 0), silent: true, emphasis: { scale: false }, label: { show: false }, labelLine: { show: false }, animation: false, data: createPieLoadingFrame({ foreground: context.resolved.tokens.foreground, background: context.resolved.tokens.background, cornerRadius, paddingAngle }) } ]; } export function buildPieOption(context: PieOptionContext): EChartsPieOption { if (context.isLoading) { return { animation: false, aria: { enabled: true }, tooltip: { show: false }, series: loadingSeries(context) }; } return { animation: false, aria: { enabled: true }, tooltip: tooltipOption(context), series: realSeries(context) }; } ``` `$lib/components/evilcharts/charts/echarts-pie-chart/pie-chart-context.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { BackgroundRegistration, PieRegistration } from './types.js'; const PIE_CHART_CONTEXT = Symbol('evilcharts.echarts-pie-chart'); export class EChartsPieChartContext { pies = new RegistrationSet(); backgrounds = new RegistrationSet(); } export function setEChartsPieChartContext(): EChartsPieChartContext { const context = new EChartsPieChartContext(); setContext(PIE_CHART_CONTEXT, context); return context; } export function useEChartsPieChart(): EChartsPieChartContext { const context = getContext(PIE_CHART_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts pie parts must be children of EChartsPieChart.'); } return context; } ``` `$lib/components/evilcharts/charts/echarts-pie-chart/pie-chart.svelte` ```svelte {#snippet overlay()} {#if legend && !isLoading} {/if} {/snippet} {@render children?.()} {#if background && !isLoading}{/if} {#if pie} {/if} {#if !legend?.isClickable} {/if} ``` `$lib/components/evilcharts/charts/echarts-pie-chart/pie-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; import { RegistrationSet } from '$lib/components/evilcharts/ui/echarts-chart/index.js'; import type { LabelRegistration } from './types.js'; const PIE_SLOTS_CONTEXT = Symbol('evilcharts.echarts-pie-slots'); export class EChartsPieSlots { labels = new RegistrationSet(); } export function setEChartsPieSlots(): EChartsPieSlots { const context = new EChartsPieSlots(); setContext(PIE_SLOTS_CONTEXT, context); return context; } export function useEChartsPieSlots(): EChartsPieSlots { const context = getContext(PIE_SLOTS_CONTEXT); if (!context) throw new Error('[EvilCharts] ECharts Label must be nested inside Pie.'); return context; } ``` `$lib/components/evilcharts/charts/echarts-pie-chart/pie.svelte` ```svelte {@render children?.()} ``` `$lib/components/evilcharts/charts/echarts-pie-chart/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/charts/echarts-pie-chart/types.ts` ```ts import type { LegendVariant } from '$lib/components/evilcharts/ui/echarts-legend/index.js'; import type { DitherBloom, DitherVariant } from '$lib/components/evilcharts/ui/echarts-dither/index.js'; import type { TooltipPosition, TooltipRoundness, TooltipVariant } from '$lib/components/evilcharts/ui/echarts-tooltip/index.js'; export const REVEAL_DURATION = 1000; export const LOADING_ANIMATION_DURATION = 2000; export const LOADING_SECTORS = 5; export const DEFAULT_INNER_RADIUS: number | string = 0; export const DEFAULT_OUTER_RADIUS: number | string = '80%'; export const DEFAULT_CORNER_RADIUS = 0; export const DEFAULT_PADDING_ANGLE = 0; export const DEFAULT_START_ANGLE = 0; export const DEFAULT_END_ANGLE = 360; export type PieVariant = 'gradient'; export type LabelPosition = 'inside' | 'outside'; export type { DitherBloom }; export type BackgroundVariant = | 'dots' | 'grid' | 'cross-hatch' | 'diagonal-lines' | 'plus' | 'falling-triangles' | '4-pointed-star' | 'tiny-checkers' | 'overlapping-circles' | 'wiggle-lines' | 'bubbles'; export type LabelRegistration = { dataKey?: string; position: LabelPosition; }; export type PieRegistration = { variant: PieVariant; innerRadius: number | string; outerRadius: number | string; cornerRadius: number; paddingAngle: number; startAngle: number; endAngle: number; isClickable: boolean; ditherVariant?: DitherVariant; labelDataKey: string | null; labelPosition: LabelPosition; }; export type BackgroundRegistration = { variant: BackgroundVariant }; export type TooltipRegistration = { variant: TooltipVariant; roundness: TooltipRoundness; defaultIndex?: number; position: TooltipPosition; }; export type LegendRegistration = { variant: LegendVariant; align: 'left' | 'center' | 'right'; verticalAlign: 'top' | 'middle' | 'bottom'; isClickable: boolean; }; ``` ### Add the shared chart module. Create a `ui` folder inside `evilcharts` and paste this one in first — it resolves your config's colors from the page's CSS variables, and every sub-component below imports from it. ### $lib/components/evilcharts/ui/echarts-chart `$lib/components/evilcharts/ui/echarts-chart/chart-container.svelte` ```svelte
{#if accessibility?.description} {accessibility.description} {/if}
{@render children?.()}
{@render overlay?.()} {@render footer?.()}
``` `$lib/components/evilcharts/ui/echarts-chart/chart-style.svelte` ```svelte {#if css} {css} {/if} ``` `$lib/components/evilcharts/ui/echarts-chart/colors.ts` ```ts import * as echarts from 'echarts/core'; import { THEMES, THEME_KEYS, type ChartConfig, type ThemeKey } from './types.js'; const ENCODED_TOKEN = /^u-(?:[0-9a-f]{6})+$/; export function chartColorToken(key: string): string { if (/^[A-Za-z0-9_-]+$/.test(key) && !ENCODED_TOKEN.test(key)) return key; return `u-${Array.from(key, (character) => (character.codePointAt(0) ?? 0).toString(16).padStart(6, '0') ).join('')}`; } export function chartColorVariableName(key: string, index: number): string { return `--color-${chartColorToken(key)}-${index}`; } export function chartColorVariable(key: string, index: number, fallbackIndex?: number): string { const name = chartColorVariableName(key, index); return fallbackIndex === undefined ? `var(${name})` : `var(${name}, var(${chartColorVariableName(key, fallbackIndex)}))`; } export function quoteCssString(value: string): string { return `"${Array.from(value, (character) => { const codePoint = character.codePointAt(0) ?? 0; if (character === '"' || character === '\\') return `\\${character}`; if (codePoint === 0) return '\uFFFD'; if (codePoint < 0x20 || codePoint === 0x7f) return `\\${codePoint.toString(16)} `; return character; }).join('')}"`; } export function getColorsCount(item: ChartConfig[string]): number { if (!item.colors) return 1; return Math.max(...THEME_KEYS.map((theme) => item.colors?.[theme]?.length ?? 0), 1); } export function distributeColors(colors: string[], maxCount: number): string[] { if (colors.length === 0) return []; if (colors.length >= maxCount) return colors.slice(0, maxCount); const result: string[] = []; const baseSlots = Math.floor(maxCount / colors.length); const extraSlots = maxCount % colors.length; for (let index = 0; index < colors.length; index += 1) { const slots = baseSlots + (index >= colors.length - extraSlots ? 1 : 0); for (let slot = 0; slot < slots; slot += 1) result.push(colors[index]); } return result; } export function buildChartCss(id: string, config: ChartConfig): string { const colorConfig = Object.entries(config).filter(([, item]) => item.colors); if (colorConfig.length === 0) return ''; const variablesFor = (theme: ThemeKey) => colorConfig .flatMap(([key, item]) => { const authored = item.colors?.[theme]; if (!authored?.length) return []; return distributeColors(authored, getColorsCount(item)).map( (color, index) => ` ${chartColorVariableName(key, index)}: ${color};` ); }) .join('\n'); return Object.entries(THEMES) .map( ([theme, prefix]) => `${prefix} [data-chart=${quoteCssString(id)}] {\n${variablesFor(theme as ThemeKey)}\n}` ) .join('\n'); } let normalizerContext: CanvasRenderingContext2D | null = null; export function normalizeColor(value: string): string { const raw = value.trim(); if (!raw || typeof document === 'undefined') return raw; if (!normalizerContext) { const canvas = document.createElement('canvas'); canvas.width = 1; canvas.height = 1; normalizerContext = canvas.getContext('2d', { willReadFrequently: true }); } if (!normalizerContext) return raw; normalizerContext.clearRect(0, 0, 1, 1); normalizerContext.fillStyle = '#000'; normalizerContext.fillStyle = raw; normalizerContext.fillRect(0, 0, 1, 1); const [red, green, blue, alpha] = normalizerContext.getImageData(0, 0, 1, 1).data; return `rgba(${red}, ${green}, ${blue}, ${(alpha / 255).toFixed(3)})`; } export function withAlpha(color: string, alpha: number): string { const match = color.match(/rgba?\(([^)]+)\)/); if (!match) return color; const [red, green, blue, sourceAlpha] = match[1].split(',').map((part) => part.trim()); const baseAlpha = sourceAlpha === undefined ? 1 : Number.parseFloat(sourceAlpha) || 0; return `rgba(${red}, ${green}, ${blue}, ${(baseAlpha * alpha).toFixed(3)})`; } export type ResolvedColors = { series: Record; tokens: { mutedForeground: string; border: string; foreground: string; background: string; }; }; export function resolveColors( container: HTMLElement, config: ChartConfig, seriesKeys: string[] ): ResolvedColors { const computed = getComputedStyle(container); const series: Record = {}; for (const key of seriesKeys) { const count = getColorsCount(config[key] ?? {}); series[key] = Array.from({ length: count }, (_, index) => { const raw = computed.getPropertyValue(chartColorVariableName(key, index)).trim(); return raw ? normalizeColor(raw) : 'rgba(120, 120, 120, 1)'; }); } const probe = document.createElement('span'); probe.style.cssText = 'position:absolute;width:0;height:0;visibility:hidden;pointer-events:none;'; container.appendChild(probe); const readToken = (className: string) => { probe.className = className; return normalizeColor(getComputedStyle(probe).color); }; const tokens = { mutedForeground: readToken('text-muted-foreground'), border: readToken('text-border'), foreground: readToken('text-foreground'), background: readToken('text-background') }; probe.remove(); return { series, tokens }; } export function seriesPaint(slots: string[]): string | echarts.graphic.LinearGradient { if (slots.length <= 1) return slots[0] ?? 'rgba(120, 120, 120, 1)'; return new echarts.graphic.LinearGradient( 0, 0, 1, 0, slots.map((color, index) => ({ offset: index / (slots.length - 1), color })) ); } export function indicatorBackground(key: string, colorsCount: number): string { if (colorsCount <= 1) return chartColorVariable(key, 0); const stops = Array.from({ length: colorsCount }, (_, index) => { const offset = (index / (colorsCount - 1)) * 100; return `${chartColorVariable(key, index)} ${offset}%`; }).join(', '); return `linear-gradient(to right, ${stops})`; } export function flattenColor(color: string, base: string): string { const parse = (value: string) => value .match(/rgba?\(([^)]+)\)/)?.[1] .split(',') .map((part) => Number.parseFloat(part)) ?? [0, 0, 0, 1]; const [red, green, blue, alpha = 1] = parse(color); const [baseRed, baseGreen, baseBlue] = parse(base); const mix = (channel: number, baseChannel: number) => Math.round(channel * alpha + baseChannel * (1 - alpha)); return `rgb(${mix(red, baseRed)}, ${mix(green, baseGreen)}, ${mix(blue, baseBlue)})`; } ``` `$lib/components/evilcharts/ui/echarts-chart/echarts-host.svelte` ```svelte
``` `$lib/components/evilcharts/ui/echarts-chart/index.ts` ```ts export { default as ChartContainer } from './chart-container.svelte'; export { default as ChartStyle } from './chart-style.svelte'; export { default as EChartsHost } from './echarts-host.svelte'; export { default as LoadingIndicator } from './loading-indicator.svelte'; export { default as SelectableSeriesControls } from './selectable-series-controls.svelte'; export { mergeLifecycleOptions } from './merge-options.js'; export type { EChartsEventHandler } from './echarts-host.svelte'; export { RegistrationSet, type RegistrationGetter } from './registrations.svelte.js'; export { getEChartsSharedSlotContext, setEChartsSharedSlotContext, type EChartsSharedSlotName } from './shared-slots.svelte.js'; export { buildChartCss, chartColorToken, chartColorVariable, chartColorVariableName, distributeColors, flattenColor, getColorsCount, indicatorBackground, normalizeColor, resolveColors, seriesPaint, withAlpha, quoteCssString, type ResolvedColors } from './colors.js'; export { DEFAULT_ECHARTS_RENDERER, ECHARTS_RENDERERS, THEMES, THEME_KEYS, validateChartConfigColors, type AtLeastOneThemeColor, type ChartAccessibility, type ChartConfig, type EChartsRenderer, type EChartsRenderStyle, type ThemeKey } from './types.js'; ``` `$lib/components/evilcharts/ui/echarts-chart/loading-indicator.svelte` ```svelte {#if isLoading}
Loading
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/merge-options.ts` ```ts type LifecycleOptions = { animation?: unknown; animationDuration?: unknown; animationDurationUpdate?: unknown; }; /** Merge consumer options without letting them break chart-owned animation lifecycles. */ export function mergeLifecycleOptions(built: T, overrides?: object): T { const lifecycle = built as T & LifecycleOptions; return { ...built, ...overrides, animation: lifecycle.animation, animationDuration: lifecycle.animationDuration, animationDurationUpdate: lifecycle.animationDurationUpdate } as T; } ``` `$lib/components/evilcharts/ui/echarts-chart/registrations.svelte.ts` ```ts import { SvelteMap } from 'svelte/reactivity'; export type RegistrationGetter = () => T; /** Ordered, reactive storage for DOM-free compound-component registrations. */ export class RegistrationSet { #entries = new SvelteMap>(); register(token: string, getter: RegistrationGetter): () => void { this.#entries.set(token, getter); return () => { if (this.#entries.get(token) === getter) this.#entries.delete(token); }; } get values(): T[] { return Array.from(this.#entries.values(), (getter) => getter()); } get first(): T | undefined { return this.#entries.values().next().value?.(); } get size(): number { return this.#entries.size; } } ``` `$lib/components/evilcharts/ui/echarts-chart/selectable-series-controls.svelte` ```svelte {#if items.length > 0}
{#each items as item (item.key)} {/each}
{/if} ``` `$lib/components/evilcharts/ui/echarts-chart/shared-slots.svelte.ts` ```ts import { getContext, setContext } from 'svelte'; export type EChartsSharedSlotName = 'tooltip' | 'legend' | 'brush'; type SharedSlotContext = { register: (slot: EChartsSharedSlotName, token: string, getter: () => unknown) => () => void; }; const ECHARTS_SHARED_SLOT_CONTEXT = Symbol('evilcharts-echarts-shared-slots'); export function setEChartsSharedSlotContext(context: SharedSlotContext): SharedSlotContext { setContext(ECHARTS_SHARED_SLOT_CONTEXT, context); return context; } export function getEChartsSharedSlotContext(): SharedSlotContext { const context = getContext(ECHARTS_SHARED_SLOT_CONTEXT); if (!context) { throw new Error('[EvilCharts] ECharts compound parts must be children of an ECharts chart.'); } return context; } ``` `$lib/components/evilcharts/ui/echarts-chart/types.ts` ```ts import type { Component, Snippet } from 'svelte'; export const ECHARTS_RENDERERS = { canvas: 'canvas', svg: 'svg' } as const; export type EChartsRenderer = (typeof ECHARTS_RENDERERS)[keyof typeof ECHARTS_RENDERERS]; export const DEFAULT_ECHARTS_RENDERER = ECHARTS_RENDERERS.canvas; export const THEMES = { light: '', dark: '.dark' } as const; export type ThemeKey = keyof typeof THEMES; export const THEME_KEYS = Object.keys(THEMES) as ThemeKey[]; type ThemeColorsBase = { [K in ThemeKey]?: string[]; }; export type AtLeastOneThemeColor = { [K in ThemeKey]: Required> & Partial>; }[ThemeKey]; export type ChartConfig = Record< string, { label?: string | Snippet; icon?: Component>; colors?: AtLeastOneThemeColor; } >; export function validateChartConfigColors(config: ChartConfig): void { for (const [key, item] of Object.entries(config)) { if (!item.colors) continue; if (THEME_KEYS.some((theme) => item.colors?.[theme] !== undefined)) continue; throw new Error( `[EvilCharts] Invalid chart config for "${key}": colors must define light or dark.` ); } } export type ChartAccessibility = | { label: string; labelledBy?: never; description?: string; describedBy?: string; } | { label?: never; labelledBy: string; description?: string; describedBy?: string; }; export type EChartsRenderStyle = 'native' | 'dither'; ``` ### Add the sub-components. Create `echarts-tooltip` in the same `ui` folder and paste the tooltip surface and its variants there. ### $lib/components/evilcharts/ui/echarts-tooltip `$lib/components/evilcharts/ui/echarts-tooltip/index.ts` ```ts export { default as Tooltip } from './tooltip.svelte'; export type { TooltipProps } from './tooltip.svelte'; export { escapeTooltipHtml, resolveTooltipPosition, roundnessClass, tooltipBaseOption, tooltipIndicatorHtml, tooltipRow, tooltipShell, tooltipVariantClass, type TooltipPosition, type TooltipRoundness, type TooltipVariant } from './tooltip.js'; ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.svelte` ```svelte ``` `$lib/components/evilcharts/ui/echarts-tooltip/tooltip.ts` ```ts import type { TooltipComponentOption } from 'echarts/components'; import { indicatorBackground } from '../echarts-chart/index.js'; export type TooltipVariant = 'default' | 'frosted-glass'; export type TooltipRoundness = 'sm' | 'md' | 'lg' | 'xl'; export type TooltipPosition = 'fixed' | 'variable'; export const roundnessClass: Record = { sm: 'rounded-sm', md: 'rounded-md', lg: 'rounded-lg', xl: 'rounded-xl' }; export const tooltipVariantClass: Record = { default: 'bg-background', 'frosted-glass': 'bg-background/50 backdrop-blur-md' }; export function escapeTooltipHtml(value: unknown): string { return String(value ?? '') .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } export function tooltipIndicatorHtml(key: string, colorsCount: number): string { return `
`; } export function tooltipRow({ indicatorHtml, labelText, valueText, dimmed }: { indicatorHtml: string; labelText: string; valueText: string; dimmed: string; }): string { return `
${indicatorHtml}
${escapeTooltipHtml(labelText)} ${escapeTooltipHtml(valueText)}
`; } export function tooltipShell({ label, body, roundness, variant }: { label: string; body: string; roundness: TooltipRoundness; variant: TooltipVariant; }): string { return `
${escapeTooltipHtml(label)}
${body}
`; } export function resolveTooltipPosition( position: TooltipPosition ): TooltipComponentOption['position'] { if (position === 'variable') return undefined; return (point, _params, _dom, _rect, size) => [point[0] - size.contentSize[0] / 2, 8]; } export function tooltipBaseOption(params: { present: boolean; cursor: boolean; position: TooltipPosition; axisPointerColor: string; strokeWidth: number; }): TooltipComponentOption { const { present, cursor, position, axisPointerColor, strokeWidth } = params; return { show: present, trigger: 'axis', confine: true, displayTransition: false, backgroundColor: 'transparent', borderWidth: 0, padding: 0, extraCssText: 'box-shadow:none;', axisPointer: cursor ? { type: 'line', lineStyle: { color: axisPointerColor, width: strokeWidth, type: [3, 3] } } : { type: 'none' }, position: resolveTooltipPosition(position) }; } ``` Finally, create `echarts-legend` in the same `ui` folder and paste the legend overlay there. ### $lib/components/evilcharts/ui/echarts-legend `$lib/components/evilcharts/ui/echarts-legend/index.ts` ```ts export { default as Legend } from './legend.svelte'; export { default as LegendIndicator } from './legend-indicator.svelte'; export { default as LegendOverlay } from './legend-overlay.svelte'; export type { LegendProps, LegendVariant } from './legend.svelte'; ``` `$lib/components/evilcharts/ui/echarts-legend/legend-indicator.svelte` ```svelte {#if variant === 'square'} {:else if variant === 'circle'} {:else if variant === 'circle-outline'} {:else if variant === 'vertical-bar'} {:else if variant === 'horizontal-bar'} {:else if variant === 'rounded-square-outline'} {:else} {/if} ``` `$lib/components/evilcharts/ui/echarts-legend/legend-overlay.svelte` ```svelte
{#each entries as entry (entry.key)} {#if isClickable} {:else}
{#if typeof entry.item?.label === 'function'} {@render entry.item.label()} {:else} {entry.item?.label ?? entry.key} {/if}
{/if} {/each}
``` `$lib/components/evilcharts/ui/echarts-legend/legend.svelte` ```svelte ``` ## Usage The ECharts pie chart is composable, sharing the LayerChart sibling's API shape. `` is the container, and every part hangs off it as a compound member — ``, ``, ``, and one `` — so a single import gives you the whole chart. The `` carries its own shape props (`innerRadius`, `paddingAngle`, `cornerRadius`, …) and an `isClickable` flag. ```svelte ``` ```svelte const data = [ { browser: "chrome", visitors: 275 }, { browser: "safari", visitors: 200 }, { browser: "firefox", visitors: 187 }, ]; const chartConfig = { chrome: { label: "Chrome", colors: { light: ["#3b82f6"], dark: ["#60a5fa"] }, }, safari: { label: "Safari", colors: { light: ["#10b981"], dark: ["#34d399"] }, }, firefox: { label: "Firefox", colors: { light: ["#f59e0b"], dark: ["#fbbf24"] }, }, } satisfies ChartConfig; ``` ```svelte ``` The difference is under the hood: these compound children are declarative configuration slots rather than visual DOM nodes. The root reads their props and compiles an ECharts option, which ECharts paints with Canvas by default or SVG when `renderer="svg"`. The `config` is the same contract as every EvilCharts chart — each key maps a sector name to a `label` and a per-theme `colors` array. See [Chart Config](/docs/chart-config) for the full shape. Colors resolve from your CSS variables at runtime, so dark mode works with no extra wiring. > The ECharts implementation brings a few small departures from the LayerChart sibling: per-sector gradients paint across each sector's own bounding box, sector gaps are constant-width background borders (parallel-edged from rim to center, not a wedge-shaped angular pad), and the <Background> pattern is a separate SVG layer behind the transparent ECharts surface. ### SVG Renderer Pass `renderer="svg"` to the chart root to opt into ECharts' SVG renderer. Omit it to use the default Canvas renderer. ### renderer="svg" ```svelte ``` ### Interactive Selection Add `isClickable` to the `` (and ``) to make sectors selectable. Selecting one pops it radially outward — the offset-slice look — while the others dim; select again to reset. Handle selection events with the `onSelectionChange` callback on ``: ```svelte { if (selection) { console.log('Selected:', selection.dataKey, 'Value:', selection.value); } else { console.log('Deselected'); } }} > ``` ### Loading State ### isLoading='true' ```svelte ``` > Pass the `isLoading` prop to show an animated skeleton ring — a shimmer sweeps around the sectors while your data loads. ## Examples Examples of the pie chart in different configurations. Customize `innerRadius`, `paddingAngle`, `cornerRadius`, and more. ### Gradient Colors ### gradient colors ```svelte ``` ### Donut Chart ### innerRadius={60} ```svelte ``` > Set `innerRadius` above 0 to create a donut chart — the inner radius carves the hole in the center. ### Padded Sectors ### paddingAngle={4} cornerRadius={8} ```svelte ``` > Use `paddingAngle` to space sectors apart and `cornerRadius` to round their corners. Combine with `innerRadius` for a modern donut look. ### innerRadius={60} paddingAngle={-25} cornerRadius={99} ```svelte ``` > Pair a negative `paddingAngle` with a high `cornerRadius` for overlapping, petal-like sectors. A background-colored border separates the petals into a flower-shaped donut. ### Labels ###