diff --git a/.changeset/per-layer-primitive-variants.md b/.changeset/per-layer-primitive-variants.md new file mode 100644 index 000000000..efb828b08 --- /dev/null +++ b/.changeset/per-layer-primitive-variants.md @@ -0,0 +1,97 @@ +--- +'layerchart': minor +--- + +feat: Per-layer variants for primitives, compound marks, and high-level charts (`layerchart/svg`, `layerchart/canvas`, `layerchart/html`) + +Layer-agnostic components auto-detect the surrounding ``, ``, or `` layer and bundle every render path. The new sub-path exports expose layer-specific variants so consumers committed to a single rendering layer can opt into a smaller bundle. + +```ts +// Default: agnostic, dispatches at runtime — works in any layer +import { Rect, Circle, Text, Path, LineChart } from 'layerchart'; + +// SVG-only — skips canvas + html branches +import { Rect, Circle, Text, Path, LineChart } from 'layerchart/svg'; + +// Canvas-only +import { Rect, Circle, Text, LineChart } from 'layerchart/canvas'; + +// HTML-only — drops canvas + svg overhead (some primitives are ~95% smaller) +import { Rect, Circle, Text, Pattern, LinearGradient } from 'layerchart/html'; +``` + +Each agnostic component (e.g. `Rect.svelte`) now dispatches to the corresponding per-layer variant under the hood (`Rect.svg.svelte`, `Rect.canvas.svelte`, `Rect.html.svelte`) — no breaking change for existing consumers. + +### What's split + +**Primitives (13)** — the basic graphics building blocks +`Circle`, `Text`, `Rect`, `Line`, `Path`, `Ellipse`, `Polygon`, `Group`, `Image`, `ClipPath`, `Pattern`, `LinearGradient`, `RadialGradient` + +**Compound marks (~30)** — chart axes, marks, annotations, and chart-relative shapes +`Axis`, `Grid`, `Rule`, `Highlight`, `Layer`, `ChartChildren`, `ChartClipPath`, `CircleClipPath`, `Bars`, `Bar`, `Spline`, `Area`, `Pie`, `Arc`, `ArcLabel`, `Points`, `Cell`, `Frame`, `Threshold`, `Trail`, `Vector`, `Link`, `Labels`, `AnnotationLine`, `AnnotationPoint`, `AnnotationRange`, `Hull`, `Density`, `Voronoi`, `Contour`, `Raster`, `Violin`, `BoxPlot`, `Calendar`, `Month` + +**Geo components (`layerchart/geo`)** +`GeoPath`, `GeoSpline`, `GeoPoint`, `GeoCircle`, `GeoTile`, `TileImage`, `Graticule`, `GeoClipPath`, `GeoEdgeFade` + +**Graph components (`layerchart/graph`)** +`Ribbon` + +**High-level chart wrappers** — pre-composed charts with built-in tooltips, highlights, and series handling +`LineChart`, `AreaChart`, `BarChart`, `ScatterChart`, `PieChart`, `ArcChart` + +The geo, graph, hierarchy, and force sub-paths also re-export every layer-agnostic helper they previously included, so a single `from 'layerchart/svg'` import covers a typical SVG chart end-to-end without falling back to `'layerchart'`. + +### Standout per-layer wins (gz, vs agnostic baseline) + +**Primitives where the per-layer rendering is dramatically simpler:** +- `Pattern` html: 14.81 → 0.92 KB (-94%) — HTML implementation is just CSS-string generation +- `LinearGradient` html: 14.38 → 0.53 KB (-96%) +- `Image` canvas: 14.95 → 3.73 KB (-75%) +- `Text` svg/html: 29.13 → ~16 KB (-45%) +- `Circle` / `Rect` / `Ellipse` / `Line` / `Path`: ~22–27% smaller per-layer + +**Compound marks:** typically 8–15% gz savings per-layer; outliers like `Highlight` (-30% canvas) and `Cell` (-22% svg) are larger because their HTML/canvas vs. SVG paths diverge significantly. + +**High-level charts:** ~5–12% gz savings (~5–11 KB) when imported from `layerchart/svg` or `layerchart/canvas`. A single-layer LineChart drops from 89.6 KB → 79.0 KB gz on the SVG path. + +For a consumer who migrates all imports to a single layer, cumulative savings across primitives and compound marks are 60–80 KB gz. + +### Bundle reductions on the default `` path + +In addition to opt-in per-layer variants, this release also makes a few previously-eager features lazy: + +- **``** is now dynamically imported when `` is set — saves ~2.8 KB gz on every chart that doesn't pan/zoom. +- **``** was already lazy; nothing changes there. + +### `` for non-cartesian charts (new) + +A new `` component is exported alongside `` from each layer sub-path (`layerchart`, `layerchart/svg`, `layerchart/canvas`, `layerchart/html`). It provides the chart context, sizing, brush, transform, and tooltip plumbing — but skips `` and the `Layer` / `Axis` / `Grid` / `Rule` / `Highlight` / `ChartClipPath` import chain it pulls in. + +Use it for geo maps, custom layouts, or any chart that renders its own primitives directly via the `children` snippet: + +```svelte + + + + {#snippet children({ context })} + + + + + + {/snippet} + +``` + +Measured savings (bundle scenarios): +- `base` (``) → `core` (``): 83.42 → 50.93 KB gz (**−39%**) +- `geo` (`` + `GeoPath`/`GeoPoint`) → `core-geo` (`` + `GeoProjection` + `GeoPath`): 87.23 → 54.67 KB gz (**−37%**) +- `base-svg` (per-layer) → `core-svg` (per-layer): 77.37 → 50.88 KB gz (**−34%**) + +### Behavior + +Identical to the agnostic versions: visual output, props, types, and bindable refs all match. The dispatcher pattern adds ~0.2 KB per primitive to `core` for users on the agnostic API (transitive cost from `Highlight` / `Axis` / `Chart`) — a worthwhile tradeoff for the opt-in per-layer savings. + +See the updated ["Bundle Size" guide](https://layerchart.com/docs/guides/bundle-size) for the full table, tradeoffs, and when to opt into per-layer imports. diff --git a/bundle-analyzer/README.md b/bundle-analyzer/README.md index 1822cf77d..7ba9dbb14 100644 --- a/bundle-analyzer/README.md +++ b/bundle-analyzer/README.md @@ -28,18 +28,22 @@ Svelte runtime is excluded from measurements since it's shared across all compon ## Scenarios -Scenarios are defined in [`define-scenarios.ts`](./define-scenarios.ts) and represent real-world usage patterns: +Scenarios are defined in [`bundle-scenarios.ts`](./bundle-scenarios.ts) and represent real-world usage patterns: | Scenario | Description | |----------|-------------| -| `core` | Bare minimum: `Chart` + `Svg` | +| `base` | Full `Chart` (with the cartesian frame: Axis, Grid, Rule, Highlight) | +| `base-svg` / `base-canvas` / `base-html` | `Chart` from `layerchart/svg`, etc. | +| `core` | Bare-bones `ChartCore` (no Axis/Grid/Rule/Highlight) | +| `core-svg` / `core-canvas` / `core-html` | `ChartCore` from `layerchart/svg`, etc. | +| `core-geo` / `core-line` / `core-scatter` | `ChartCore` + manual primitives (geo / spline / points) | | `line-chart` | Line chart with axes and grid | | `line-chart-interactive` | Line chart with tooltip and highlight | | `area-chart` | Area chart with axes | | `bar-chart` | Bar chart with axes | | `scatter-chart` | Scatter plot with points | | `pie-chart` | Pie/donut chart with arcs | -| `high-level-charts` | All high-level chart components | +| `LineChart` / `AreaChart` / `BarChart` / etc. | High-level chart wrappers | | `geo` | Geographic map with paths | | `geo-tiles` | Geographic map with tile layer | | `geo-full` | All geo components | @@ -50,7 +54,6 @@ Scenarios are defined in [`define-scenarios.ts`](./define-scenarios.ts) and repr | `dagre` | Dagre directed graph | | `sankey` | Sankey flow diagram | | `chord` | Chord diagram | -| `canvas` | Canvas-based rendering | | `all` | Everything from layerchart | ## CLI options @@ -83,7 +86,7 @@ Two GitHub Actions workflows automate bundle tracking: ## Adding scenarios -Edit [`define-scenarios.ts`](./define-scenarios.ts) to add new scenarios to the `scenarios` array: +Edit [`bundle-scenarios.ts`](./bundle-scenarios.ts) to add new scenarios to the `scenarios` array: ```ts { diff --git a/bundle-analyzer/bundle-analyzer.ts b/bundle-analyzer/bundle-analyzer.ts index a6324253a..ef7f074d4 100644 --- a/bundle-analyzer/bundle-analyzer.ts +++ b/bundle-analyzer/bundle-analyzer.ts @@ -16,7 +16,7 @@ import { getScenarios, getComponentScenarios, type Scenario, -} from "./define-scenarios.js"; +} from "./bundle-scenarios.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -190,9 +190,12 @@ import * as LayerChartGraph from "layerchart/graph"; `; } else { // Group imports by source module (root vs each sub-path). + // Per-scenario `layers` win over the default mapping — + // used to test layer-specific variants like `layerchart/svg`. const groups = new Map([["layerchart", []]]); for (const name of scenario.imports) { - const sub = SUBPATH_FOR_COMPONENT[name]; + const overrideSub = scenario.layers?.[name]; + const sub = overrideSub ?? SUBPATH_FOR_COMPONENT[name]; const mod = sub ? `layerchart/${sub}` : "layerchart"; const list = groups.get(mod) ?? []; list.push(name); diff --git a/bundle-analyzer/bundle-reports/latest.json b/bundle-analyzer/bundle-reports/latest.json index 4ffe66b7c..b7d0ff8c4 100644 --- a/bundle-analyzer/bundle-reports/latest.json +++ b/bundle-analyzer/bundle-reports/latest.json @@ -1,34 +1,168 @@ { - "timestamp": "2026-04-27T16:12:14.840Z", + "timestamp": "2026-04-29T16:24:54.584Z", "results": [ { "scenario": "core", - "description": "Bare minimum: Chart context + Svg layer", - "group": "Foundation", - "size": 443003, - "gzipSize": 107878, + "description": "`ChartCore` — bare-bones chart without `ChartChildren`", + "group": "Core", + "size": 199234, + "gzipSize": 52157, + "imports": [ + "ChartCore" + ] + }, + { + "scenario": "core-svg", + "description": "`ChartCore` + `Svg` from `layerchart/svg`", + "group": "Core", + "size": 199243, + "gzipSize": 52102, + "imports": [ + "ChartCore", + "Svg" + ] + }, + { + "scenario": "core-canvas", + "description": "`ChartCore` + `Canvas` from `layerchart/canvas`", + "group": "Core", + "size": 217532, + "gzipSize": 57609, + "imports": [ + "ChartCore", + "Canvas" + ] + }, + { + "scenario": "core-html", + "description": "`ChartCore` + `Html` from `layerchart/html`", + "group": "Core", + "size": 201174, + "gzipSize": 52537, + "imports": [ + "ChartCore", + "Html" + ] + }, + { + "scenario": "core-geo", + "description": "`ChartCore`-based geo map (`GeoProjection` + `GeoPath`)", + "group": "Core", + "size": 213765, + "gzipSize": 55979, + "imports": [ + "ChartCore", + "Svg", + "GeoProjection", + "GeoPath" + ] + }, + { + "scenario": "core-line", + "description": "`ChartCore` + manual `Spline` line (no Axis/Grid)", + "group": "Core", + "size": 220340, + "gzipSize": 57523, + "imports": [ + "ChartCore", + "Svg", + "Spline" + ] + }, + { + "scenario": "core-scatter", + "description": "`ChartCore` + manual `Points` scatter (no Axis/Grid)", + "group": "Core", + "size": 217331, + "gzipSize": 56169, + "imports": [ + "ChartCore", + "Svg", + "Points" + ] + }, + { + "scenario": "base", + "description": "`Chart` — full charting frame without rendering layer", + "group": "Base (agnostic)", + "size": 365532, + "gzipSize": 85425, + "imports": [ + "Chart" + ] + }, + { + "scenario": "base-svg-agnostic", + "description": "`Chart` + `Svg` from `layerchart` (agnostic dispatcher)", + "group": "Base (agnostic)", + "size": 365536, + "gzipSize": 85428, + "imports": [ + "Chart", + "Svg" + ] + }, + { + "scenario": "base-canvas-agnostic", + "description": "`Chart` + `Canvas` from `layerchart` (agnostic dispatcher)", + "group": "Base (agnostic)", + "size": 365536, + "gzipSize": 85425, + "imports": [ + "Chart", + "Canvas" + ] + }, + { + "scenario": "base-html-agnostic", + "description": "`Chart` + `Html` from `layerchart` (agnostic dispatcher)", + "group": "Base (agnostic)", + "size": 365536, + "gzipSize": 85422, + "imports": [ + "Chart", + "Html" + ] + }, + { + "scenario": "base-svg", + "description": "`Chart` + `Svg` from `layerchart/svg`", + "group": "Base (layer-specific)", + "size": 337713, + "gzipSize": 79225, "imports": [ "Chart", "Svg" ] }, { - "scenario": "canvas", - "description": "Canvas-based rendering", - "group": "Foundation", - "size": 443003, - "gzipSize": 107877, + "scenario": "base-canvas", + "description": "`Chart` + `Canvas` from `layerchart/canvas`", + "group": "Base (layer-specific)", + "size": 344259, + "gzipSize": 80925, "imports": [ "Chart", "Canvas" ] }, + { + "scenario": "base-html", + "description": "`Chart` + `Html` from `layerchart/html`", + "group": "Base (layer-specific)", + "size": 345563, + "gzipSize": 81246, + "imports": [ + "Chart", + "Html" + ] + }, { "scenario": "line-chart", "description": "Basic line chart with axes and grid", "group": "Cartesian charts", - "size": 443027, - "gzipSize": 107888, + "size": 366031, + "gzipSize": 85452, "imports": [ "Chart", "Svg", @@ -37,12 +171,54 @@ "Grid" ] }, + { + "scenario": "line-chart-svg", + "description": "Line chart composed from `layerchart/svg`", + "group": "Cartesian charts", + "size": 337737, + "gzipSize": 79229, + "imports": [ + "Chart", + "Layer", + "Line", + "Axis", + "Grid" + ] + }, + { + "scenario": "line-chart-canvas", + "description": "Line chart composed from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 344283, + "gzipSize": 80944, + "imports": [ + "Chart", + "Layer", + "Line", + "Axis", + "Grid" + ] + }, + { + "scenario": "line-chart-html", + "description": "Line chart composed from `layerchart/html`", + "group": "Cartesian charts", + "size": 345587, + "gzipSize": 81259, + "imports": [ + "Chart", + "Layer", + "Line", + "Axis", + "Grid" + ] + }, { "scenario": "line-chart-interactive", "description": "Line chart with tooltip and highlight", "group": "Cartesian charts", - "size": 457593, - "gzipSize": 110321, + "size": 380910, + "gzipSize": 88751, "imports": [ "Chart", "Svg", @@ -53,12 +229,42 @@ "Tooltip" ] }, + { + "scenario": "LineChart", + "description": "High-level `LineChart` component", + "group": "Cartesian charts", + "size": 390503, + "gzipSize": 91714, + "imports": [ + "LineChart" + ] + }, + { + "scenario": "LineChart-svg", + "description": "High-level `LineChart` from `layerchart/svg`", + "group": "Cartesian charts", + "size": 344908, + "gzipSize": 80940, + "imports": [ + "LineChart" + ] + }, + { + "scenario": "LineChart-canvas", + "description": "High-level `LineChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 351437, + "gzipSize": 82657, + "imports": [ + "LineChart" + ] + }, { "scenario": "area-chart", "description": "Area chart with axes", "group": "Cartesian charts", - "size": 467575, - "gzipSize": 113625, + "size": 379547, + "gzipSize": 88434, "imports": [ "Chart", "Svg", @@ -67,12 +273,42 @@ "Grid" ] }, + { + "scenario": "AreaChart", + "description": "High-level `AreaChart` component", + "group": "Cartesian charts", + "size": 383949, + "gzipSize": 89410, + "imports": [ + "AreaChart" + ] + }, + { + "scenario": "AreaChart-svg", + "description": "High-level `AreaChart` from `layerchart/svg`", + "group": "Cartesian charts", + "size": 355341, + "gzipSize": 83143, + "imports": [ + "AreaChart" + ] + }, + { + "scenario": "AreaChart-canvas", + "description": "High-level `AreaChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 361875, + "gzipSize": 84810, + "imports": [ + "AreaChart" + ] + }, { "scenario": "bar-chart", "description": "Bar chart with axes", "group": "Cartesian charts", - "size": 451859, - "gzipSize": 109976, + "size": 369875, + "gzipSize": 86210, "imports": [ "Chart", "Svg", @@ -81,12 +317,42 @@ "Grid" ] }, + { + "scenario": "BarChart", + "description": "High-level `BarChart` component", + "group": "Cartesian charts", + "size": 375897, + "gzipSize": 87442, + "imports": [ + "BarChart" + ] + }, + { + "scenario": "BarChart-svg", + "description": "High-level `BarChart` from `layerchart/svg`", + "group": "Cartesian charts", + "size": 347307, + "gzipSize": 81133, + "imports": [ + "BarChart" + ] + }, + { + "scenario": "BarChart-canvas", + "description": "High-level `BarChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 353834, + "gzipSize": 82808, + "imports": [ + "BarChart" + ] + }, { "scenario": "scatter-chart", "description": "Scatter plot with points", "group": "Cartesian charts", - "size": 447253, - "gzipSize": 109016, + "size": 366813, + "gzipSize": 85837, "imports": [ "Chart", "Svg", @@ -96,12 +362,42 @@ "Grid" ] }, + { + "scenario": "ScatterChart", + "description": "High-level `ScatterChart` component", + "group": "Cartesian charts", + "size": 370358, + "gzipSize": 86538, + "imports": [ + "ScatterChart" + ] + }, + { + "scenario": "ScatterChart-svg", + "description": "High-level `ScatterChart` from `layerchart/svg`", + "group": "Cartesian charts", + "size": 341478, + "gzipSize": 80000, + "imports": [ + "ScatterChart" + ] + }, + { + "scenario": "ScatterChart-canvas", + "description": "High-level `ScatterChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 348007, + "gzipSize": 81679, + "imports": [ + "ScatterChart" + ] + }, { "scenario": "pie-chart", "description": "Pie/donut chart with arcs", "group": "Cartesian charts", - "size": 450052, - "gzipSize": 109679, + "size": 378280, + "gzipSize": 88073, "imports": [ "Chart", "Svg", @@ -111,17 +407,62 @@ ] }, { - "scenario": "high-level-charts", - "description": "All high-level chart components (LineChart, BarChart, etc.)", + "scenario": "PieChart", + "description": "High-level `PieChart` component", + "group": "Cartesian charts", + "size": 405863, + "gzipSize": 94060, + "imports": [ + "PieChart" + ] + }, + { + "scenario": "PieChart-svg", + "description": "High-level `PieChart` from `layerchart/svg`", + "group": "Cartesian charts", + "size": 375829, + "gzipSize": 87292, + "imports": [ + "PieChart" + ] + }, + { + "scenario": "PieChart-canvas", + "description": "High-level `PieChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 382358, + "gzipSize": 88986, + "imports": [ + "PieChart" + ] + }, + { + "scenario": "ArcChart", + "description": "High-level `ArcChart` component", + "group": "Cartesian charts", + "size": 398981, + "gzipSize": 92901, + "imports": [ + "ArcChart" + ] + }, + { + "scenario": "ArcChart-svg", + "description": "High-level `ArcChart` from `layerchart/svg`", "group": "Cartesian charts", - "size": 540641, - "gzipSize": 130003, - "imports": [ - "LineChart", - "AreaChart", - "BarChart", - "PieChart", - "ScatterChart", + "size": 369500, + "gzipSize": 86221, + "imports": [ + "ArcChart" + ] + }, + { + "scenario": "ArcChart-canvas", + "description": "High-level `ArcChart` from `layerchart/canvas`", + "group": "Cartesian charts", + "size": 376033, + "gzipSize": 87964, + "imports": [ "ArcChart" ] }, @@ -129,8 +470,8 @@ "scenario": "geo", "description": "Geographic map with paths", "group": "Geo", - "size": 457955, - "gzipSize": 111341, + "size": 382870, + "gzipSize": 89322, "imports": [ "Chart", "Svg", @@ -143,8 +484,8 @@ "scenario": "geo-tiles", "description": "Geographic map with tile layer", "group": "Geo", - "size": 462402, - "gzipSize": 112886, + "size": 388059, + "gzipSize": 90957, "imports": [ "Chart", "Svg", @@ -158,8 +499,8 @@ "scenario": "geo-full", "description": "Full geo setup with all geo components", "group": "Geo", - "size": 510200, - "gzipSize": 126231, + "size": 441539, + "gzipSize": 104917, "imports": [ "Chart", "Svg", @@ -181,8 +522,8 @@ "scenario": "hierarchy-tree", "description": "Tree layout with links", "group": "Hierarchy", - "size": 469258, - "gzipSize": 114832, + "size": 405680, + "gzipSize": 95137, "imports": [ "Chart", "Svg", @@ -196,8 +537,8 @@ "scenario": "hierarchy-treemap", "description": "Treemap layout", "group": "Hierarchy", - "size": 449442, - "gzipSize": 109899, + "size": 384492, + "gzipSize": 90077, "imports": [ "Chart", "Svg", @@ -211,8 +552,8 @@ "scenario": "hierarchy-pack", "description": "Circle packing layout", "group": "Hierarchy", - "size": 449262, - "gzipSize": 110010, + "size": 384215, + "gzipSize": 90176, "imports": [ "Chart", "Svg", @@ -225,8 +566,8 @@ "scenario": "force", "description": "Force-directed graph layout", "group": "Graph / network", - "size": 471710, - "gzipSize": 115673, + "size": 408136, + "gzipSize": 95979, "imports": [ "Chart", "Svg", @@ -240,8 +581,8 @@ "scenario": "dagre", "description": "Dagre directed graph layout", "group": "Graph / network", - "size": 529140, - "gzipSize": 131092, + "size": 465647, + "gzipSize": 111506, "imports": [ "Chart", "Svg", @@ -255,8 +596,8 @@ "scenario": "sankey", "description": "Sankey flow diagram", "group": "Graph / network", - "size": 471368, - "gzipSize": 114997, + "size": 407868, + "gzipSize": 95361, "imports": [ "Chart", "Svg", @@ -270,8 +611,8 @@ "scenario": "chord", "description": "Chord diagram", "group": "Graph / network", - "size": 451985, - "gzipSize": 110055, + "size": 375470, + "gzipSize": 87565, "imports": [ "Chart", "Svg", @@ -279,12 +620,1912 @@ "Ribbon" ] }, + { + "scenario": "Circle", + "description": "Standalone Circle (agnostic) — baseline", + "group": "Components", + "size": 69174, + "gzipSize": 17473, + "imports": [ + "Circle" + ] + }, + { + "scenario": "Circle.svg", + "description": "Standalone Circle from `layerchart/svg`", + "group": "Components", + "size": 55222, + "gzipSize": 13601, + "imports": [ + "Circle" + ] + }, + { + "scenario": "Circle.canvas", + "description": "Standalone Circle from `layerchart/canvas`", + "group": "Components", + "size": 64014, + "gzipSize": 16365, + "imports": [ + "Circle" + ] + }, + { + "scenario": "Circle.html", + "description": "Standalone Circle from `layerchart/html`", + "group": "Components", + "size": 55859, + "gzipSize": 13738, + "imports": [ + "Circle" + ] + }, + { + "scenario": "Text", + "description": "Standalone Text (agnostic) — baseline", + "group": "Components", + "size": 119980, + "gzipSize": 29817, + "imports": [ + "Text" + ] + }, + { + "scenario": "Text.svg", + "description": "Standalone Text from `layerchart/svg`", + "group": "Components", + "size": 64857, + "gzipSize": 16644, + "imports": [ + "Text" + ] + }, + { + "scenario": "Text.canvas", + "description": "Standalone Text from `layerchart/canvas`", + "group": "Components", + "size": 109393, + "gzipSize": 27499, + "imports": [ + "Text" + ] + }, + { + "scenario": "Text.html", + "description": "Standalone Text from `layerchart/html`", + "group": "Components", + "size": 62401, + "gzipSize": 16085, + "imports": [ + "Text" + ] + }, + { + "scenario": "Rect", + "description": "Standalone Rect (agnostic) — baseline", + "group": "Components", + "size": 76126, + "gzipSize": 19000, + "imports": [ + "Rect" + ] + }, + { + "scenario": "Rect.svg", + "description": "Standalone Rect from `layerchart/svg`", + "group": "Components", + "size": 60704, + "gzipSize": 14779, + "imports": [ + "Rect" + ] + }, + { + "scenario": "Rect.canvas", + "description": "Standalone Rect from `layerchart/canvas`", + "group": "Components", + "size": 69853, + "gzipSize": 17733, + "imports": [ + "Rect" + ] + }, + { + "scenario": "Rect.html", + "description": "Standalone Rect from `layerchart/html`", + "group": "Components", + "size": 60139, + "gzipSize": 14727, + "imports": [ + "Rect" + ] + }, + { + "scenario": "Line", + "description": "Standalone Line (agnostic) — baseline", + "group": "Components", + "size": 75984, + "gzipSize": 19053, + "imports": [ + "Line" + ] + }, + { + "scenario": "Line.svg", + "description": "Standalone Line from `layerchart/svg`", + "group": "Components", + "size": 61322, + "gzipSize": 14915, + "imports": [ + "Line" + ] + }, + { + "scenario": "Line.canvas", + "description": "Standalone Line from `layerchart/canvas`", + "group": "Components", + "size": 64635, + "gzipSize": 16482, + "imports": [ + "Line" + ] + }, + { + "scenario": "Line.html", + "description": "Standalone Line from `layerchart/html`", + "group": "Components", + "size": 57170, + "gzipSize": 14089, + "imports": [ + "Line" + ] + }, + { + "scenario": "Path", + "description": "Standalone Path (agnostic) — baseline", + "group": "Components", + "size": 86119, + "gzipSize": 21790, + "imports": [ + "Path" + ] + }, + { + "scenario": "Path.svg", + "description": "Standalone Path from `layerchart/svg`", + "group": "Components", + "size": 75067, + "gzipSize": 18777, + "imports": [ + "Path" + ] + }, + { + "scenario": "Path.canvas", + "description": "Standalone Path from `layerchart/canvas`", + "group": "Components", + "size": 65657, + "gzipSize": 17345, + "imports": [ + "Path" + ] + }, + { + "scenario": "ClipPath", + "description": "Standalone ClipPath (agnostic) — baseline", + "group": "Components", + "size": 7283, + "gzipSize": 2105, + "imports": [ + "ClipPath" + ] + }, + { + "scenario": "ClipPath.svg", + "description": "Standalone ClipPath from `layerchart/svg`", + "group": "Components", + "size": 4090, + "gzipSize": 1526, + "imports": [ + "ClipPath" + ] + }, + { + "scenario": "ClipPath.canvas", + "description": "Standalone ClipPath from `layerchart/canvas`", + "group": "Components", + "size": 3096, + "gzipSize": 1254, + "imports": [ + "ClipPath" + ] + }, + { + "scenario": "ClipPath.html", + "description": "Standalone ClipPath from `layerchart/html`", + "group": "Components", + "size": 3403, + "gzipSize": 1342, + "imports": [ + "ClipPath" + ] + }, + { + "scenario": "RadialGradient", + "description": "Standalone RadialGradient (agnostic) — baseline", + "group": "Components", + "size": 55787, + "gzipSize": 14410, + "imports": [ + "RadialGradient" + ] + }, + { + "scenario": "RadialGradient.svg", + "description": "Standalone RadialGradient from `layerchart/svg`", + "group": "Components", + "size": 44548, + "gzipSize": 10829, + "imports": [ + "RadialGradient" + ] + }, + { + "scenario": "RadialGradient.canvas", + "description": "Standalone RadialGradient from `layerchart/canvas`", + "group": "Components", + "size": 52628, + "gzipSize": 13611, + "imports": [ + "RadialGradient" + ] + }, + { + "scenario": "LinearGradient", + "description": "Standalone LinearGradient (agnostic) — baseline", + "group": "Components", + "size": 57180, + "gzipSize": 14718, + "imports": [ + "LinearGradient" + ] + }, + { + "scenario": "LinearGradient.svg", + "description": "Standalone LinearGradient from `layerchart/svg`", + "group": "Components", + "size": 44652, + "gzipSize": 10869, + "imports": [ + "LinearGradient" + ] + }, + { + "scenario": "LinearGradient.canvas", + "description": "Standalone LinearGradient from `layerchart/canvas`", + "group": "Components", + "size": 52898, + "gzipSize": 13681, + "imports": [ + "LinearGradient" + ] + }, + { + "scenario": "LinearGradient.html", + "description": "Standalone LinearGradient from `layerchart/html`", + "group": "Components", + "size": 940, + "gzipSize": 547, + "imports": [ + "LinearGradient" + ] + }, + { + "scenario": "Group", + "description": "Standalone Group (agnostic) — baseline", + "group": "Components", + "size": 15713, + "gzipSize": 4078, + "imports": [ + "Group" + ] + }, + { + "scenario": "Group.svg", + "description": "Standalone Group from `layerchart/svg`", + "group": "Components", + "size": 11747, + "gzipSize": 3580, + "imports": [ + "Group" + ] + }, + { + "scenario": "Group.canvas", + "description": "Standalone Group from `layerchart/canvas`", + "group": "Components", + "size": 10444, + "gzipSize": 3184, + "imports": [ + "Group" + ] + }, + { + "scenario": "Group.html", + "description": "Standalone Group from `layerchart/html`", + "group": "Components", + "size": 11881, + "gzipSize": 3606, + "imports": [ + "Group" + ] + }, + { + "scenario": "Pattern", + "description": "Standalone Pattern (agnostic) — baseline", + "group": "Components", + "size": 58891, + "gzipSize": 15157, + "imports": [ + "Pattern" + ] + }, + { + "scenario": "Pattern.svg", + "description": "Standalone Pattern from `layerchart/svg`", + "group": "Components", + "size": 46150, + "gzipSize": 11187, + "imports": [ + "Pattern" + ] + }, + { + "scenario": "Pattern.canvas", + "description": "Standalone Pattern from `layerchart/canvas`", + "group": "Components", + "size": 53516, + "gzipSize": 13818, + "imports": [ + "Pattern" + ] + }, + { + "scenario": "Pattern.html", + "description": "Standalone Pattern from `layerchart/html`", + "group": "Components", + "size": 2282, + "gzipSize": 943, + "imports": [ + "Pattern" + ] + }, + { + "scenario": "Ellipse", + "description": "Standalone Ellipse (agnostic) — baseline", + "group": "Components", + "size": 69294, + "gzipSize": 17385, + "imports": [ + "Ellipse" + ] + }, + { + "scenario": "Ellipse.svg", + "description": "Standalone Ellipse from `layerchart/svg`", + "group": "Components", + "size": 55383, + "gzipSize": 13520, + "imports": [ + "Ellipse" + ] + }, + { + "scenario": "Ellipse.canvas", + "description": "Standalone Ellipse from `layerchart/canvas`", + "group": "Components", + "size": 64116, + "gzipSize": 16266, + "imports": [ + "Ellipse" + ] + }, + { + "scenario": "Ellipse.html", + "description": "Standalone Ellipse from `layerchart/html`", + "group": "Components", + "size": 55584, + "gzipSize": 13544, + "imports": [ + "Ellipse" + ] + }, + { + "scenario": "Polygon", + "description": "Standalone Polygon (agnostic) — baseline", + "group": "Components", + "size": 77512, + "gzipSize": 20293, + "imports": [ + "Polygon" + ] + }, + { + "scenario": "Polygon.svg", + "description": "Standalone Polygon from `layerchart/svg`", + "group": "Components", + "size": 66237, + "gzipSize": 17077, + "imports": [ + "Polygon" + ] + }, + { + "scenario": "Polygon.canvas", + "description": "Standalone Polygon from `layerchart/canvas`", + "group": "Components", + "size": 75048, + "gzipSize": 19794, + "imports": [ + "Polygon" + ] + }, + { + "scenario": "Image", + "description": "Standalone Image (agnostic) — baseline", + "group": "Components", + "size": 62632, + "gzipSize": 15256, + "imports": [ + "Image" + ] + }, + { + "scenario": "Image.svg", + "description": "Standalone Image from `layerchart/svg`", + "group": "Components", + "size": 56750, + "gzipSize": 14096, + "imports": [ + "Image" + ] + }, + { + "scenario": "Image.canvas", + "description": "Standalone Image from `layerchart/canvas`", + "group": "Components", + "size": 13092, + "gzipSize": 3834, + "imports": [ + "Image" + ] + }, + { + "scenario": "Image.html", + "description": "Standalone Image from `layerchart/html`", + "group": "Components", + "size": 55647, + "gzipSize": 13741, + "imports": [ + "Image" + ] + }, + { + "scenario": "Axis", + "description": "Standalone Axis (agnostic) — baseline", + "group": "Components", + "size": 198525, + "gzipSize": 44216, + "imports": [ + "Axis" + ] + }, + { + "scenario": "Axis.svg", + "description": "Standalone Axis from `layerchart/svg`", + "group": "Components", + "size": 171319, + "gzipSize": 38631, + "imports": [ + "Axis" + ] + }, + { + "scenario": "Axis.canvas", + "description": "Standalone Axis from `layerchart/canvas`", + "group": "Components", + "size": 166944, + "gzipSize": 37984, + "imports": [ + "Axis" + ] + }, + { + "scenario": "Axis.html", + "description": "Standalone Axis from `layerchart/html`", + "group": "Components", + "size": 165573, + "gzipSize": 37515, + "imports": [ + "Axis" + ] + }, + { + "scenario": "Rule", + "description": "Standalone Rule (agnostic) — baseline", + "group": "Components", + "size": 103881, + "gzipSize": 23887, + "imports": [ + "Rule" + ] + }, + { + "scenario": "Rule.svg", + "description": "Standalone Rule from `layerchart/svg`", + "group": "Components", + "size": 81053, + "gzipSize": 18549, + "imports": [ + "Rule" + ] + }, + { + "scenario": "Rule.canvas", + "description": "Standalone Rule from `layerchart/canvas`", + "group": "Components", + "size": 82892, + "gzipSize": 19525, + "imports": [ + "Rule" + ] + }, + { + "scenario": "Rule.html", + "description": "Standalone Rule from `layerchart/html`", + "group": "Components", + "size": 77678, + "gzipSize": 17931, + "imports": [ + "Rule" + ] + }, + { + "scenario": "Grid", + "description": "Standalone Grid (agnostic) — baseline", + "group": "Components", + "size": 53316, + "gzipSize": 9508, + "imports": [ + "Grid" + ] + }, + { + "scenario": "Grid.svg", + "description": "Standalone Grid from `layerchart/svg`", + "group": "Components", + "size": 101838, + "gzipSize": 22447, + "imports": [ + "Grid" + ] + }, + { + "scenario": "Grid.canvas", + "description": "Standalone Grid from `layerchart/canvas`", + "group": "Components", + "size": 39630, + "gzipSize": 6991, + "imports": [ + "Grid" + ] + }, + { + "scenario": "Grid.html", + "description": "Standalone Grid from `layerchart/html`", + "group": "Components", + "size": 98439, + "gzipSize": 21867, + "imports": [ + "Grid" + ] + }, + { + "scenario": "Highlight", + "description": "Standalone Highlight (agnostic) — baseline", + "group": "Components", + "size": 48346, + "gzipSize": 8828, + "imports": [ + "Highlight" + ] + }, + { + "scenario": "Highlight.svg", + "description": "Standalone Highlight from `layerchart/svg`", + "group": "Components", + "size": 35567, + "gzipSize": 6784, + "imports": [ + "Highlight" + ] + }, + { + "scenario": "Highlight.canvas", + "description": "Standalone Highlight from `layerchart/canvas`", + "group": "Components", + "size": 32884, + "gzipSize": 6215, + "imports": [ + "Highlight" + ] + }, + { + "scenario": "Highlight.html", + "description": "Standalone Highlight from `layerchart/html`", + "group": "Components", + "size": 37229, + "gzipSize": 7002, + "imports": [ + "Highlight" + ] + }, + { + "scenario": "RectClipPath", + "description": "Standalone RectClipPath (agnostic) — baseline", + "group": "Components", + "size": 8799, + "gzipSize": 2386, + "imports": [ + "RectClipPath" + ] + }, + { + "scenario": "RectClipPath.svg", + "description": "Standalone RectClipPath from `layerchart/svg`", + "group": "Components", + "size": 5258, + "gzipSize": 1803, + "imports": [ + "RectClipPath" + ] + }, + { + "scenario": "RectClipPath.canvas", + "description": "Standalone RectClipPath from `layerchart/canvas`", + "group": "Components", + "size": 4264, + "gzipSize": 1526, + "imports": [ + "RectClipPath" + ] + }, + { + "scenario": "RectClipPath.html", + "description": "Standalone RectClipPath from `layerchart/html`", + "group": "Components", + "size": 4571, + "gzipSize": 1600, + "imports": [ + "RectClipPath" + ] + }, + { + "scenario": "ChartClipPath", + "description": "Standalone ChartClipPath (agnostic) — baseline", + "group": "Components", + "size": 52418, + "gzipSize": 12417, + "imports": [ + "ChartClipPath" + ] + }, + { + "scenario": "ChartClipPath.svg", + "description": "Standalone ChartClipPath from `layerchart/svg`", + "group": "Components", + "size": 48496, + "gzipSize": 11849, + "imports": [ + "ChartClipPath" + ] + }, + { + "scenario": "ChartClipPath.canvas", + "description": "Standalone ChartClipPath from `layerchart/canvas`", + "group": "Components", + "size": 47491, + "gzipSize": 11587, + "imports": [ + "ChartClipPath" + ] + }, + { + "scenario": "ChartClipPath.html", + "description": "Standalone ChartClipPath from `layerchart/html`", + "group": "Components", + "size": 47800, + "gzipSize": 11634, + "imports": [ + "ChartClipPath" + ] + }, + { + "scenario": "Arc", + "description": "Standalone Arc (agnostic) — baseline", + "group": "Components", + "size": 135246, + "gzipSize": 36233, + "imports": [ + "Arc" + ] + }, + { + "scenario": "Arc.svg", + "description": "Standalone Arc from `layerchart/svg`", + "group": "Components", + "size": 124171, + "gzipSize": 33094, + "imports": [ + "Arc" + ] + }, + { + "scenario": "Arc.canvas", + "description": "Standalone Arc from `layerchart/canvas`", + "group": "Components", + "size": 114773, + "gzipSize": 31854, + "imports": [ + "Arc" + ] + }, + { + "scenario": "Spline", + "description": "Standalone Spline (agnostic) — baseline", + "group": "Components", + "size": 108808, + "gzipSize": 27771, + "imports": [ + "Spline" + ] + }, + { + "scenario": "Spline.svg", + "description": "Standalone Spline from `layerchart/svg`", + "group": "Components", + "size": 97733, + "gzipSize": 24719, + "imports": [ + "Spline" + ] + }, + { + "scenario": "Spline.canvas", + "description": "Standalone Spline from `layerchart/canvas`", + "group": "Components", + "size": 89523, + "gzipSize": 23447, + "imports": [ + "Spline" + ] + }, + { + "scenario": "Area", + "description": "Standalone Area (agnostic) — baseline", + "group": "Components", + "size": 119991, + "gzipSize": 29617, + "imports": [ + "Area" + ] + }, + { + "scenario": "Area.svg", + "description": "Standalone Area from `layerchart/svg`", + "group": "Components", + "size": 108687, + "gzipSize": 26502, + "imports": [ + "Area" + ] + }, + { + "scenario": "Area.canvas", + "description": "Standalone Area from `layerchart/canvas`", + "group": "Components", + "size": 100480, + "gzipSize": 25307, + "imports": [ + "Area" + ] + }, + { + "scenario": "Pie", + "description": "Standalone Pie (agnostic) — baseline", + "group": "Components", + "size": 140620, + "gzipSize": 37308, + "imports": [ + "Pie" + ] + }, + { + "scenario": "Pie.svg", + "description": "Standalone Pie from `layerchart/svg`", + "group": "Components", + "size": 129373, + "gzipSize": 34152, + "imports": [ + "Pie" + ] + }, + { + "scenario": "Pie.canvas", + "description": "Standalone Pie from `layerchart/canvas`", + "group": "Components", + "size": 119975, + "gzipSize": 32920, + "imports": [ + "Pie" + ] + }, + { + "scenario": "ArcLabel", + "description": "Standalone ArcLabel (agnostic) — baseline", + "group": "Components", + "size": 150472, + "gzipSize": 36779, + "imports": [ + "ArcLabel" + ] + }, + { + "scenario": "ArcLabel.svg", + "description": "Standalone ArcLabel from `layerchart/svg`", + "group": "Components", + "size": 139392, + "gzipSize": 34011, + "imports": [ + "ArcLabel" + ] + }, + { + "scenario": "ArcLabel.canvas", + "description": "Standalone ArcLabel from `layerchart/canvas`", + "group": "Components", + "size": 126432, + "gzipSize": 32060, + "imports": [ + "ArcLabel" + ] + }, + { + "scenario": "Bar", + "description": "Standalone Bar (agnostic) — baseline", + "group": "Components", + "size": 164999, + "gzipSize": 41847, + "imports": [ + "Bar" + ] + }, + { + "scenario": "Bar.svg", + "description": "Standalone Bar from `layerchart/svg`", + "group": "Components", + "size": 150439, + "gzipSize": 38431, + "imports": [ + "Bar" + ] + }, + { + "scenario": "Bar.canvas", + "description": "Standalone Bar from `layerchart/canvas`", + "group": "Components", + "size": 144062, + "gzipSize": 37901, + "imports": [ + "Bar" + ] + }, + { + "scenario": "Bars", + "description": "Standalone Bars (agnostic) — baseline", + "group": "Components", + "size": 168855, + "gzipSize": 42482, + "imports": [ + "Bars" + ] + }, + { + "scenario": "Bars.svg", + "description": "Standalone Bars from `layerchart/svg`", + "group": "Components", + "size": 154068, + "gzipSize": 39037, + "imports": [ + "Bars" + ] + }, + { + "scenario": "Bars.canvas", + "description": "Standalone Bars from `layerchart/canvas`", + "group": "Components", + "size": 151590, + "gzipSize": 39184, + "imports": [ + "Bars" + ] + }, + { + "scenario": "Points", + "description": "Standalone Points (agnostic) — baseline", + "group": "Components", + "size": 75388, + "gzipSize": 18756, + "imports": [ + "Points" + ] + }, + { + "scenario": "Points.svg", + "description": "Standalone Points from `layerchart/svg`", + "group": "Components", + "size": 61088, + "gzipSize": 14969, + "imports": [ + "Points" + ] + }, + { + "scenario": "Points.canvas", + "description": "Standalone Points from `layerchart/canvas`", + "group": "Components", + "size": 69880, + "gzipSize": 17752, + "imports": [ + "Points" + ] + }, + { + "scenario": "Points.html", + "description": "Standalone Points from `layerchart/html`", + "group": "Components", + "size": 61725, + "gzipSize": 15064, + "imports": [ + "Points" + ] + }, + { + "scenario": "Labels", + "description": "Standalone Labels (agnostic) — baseline", + "group": "Components", + "size": 156668, + "gzipSize": 36334, + "imports": [ + "Labels" + ] + }, + { + "scenario": "Labels.svg", + "description": "Standalone Labels from `layerchart/svg`", + "group": "Components", + "size": 134877, + "gzipSize": 31974, + "imports": [ + "Labels" + ] + }, + { + "scenario": "Labels.canvas", + "description": "Standalone Labels from `layerchart/canvas`", + "group": "Components", + "size": 136089, + "gzipSize": 32596, + "imports": [ + "Labels" + ] + }, + { + "scenario": "Labels.html", + "description": "Standalone Labels from `layerchart/html`", + "group": "Components", + "size": 133211, + "gzipSize": 31541, + "imports": [ + "Labels" + ] + }, + { + "scenario": "Frame", + "description": "Standalone Frame (agnostic) — baseline", + "group": "Components", + "size": 77932, + "gzipSize": 19362, + "imports": [ + "Frame" + ] + }, + { + "scenario": "Frame.svg", + "description": "Standalone Frame from `layerchart/svg`", + "group": "Components", + "size": 62299, + "gzipSize": 15130, + "imports": [ + "Frame" + ] + }, + { + "scenario": "Frame.canvas", + "description": "Standalone Frame from `layerchart/canvas`", + "group": "Components", + "size": 71449, + "gzipSize": 18128, + "imports": [ + "Frame" + ] + }, + { + "scenario": "Frame.html", + "description": "Standalone Frame from `layerchart/html`", + "group": "Components", + "size": 61735, + "gzipSize": 15114, + "imports": [ + "Frame" + ] + }, + { + "scenario": "Cell", + "description": "Standalone Cell (agnostic) — baseline", + "group": "Components", + "size": 97791, + "gzipSize": 21920, + "imports": [ + "Cell" + ] + }, + { + "scenario": "Cell.svg", + "description": "Standalone Cell from `layerchart/svg`", + "group": "Components", + "size": 74339, + "gzipSize": 17169, + "imports": [ + "Cell" + ] + }, + { + "scenario": "Cell.canvas", + "description": "Standalone Cell from `layerchart/canvas`", + "group": "Components", + "size": 82010, + "gzipSize": 19591, + "imports": [ + "Cell" + ] + }, + { + "scenario": "Cell.html", + "description": "Standalone Cell from `layerchart/html`", + "group": "Components", + "size": 74549, + "gzipSize": 17048, + "imports": [ + "Cell" + ] + }, + { + "scenario": "Threshold", + "description": "Standalone Threshold (agnostic) — baseline", + "group": "Components", + "size": 126563, + "gzipSize": 30802, + "imports": [ + "Threshold" + ] + }, + { + "scenario": "Threshold.svg", + "description": "Standalone Threshold from `layerchart/svg`", + "group": "Components", + "size": 113853, + "gzipSize": 27475, + "imports": [ + "Threshold" + ] + }, + { + "scenario": "Threshold.canvas", + "description": "Standalone Threshold from `layerchart/canvas`", + "group": "Components", + "size": 104686, + "gzipSize": 26129, + "imports": [ + "Threshold" + ] + }, + { + "scenario": "AnnotationLine", + "description": "Standalone AnnotationLine (agnostic) — baseline", + "group": "Components", + "size": 134545, + "gzipSize": 32637, + "imports": [ + "AnnotationLine" + ] + }, + { + "scenario": "AnnotationLine.svg", + "description": "Standalone AnnotationLine from `layerchart/svg`", + "group": "Components", + "size": 123116, + "gzipSize": 29710, + "imports": [ + "AnnotationLine" + ] + }, + { + "scenario": "AnnotationLine.canvas", + "description": "Standalone AnnotationLine from `layerchart/canvas`", + "group": "Components", + "size": 120192, + "gzipSize": 29522, + "imports": [ + "AnnotationLine" + ] + }, + { + "scenario": "AnnotationPoint", + "description": "Standalone AnnotationPoint (agnostic) — baseline", + "group": "Components", + "size": 187578, + "gzipSize": 44677, + "imports": [ + "AnnotationPoint" + ] + }, + { + "scenario": "AnnotationPoint.svg", + "description": "Standalone AnnotationPoint from `layerchart/svg`", + "group": "Components", + "size": 167867, + "gzipSize": 40424, + "imports": [ + "AnnotationPoint" + ] + }, + { + "scenario": "AnnotationPoint.canvas", + "description": "Standalone AnnotationPoint from `layerchart/canvas`", + "group": "Components", + "size": 154738, + "gzipSize": 38349, + "imports": [ + "AnnotationPoint" + ] + }, + { + "scenario": "Trail", + "description": "Standalone Trail (agnostic) — baseline", + "group": "Components", + "size": 95744, + "gzipSize": 24522, + "imports": [ + "Trail" + ] + }, + { + "scenario": "Trail.svg", + "description": "Standalone Trail from `layerchart/svg`", + "group": "Components", + "size": 84669, + "gzipSize": 21482, + "imports": [ + "Trail" + ] + }, + { + "scenario": "Trail.canvas", + "description": "Standalone Trail from `layerchart/canvas`", + "group": "Components", + "size": 76717, + "gzipSize": 20430, + "imports": [ + "Trail" + ] + }, + { + "scenario": "Vector", + "description": "Standalone Vector (agnostic) — baseline", + "group": "Components", + "size": 95267, + "gzipSize": 23983, + "imports": [ + "Vector" + ] + }, + { + "scenario": "Vector.svg", + "description": "Standalone Vector from `layerchart/svg`", + "group": "Components", + "size": 84192, + "gzipSize": 20925, + "imports": [ + "Vector" + ] + }, + { + "scenario": "Vector.canvas", + "description": "Standalone Vector from `layerchart/canvas`", + "group": "Components", + "size": 77626, + "gzipSize": 20371, + "imports": [ + "Vector" + ] + }, + { + "scenario": "Link", + "description": "Standalone Link (agnostic) — baseline", + "group": "Components", + "size": 109767, + "gzipSize": 27587, + "imports": [ + "Link" + ] + }, + { + "scenario": "Link.svg", + "description": "Standalone Link from `layerchart/svg`", + "group": "Components", + "size": 98542, + "gzipSize": 24502, + "imports": [ + "Link" + ] + }, + { + "scenario": "Link.canvas", + "description": "Standalone Link from `layerchart/canvas`", + "group": "Components", + "size": 90094, + "gzipSize": 23361, + "imports": [ + "Link" + ] + }, + { + "scenario": "AnnotationRange", + "description": "Standalone AnnotationRange (agnostic) — baseline", + "group": "Components", + "size": 145641, + "gzipSize": 34828, + "imports": [ + "AnnotationRange" + ] + }, + { + "scenario": "AnnotationRange.svg", + "description": "Standalone AnnotationRange from `layerchart/svg`", + "group": "Components", + "size": 129973, + "gzipSize": 31140, + "imports": [ + "AnnotationRange" + ] + }, + { + "scenario": "AnnotationRange.canvas", + "description": "Standalone AnnotationRange from `layerchart/canvas`", + "group": "Components", + "size": 130293, + "gzipSize": 31727, + "imports": [ + "AnnotationRange" + ] + }, + { + "scenario": "Hull", + "description": "Standalone Hull (agnostic) — baseline", + "group": "Components", + "size": 181172, + "gzipSize": 47939, + "imports": [ + "Hull" + ] + }, + { + "scenario": "Hull.svg", + "description": "Standalone Hull from `layerchart/svg`", + "group": "Components", + "size": 180384, + "gzipSize": 48106, + "imports": [ + "Hull" + ] + }, + { + "scenario": "Hull.canvas", + "description": "Standalone Hull from `layerchart/canvas`", + "group": "Components", + "size": 180388, + "gzipSize": 47864, + "imports": [ + "Hull" + ] + }, + { + "scenario": "Density", + "description": "Standalone Density (agnostic) — baseline", + "group": "Components", + "size": 133590, + "gzipSize": 36397, + "imports": [ + "Density" + ] + }, + { + "scenario": "Density.svg", + "description": "Standalone Density from `layerchart/svg`", + "group": "Components", + "size": 122467, + "gzipSize": 33345, + "imports": [ + "Density" + ] + }, + { + "scenario": "Density.canvas", + "description": "Standalone Density from `layerchart/canvas`", + "group": "Components", + "size": 119805, + "gzipSize": 33567, + "imports": [ + "Density" + ] + }, + { + "scenario": "Calendar", + "description": "Standalone Calendar (agnostic) — baseline", + "group": "Components", + "size": 167341, + "gzipSize": 39831, + "imports": [ + "Calendar" + ] + }, + { + "scenario": "Calendar.svg", + "description": "Standalone Calendar from `layerchart/svg`", + "group": "Components", + "size": 160010, + "gzipSize": 38753, + "imports": [ + "Calendar" + ] + }, + { + "scenario": "Calendar.canvas", + "description": "Standalone Calendar from `layerchart/canvas`", + "group": "Components", + "size": 157483, + "gzipSize": 37918, + "imports": [ + "Calendar" + ] + }, + { + "scenario": "CircleClipPath", + "description": "Standalone CircleClipPath (agnostic) — baseline", + "group": "Components", + "size": 8570, + "gzipSize": 2302, + "imports": [ + "CircleClipPath" + ] + }, + { + "scenario": "CircleClipPath.svg", + "description": "Standalone CircleClipPath from `layerchart/svg`", + "group": "Components", + "size": 5029, + "gzipSize": 1736, + "imports": [ + "CircleClipPath" + ] + }, + { + "scenario": "CircleClipPath.canvas", + "description": "Standalone CircleClipPath from `layerchart/canvas`", + "group": "Components", + "size": 4035, + "gzipSize": 1453, + "imports": [ + "CircleClipPath" + ] + }, + { + "scenario": "CircleClipPath.html", + "description": "Standalone CircleClipPath from `layerchart/html`", + "group": "Components", + "size": 4342, + "gzipSize": 1532, + "imports": [ + "CircleClipPath" + ] + }, + { + "scenario": "Voronoi", + "description": "Standalone Voronoi (agnostic) — baseline", + "group": "Components", + "size": 177532, + "gzipSize": 46615, + "imports": [ + "Voronoi" + ] + }, + { + "scenario": "Voronoi.svg", + "description": "Standalone Voronoi from `layerchart/svg`", + "group": "Components", + "size": 175513, + "gzipSize": 46519, + "imports": [ + "Voronoi" + ] + }, + { + "scenario": "Voronoi.canvas", + "description": "Standalone Voronoi from `layerchart/canvas`", + "group": "Components", + "size": 174515, + "gzipSize": 46072, + "imports": [ + "Voronoi" + ] + }, + { + "scenario": "Contour", + "description": "Standalone Contour (agnostic) — baseline", + "group": "Components", + "size": 165253, + "gzipSize": 45066, + "imports": [ + "Contour" + ] + }, + { + "scenario": "Contour.svg", + "description": "Standalone Contour from `layerchart/svg`", + "group": "Components", + "size": 154130, + "gzipSize": 41991, + "imports": [ + "Contour" + ] + }, + { + "scenario": "Contour.canvas", + "description": "Standalone Contour from `layerchart/canvas`", + "group": "Components", + "size": 151468, + "gzipSize": 42247, + "imports": [ + "Contour" + ] + }, + { + "scenario": "Month", + "description": "Standalone Month (agnostic) — baseline", + "group": "Components", + "size": 145387, + "gzipSize": 34710, + "imports": [ + "Month" + ] + }, + { + "scenario": "Month.svg", + "description": "Standalone Month from `layerchart/svg`", + "group": "Components", + "size": 131590, + "gzipSize": 31322, + "imports": [ + "Month" + ] + }, + { + "scenario": "Month.canvas", + "description": "Standalone Month from `layerchart/canvas`", + "group": "Components", + "size": 133179, + "gzipSize": 32172, + "imports": [ + "Month" + ] + }, + { + "scenario": "Raster", + "description": "Standalone Raster (agnostic) — baseline", + "group": "Components", + "size": 124885, + "gzipSize": 33810, + "imports": [ + "Raster" + ] + }, + { + "scenario": "Raster.svg", + "description": "Standalone Raster from `layerchart/svg`", + "group": "Components", + "size": 118793, + "gzipSize": 32840, + "imports": [ + "Raster" + ] + }, + { + "scenario": "Raster.canvas", + "description": "Standalone Raster from `layerchart/canvas`", + "group": "Components", + "size": 116920, + "gzipSize": 32309, + "imports": [ + "Raster" + ] + }, + { + "scenario": "Raster.html", + "description": "Standalone Raster from `layerchart/html`", + "group": "Components", + "size": 117693, + "gzipSize": 32508, + "imports": [ + "Raster" + ] + }, + { + "scenario": "Violin", + "description": "Standalone Violin (agnostic) — baseline", + "group": "Components", + "size": 134192, + "gzipSize": 31175, + "imports": [ + "Violin" + ] + }, + { + "scenario": "Violin.svg", + "description": "Standalone Violin from `layerchart/svg`", + "group": "Components", + "size": 117906, + "gzipSize": 27216, + "imports": [ + "Violin" + ] + }, + { + "scenario": "Violin.canvas", + "description": "Standalone Violin from `layerchart/canvas`", + "group": "Components", + "size": 112909, + "gzipSize": 27177, + "imports": [ + "Violin" + ] + }, + { + "scenario": "BoxPlot", + "description": "Standalone BoxPlot (agnostic) — baseline", + "group": "Components", + "size": 122641, + "gzipSize": 25593, + "imports": [ + "BoxPlot" + ] + }, + { + "scenario": "BoxPlot.svg", + "description": "Standalone BoxPlot from `layerchart/svg`", + "group": "Components", + "size": 105136, + "gzipSize": 21519, + "imports": [ + "BoxPlot" + ] + }, + { + "scenario": "BoxPlot.canvas", + "description": "Standalone BoxPlot from `layerchart/canvas`", + "group": "Components", + "size": 107149, + "gzipSize": 22436, + "imports": [ + "BoxPlot" + ] + }, + { + "scenario": "GeoPath", + "description": "Standalone GeoPath (agnostic) — baseline", + "group": "Components", + "size": 100716, + "gzipSize": 25972, + "imports": [ + "GeoPath" + ] + }, + { + "scenario": "GeoPath.svg", + "description": "Standalone GeoPath from `layerchart/svg`", + "group": "Components", + "size": 89637, + "gzipSize": 22852, + "imports": [ + "GeoPath" + ] + }, + { + "scenario": "GeoPath.canvas", + "description": "Standalone GeoPath from `layerchart/canvas`", + "group": "Components", + "size": 80311, + "gzipSize": 21335, + "imports": [ + "GeoPath" + ] + }, + { + "scenario": "GeoSpline", + "description": "Standalone GeoSpline (agnostic) — baseline", + "group": "Components", + "size": 118553, + "gzipSize": 31572, + "imports": [ + "GeoSpline" + ] + }, + { + "scenario": "GeoSpline.svg", + "description": "Standalone GeoSpline from `layerchart/svg`", + "group": "Components", + "size": 107470, + "gzipSize": 28482, + "imports": [ + "GeoSpline" + ] + }, + { + "scenario": "GeoSpline.canvas", + "description": "Standalone GeoSpline from `layerchart/canvas`", + "group": "Components", + "size": 98160, + "gzipSize": 27065, + "imports": [ + "GeoSpline" + ] + }, + { + "scenario": "GeoPoint", + "description": "Standalone GeoPoint (agnostic) — baseline", + "group": "Components", + "size": 76843, + "gzipSize": 18884, + "imports": [ + "GeoPoint" + ] + }, + { + "scenario": "GeoPoint.svg", + "description": "Standalone GeoPoint from `layerchart/svg`", + "group": "Components", + "size": 64553, + "gzipSize": 15396, + "imports": [ + "GeoPoint" + ] + }, + { + "scenario": "GeoPoint.canvas", + "description": "Standalone GeoPoint from `layerchart/canvas`", + "group": "Components", + "size": 72046, + "gzipSize": 17822, + "imports": [ + "GeoPoint" + ] + }, + { + "scenario": "GeoCircle", + "description": "Standalone GeoCircle (agnostic) — baseline", + "group": "Components", + "size": 104782, + "gzipSize": 27135, + "imports": [ + "GeoCircle" + ] + }, + { + "scenario": "GeoCircle.svg", + "description": "Standalone GeoCircle from `layerchart/svg`", + "group": "Components", + "size": 93523, + "gzipSize": 23959, + "imports": [ + "GeoCircle" + ] + }, + { + "scenario": "GeoCircle.canvas", + "description": "Standalone GeoCircle from `layerchart/canvas`", + "group": "Components", + "size": 84197, + "gzipSize": 22436, + "imports": [ + "GeoCircle" + ] + }, + { + "scenario": "GeoTile", + "description": "Standalone GeoTile (agnostic) — baseline", + "group": "Components", + "size": 131416, + "gzipSize": 32447, + "imports": [ + "GeoTile" + ] + }, + { + "scenario": "GeoTile.svg", + "description": "Standalone GeoTile from `layerchart/svg`", + "group": "Components", + "size": 120805, + "gzipSize": 29581, + "imports": [ + "GeoTile" + ] + }, + { + "scenario": "GeoTile.canvas", + "description": "Standalone GeoTile from `layerchart/canvas`", + "group": "Components", + "size": 122195, + "gzipSize": 30421, + "imports": [ + "GeoTile" + ] + }, + { + "scenario": "TileImage", + "description": "Standalone TileImage (agnostic) — baseline", + "group": "Components", + "size": 119479, + "gzipSize": 29955, + "imports": [ + "TileImage" + ] + }, + { + "scenario": "TileImage.svg", + "description": "Standalone TileImage from `layerchart/svg`", + "group": "Components", + "size": 109907, + "gzipSize": 27283, + "imports": [ + "TileImage" + ] + }, + { + "scenario": "TileImage.canvas", + "description": "Standalone TileImage from `layerchart/canvas`", + "group": "Components", + "size": 112589, + "gzipSize": 28436, + "imports": [ + "TileImage" + ] + }, + { + "scenario": "Graticule", + "description": "Standalone Graticule (agnostic) — baseline", + "group": "Components", + "size": 105996, + "gzipSize": 27158, + "imports": [ + "Graticule" + ] + }, + { + "scenario": "Graticule.svg", + "description": "Standalone Graticule from `layerchart/svg`", + "group": "Components", + "size": 94685, + "gzipSize": 23998, + "imports": [ + "Graticule" + ] + }, + { + "scenario": "Graticule.canvas", + "description": "Standalone Graticule from `layerchart/canvas`", + "group": "Components", + "size": 92012, + "gzipSize": 24168, + "imports": [ + "Graticule" + ] + }, + { + "scenario": "GeoClipPath", + "description": "Standalone GeoClipPath (agnostic) — baseline", + "group": "Components", + "size": 15028, + "gzipSize": 4397, + "imports": [ + "GeoClipPath" + ] + }, + { + "scenario": "GeoClipPath.svg", + "description": "Standalone GeoClipPath from `layerchart/svg`", + "group": "Components", + "size": 13207, + "gzipSize": 4095, + "imports": [ + "GeoClipPath" + ] + }, + { + "scenario": "GeoClipPath.canvas", + "description": "Standalone GeoClipPath from `layerchart/canvas`", + "group": "Components", + "size": 12175, + "gzipSize": 3811, + "imports": [ + "GeoClipPath" + ] + }, + { + "scenario": "GeoEdgeFade", + "description": "Standalone GeoEdgeFade (agnostic) — baseline", + "group": "Components", + "size": 86116, + "gzipSize": 23790, + "imports": [ + "GeoEdgeFade" + ] + }, + { + "scenario": "GeoEdgeFade.svg", + "description": "Standalone GeoEdgeFade from `layerchart/svg`", + "group": "Components", + "size": 84596, + "gzipSize": 23322, + "imports": [ + "GeoEdgeFade" + ] + }, + { + "scenario": "GeoEdgeFade.canvas", + "description": "Standalone GeoEdgeFade from `layerchart/canvas`", + "group": "Components", + "size": 83293, + "gzipSize": 23085, + "imports": [ + "GeoEdgeFade" + ] + }, { "scenario": "all", "description": "Everything from layerchart (worst case)", "group": "Worst case", - "size": 987143, - "gzipSize": 243963, + "size": 1014956, + "gzipSize": 237523, "imports": [ "*" ] diff --git a/bundle-analyzer/bundle-scenarios.ts b/bundle-analyzer/bundle-scenarios.ts new file mode 100644 index 000000000..43e032454 --- /dev/null +++ b/bundle-analyzer/bundle-scenarios.ts @@ -0,0 +1,1851 @@ +/** + * Bundle analysis scenarios representing real-world use cases. + * + * Each scenario defines a set of imports that a user would typically + * use together, letting us track bundle cost for common chart types. + */ + +export interface Scenario { + /** Scenario name shown in reports */ + name: string; + /** Description of what this scenario represents */ + description: string; + /** Named imports from "layerchart" */ + imports: string[]; + /** Additional import lines (e.g. from "layerchart/utils/foo") */ + extraImports?: string[]; + /** Optional grouping label used to organize scenarios in the PR comment */ + group?: string; + /** + * Per-import sub-path overrides. Maps a component name to a sub-path + * suffix (e.g. `"svg"` → `"layerchart/svg"`) for scenarios that test + * per-layer variants of agnostic components. + * + * Overrides take precedence over the analyzer's default subpath map. + */ + layers?: Record; +} + +export interface ComponentInfo { + name: string; +} + +/** + * Use-case scenarios that represent how developers actually use layerchart. + * Each scenario includes the minimum set of components for that chart type. + */ +export const scenarios: Scenario[] = [ + // --- Core --- + // The bare-bones `` (no `` — no Axis/Grid/Rule/Highlight/Layer). + // Use cases: geo maps, custom layouts, or anything that doesn't need the cartesian frame. + { + name: 'core', + group: 'Core', + description: '`ChartCore` — bare-bones chart without `ChartChildren`', + imports: ['ChartCore'], + }, + { + name: 'core-svg', + group: 'Core', + description: '`ChartCore` + `Svg` from `layerchart/svg`', + imports: ['ChartCore', 'Svg'], + layers: { ChartCore: 'svg', Svg: 'svg' }, + }, + { + name: 'core-canvas', + group: 'Core', + description: '`ChartCore` + `Canvas` from `layerchart/canvas`', + imports: ['ChartCore', 'Canvas'], + layers: { ChartCore: 'canvas', Canvas: 'canvas' }, + }, + { + name: 'core-html', + group: 'Core', + description: '`ChartCore` + `Html` from `layerchart/html`', + imports: ['ChartCore', 'Html'], + layers: { ChartCore: 'html', Html: 'html' }, + }, + { + name: 'core-geo', + group: 'Core', + description: '`ChartCore`-based geo map (`GeoProjection` + `GeoPath`)', + imports: ['ChartCore', 'Svg', 'GeoProjection', 'GeoPath'], + }, + { + name: 'core-line', + group: 'Core', + description: '`ChartCore` + manual `Spline` line (no Axis/Grid)', + imports: ['ChartCore', 'Svg', 'Spline'], + }, + { + name: 'core-scatter', + group: 'Core', + description: '`ChartCore` + manual `Points` scatter (no Axis/Grid)', + imports: ['ChartCore', 'Svg', 'Points'], + }, + + // --- Base (agnostic) --- + // The full `` (with Axis/Grid/Rule/Highlight/Layer/ChartClipPath baked in). + { + name: 'base', + group: 'Base (agnostic)', + description: '`Chart` — full charting frame without rendering layer', + imports: ['Chart'], + }, + { + name: 'base-svg-agnostic', + group: 'Base (agnostic)', + description: '`Chart` + `Svg` from `layerchart` (agnostic dispatcher)', + imports: ['Chart', 'Svg'], + }, + { + name: 'base-canvas-agnostic', + group: 'Base (agnostic)', + description: '`Chart` + `Canvas` from `layerchart` (agnostic dispatcher)', + imports: ['Chart', 'Canvas'], + }, + { + name: 'base-html-agnostic', + group: 'Base (agnostic)', + description: '`Chart` + `Html` from `layerchart` (agnostic dispatcher)', + imports: ['Chart', 'Html'], + }, + + // --- Base (layer-specific) --- + { + name: 'base-svg', + group: 'Base (layer-specific)', + description: '`Chart` + `Svg` from `layerchart/svg`', + imports: ['Chart', 'Svg'], + layers: { + Chart: 'svg', + Svg: 'svg', + }, + }, + { + name: 'base-canvas', + group: 'Base (layer-specific)', + description: '`Chart` + `Canvas` from `layerchart/canvas`', + imports: ['Chart', 'Canvas'], + layers: { + Chart: 'canvas', + Canvas: 'canvas', + }, + }, + { + name: 'base-html', + group: 'Base (layer-specific)', + description: '`Chart` + `Html` from `layerchart/html`', + imports: ['Chart', 'Html'], + layers: { + Chart: 'html', + Html: 'html', + }, + }, + + // --- Cartesian charts --- + { + name: 'line-chart', + group: 'Cartesian charts', + description: 'Basic line chart with axes and grid', + imports: ['Chart', 'Svg', 'Line', 'Axis', 'Grid'], + }, + + { + name: 'line-chart-svg', + group: 'Cartesian charts', + description: 'Line chart composed from `layerchart/svg`', + imports: ['Chart', 'Layer', 'Line', 'Axis', 'Grid'], + layers: { + Chart: 'svg', + Layer: 'svg', + Line: 'svg', + Axis: 'svg', + Grid: 'svg', + }, + }, + { + name: 'line-chart-canvas', + group: 'Cartesian charts', + description: 'Line chart composed from `layerchart/canvas`', + imports: ['Chart', 'Layer', 'Line', 'Axis', 'Grid'], + layers: { + Chart: 'canvas', + Layer: 'canvas', + Line: 'canvas', + Axis: 'canvas', + Grid: 'canvas', + }, + }, + { + name: 'line-chart-html', + group: 'Cartesian charts', + description: 'Line chart composed from `layerchart/html`', + imports: ['Chart', 'Layer', 'Line', 'Axis', 'Grid'], + layers: { + Chart: 'html', + Layer: 'html', + Line: 'html', + Axis: 'html', + Grid: 'html', + }, + }, + { + name: 'line-chart-interactive', + group: 'Cartesian charts', + description: 'Line chart with tooltip and highlight', + imports: ['Chart', 'Svg', 'Line', 'Axis', 'Grid', 'Highlight', 'Tooltip'], + }, + { + name: 'LineChart', + group: 'Cartesian charts', + description: 'High-level `LineChart` component', + imports: ['LineChart'], + }, + { + name: 'LineChart-svg', + group: 'Cartesian charts', + description: 'High-level `LineChart` from `layerchart/svg`', + imports: ['LineChart'], + layers: { LineChart: 'svg' }, + }, + { + name: 'LineChart-canvas', + group: 'Cartesian charts', + description: 'High-level `LineChart` from `layerchart/canvas`', + imports: ['LineChart'], + layers: { LineChart: 'canvas' }, + }, + { + name: 'area-chart', + group: 'Cartesian charts', + description: 'Area chart with axes', + imports: ['Chart', 'Svg', 'Area', 'Axis', 'Grid'], + }, + { + name: 'AreaChart', + group: 'Cartesian charts', + description: 'High-level `AreaChart` component', + imports: ['AreaChart'], + }, + { + name: 'AreaChart-svg', + group: 'Cartesian charts', + description: 'High-level `AreaChart` from `layerchart/svg`', + imports: ['AreaChart'], + layers: { AreaChart: 'svg' }, + }, + { + name: 'AreaChart-canvas', + group: 'Cartesian charts', + description: 'High-level `AreaChart` from `layerchart/canvas`', + imports: ['AreaChart'], + layers: { AreaChart: 'canvas' }, + }, + { + name: 'bar-chart', + group: 'Cartesian charts', + description: 'Bar chart with axes', + imports: ['Chart', 'Svg', 'Bars', 'Axis', 'Grid'], + }, + { + name: 'BarChart', + group: 'Cartesian charts', + description: 'High-level `BarChart` component', + imports: ['BarChart'], + }, + { + name: 'BarChart-svg', + group: 'Cartesian charts', + description: 'High-level `BarChart` from `layerchart/svg`', + imports: ['BarChart'], + layers: { BarChart: 'svg' }, + }, + { + name: 'BarChart-canvas', + group: 'Cartesian charts', + description: 'High-level `BarChart` from `layerchart/canvas`', + imports: ['BarChart'], + layers: { BarChart: 'canvas' }, + }, + { + name: 'scatter-chart', + group: 'Cartesian charts', + description: 'Scatter plot with points', + imports: ['Chart', 'Svg', 'Points', 'Point', 'Axis', 'Grid'], + }, + { + name: 'ScatterChart', + group: 'Cartesian charts', + description: 'High-level `ScatterChart` component', + imports: ['ScatterChart'], + }, + { + name: 'ScatterChart-svg', + group: 'Cartesian charts', + description: 'High-level `ScatterChart` from `layerchart/svg`', + imports: ['ScatterChart'], + layers: { ScatterChart: 'svg' }, + }, + { + name: 'ScatterChart-canvas', + group: 'Cartesian charts', + description: 'High-level `ScatterChart` from `layerchart/canvas`', + imports: ['ScatterChart'], + layers: { ScatterChart: 'canvas' }, + }, + { + name: 'pie-chart', + group: 'Cartesian charts', + description: 'Pie/donut chart with arcs', + imports: ['Chart', 'Svg', 'Pie', 'Arc', 'ArcLabel'], + }, + { + name: 'PieChart', + group: 'Cartesian charts', + description: 'High-level `PieChart` component', + imports: ['PieChart'], + }, + { + name: 'PieChart-svg', + group: 'Cartesian charts', + description: 'High-level `PieChart` from `layerchart/svg`', + imports: ['PieChart'], + layers: { PieChart: 'svg' }, + }, + { + name: 'PieChart-canvas', + group: 'Cartesian charts', + description: 'High-level `PieChart` from `layerchart/canvas`', + imports: ['PieChart'], + layers: { PieChart: 'canvas' }, + }, + { + name: 'ArcChart', + group: 'Cartesian charts', + description: 'High-level `ArcChart` component', + imports: ['ArcChart'], + }, + { + name: 'ArcChart-svg', + group: 'Cartesian charts', + description: 'High-level `ArcChart` from `layerchart/svg`', + imports: ['ArcChart'], + layers: { ArcChart: 'svg' }, + }, + { + name: 'ArcChart-canvas', + group: 'Cartesian charts', + description: 'High-level `ArcChart` from `layerchart/canvas`', + imports: ['ArcChart'], + layers: { ArcChart: 'canvas' }, + }, + + // --- Geo --- + { + name: 'geo', + group: 'Geo', + description: 'Geographic map with paths', + imports: ['Chart', 'Svg', 'GeoProjection', 'GeoPath', 'GeoPoint'], + }, + { + name: 'geo-tiles', + group: 'Geo', + description: 'Geographic map with tile layer', + imports: ['Chart', 'Svg', 'GeoProjection', 'GeoPath', 'GeoTile', 'TileImage'], + }, + { + name: 'geo-full', + group: 'Geo', + description: 'Full geo setup with all geo components', + imports: [ + 'Chart', + 'Svg', + 'GeoProjection', + 'GeoPath', + 'GeoPoint', + 'GeoCircle', + 'GeoSpline', + 'GeoTile', + 'GeoRaster', + 'GeoEdgeFade', + 'GeoVisible', + 'Graticule', + 'GeoLegend', + 'TileImage', + ], + }, + + // --- Hierarchy --- + { + name: 'hierarchy-tree', + group: 'Hierarchy', + description: 'Tree layout with links', + imports: ['Chart', 'Svg', 'Tree', 'Link', 'Circle', 'Text'], + }, + { + name: 'hierarchy-treemap', + group: 'Hierarchy', + description: 'Treemap layout', + imports: ['Chart', 'Svg', 'Treemap', 'Group', 'Rect', 'Text'], + }, + { + name: 'hierarchy-pack', + group: 'Hierarchy', + description: 'Circle packing layout', + imports: ['Chart', 'Svg', 'Pack', 'Circle', 'Text'], + }, + + // --- Graph / network --- + { + name: 'force', + group: 'Graph / network', + description: 'Force-directed graph layout', + imports: ['Chart', 'Svg', 'ForceSimulation', 'Link', 'Circle', 'Text'], + }, + { + name: 'dagre', + group: 'Graph / network', + description: 'Dagre directed graph layout', + imports: ['Chart', 'Svg', 'Dagre', 'Link', 'Circle', 'Text'], + }, + { + name: 'sankey', + group: 'Graph / network', + description: 'Sankey flow diagram', + imports: ['Chart', 'Svg', 'Sankey', 'Link', 'Rect', 'Text'], + }, + { + name: 'chord', + group: 'Graph / network', + description: 'Chord diagram', + imports: ['Chart', 'Svg', 'Chord', 'Ribbon'], + }, + + // --- Components (opt-in per-layer variants) --- + // Standalone Circle measurements (in isolation — no Chart context, no + // Highlight pulling in the agnostic version transitively). These are the + // cleanest way to see what `layerchart/svg` etc. actually save. + { + name: 'Circle', + group: 'Components', + description: 'Standalone Circle (agnostic) — baseline', + imports: ['Circle'], + }, + { + name: 'Circle.svg', + group: 'Components', + description: 'Standalone Circle from `layerchart/svg`', + imports: ['Circle'], + layers: { Circle: 'svg' }, + }, + { + name: 'Circle.canvas', + group: 'Components', + description: 'Standalone Circle from `layerchart/canvas`', + imports: ['Circle'], + layers: { Circle: 'canvas' }, + }, + { + name: 'Circle.html', + group: 'Components', + description: 'Standalone Circle from `layerchart/html`', + imports: ['Circle'], + layers: { Circle: 'html' }, + }, + { + name: 'Text', + group: 'Components', + description: 'Standalone Text (agnostic) — baseline', + imports: ['Text'], + }, + { + name: 'Text.svg', + group: 'Components', + description: 'Standalone Text from `layerchart/svg`', + imports: ['Text'], + layers: { Text: 'svg' }, + }, + { + name: 'Text.canvas', + group: 'Components', + description: 'Standalone Text from `layerchart/canvas`', + imports: ['Text'], + layers: { Text: 'canvas' }, + }, + { + name: 'Text.html', + group: 'Components', + description: 'Standalone Text from `layerchart/html`', + imports: ['Text'], + layers: { Text: 'html' }, + }, + { + name: 'Rect', + group: 'Components', + description: 'Standalone Rect (agnostic) — baseline', + imports: ['Rect'], + }, + { + name: 'Rect.svg', + group: 'Components', + description: 'Standalone Rect from `layerchart/svg`', + imports: ['Rect'], + layers: { Rect: 'svg' }, + }, + { + name: 'Rect.canvas', + group: 'Components', + description: 'Standalone Rect from `layerchart/canvas`', + imports: ['Rect'], + layers: { Rect: 'canvas' }, + }, + { + name: 'Rect.html', + group: 'Components', + description: 'Standalone Rect from `layerchart/html`', + imports: ['Rect'], + layers: { Rect: 'html' }, + }, + { + name: 'Line', + group: 'Components', + description: 'Standalone Line (agnostic) — baseline', + imports: ['Line'], + }, + { + name: 'Line.svg', + group: 'Components', + description: 'Standalone Line from `layerchart/svg`', + imports: ['Line'], + layers: { Line: 'svg' }, + }, + { + name: 'Line.canvas', + group: 'Components', + description: 'Standalone Line from `layerchart/canvas`', + imports: ['Line'], + layers: { Line: 'canvas' }, + }, + { + name: 'Line.html', + group: 'Components', + description: 'Standalone Line from `layerchart/html`', + imports: ['Line'], + layers: { Line: 'html' }, + }, + { + name: 'Path', + group: 'Components', + description: 'Standalone Path (agnostic) — baseline', + imports: ['Path'], + }, + { + name: 'Path.svg', + group: 'Components', + description: 'Standalone Path from `layerchart/svg`', + imports: ['Path'], + layers: { Path: 'svg' }, + }, + { + name: 'Path.canvas', + group: 'Components', + description: 'Standalone Path from `layerchart/canvas`', + imports: ['Path'], + layers: { Path: 'canvas' }, + }, + { + name: 'ClipPath', + group: 'Components', + description: 'Standalone ClipPath (agnostic) — baseline', + imports: ['ClipPath'], + }, + { + name: 'ClipPath.svg', + group: 'Components', + description: 'Standalone ClipPath from `layerchart/svg`', + imports: ['ClipPath'], + layers: { ClipPath: 'svg' }, + }, + { + name: 'ClipPath.canvas', + group: 'Components', + description: 'Standalone ClipPath from `layerchart/canvas`', + imports: ['ClipPath'], + layers: { ClipPath: 'canvas' }, + }, + { + name: 'ClipPath.html', + group: 'Components', + description: 'Standalone ClipPath from `layerchart/html`', + imports: ['ClipPath'], + layers: { ClipPath: 'html' }, + }, + { + name: 'RadialGradient', + group: 'Components', + description: 'Standalone RadialGradient (agnostic) — baseline', + imports: ['RadialGradient'], + }, + { + name: 'RadialGradient.svg', + group: 'Components', + description: 'Standalone RadialGradient from `layerchart/svg`', + imports: ['RadialGradient'], + layers: { RadialGradient: 'svg' }, + }, + { + name: 'RadialGradient.canvas', + group: 'Components', + description: 'Standalone RadialGradient from `layerchart/canvas`', + imports: ['RadialGradient'], + layers: { RadialGradient: 'canvas' }, + }, + { + name: 'LinearGradient', + group: 'Components', + description: 'Standalone LinearGradient (agnostic) — baseline', + imports: ['LinearGradient'], + }, + { + name: 'LinearGradient.svg', + group: 'Components', + description: 'Standalone LinearGradient from `layerchart/svg`', + imports: ['LinearGradient'], + layers: { LinearGradient: 'svg' }, + }, + { + name: 'LinearGradient.canvas', + group: 'Components', + description: 'Standalone LinearGradient from `layerchart/canvas`', + imports: ['LinearGradient'], + layers: { LinearGradient: 'canvas' }, + }, + { + name: 'LinearGradient.html', + group: 'Components', + description: 'Standalone LinearGradient from `layerchart/html`', + imports: ['LinearGradient'], + layers: { LinearGradient: 'html' }, + }, + { + name: 'Group', + group: 'Components', + description: 'Standalone Group (agnostic) — baseline', + imports: ['Group'], + }, + { + name: 'Group.svg', + group: 'Components', + description: 'Standalone Group from `layerchart/svg`', + imports: ['Group'], + layers: { Group: 'svg' }, + }, + { + name: 'Group.canvas', + group: 'Components', + description: 'Standalone Group from `layerchart/canvas`', + imports: ['Group'], + layers: { Group: 'canvas' }, + }, + { + name: 'Group.html', + group: 'Components', + description: 'Standalone Group from `layerchart/html`', + imports: ['Group'], + layers: { Group: 'html' }, + }, + { + name: 'Pattern', + group: 'Components', + description: 'Standalone Pattern (agnostic) — baseline', + imports: ['Pattern'], + }, + { + name: 'Pattern.svg', + group: 'Components', + description: 'Standalone Pattern from `layerchart/svg`', + imports: ['Pattern'], + layers: { Pattern: 'svg' }, + }, + { + name: 'Pattern.canvas', + group: 'Components', + description: 'Standalone Pattern from `layerchart/canvas`', + imports: ['Pattern'], + layers: { Pattern: 'canvas' }, + }, + { + name: 'Pattern.html', + group: 'Components', + description: 'Standalone Pattern from `layerchart/html`', + imports: ['Pattern'], + layers: { Pattern: 'html' }, + }, + { + name: 'Ellipse', + group: 'Components', + description: 'Standalone Ellipse (agnostic) — baseline', + imports: ['Ellipse'], + }, + { + name: 'Ellipse.svg', + group: 'Components', + description: 'Standalone Ellipse from `layerchart/svg`', + imports: ['Ellipse'], + layers: { Ellipse: 'svg' }, + }, + { + name: 'Ellipse.canvas', + group: 'Components', + description: 'Standalone Ellipse from `layerchart/canvas`', + imports: ['Ellipse'], + layers: { Ellipse: 'canvas' }, + }, + { + name: 'Ellipse.html', + group: 'Components', + description: 'Standalone Ellipse from `layerchart/html`', + imports: ['Ellipse'], + layers: { Ellipse: 'html' }, + }, + { + name: 'Polygon', + group: 'Components', + description: 'Standalone Polygon (agnostic) — baseline', + imports: ['Polygon'], + }, + { + name: 'Polygon.svg', + group: 'Components', + description: 'Standalone Polygon from `layerchart/svg`', + imports: ['Polygon'], + layers: { Polygon: 'svg' }, + }, + { + name: 'Polygon.canvas', + group: 'Components', + description: 'Standalone Polygon from `layerchart/canvas`', + imports: ['Polygon'], + layers: { Polygon: 'canvas' }, + }, + { + name: 'Image', + group: 'Components', + description: 'Standalone Image (agnostic) — baseline', + imports: ['Image'], + }, + { + name: 'Image.svg', + group: 'Components', + description: 'Standalone Image from `layerchart/svg`', + imports: ['Image'], + layers: { Image: 'svg' }, + }, + { + name: 'Image.canvas', + group: 'Components', + description: 'Standalone Image from `layerchart/canvas`', + imports: ['Image'], + layers: { Image: 'canvas' }, + }, + { + name: 'Image.html', + group: 'Components', + description: 'Standalone Image from `layerchart/html`', + imports: ['Image'], + layers: { Image: 'html' }, + }, + + // Axis is a compound mark: pulls Group + Line + Text + Rule. The per-layer + // variants use the corresponding per-layer Group/Line/Text directly. Measured + // in isolation (without Chart) since `Chart`'s `ChartChildren` statically + // imports the agnostic Axis variant. + { + name: 'Axis', + group: 'Components', + description: 'Standalone Axis (agnostic) — baseline', + imports: ['Axis'], + }, + { + name: 'Axis.svg', + group: 'Components', + description: 'Standalone Axis from `layerchart/svg`', + imports: ['Axis'], + layers: { Axis: 'svg' }, + }, + { + name: 'Axis.canvas', + group: 'Components', + description: 'Standalone Axis from `layerchart/canvas`', + imports: ['Axis'], + layers: { Axis: 'canvas' }, + }, + { + name: 'Axis.html', + group: 'Components', + description: 'Standalone Axis from `layerchart/html`', + imports: ['Axis'], + layers: { Axis: 'html' }, + }, + + // Rule is a compound mark: pulls Group + Line + Circle. Per-layer variants + // use the corresponding per-layer primitives directly. + { + name: 'Rule', + group: 'Components', + description: 'Standalone Rule (agnostic) — baseline', + imports: ['Rule'], + }, + { + name: 'Rule.svg', + group: 'Components', + description: 'Standalone Rule from `layerchart/svg`', + imports: ['Rule'], + layers: { Rule: 'svg' }, + }, + { + name: 'Rule.canvas', + group: 'Components', + description: 'Standalone Rule from `layerchart/canvas`', + imports: ['Rule'], + layers: { Rule: 'canvas' }, + }, + { + name: 'Rule.html', + group: 'Components', + description: 'Standalone Rule from `layerchart/html`', + imports: ['Rule'], + layers: { Rule: 'html' }, + }, + + // Grid is a compound mark: pulls Group + Line + Circle + Rule. Per-layer + // variants use the corresponding per-layer primitives directly. + { + name: 'Grid', + group: 'Components', + description: 'Standalone Grid (agnostic) — baseline', + imports: ['Grid'], + }, + { + name: 'Grid.svg', + group: 'Components', + description: 'Standalone Grid from `layerchart/svg`', + imports: ['Grid'], + layers: { Grid: 'svg' }, + }, + { + name: 'Grid.canvas', + group: 'Components', + description: 'Standalone Grid from `layerchart/canvas`', + imports: ['Grid'], + layers: { Grid: 'canvas' }, + }, + { + name: 'Grid.html', + group: 'Components', + description: 'Standalone Grid from `layerchart/html`', + imports: ['Grid'], + layers: { Grid: 'html' }, + }, + + // Highlight is a compound mark: pulls Circle + Line + Rect (+ Arc when + // radial). Per-layer variants use the corresponding per-layer primitives. + { + name: 'Highlight', + group: 'Components', + description: 'Standalone Highlight (agnostic) — baseline', + imports: ['Highlight'], + }, + { + name: 'Highlight.svg', + group: 'Components', + description: 'Standalone Highlight from `layerchart/svg`', + imports: ['Highlight'], + layers: { Highlight: 'svg' }, + }, + { + name: 'Highlight.canvas', + group: 'Components', + description: 'Standalone Highlight from `layerchart/canvas`', + imports: ['Highlight'], + layers: { Highlight: 'canvas' }, + }, + { + name: 'Highlight.html', + group: 'Components', + description: 'Standalone Highlight from `layerchart/html`', + imports: ['Highlight'], + layers: { Highlight: 'html' }, + }, + + // RectClipPath / ChartClipPath chain pulls ClipPath. Per-layer variants + // use the corresponding per-layer ClipPath directly. + { + name: 'RectClipPath', + group: 'Components', + description: 'Standalone RectClipPath (agnostic) — baseline', + imports: ['RectClipPath'], + }, + { + name: 'RectClipPath.svg', + group: 'Components', + description: 'Standalone RectClipPath from `layerchart/svg`', + imports: ['RectClipPath'], + layers: { RectClipPath: 'svg' }, + }, + { + name: 'RectClipPath.canvas', + group: 'Components', + description: 'Standalone RectClipPath from `layerchart/canvas`', + imports: ['RectClipPath'], + layers: { RectClipPath: 'canvas' }, + }, + { + name: 'RectClipPath.html', + group: 'Components', + description: 'Standalone RectClipPath from `layerchart/html`', + imports: ['RectClipPath'], + layers: { RectClipPath: 'html' }, + }, + { + name: 'ChartClipPath', + group: 'Components', + description: 'Standalone ChartClipPath (agnostic) — baseline', + imports: ['ChartClipPath'], + }, + { + name: 'ChartClipPath.svg', + group: 'Components', + description: 'Standalone ChartClipPath from `layerchart/svg`', + imports: ['ChartClipPath'], + layers: { ChartClipPath: 'svg' }, + }, + { + name: 'ChartClipPath.canvas', + group: 'Components', + description: 'Standalone ChartClipPath from `layerchart/canvas`', + imports: ['ChartClipPath'], + layers: { ChartClipPath: 'canvas' }, + }, + { + name: 'ChartClipPath.html', + group: 'Components', + description: 'Standalone ChartClipPath from `layerchart/html`', + imports: ['ChartClipPath'], + layers: { ChartClipPath: 'html' }, + }, + + // Arc / Spline / Area are heavy compound marks built on Path. Per-layer + // variants use the corresponding per-layer Path directly. (Arc has no html + // variant since it renders SVG path geometry.) + { + name: 'Arc', + group: 'Components', + description: 'Standalone Arc (agnostic) — baseline', + imports: ['Arc'], + }, + { + name: 'Arc.svg', + group: 'Components', + description: 'Standalone Arc from `layerchart/svg`', + imports: ['Arc'], + layers: { Arc: 'svg' }, + }, + { + name: 'Arc.canvas', + group: 'Components', + description: 'Standalone Arc from `layerchart/canvas`', + imports: ['Arc'], + layers: { Arc: 'canvas' }, + }, + { + name: 'Spline', + group: 'Components', + description: 'Standalone Spline (agnostic) — baseline', + imports: ['Spline'], + }, + { + name: 'Spline.svg', + group: 'Components', + description: 'Standalone Spline from `layerchart/svg`', + imports: ['Spline'], + layers: { Spline: 'svg' }, + }, + { + name: 'Spline.canvas', + group: 'Components', + description: 'Standalone Spline from `layerchart/canvas`', + imports: ['Spline'], + layers: { Spline: 'canvas' }, + }, + { + name: 'Area', + group: 'Components', + description: 'Standalone Area (agnostic) — baseline', + imports: ['Area'], + }, + { + name: 'Area.svg', + group: 'Components', + description: 'Standalone Area from `layerchart/svg`', + imports: ['Area'], + layers: { Area: 'svg' }, + }, + { + name: 'Area.canvas', + group: 'Components', + description: 'Standalone Area from `layerchart/canvas`', + imports: ['Area'], + layers: { Area: 'canvas' }, + }, + + // Pie / ArcLabel / Bar / Bars / Points / Labels — heavy compounds built on + // primitives. Per-layer variants use the corresponding per-layer primitives. + { + name: 'Pie', + group: 'Components', + description: 'Standalone Pie (agnostic) — baseline', + imports: ['Pie'], + }, + { + name: 'Pie.svg', + group: 'Components', + description: 'Standalone Pie from `layerchart/svg`', + imports: ['Pie'], + layers: { Pie: 'svg' }, + }, + { + name: 'Pie.canvas', + group: 'Components', + description: 'Standalone Pie from `layerchart/canvas`', + imports: ['Pie'], + layers: { Pie: 'canvas' }, + }, + { + name: 'ArcLabel', + group: 'Components', + description: 'Standalone ArcLabel (agnostic) — baseline', + imports: ['ArcLabel'], + }, + { + name: 'ArcLabel.svg', + group: 'Components', + description: 'Standalone ArcLabel from `layerchart/svg`', + imports: ['ArcLabel'], + layers: { ArcLabel: 'svg' }, + }, + { + name: 'ArcLabel.canvas', + group: 'Components', + description: 'Standalone ArcLabel from `layerchart/canvas`', + imports: ['ArcLabel'], + layers: { ArcLabel: 'canvas' }, + }, + { + name: 'Bar', + group: 'Components', + description: 'Standalone Bar (agnostic) — baseline', + imports: ['Bar'], + }, + { + name: 'Bar.svg', + group: 'Components', + description: 'Standalone Bar from `layerchart/svg`', + imports: ['Bar'], + layers: { Bar: 'svg' }, + }, + { + name: 'Bar.canvas', + group: 'Components', + description: 'Standalone Bar from `layerchart/canvas`', + imports: ['Bar'], + layers: { Bar: 'canvas' }, + }, + { + name: 'Bars', + group: 'Components', + description: 'Standalone Bars (agnostic) — baseline', + imports: ['Bars'], + }, + { + name: 'Bars.svg', + group: 'Components', + description: 'Standalone Bars from `layerchart/svg`', + imports: ['Bars'], + layers: { Bars: 'svg' }, + }, + { + name: 'Bars.canvas', + group: 'Components', + description: 'Standalone Bars from `layerchart/canvas`', + imports: ['Bars'], + layers: { Bars: 'canvas' }, + }, + { + name: 'Points', + group: 'Components', + description: 'Standalone Points (agnostic) — baseline', + imports: ['Points'], + }, + { + name: 'Points.svg', + group: 'Components', + description: 'Standalone Points from `layerchart/svg`', + imports: ['Points'], + layers: { Points: 'svg' }, + }, + { + name: 'Points.canvas', + group: 'Components', + description: 'Standalone Points from `layerchart/canvas`', + imports: ['Points'], + layers: { Points: 'canvas' }, + }, + { + name: 'Points.html', + group: 'Components', + description: 'Standalone Points from `layerchart/html`', + imports: ['Points'], + layers: { Points: 'html' }, + }, + { + name: 'Labels', + group: 'Components', + description: 'Standalone Labels (agnostic) — baseline', + imports: ['Labels'], + }, + { + name: 'Labels.svg', + group: 'Components', + description: 'Standalone Labels from `layerchart/svg`', + imports: ['Labels'], + layers: { Labels: 'svg' }, + }, + { + name: 'Labels.canvas', + group: 'Components', + description: 'Standalone Labels from `layerchart/canvas`', + imports: ['Labels'], + layers: { Labels: 'canvas' }, + }, + { + name: 'Labels.html', + group: 'Components', + description: 'Standalone Labels from `layerchart/html`', + imports: ['Labels'], + layers: { Labels: 'html' }, + }, + + // Niche compounds. + { + name: 'Frame', + group: 'Components', + description: 'Standalone Frame (agnostic) — baseline', + imports: ['Frame'], + }, + { + name: 'Frame.svg', + group: 'Components', + description: 'Standalone Frame from `layerchart/svg`', + imports: ['Frame'], + layers: { Frame: 'svg' }, + }, + { + name: 'Frame.canvas', + group: 'Components', + description: 'Standalone Frame from `layerchart/canvas`', + imports: ['Frame'], + layers: { Frame: 'canvas' }, + }, + { + name: 'Frame.html', + group: 'Components', + description: 'Standalone Frame from `layerchart/html`', + imports: ['Frame'], + layers: { Frame: 'html' }, + }, + { + name: 'Cell', + group: 'Components', + description: 'Standalone Cell (agnostic) — baseline', + imports: ['Cell'], + }, + { + name: 'Cell.svg', + group: 'Components', + description: 'Standalone Cell from `layerchart/svg`', + imports: ['Cell'], + layers: { Cell: 'svg' }, + }, + { + name: 'Cell.canvas', + group: 'Components', + description: 'Standalone Cell from `layerchart/canvas`', + imports: ['Cell'], + layers: { Cell: 'canvas' }, + }, + { + name: 'Cell.html', + group: 'Components', + description: 'Standalone Cell from `layerchart/html`', + imports: ['Cell'], + layers: { Cell: 'html' }, + }, + { + name: 'Threshold', + group: 'Components', + description: 'Standalone Threshold (agnostic) — baseline', + imports: ['Threshold'], + }, + { + name: 'Threshold.svg', + group: 'Components', + description: 'Standalone Threshold from `layerchart/svg`', + imports: ['Threshold'], + layers: { Threshold: 'svg' }, + }, + { + name: 'Threshold.canvas', + group: 'Components', + description: 'Standalone Threshold from `layerchart/canvas`', + imports: ['Threshold'], + layers: { Threshold: 'canvas' }, + }, + { + name: 'AnnotationLine', + group: 'Components', + description: 'Standalone AnnotationLine (agnostic) — baseline', + imports: ['AnnotationLine'], + }, + { + name: 'AnnotationLine.svg', + group: 'Components', + description: 'Standalone AnnotationLine from `layerchart/svg`', + imports: ['AnnotationLine'], + layers: { AnnotationLine: 'svg' }, + }, + { + name: 'AnnotationLine.canvas', + group: 'Components', + description: 'Standalone AnnotationLine from `layerchart/canvas`', + imports: ['AnnotationLine'], + layers: { AnnotationLine: 'canvas' }, + }, + { + name: 'AnnotationPoint', + group: 'Components', + description: 'Standalone AnnotationPoint (agnostic) — baseline', + imports: ['AnnotationPoint'], + }, + { + name: 'AnnotationPoint.svg', + group: 'Components', + description: 'Standalone AnnotationPoint from `layerchart/svg`', + imports: ['AnnotationPoint'], + layers: { AnnotationPoint: 'svg' }, + }, + { + name: 'AnnotationPoint.canvas', + group: 'Components', + description: 'Standalone AnnotationPoint from `layerchart/canvas`', + imports: ['AnnotationPoint'], + layers: { AnnotationPoint: 'canvas' }, + }, + { + name: 'Trail', + group: 'Components', + description: 'Standalone Trail (agnostic) — baseline', + imports: ['Trail'], + }, + { + name: 'Trail.svg', + group: 'Components', + description: 'Standalone Trail from `layerchart/svg`', + imports: ['Trail'], + layers: { Trail: 'svg' }, + }, + { + name: 'Trail.canvas', + group: 'Components', + description: 'Standalone Trail from `layerchart/canvas`', + imports: ['Trail'], + layers: { Trail: 'canvas' }, + }, + + { + name: 'Vector', + group: 'Components', + description: 'Standalone Vector (agnostic) — baseline', + imports: ['Vector'], + }, + { + name: 'Vector.svg', + group: 'Components', + description: 'Standalone Vector from `layerchart/svg`', + imports: ['Vector'], + layers: { Vector: 'svg' }, + }, + { + name: 'Vector.canvas', + group: 'Components', + description: 'Standalone Vector from `layerchart/canvas`', + imports: ['Vector'], + layers: { Vector: 'canvas' }, + }, + { + name: 'Link', + group: 'Components', + description: 'Standalone Link (agnostic) — baseline', + imports: ['Link'], + }, + { + name: 'Link.svg', + group: 'Components', + description: 'Standalone Link from `layerchart/svg`', + imports: ['Link'], + layers: { Link: 'svg' }, + }, + { + name: 'Link.canvas', + group: 'Components', + description: 'Standalone Link from `layerchart/canvas`', + imports: ['Link'], + layers: { Link: 'canvas' }, + }, + { + name: 'AnnotationRange', + group: 'Components', + description: 'Standalone AnnotationRange (agnostic) — baseline', + imports: ['AnnotationRange'], + }, + { + name: 'AnnotationRange.svg', + group: 'Components', + description: 'Standalone AnnotationRange from `layerchart/svg`', + imports: ['AnnotationRange'], + layers: { AnnotationRange: 'svg' }, + }, + { + name: 'AnnotationRange.canvas', + group: 'Components', + description: 'Standalone AnnotationRange from `layerchart/canvas`', + imports: ['AnnotationRange'], + layers: { AnnotationRange: 'canvas' }, + }, + { + name: 'Hull', + group: 'Components', + description: 'Standalone Hull (agnostic) — baseline', + imports: ['Hull'], + }, + { + name: 'Hull.svg', + group: 'Components', + description: 'Standalone Hull from `layerchart/svg`', + imports: ['Hull'], + layers: { Hull: 'svg' }, + }, + { + name: 'Hull.canvas', + group: 'Components', + description: 'Standalone Hull from `layerchart/canvas`', + imports: ['Hull'], + layers: { Hull: 'canvas' }, + }, + { + name: 'Density', + group: 'Components', + description: 'Standalone Density (agnostic) — baseline', + imports: ['Density'], + }, + { + name: 'Density.svg', + group: 'Components', + description: 'Standalone Density from `layerchart/svg`', + imports: ['Density'], + layers: { Density: 'svg' }, + }, + { + name: 'Density.canvas', + group: 'Components', + description: 'Standalone Density from `layerchart/canvas`', + imports: ['Density'], + layers: { Density: 'canvas' }, + }, + { + name: 'Calendar', + group: 'Components', + description: 'Standalone Calendar (agnostic) — baseline', + imports: ['Calendar'], + }, + { + name: 'Calendar.svg', + group: 'Components', + description: 'Standalone Calendar from `layerchart/svg`', + imports: ['Calendar'], + layers: { Calendar: 'svg' }, + }, + { + name: 'Calendar.canvas', + group: 'Components', + description: 'Standalone Calendar from `layerchart/canvas`', + imports: ['Calendar'], + layers: { Calendar: 'canvas' }, + }, + { + name: 'CircleClipPath', + group: 'Components', + description: 'Standalone CircleClipPath (agnostic) — baseline', + imports: ['CircleClipPath'], + }, + { + name: 'CircleClipPath.svg', + group: 'Components', + description: 'Standalone CircleClipPath from `layerchart/svg`', + imports: ['CircleClipPath'], + layers: { CircleClipPath: 'svg' }, + }, + { + name: 'CircleClipPath.canvas', + group: 'Components', + description: 'Standalone CircleClipPath from `layerchart/canvas`', + imports: ['CircleClipPath'], + layers: { CircleClipPath: 'canvas' }, + }, + { + name: 'CircleClipPath.html', + group: 'Components', + description: 'Standalone CircleClipPath from `layerchart/html`', + imports: ['CircleClipPath'], + layers: { CircleClipPath: 'html' }, + }, + + { + name: 'Voronoi', + group: 'Components', + description: 'Standalone Voronoi (agnostic) — baseline', + imports: ['Voronoi'], + }, + { + name: 'Voronoi.svg', + group: 'Components', + description: 'Standalone Voronoi from `layerchart/svg`', + imports: ['Voronoi'], + layers: { Voronoi: 'svg' }, + }, + { + name: 'Voronoi.canvas', + group: 'Components', + description: 'Standalone Voronoi from `layerchart/canvas`', + imports: ['Voronoi'], + layers: { Voronoi: 'canvas' }, + }, + { + name: 'Contour', + group: 'Components', + description: 'Standalone Contour (agnostic) — baseline', + imports: ['Contour'], + }, + { + name: 'Contour.svg', + group: 'Components', + description: 'Standalone Contour from `layerchart/svg`', + imports: ['Contour'], + layers: { Contour: 'svg' }, + }, + { + name: 'Contour.canvas', + group: 'Components', + description: 'Standalone Contour from `layerchart/canvas`', + imports: ['Contour'], + layers: { Contour: 'canvas' }, + }, + { + name: 'Month', + group: 'Components', + description: 'Standalone Month (agnostic) — baseline', + imports: ['Month'], + }, + { + name: 'Month.svg', + group: 'Components', + description: 'Standalone Month from `layerchart/svg`', + imports: ['Month'], + layers: { Month: 'svg' }, + }, + { + name: 'Month.canvas', + group: 'Components', + description: 'Standalone Month from `layerchart/canvas`', + imports: ['Month'], + layers: { Month: 'canvas' }, + }, + { + name: 'Raster', + group: 'Components', + description: 'Standalone Raster (agnostic) — baseline', + imports: ['Raster'], + }, + { + name: 'Raster.svg', + group: 'Components', + description: 'Standalone Raster from `layerchart/svg`', + imports: ['Raster'], + layers: { Raster: 'svg' }, + }, + { + name: 'Raster.canvas', + group: 'Components', + description: 'Standalone Raster from `layerchart/canvas`', + imports: ['Raster'], + layers: { Raster: 'canvas' }, + }, + { + name: 'Raster.html', + group: 'Components', + description: 'Standalone Raster from `layerchart/html`', + imports: ['Raster'], + layers: { Raster: 'html' }, + }, + { + name: 'Violin', + group: 'Components', + description: 'Standalone Violin (agnostic) — baseline', + imports: ['Violin'], + }, + { + name: 'Violin.svg', + group: 'Components', + description: 'Standalone Violin from `layerchart/svg`', + imports: ['Violin'], + layers: { Violin: 'svg' }, + }, + { + name: 'Violin.canvas', + group: 'Components', + description: 'Standalone Violin from `layerchart/canvas`', + imports: ['Violin'], + layers: { Violin: 'canvas' }, + }, + { + name: 'BoxPlot', + group: 'Components', + description: 'Standalone BoxPlot (agnostic) — baseline', + imports: ['BoxPlot'], + }, + { + name: 'BoxPlot.svg', + group: 'Components', + description: 'Standalone BoxPlot from `layerchart/svg`', + imports: ['BoxPlot'], + layers: { BoxPlot: 'svg' }, + }, + { + name: 'BoxPlot.canvas', + group: 'Components', + description: 'Standalone BoxPlot from `layerchart/canvas`', + imports: ['BoxPlot'], + layers: { BoxPlot: 'canvas' }, + }, + + // Geo components + { + name: 'GeoPath', + group: 'Components', + description: 'Standalone GeoPath (agnostic) — baseline', + imports: ['GeoPath'], + }, + { + name: 'GeoPath.svg', + group: 'Components', + description: 'Standalone GeoPath from `layerchart/svg`', + imports: ['GeoPath'], + layers: { GeoPath: 'svg' }, + }, + { + name: 'GeoPath.canvas', + group: 'Components', + description: 'Standalone GeoPath from `layerchart/canvas`', + imports: ['GeoPath'], + layers: { GeoPath: 'canvas' }, + }, + { + name: 'GeoSpline', + group: 'Components', + description: 'Standalone GeoSpline (agnostic) — baseline', + imports: ['GeoSpline'], + }, + { + name: 'GeoSpline.svg', + group: 'Components', + description: 'Standalone GeoSpline from `layerchart/svg`', + imports: ['GeoSpline'], + layers: { GeoSpline: 'svg' }, + }, + { + name: 'GeoSpline.canvas', + group: 'Components', + description: 'Standalone GeoSpline from `layerchart/canvas`', + imports: ['GeoSpline'], + layers: { GeoSpline: 'canvas' }, + }, + { + name: 'GeoPoint', + group: 'Components', + description: 'Standalone GeoPoint (agnostic) — baseline', + imports: ['GeoPoint'], + }, + { + name: 'GeoPoint.svg', + group: 'Components', + description: 'Standalone GeoPoint from `layerchart/svg`', + imports: ['GeoPoint'], + layers: { GeoPoint: 'svg' }, + }, + { + name: 'GeoPoint.canvas', + group: 'Components', + description: 'Standalone GeoPoint from `layerchart/canvas`', + imports: ['GeoPoint'], + layers: { GeoPoint: 'canvas' }, + }, + { + name: 'GeoCircle', + group: 'Components', + description: 'Standalone GeoCircle (agnostic) — baseline', + imports: ['GeoCircle'], + }, + { + name: 'GeoCircle.svg', + group: 'Components', + description: 'Standalone GeoCircle from `layerchart/svg`', + imports: ['GeoCircle'], + layers: { GeoCircle: 'svg' }, + }, + { + name: 'GeoCircle.canvas', + group: 'Components', + description: 'Standalone GeoCircle from `layerchart/canvas`', + imports: ['GeoCircle'], + layers: { GeoCircle: 'canvas' }, + }, + { + name: 'GeoTile', + group: 'Components', + description: 'Standalone GeoTile (agnostic) — baseline', + imports: ['GeoTile'], + }, + { + name: 'GeoTile.svg', + group: 'Components', + description: 'Standalone GeoTile from `layerchart/svg`', + imports: ['GeoTile'], + layers: { GeoTile: 'svg' }, + }, + { + name: 'GeoTile.canvas', + group: 'Components', + description: 'Standalone GeoTile from `layerchart/canvas`', + imports: ['GeoTile'], + layers: { GeoTile: 'canvas' }, + }, + { + name: 'TileImage', + group: 'Components', + description: 'Standalone TileImage (agnostic) — baseline', + imports: ['TileImage'], + }, + { + name: 'TileImage.svg', + group: 'Components', + description: 'Standalone TileImage from `layerchart/svg`', + imports: ['TileImage'], + layers: { TileImage: 'svg' }, + }, + { + name: 'TileImage.canvas', + group: 'Components', + description: 'Standalone TileImage from `layerchart/canvas`', + imports: ['TileImage'], + layers: { TileImage: 'canvas' }, + }, + { + name: 'Graticule', + group: 'Components', + description: 'Standalone Graticule (agnostic) — baseline', + imports: ['Graticule'], + }, + { + name: 'Graticule.svg', + group: 'Components', + description: 'Standalone Graticule from `layerchart/svg`', + imports: ['Graticule'], + layers: { Graticule: 'svg' }, + }, + { + name: 'Graticule.canvas', + group: 'Components', + description: 'Standalone Graticule from `layerchart/canvas`', + imports: ['Graticule'], + layers: { Graticule: 'canvas' }, + }, + { + name: 'GeoClipPath', + group: 'Components', + description: 'Standalone GeoClipPath (agnostic) — baseline', + imports: ['GeoClipPath'], + }, + { + name: 'GeoClipPath.svg', + group: 'Components', + description: 'Standalone GeoClipPath from `layerchart/svg`', + imports: ['GeoClipPath'], + layers: { GeoClipPath: 'svg' }, + }, + { + name: 'GeoClipPath.canvas', + group: 'Components', + description: 'Standalone GeoClipPath from `layerchart/canvas`', + imports: ['GeoClipPath'], + layers: { GeoClipPath: 'canvas' }, + }, + { + name: 'GeoEdgeFade', + group: 'Components', + description: 'Standalone GeoEdgeFade (agnostic) — baseline', + imports: ['GeoEdgeFade'], + }, + { + name: 'GeoEdgeFade.svg', + group: 'Components', + description: 'Standalone GeoEdgeFade from `layerchart/svg`', + imports: ['GeoEdgeFade'], + layers: { GeoEdgeFade: 'svg' }, + }, + { + name: 'GeoEdgeFade.canvas', + group: 'Components', + description: 'Standalone GeoEdgeFade from `layerchart/canvas`', + imports: ['GeoEdgeFade'], + layers: { GeoEdgeFade: 'canvas' }, + }, + + // --- Worst case --- + { + name: 'all', + group: 'Worst case', + description: 'Everything from layerchart (worst case)', + imports: ['*'], + }, +]; + +/** + * Individual components to measure in isolation. + * Auto-extracted from the layerchart components index. + */ +const INDIVIDUAL_COMPONENTS: string[] = [ + 'AnnotationLine', + 'AnnotationPoint', + 'AnnotationRange', + 'Arc', + 'ArcLabel', + 'Area', + 'Axis', + 'Bar', + 'Bars', + 'Blur', + 'BoxPlot', + 'Bounds', + 'BrushContext', + 'Calendar', + 'Canvas', + 'Cell', + 'Chart', + 'ChartCore', + 'Chord', + 'ChartClipPath', + 'Circle', + 'CircleClipPath', + 'CircleLegend', + 'ClipPath', + 'ColorRamp', + 'Connector', + 'Contour', + 'Dagre', + 'Density', + 'Ellipse', + 'ForceSimulation', + 'Frame', + 'GeoCircle', + 'GeoEdgeFade', + 'GeoLegend', + 'GeoPath', + 'GeoPoint', + 'GeoProjection', + 'GeoRaster', + 'GeoSpline', + 'GeoTile', + 'GeoVisible', + 'Graticule', + 'Grid', + 'Group', + 'Highlight', + 'Html', + 'Hull', + 'Image', + 'Labels', + 'Layer', + 'Legend', + 'Line', + 'LinearGradient', + 'Link', + 'Month', + 'MotionPath', + 'Pack', + 'Partition', + 'Path', + 'Pattern', + 'Pie', + 'Point', + 'Points', + 'Polygon', + 'RadialGradient', + 'Raster', + 'Rect', + 'RectClipPath', + 'Ribbon', + 'Rule', + 'Sankey', + 'Spline', + 'Svg', + 'Text', + 'Threshold', + 'TileImage', + 'Tooltip', + 'TransformContext', + 'Trail', + 'Tree', + 'Treemap', + 'Vector', + 'Violin', + 'Voronoi', + 'WebGL', +]; + +export function getScenarios(filter?: string[]): Scenario[] { + if (!filter) return scenarios; + return scenarios.filter((s) => filter.includes(s.name)); +} + +export function getComponentScenarios(filter?: string[]): Scenario[] { + const components = filter + ? INDIVIDUAL_COMPONENTS.filter((c) => filter.includes(c)) + : INDIVIDUAL_COMPONENTS; + + return components.map((name) => ({ + name: `component:${name}`, + description: `Individual component: ${name}`, + imports: [name], + })); +} diff --git a/bundle-analyzer/define-scenarios.ts b/bundle-analyzer/define-scenarios.ts deleted file mode 100644 index 2d1d50918..000000000 --- a/bundle-analyzer/define-scenarios.ts +++ /dev/null @@ -1,285 +0,0 @@ -/** - * Bundle analysis scenarios representing real-world use cases. - * - * Each scenario defines a set of imports that a user would typically - * use together, letting us track bundle cost for common chart types. - */ - -export interface Scenario { - /** Scenario name shown in reports */ - name: string; - /** Description of what this scenario represents */ - description: string; - /** Named imports from "layerchart" */ - imports: string[]; - /** Additional import lines (e.g. from "layerchart/utils/foo") */ - extraImports?: string[]; - /** Optional grouping label used to organize scenarios in the PR comment */ - group?: string; -} - -export interface ComponentInfo { - name: string; -} - -/** - * Use-case scenarios that represent how developers actually use layerchart. - * Each scenario includes the minimum set of components for that chart type. - */ -export const scenarios: Scenario[] = [ - // --- Foundation --- - { - name: "core", - group: "Foundation", - description: "Bare minimum: Chart context + Svg layer", - imports: ["Chart", "Svg"], - }, - { - name: "canvas", - group: "Foundation", - description: "Canvas-based rendering", - imports: ["Chart", "Canvas"], - }, - - // --- Cartesian charts --- - { - name: "line-chart", - group: "Cartesian charts", - description: "Basic line chart with axes and grid", - imports: ["Chart", "Svg", "Line", "Axis", "Grid"], - }, - { - name: "line-chart-interactive", - group: "Cartesian charts", - description: "Line chart with tooltip and highlight", - imports: ["Chart", "Svg", "Line", "Axis", "Grid", "Highlight", "Tooltip"], - }, - { - name: "area-chart", - group: "Cartesian charts", - description: "Area chart with axes", - imports: ["Chart", "Svg", "Area", "Axis", "Grid"], - }, - { - name: "bar-chart", - group: "Cartesian charts", - description: "Bar chart with axes", - imports: ["Chart", "Svg", "Bars", "Axis", "Grid"], - }, - { - name: "scatter-chart", - group: "Cartesian charts", - description: "Scatter plot with points", - imports: ["Chart", "Svg", "Points", "Point", "Axis", "Grid"], - }, - { - name: "pie-chart", - group: "Cartesian charts", - description: "Pie/donut chart with arcs", - imports: ["Chart", "Svg", "Pie", "Arc", "ArcLabel"], - }, - { - name: "high-level-charts", - group: "Cartesian charts", - description: "All high-level chart components (LineChart, BarChart, etc.)", - imports: ["LineChart", "AreaChart", "BarChart", "PieChart", "ScatterChart", "ArcChart"], - }, - - // --- Geo --- - { - name: "geo", - group: "Geo", - description: "Geographic map with paths", - imports: ["Chart", "Svg", "GeoProjection", "GeoPath", "GeoPoint"], - }, - { - name: "geo-tiles", - group: "Geo", - description: "Geographic map with tile layer", - imports: ["Chart", "Svg", "GeoProjection", "GeoPath", "GeoTile", "TileImage"], - }, - { - name: "geo-full", - group: "Geo", - description: "Full geo setup with all geo components", - imports: [ - "Chart", - "Svg", - "GeoProjection", - "GeoPath", - "GeoPoint", - "GeoCircle", - "GeoSpline", - "GeoTile", - "GeoRaster", - "GeoEdgeFade", - "GeoVisible", - "Graticule", - "GeoLegend", - "TileImage", - ], - }, - - // --- Hierarchy --- - { - name: "hierarchy-tree", - group: "Hierarchy", - description: "Tree layout with links", - imports: ["Chart", "Svg", "Tree", "Link", "Circle", "Text"], - }, - { - name: "hierarchy-treemap", - group: "Hierarchy", - description: "Treemap layout", - imports: ["Chart", "Svg", "Treemap", "Group", "Rect", "Text"], - }, - { - name: "hierarchy-pack", - group: "Hierarchy", - description: "Circle packing layout", - imports: ["Chart", "Svg", "Pack", "Circle", "Text"], - }, - - // --- Graph / network --- - { - name: "force", - group: "Graph / network", - description: "Force-directed graph layout", - imports: ["Chart", "Svg", "ForceSimulation", "Link", "Circle", "Text"], - }, - { - name: "dagre", - group: "Graph / network", - description: "Dagre directed graph layout", - imports: ["Chart", "Svg", "Dagre", "Link", "Circle", "Text"], - }, - { - name: "sankey", - group: "Graph / network", - description: "Sankey flow diagram", - imports: ["Chart", "Svg", "Sankey", "Link", "Rect", "Text"], - }, - { - name: "chord", - group: "Graph / network", - description: "Chord diagram", - imports: ["Chart", "Svg", "Chord", "Ribbon"], - }, - - // --- Worst case --- - { - name: "all", - group: "Worst case", - description: "Everything from layerchart (worst case)", - imports: ["*"], - }, -]; - -/** - * Individual components to measure in isolation. - * Auto-extracted from the layerchart components index. - */ -const INDIVIDUAL_COMPONENTS: string[] = [ - "AnnotationLine", - "AnnotationPoint", - "AnnotationRange", - "Arc", - "ArcLabel", - "Area", - "Axis", - "Bar", - "Bars", - "Blur", - "BoxPlot", - "Bounds", - "BrushContext", - "Calendar", - "Canvas", - "Cell", - "Chart", - "Chord", - "ChartClipPath", - "Circle", - "CircleClipPath", - "CircleLegend", - "ClipPath", - "ColorRamp", - "Connector", - "Contour", - "Dagre", - "Density", - "Ellipse", - "ForceSimulation", - "Frame", - "GeoCircle", - "GeoEdgeFade", - "GeoLegend", - "GeoPath", - "GeoPoint", - "GeoProjection", - "GeoRaster", - "GeoSpline", - "GeoTile", - "GeoVisible", - "Graticule", - "Grid", - "Group", - "Highlight", - "Html", - "Hull", - "Image", - "Labels", - "Layer", - "Legend", - "Line", - "LinearGradient", - "Link", - "Month", - "MotionPath", - "Pack", - "Partition", - "Path", - "Pattern", - "Pie", - "Point", - "Points", - "Polygon", - "RadialGradient", - "Raster", - "Rect", - "RectClipPath", - "Ribbon", - "Rule", - "Sankey", - "Spline", - "Svg", - "Text", - "Threshold", - "TileImage", - "Tooltip", - "TransformContext", - "Trail", - "Tree", - "Treemap", - "Vector", - "Violin", - "Voronoi", - "WebGL", -]; - -export function getScenarios(filter?: string[]): Scenario[] { - if (!filter) return scenarios; - return scenarios.filter((s) => filter.includes(s.name)); -} - -export function getComponentScenarios(filter?: string[]): Scenario[] { - const components = filter - ? INDIVIDUAL_COMPONENTS.filter((c) => filter.includes(c)) - : INDIVIDUAL_COMPONENTS; - - return components.map((name) => ({ - name: `component:${name}`, - description: `Individual component: ${name}`, - imports: [name], - })); -} diff --git a/bundle-analyzer/generate-pr-comment.js b/bundle-analyzer/generate-pr-comment.js index a6201bbd6..2de8c4a08 100644 --- a/bundle-analyzer/generate-pr-comment.js +++ b/bundle-analyzer/generate-pr-comment.js @@ -41,6 +41,10 @@ function formatDiff(bytes) { } function formatPercent(percent) { + // Added scenarios (target was 0) have +Infinity; render as "+100%". Removed + // scenarios go through the explicit -100 branch, but handle -Infinity too. + if (percent === Infinity) return "+100%"; + if (percent === -Infinity) return "-100%"; if (!isFinite(percent)) return ""; const sign = percent > 0 ? "+" : ""; return `${sign}${percent.toFixed(1)}%`; @@ -127,7 +131,7 @@ function analyzeChanges(prReport, targetReport) { // Preserve the input order — PR scenarios first (which start with `core`), // then any target-only scenarios. Order within a group reflects the order - // in `define-scenarios.ts`. + // in `bundle-scenarios.ts`. return changes; } @@ -150,12 +154,28 @@ function generateComment(changes, hasBaseline = true) { if (scenarios.length > 0) { comment += "### Use-Case Scenarios\n\n"; - comment += "| Scenario | Size | Gzipped |\n"; - comment += "|----------|-----:|--------:|\n"; + + /** @type {Map} */ + const byGroup = new Map(); for (const s of scenarios) { - comment += `| \`${s.scenario}\` | ${formatKB(s.currentSize)} KB | ${formatKB(s.currentGzipSize)} KB |\n`; + const g = s.group || "Other"; + if (!byGroup.has(g)) byGroup.set(g, []); + byGroup.get(g).push(s); + } + + const expandedGroups = new Set(["Base (agnostic)", "Base (layer-specific)", "Core"]); + + for (const [groupName, rows] of byGroup) { + const open = expandedGroups.has(groupName) ? " open" : ""; + comment += `\n`; + comment += `${groupName} (${rows.length})\n\n`; + comment += "| Scenario | Size | Gzipped |\n"; + comment += "|----------|-----:|--------:|\n"; + for (const s of rows) { + comment += `| \`${s.scenario}\` | ${formatKB(s.currentSize)} KB | ${formatKB(s.currentGzipSize)} KB |\n`; + } + comment += "\n\n\n"; } - comment += "\n"; } if (components.length > 0) { @@ -202,18 +222,24 @@ function generateComment(changes, hasBaseline = true) { const renderRow = (s) => { const icon = getStatusIcon(s.status, s.sizeDiff); - const current = `${formatKB(s.targetSize)} KB (${formatKB(s.targetGzipSize)} gz)`; - const newSize = `${formatKB(s.currentSize)} KB (${formatKB(s.currentGzipSize)} gz)`; - const change = `${formatDiff(s.sizeDiff)} KB (${formatPercent(s.sizePercent)}) (${formatDiff(s.gzipSizeDiff)} gz, ${formatPercent(s.gzipSizePercent)})`; + const current = `${formatKB(s.targetSize)} KB
${formatKB(s.targetGzipSize)} gz`; + const newSize = `${formatKB(s.currentSize)} KB
${formatKB(s.currentGzipSize)} gz`; + const change = `${formatDiff(s.sizeDiff)} KB (${formatPercent(s.sizePercent)})
${formatDiff(s.gzipSizeDiff)} gz (${formatPercent(s.gzipSizePercent)})`; return `| ${icon} \`${s.scenario}\` | ${current} | ${newSize} | ${change} |\n`; }; + // Base + Core groups expanded by default; other groups collapsed to keep + // the comment scannable. Each group shows the count of changed scenarios. + const expandedGroups = new Set(["Base (agnostic)", "Base (layer-specific)", "Core"]); + for (const [groupName, rows] of byGroup) { - comment += `#### ${groupName}\n\n`; + const open = expandedGroups.has(groupName) ? " open" : ""; + comment += `\n`; + comment += `${groupName} (${rows.length} changed)\n\n`; comment += "| Scenario | Current | New | Change |\n"; comment += "|----------|--------:|----:|-------:|\n"; for (const s of rows) comment += renderRow(s); - comment += "\n"; + comment += "\n\n\n"; } } @@ -284,6 +310,7 @@ function main() { changes = prReport.results.map((result) => ({ scenario: result.scenario, description: result.description, + group: result.group, status: "added", sizeDiff: result.size, gzipSizeDiff: result.gzipSize, diff --git a/bundle-analyzer/tsconfig.json b/bundle-analyzer/tsconfig.json index 963fafeed..61a49a120 100644 --- a/bundle-analyzer/tsconfig.json +++ b/bundle-analyzer/tsconfig.json @@ -11,5 +11,5 @@ "verbatimModuleSyntax": true, "noEmit": true }, - "include": ["bundle-analyzer.ts", "define-scenarios.ts"] + "include": ["bundle-analyzer.ts", "bundle-scenarios.ts"] } diff --git a/docs/generated/releases/layerchart-2.0.0-next.58.md b/docs/generated/releases/layerchart-2.0.0-next.58.md new file mode 100644 index 000000000..3167749b0 --- /dev/null +++ b/docs/generated/releases/layerchart-2.0.0-next.58.md @@ -0,0 +1,66 @@ +--- +title: "layerchart@2.0.0-next.58" +tag: "layerchart@2.0.0-next.58" +date: "2026-04-21T15:03:08Z" +url: "https://github.com/techniq/layerchart/releases/tag/layerchart%402.0.0-next.58" +draft: false +prerelease: true +author: "github-actions[bot]" +--- +### Major Changes + +- breaking: Merge `Connector` into `Link`, remove `Connector` component ([#449](https://github.com/techniq/layerchart/pull/449)) + + `Link` now supports both **pixel mode** (`x1`/`y1`/`x2`/`y2` props) and **data mode** (`data` + `source`/`target`/`x`/`y` accessors), mirroring the pattern used by primitives like `Circle`, `Text`, and `Rect`. + + **Migration:** + + - `` → `` + - `` → `` (or ``) + + All Connector props (`type`, `curve`, `sweep`, `radius`, `bend`, `orientation`, `radial`, markers, motion) are available directly on `Link`. The `explicitCoords` prop and `Connector` export are removed. + +### Minor Changes + +- feat(AnnotationLine): Add `x1`/`y1`/`x2`/`y2` props for sloped lines ([#449](https://github.com/techniq/layerchart/pull/449)) + - Pass any combination of `x1`, `y1`, `x2`, `y2` to draw a line between arbitrary points. Missing coordinates fall back to the corresponding axis range (so `x1`/`x2` alone still span the y range, etc.). The existing `x` / `y` shorthand for full-span vertical/horizontal lines is unchanged. + - Labels on sloped lines automatically rotate to follow the line angle (normalized to stay upright), with `labelPlacement`, `labelXOffset`, and `labelYOffset` applied along and perpendicular to the line. + +- feat(AnnotationPoint): Add `link` prop for ring-note style callouts, plus geo projection support ([#449](https://github.com/techniq/layerchart/pull/449)) + - Pass `link={true}` or `link={{ type: 'beveled', radius: 20, ... }}` etc. to draw a `` from the ring edge to the label. Any `Link` prop (`type`, `curve`, `sweep`, `radius`, `bend`, `class`, ...) can be passed through. + - Inside a geo ``, `x`/`y` are now interpreted as `[lon, lat]` and projected directly, so `AnnotationPoint` can be used on maps. + +- feat(Connector): Add `'swoop'` connector type ([#449](https://github.com/techniq/layerchart/pull/449)) + + New `'swoop'` connector type draws a circular arc between source and target, equivalent to ObservablePlot's Arrow `bend` option. Configured via a new `bend` prop (degrees, default `22.5`) — positive bends right (clockwise from source to target), negative bends left, `0` draws a straight line. Works in both cartesian and radial modes; `Link` forwards it automatically. + +- feat(tooltipContext, Voronoi): Add `x`/`y` accessor overrides and default array endpoint to max ([#449](https://github.com/techniq/layerchart/pull/449)) + - **New `x`/`y` props** on `tooltipContext` and `Voronoi` accept an `Accessor` (property name string or function). When set, hit-detection points use these accessors instead of the Chart's `x`/`y`. Useful when the Chart's accessor returns an array (e.g. `x={['POP_1980', 'POP_2015']}`) and you want detection at a specific endpoint: + ```svelte + + ``` + - **Breaking (minor)**: when the chart's x/y accessor returns an array (duration bars, candlesticks, stacked areas, etc.), quadtree and voronoi hit-detection now default to the **max** value of the array instead of the **min**. For most use cases (target endpoint, stack top) this is the more natural hover position. If you need the old behavior, pass an explicit `x`/`y` accessor on `tooltipContext`/`Voronoi`. + +### Patch Changes + +- fix(Chart): Explicit `` now takes precedence over marks' implicit-series data ([#449](https://github.com/techniq/layerchart/pull/449)) + + When a mark registered its own filtered dataset via `markInfo` (e.g. a decorative `` showing labels for a subset), two things went wrong: + + 1. `ctx.data` would silently switch to the filtered subset via `seriesState.visibleSeriesData`, causing sibling array-driven marks (like ``) to iterate only the subset. + 2. An implicit series would be created from the decorative mark, narrowing the domain calculation to only the subset's values. + + Now when `` is explicit (non-empty): + + - `ctx.data` always returns the chart's data. + - Marks whose axis accessor matches the chart's axis accessor (including any element of an array accessor like `y={['v1', 'v2']}`) are treated as decorative and don't create implicit series — even if they have their own `data` array. + + Marks with their own data still contribute to `flatData` for domain calculation when their accessor differs from the chart's (the multi-dataset / multi-series scenario). + +- fix(Connector, Link): Orient d3 step curves by `orientation` ([#449](https://github.com/techniq/layerchart/pull/449)) + - Added `orientation?: 'horizontal' | 'vertical'` prop to `Connector` (defaults to `'horizontal'`). `Link` forwards its own orientation so step curves step along the natural flow direction. + - `curveStep`, `curveStepBefore`, and `curveStepAfter` now step along `y` in vertical orientation instead of always stepping along `x`. + +- fix(GeoPath): Avoid passing `undefined` event handlers to underlying `Path`, preventing a Svelte error while preserving canvas hit-testing for non-interactive paths ([#449](https://github.com/techniq/layerchart/pull/449)) + +- fix: Allow negative string values (e.g. `y="-6"`) in `Text` position props to be treated as pixel values instead of data property names ([#449](https://github.com/techniq/layerchart/pull/449)) diff --git a/docs/generated/releases/layerchart-2.0.0-next.59.md b/docs/generated/releases/layerchart-2.0.0-next.59.md new file mode 100644 index 000000000..5ca090988 --- /dev/null +++ b/docs/generated/releases/layerchart-2.0.0-next.59.md @@ -0,0 +1,12 @@ +--- +title: "layerchart@2.0.0-next.59" +tag: "layerchart@2.0.0-next.59" +date: "2026-04-24T16:36:01Z" +url: "https://github.com/techniq/layerchart/releases/tag/layerchart%402.0.0-next.59" +draft: false +prerelease: true +author: "github-actions[bot]" +--- +### Patch Changes + +- fix: Prevent submitting forms when clicking legend buttons ([#841](https://github.com/techniq/layerchart/pull/841)) diff --git a/docs/src/content/guides/bundle-size.md b/docs/src/content/guides/bundle-size.md new file mode 100644 index 000000000..97f4edcab --- /dev/null +++ b/docs/src/content/guides/bundle-size.md @@ -0,0 +1,250 @@ +--- +title: Bundle Size +description: How to reduce LayerChart's footprint in your application bundle +category: advanced +--- + +LayerChart ships a layer-agnostic, batteries-included API by default — `` works with ``, ``, and `` rendering, primitives like `` and `` auto-detect the surrounding layer, and chart-level features like brushing, tooltips, and tile maps activate when you pass the right prop. + +That flexibility has a cost: every consumer of `import { Chart } from 'layerchart'` would otherwise pay for code paths they may never reach. To keep the default bundle small, LayerChart uses three layered strategies: + +1. **Lazy-loaded opt-in features** — Heavy features are dynamically imported only when activated +2. **Sub-path exports for heavy dependencies** — Components that pull in big external deps live behind opt-in sub-paths +3. **Per-layer variants** — Almost every component has SVG/Canvas/HTML-specific variants for users who commit to one layer (primitives, compound marks, geo, graph, and the high-level chart wrappers like ``) + +The first two cost you nothing — they're transparent. The third is opt-in: you swap an import to get a smaller bundle in exchange for losing layer flexibility on that import. + +## What you get for free + +The following heavy features are loaded only when you use them, with no code change required from you: + +| Feature | When it loads | +| --- | --- | +| `` (and brush state) | When `` is set | +| `` (and transform state) | When `` is set | +| `` | When `tooltipContext` is set and you don't provide a custom `tooltip` snippet | +| `Voronoi` hit-detection | When `` is used | +| `Arc` (radial tooltip rects) | When `` or `mode="band"` is used inside a radial chart | +| `d3-quadtree` | When `` (or `quadtree-x` / `quadtree-y`) is used | +| `Spline` (radial linear grid) | When `` with `` is used | +| `Bar` highlight overlay | When `` is set | +| ``, ``, ``, `` | When the corresponding prop is set on `` | + +These additions to your chart cause an extra HTTP fetch the first time the corresponding feature is used. On a fast network this is unnoticeable; on slow networks the chart paints first and the optional feature appears as it loads. + +## Sub-path exports for heavy dependencies + +Components that bring in large d3 modules or framework-specific libraries are not re-exported from the root `'layerchart'` entry. They live behind opt-in sub-paths so the default barrel doesn't drag those deps into bundlers that don't tree-shake aggressively. + +| Sub-path | Components | Heavy dep saved | +| --- | --- | --- | +| `layerchart/geo` | `Geo*` (12), `Graticule`, `TileImage` | `d3-geo` (~15 KB), `d3-tile` | +| `layerchart/hierarchy` | `Tree`, `Treemap`, `Pack`, `Partition` | `d3-hierarchy` (~6 KB) | +| `layerchart/force` | `ForceSimulation` | `d3-force` (~7 KB) | +| `layerchart/graph` | `Dagre`, `Sankey`, `Chord`, `Ribbon` | `@dagrejs/dagre` (~22 KB), `d3-sankey`, `d3-chord` | + +If you use these components, just import from the sub-path: + +```ts +import { Tree, Treemap } from 'layerchart/hierarchy'; +import { GeoPath, GeoProjection } from 'layerchart/geo'; +import { ForceSimulation } from 'layerchart/force'; +import { Sankey, Dagre } from 'layerchart/graph'; +``` + +If you don't use them, you don't pay for them — the agnostic root export simply doesn't expose them, so even bundlers that mishandle tree-shaking can't accidentally include them. + +Each sub-path also re-exports the layer-agnostic helpers you'd need alongside its specialty components (e.g. `Chart`, `Tooltip`, `Axis`, `Highlight`, scales, layouts). For typical usage you can stay on a single sub-path import line for an entire chart. + +## `` for non-cartesian charts (opt-in) + +`` includes the standard cartesian frame: ``, ``, ``, ``, ``, and ``. Those are baked into `Chart`'s import graph so cartesian users get them automatically. But if you're rendering a geo map, a custom layout, or any chart that doesn't need axes/grids, that import chain is dead weight. + +`` is a bare-bones variant that provides the chart context, sizing, brush, transform, and tooltip plumbing — but skips `ChartChildren` and everything it pulls in. You provide your own primitives directly via the `children` snippet: + +```svelte + + + + {#snippet children({ context })} + + + + + + {/snippet} + +``` + +A typical geo chart drops from ~87 KB gz with `` to ~55 KB gz with `` (a ~32 KB gz / ~37% saving) because the entire `Axis` / `Grid` / `Rule` / `Highlight` / `ChartClipPath` / `Layer` chain is no longer in the import graph. + +`` is exported from each layer sub-path (`layerchart`, `layerchart/svg`, `layerchart/canvas`, `layerchart/html`). The component itself is layer-agnostic — the layer is whatever you put inside the `children` snippet — so the sub-path choice only affects what other components (like `` or ``) tree-shake to. + +When to use ``: +- ✅ Geo maps (you'll render `` + `` directly, no axes needed) +- ✅ Custom force-directed or hierarchy layouts that compose their own primitives +- ✅ Pre-rendered SVG/canvas content that just needs chart context for sizing +- 🤷 Cartesian charts — keep using ``; you'd just have to re-add Axis/Grid/etc. yourself + +## Per-layer variants (opt-in) + +Almost every layer-agnostic component — primitives like `` / `` / ``, compound marks like `` / `` / ``, geo components like ``, and the high-level chart wrappers like `` — auto-detects the surrounding layer (``, ``, or ``) and renders appropriately. To do this they bundle every rendering path. + +If you know your chart only renders to one layer, you can opt into a layer-specific variant: + +```ts +// Default: agnostic, ~17 KB gz, works with Svg/Canvas/Html +import { Circle } from 'layerchart'; + +// SVG-only, ~13 KB gz (~25% smaller) +import { Circle } from 'layerchart/svg'; + +// Canvas-only +import { Circle } from 'layerchart/canvas'; + +// HTML-only, ~13 KB gz +import { Circle } from 'layerchart/html'; +``` + +The agnostic version (`Circle.svelte`) dispatches to the appropriate per-layer variant under the hood at runtime, so you can mix per-layer and agnostic imports in the same chart — the resolved code path is identical. + +The `layerchart/svg`, `layerchart/canvas`, and `layerchart/html` sub-paths re-export every layer-agnostic helper too (layouts, scales, tooltip primitives, etc.), so a single per-layer import path can cover a typical chart end-to-end. + +### When per-layer is worth it + +- ✅ You're building many charts in a single layer (most likely SVG) +- ✅ You're shipping to a bandwidth-sensitive context (mobile, embedded views, AMP-style pages) +- ✅ You want to sketch out the absolute minimum bundle for a specific use case + +### When to stay on the agnostic API + +- 🤷 You mix layers in the same project (some charts SVG, some Canvas) +- 🤷 Your bundle savings would be small relative to the rest of your app +- 🤷 You value the flexibility to swap a chart's rendering layer later without touching imports + +### Primitives + +| Primitive | Svg-only saves | Canvas-only saves | Html-only saves | +| --- | --- | --- | --- | +| `Circle` | ~4 KB gz (~23%) | ~1 KB gz (~7%) | ~4 KB gz (~22%) | +| `Text` | ~13 KB gz (~45%) | ~2 KB gz (~8%) | ~13 KB gz (~46%) | +| `Rect` | ~4 KB gz (~23%) | ~1 KB gz (~7%) | ~4 KB gz (~23%) | +| `Line` | ~4 KB gz (~22%) | ~3 KB gz (~14%) | ~5 KB gz (~27%) | +| `Path` | ~3 KB gz (~15%) | ~4 KB gz (~20%) | n/a (no HTML variant) | +| `Ellipse` | ~4 KB gz (~23%) | ~1 KB gz (~7%) | ~4 KB gz (~23%) | +| `Polygon` | ~3 KB gz (~16%) | ~1 KB gz (~3%) | n/a (no HTML variant) | +| `Group` | ~0.5 KB gz (~13%) | ~1 KB gz (~22%) | ~0.5 KB gz (~12%) | +| `Image` | ~1 KB gz (~8%) | **~11 KB gz (~75%)** | ~2 KB gz (~11%) | +| `ClipPath` | ~0.5 KB gz (~27%) | ~0.8 KB gz (~40%) | ~0.7 KB gz (~36%) | +| `Pattern` | ~4 KB gz (~26%) | ~1 KB gz (~9%) | **~14 KB gz (~94%)** | +| `LinearGradient` | ~4 KB gz (~26%) | ~1 KB gz (~7%) | **~14 KB gz (~96%)** | +| `RadialGradient` | ~4 KB gz (~25%) | ~0.8 KB gz (~6%) | n/a (no HTML variant) | + +Notice the dramatic per-layer savings for components like `Pattern` and `LinearGradient` on HTML — the HTML implementation is just CSS-string generation (no canvas API or SVG element overhead), so the per-layer variant is ~95% smaller than agnostic. + +### Compound marks + +These are the chart-relative shapes built on top of primitives — bars, splines, areas, axes, points, annotations, etc. Per-layer savings here are typically 8–15% gz; outliers like `Highlight` (-30% canvas), `Cell` (-22% svg), and `CircleClipPath` (-37% canvas) are larger because their HTML/canvas vs. SVG paths diverge significantly. + +| Component | Svg-only saves | Canvas-only saves | Html-only saves | +| --- | --- | --- | --- | +| `Axis` | ~5 KB gz (~13%) | ~6 KB gz (~14%) | ~7 KB gz (~15%) | +| `Highlight` | ~2 KB gz (~23%) | ~3 KB gz (~30%) | ~2 KB gz (~21%) | +| `Bars` / `Bar` | ~3 KB gz (~8%) | ~3–4 KB gz (~8–9%) | n/a | +| `Spline` | ~3 KB gz (~11%) | ~4 KB gz (~16%) | n/a | +| `Area` | ~3 KB gz (~11%) | ~4 KB gz (~15%) | n/a | +| `Pie` / `Arc` / `ArcLabel` | ~3 KB gz (~8–9%) | ~4 KB gz (~12–13%) | n/a | +| `Points` | ~4 KB gz (~20%) | ~1 KB gz (~5%) | ~4 KB gz (~20%) | +| `Cell` | ~5 KB gz (~22%) | ~2 KB gz (~11%) | ~5 KB gz (~22%) | +| `Frame` | ~4 KB gz (~22%) | ~1 KB gz (~6%) | ~4 KB gz (~22%) | +| `Threshold` | ~3 KB gz (~11%) | ~5 KB gz (~15%) | n/a | +| `Trail` / `Vector` / `Link` | ~3 KB gz (~11–13%) | ~3–4 KB gz (~15–17%) | n/a | +| `AnnotationLine` / `AnnotationPoint` / `AnnotationRange` | ~3–4 KB gz (~9–11%) | ~3–6 KB gz (~9–14%) | n/a | +| `Labels` | ~4 KB gz (~12%) | ~4 KB gz (~10%) | ~5 KB gz (~13%) | +| `ChartClipPath` | ~0.5 KB gz (~5%) | ~0.8 KB gz (~7%) | ~0.8 KB gz (~6%) | +| `CircleClipPath` | ~0.5 KB gz (~25%) | ~0.8 KB gz (~37%) | ~0.7 KB gz (~33%) | +| `Density` / `Contour` / `Raster` | ~1–3 KB gz (~3–8%) | ~1–3 KB gz (~4–8%) | `Raster` only on html (~4%) | +| `Violin` / `BoxPlot` | ~4 KB gz (~13–16%) | ~3–4 KB gz (~12–13%) | n/a | +| `Calendar` / `Month` | ~1–3 KB gz (~3–10%) | ~2 KB gz (~5–7%) | n/a | +| `Hull` / `Voronoi` | ~0 KB | ~0–0.5 KB gz | n/a | + +`Hull` and `Voronoi` show no per-layer wins because their cost is dominated by the d3 algorithm rather than the rendering path. + +### Geo components + +Geo marks live on `layerchart/geo` (and `layerchart/geo`-prefixed variants like `layerchart/svg` re-export them too). + +| Component | Svg-only saves | Canvas-only saves | +| --- | --- | --- | +| `GeoPath` | ~3 KB gz (~12%) | ~5 KB gz (~18%) | +| `GeoSpline` | ~3 KB gz (~10%) | ~4 KB gz (~14%) | +| `GeoPoint` | ~3 KB gz (~18%) | ~1 KB gz (~6%) | +| `GeoCircle` | ~3 KB gz (~12%) | ~5 KB gz (~17%) | +| `GeoTile` / `TileImage` | ~3 KB gz (~9%) | ~2 KB gz (~5–6%) | +| `Graticule` | ~3 KB gz (~12%) | ~3 KB gz (~11%) | +| `GeoClipPath` | ~0.3 KB gz (~7%) | ~0.6 KB gz (~13%) | +| `GeoEdgeFade` | ~0.5 KB gz (~2%) | ~0.7 KB gz (~3%) | + +### High-level chart wrappers + +``, ``, ``, ``, ``, and `` are pre-composed charts — they include `` itself plus the appropriate marks, tooltip wiring, highlight handling, and series management. Importing a high-level chart from a per-layer sub-path skips both the chart wrapper's layer dispatch *and* every primitive/mark it composes, so the savings are larger than any single primitive. + +| Chart | Agnostic | `from 'layerchart/svg'` | `from 'layerchart/canvas'` | +| --- | --- | --- | --- | +| `LineChart` | 89.6 KB gz | 79.0 KB gz (-12%) | 80.7 KB gz (-10%) | +| `AreaChart` | 87.3 KB gz | 81.2 KB gz (-7%) | 82.8 KB gz (-5%) | +| `BarChart` | 85.4 KB gz | 79.2 KB gz (-7%) | 80.9 KB gz (-5%) | +| `ScatterChart` | 84.5 KB gz | 78.1 KB gz (-8%) | 79.8 KB gz (-6%) | +| `PieChart` | 91.9 KB gz | 85.3 KB gz (-7%) | 86.9 KB gz (-5%) | +| `ArcChart` | 90.7 KB gz | 84.2 KB gz (-7%) | 85.9 KB gz (-5%) | + +```ts +// Agnostic — supports Svg / Canvas / Html +import { LineChart } from 'layerchart'; + +// SVG-only +import { LineChart } from 'layerchart/svg'; + +// Canvas-only +import { LineChart } from 'layerchart/canvas'; +``` + +There is no `layerchart/html` variant for the high-level charts because the marks they compose (``, ``, ``, ``, ``) don't have a pure HTML rendering. Importing `LineChart` from `layerchart/html` falls back to the agnostic dispatcher. + +## Worst case: importing everything + +If you `import * as LayerChart from 'layerchart'` (or your bundler can't tree-shake at all), you'll pay for the entire surface area of the root barrel — currently around 232 KB gz across all components. The strategies above exist precisely to keep this from happening for typical consumers. + +If you're not sure what your bundle looks like in practice, run a tool like [`rollup-plugin-visualizer`](https://github.com/btd/rollup-plugin-visualizer) or `vite build --mode=production` with source maps and inspect the output. + +## Reference: scenario sizes + +The numbers below are gzipped totals from LayerChart's own bundle analyzer. They represent the cost of importing the listed components from `'layerchart'` (or the sub-path when noted), measured against a minimal Svelte app with Svelte's own runtime treated as external. + +| Scenario | Imports | Gzipped | +| --- | --- | --- | +| `base` | `Chart` | ~83 KB | +| `base-svg` (per-layer) | `Chart`, `Svg` from `layerchart/svg` | ~77 KB | +| `base-canvas` (per-layer) | `Chart`, `Canvas` from `layerchart/canvas` | ~79 KB | +| `base-html` (per-layer) | `Chart`, `Html` from `layerchart/html` | ~79 KB | +| `core` | `ChartCore` (no Axis/Grid/Highlight) | ~51 KB | +| `core-svg` (per-layer) | `ChartCore`, `Svg` from `layerchart/svg` | ~51 KB | +| `core-geo` | `ChartCore`, `Svg`, `GeoProjection`, `GeoPath` | ~55 KB | +| `line-chart` | `Chart`, `Svg`, `Line`, `Axis`, `Grid` | ~83 KB | +| `LineChart` | high-level `LineChart` | ~90 KB | +| `LineChart-svg` (per-layer) | high-level `LineChart` from `layerchart/svg` | ~79 KB | +| `geo` (sub-path) | `Chart`, `Svg`, `GeoProjection`, `GeoPath`, `GeoPoint` | ~87 KB | +| `force` (sub-path) | `Chart`, `Svg`, `ForceSimulation`, `Link`, `Circle`, `Text` | ~94 KB | +| `dagre` (sub-path) | `Chart`, `Svg`, `Dagre`, `Link`, `Circle`, `Text` | ~109 KB | +| `circle-svg` (per-layer) | `Circle` from `layerchart/svg` | ~13 KB | +| `circle-agnostic` | `Circle` from `layerchart` | ~17 KB | +| `text-svg` (per-layer) | `Text` from `layerchart/svg` | ~16 KB | +| `text-agnostic` | `Text` from `layerchart` | ~29 KB | + +`base` is the cost of `` (with the cartesian frame baked in) — what every cartesian chart pays. `core` is the cost of `` — what geo and custom-layout charts pay instead. + +## Background: how LayerChart minimizes baseline cost + +If you want to dig deeper, every release of LayerChart runs an automated bundle analyzer ([`bundle-analyzer/`](https://github.com/techniq/layerchart/tree/main/bundle-analyzer) in the repo) across ~25 representative scenarios and posts the per-scenario size to PR comments. The CI guards against unintended bundle regressions and lets us continue to land lazy-loading and code-split improvements without slowing the default chart down. diff --git a/docs/static/remote-sources.json b/docs/static/remote-sources.json index ac838f24f..87231768e 100644 --- a/docs/static/remote-sources.json +++ b/docs/static/remote-sources.json @@ -1,5 +1,5 @@ { - "data.remote.ts": "import { celsiusToFahrenheit } from 'layerchart';\nimport { parse, sortFunc } from '@layerstack/utils';\nimport { ascending, flatGroup, max, mean, min } from 'd3-array';\nimport { csvParse, csvParseRows, autoType } from 'd3-dsv';\n\nimport { prerender, getRequestEvent, query } from '$app/server';\nimport { z } from 'zod';\n\nimport type { PenguinsData } from '$static/data/examples/penguins.js';\nimport type { AppleStockData } from '$static/data/examples/date/apple-stock.js';\nimport type { USSenatorsData } from '$static/data/examples/us-senators';\nimport type { CivilizationTimeline } from '$static/data/examples/date/civilization-timeline.js';\nimport type { HydroData } from '$static/data/examples/date/hydro.js';\nimport type { AppleTickerData } from '$static/data/examples/date/apple-ticker.js';\nimport type { NewPassengerCars } from '$static/data/examples/new-passenger-cars.js';\n\nexport const getGroupData = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/group-data.json').then((r) => r.json())) as {\n\t\tx: number;\n\t\ty: number;\n\t\tgroup: string;\n\t}[];\n\treturn data;\n});\n\nexport const getAppleStock = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/apple-stock.json').then(async (r) =>\n\t\tparse(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getAppleStockRange = query(\n\tz.object({\n\t\tstart: z.string().optional(),\n\t\tend: z.string().optional(),\n\t\tmaxPoints: z.number().optional().default(300)\n\t}),\n\tasync ({ start, end, maxPoints }) => {\n\t\tconst { fetch } = getRequestEvent();\n\t\tlet data = await fetch('/data/examples/date/apple-stock.json').then(async (r) =>\n\t\t\tparse(await r.text())\n\t\t);\n\n\t\tif (start || end) {\n\t\t\tconst startDate = start ? new Date(start) : undefined;\n\t\t\tconst endDate = end ? new Date(end) : undefined;\n\t\t\tdata = data.filter(\n\t\t\t\t(d) => (!startDate || d.date >= startDate) && (!endDate || d.date <= endDate)\n\t\t\t);\n\t\t}\n\n\t\tif (data.length > maxPoints) {\n\t\t\tconst step = (data.length - 1) / (maxPoints - 1);\n\t\t\tdata = Array.from({ length: maxPoints }, (_, i) => data![Math.round(i * step)]);\n\t\t}\n\n\t\treturn data;\n\t}\n);\n\nexport const getDailyTemperature = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/daily-temperature.json').then(async (r) =>\n\t\tparse<{ date: Date; value: number }[]>(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getDailyTemperatures = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/dailyTemperatures.csv').then(async (r) => {\n\t\treturn csvParse<{ dayOfYear: number; year: number; value: number | 'NA' }>(\n\t\t\tawait r.text(),\n\t\t\t// @ts-expect-error - autoType\n\t\t\tautoType\n\t\t)\n\t\t\t.filter((d) => d.value !== 'NA' && d.dayOfYear <= 365 /* Ignore 366th day */)\n\t\t\t.map((d) => {\n\t\t\t\tconst origDate = new Date(d.year, 0, d.dayOfYear);\n\t\t\t\treturn {\n\t\t\t\t\t...d,\n\t\t\t\t\tdate: new Date(Date.UTC(2000, origDate.getUTCMonth(), origDate.getUTCDate())),\n\t\t\t\t\tvalue: d.value !== 'NA' ? celsiusToFahrenheit(d.value) : 'NA'\n\t\t\t\t};\n\t\t\t});\n\t});\n\treturn data;\n});\n\nexport const getSfoTemperatures = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/sfoTemperatures.csv').then(async (r) => {\n\t\treturn flatGroup(\n\t\t\t// @ts-expect-error - autoType\n\t\t\tcsvParse<{ date: Date; tavg: number; tmax: number; tmin: number }>(await r.text(), autoType),\n\t\t\t(d) => new Date(Date.UTC(2000, d.date.getUTCMonth(), d.date.getUTCDate())) // group by day of year\n\t\t)\n\t\t\t.sort(([a], [b]) => ascending(a, b)) // sort chronologically\n\t\t\t.map(([date, v]) => ({\n\t\t\t\tdate,\n\t\t\t\tavg: mean(v, (d) => d.tavg || NaN),\n\t\t\t\tmin: mean(v, (d) => d.tmin || NaN),\n\t\t\t\tmax: mean(v, (d) => d.tmax || NaN),\n\t\t\t\tminmin: min(v, (d) => d.tmin || NaN),\n\t\t\t\tmaxmax: max(v, (d) => d.tmax || NaN)\n\t\t\t}));\n\t});\n\treturn data;\n});\n\nexport const getPenguins = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/penguins.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as PenguinsData;\n\treturn data;\n});\n\nexport const getFlare = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/hierarchy/flare.json').then((r) => r.json());\n\treturn data;\n});\n\nexport const getSimpleTree = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/hierarchy/simple-tree.json').then((r) => r.json());\n\treturn data;\n});\n\nexport type MetroData = {\n\tMetro: string;\n\tPOP_1980: number;\n\tLPOP_1980: number;\n\tR90_10_1980: number;\n\tPOP_2015: number;\n\tLPOP_2015: number;\n\tR90_10_2015: number;\n\tnyt_display: string;\n\tstate_display: string;\n\thighlight: number;\n};\n\nexport const getMetros = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = csvParse(await (await fetch('/data/examples/metros.csv')).text(), autoType) as unknown as MetroData[];\n\treturn data;\n});\n\nexport const getUsSenators = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/us-senators.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as USSenatorsData;\n\treturn data;\n});\n\nexport const getAlphabet = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/alphabet.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { letter: string; frequency: number }[];\n\treturn data;\n});\n\nexport const getOlympians = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/olympians.json').then((r) => r.json())) as {\n\t\tname: string;\n\t\tweight: number;\n\t\theight: number;\n\t}[];\n\treturn data;\n});\n\nexport const getUsEvents = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/us-events.csv').then(async (r) => {\n\t\treturn csvParse(await r.text(), autoType).map((d: any) => {\n\t\t\treturn {\n\t\t\t\tstartDate: new Date(d.startYear, 0, 1),\n\t\t\t\tendDate: new Date(d.endYear, 11, 31),\n\t\t\t\tevent: d.event\n\t\t\t};\n\t\t});\n\t});\n\treturn data;\n});\n\nexport const getCivilizationEvents = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/civilization-timeline.csv').then(async (r) => {\n\t\treturn csvParse(\n\t\t\tawait r.text(),\n\t\t\t// @ts-expect-error - shh\n\t\t\tautoType\n\t\t).sort(sortFunc('start'));\n\t});\n\n\treturn data;\n});\n\nexport const getAppleTicker = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/apple-ticker.json').then(async (r) =>\n\t\tparse(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getCars = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/cars.csv').then(async (r) =>\n\t\t// @ts-expect-error - shh\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport const getNewPassengerCars = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/new-passenger-cars.csv').then(async (r) =>\n\t\t// @ts-expect-error - shh\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport const getHydro = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/date/hydro.json').then(async (r) =>\n\t\tparse(await r.text())\n\t)) as HydroData;\n\treturn data;\n});\n\nexport type CountryGdpLifeExpectancy = {\n\ttitle: string;\n\tid: string;\n\tcontinent: string;\n\tx: number;\n\ty: number;\n\tvalue: number;\n};\n\nexport const getCountryGdpLifeExpectancy = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/country-gdp-life-expectancy.json').then((r) =>\n\t\tr.json()\n\t)) as CountryGdpLifeExpectancy[];\n\treturn data;\n});\n\nexport const getForceGroupDots = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/force-group-dots.json').then((r) => r.json())) as {\n\t\tcategory: string;\n\t\tvalue: number;\n\t}[];\n\treturn data;\n});\n\nexport const getWideData = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/wide_data/data.json').then((r) => r.json())) as {\n\t\tepoch: number;\n\t\tidl: number;\n\t\trecv: number;\n\t\tsend: number;\n\t\twrit: number;\n\t\tused: number;\n\t\tfree: number;\n\t}[];\n\treturn data;\n});\n\nexport const getDimensionArrays = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/dimension_arrays/data.json').then((r) =>\n\t\tr.json()\n\t)) as {\n\t\tdate: number[];\n\t\tcpu: number[];\n\t\tram: number[];\n\t\ttcp: number[];\n\t};\n\treturn data;\n});\n\nexport const getSeriesArrays = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/series_arrays/data.json').then((r) =>\n\t\tr.json()\n\t)) as {\n\t\tcpu: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t\tram: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t\ttcp: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t};\n\treturn data;\n});\n\nexport type VolcanoData = {\n\twidth: number;\n\theight: number;\n\tvalues: number[];\n};\n\nexport const getVolcano = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/volcano.json').then((r) => r.json())) as VolcanoData;\n\treturn data;\n});\n\nexport type WaterVaporData = {\n\twidth: number;\n\theight: number;\n\tvalues: number[];\n};\n\nexport const getWaterVapor = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst rows = csvParseRows(\n\t\tawait fetch('/data/examples/geo/water-vapor.csv').then((r) => r.text())\n\t);\n\treturn {\n\t\twidth: rows[0]?.length ?? 0,\n\t\theight: rows.length,\n\t\tvalues: rows.flat().map((value) => (value === '99999.0' ? NaN : +value))\n\t} satisfies WaterVaporData;\n});\n\nexport type FaithfulData = { eruptions: number; waiting: number };\n\nexport const getFaithful = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/faithful.json').then((r) =>\n\t\tr.json()\n\t)) as FaithfulData[];\n\treturn data;\n});\n\nexport type CategoryBrand = {\n\tdate: Date;\n\tname: string;\n\tcategory: string;\n\tvalue: number;\n};\n\nexport const getCategoryBrands = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/category-brands.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as unknown as CategoryBrand[];\n\n\t// Ensure dates are Date objects\n\tfor (const d of data) {\n\t\td.date = new Date(d.date as unknown as string);\n\t}\n\n\treturn data;\n});\n\nexport type ProgrammingLanguage = {\n\tdate: Date;\n\tname: string;\n\tvalue: number;\n};\n\nexport const getProgrammingLanguages = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/programming-languages.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as unknown as ProgrammingLanguage[];\n\n\tfor (const d of data) {\n\t\td.date = new Date(d.date as unknown as string);\n\t}\n\n\treturn data;\n});\n\nexport const getShapeData = query(z.string().nullable(), async (file) => {\n\tif (!file) return null;\n\tconst { fetch } = getRequestEvent();\n\tconst geojson = await fetch(file).then((r) => r.json());\n\treturn geojson;\n});\n\nexport type TdfStageData = { long: number; lat: number; elev: number }[];\n\nexport const getTdfStage = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/tdf-stage.json').then((r) =>\n\t\tr.json()\n\t)) as TdfStageData;\n\treturn data;\n});\n", + "data.remote.ts": "import { celsiusToFahrenheit } from 'layerchart';\nimport { parse, sortFunc } from '@layerstack/utils';\nimport { ascending, flatGroup, max, mean, min } from 'd3-array';\nimport { csvParse, csvParseRows, autoType } from 'd3-dsv';\n\nimport { prerender, getRequestEvent, query } from '$app/server';\nimport { z } from 'zod';\n\nimport type { PenguinsData } from '$static/data/examples/penguins.js';\nimport type { AppleStockData } from '$static/data/examples/date/apple-stock.js';\nimport type { USSenatorsData } from '$static/data/examples/us-senators';\nimport type { CivilizationTimeline } from '$static/data/examples/date/civilization-timeline.js';\nimport type { HydroData } from '$static/data/examples/date/hydro.js';\nimport type { AppleTickerData } from '$static/data/examples/date/apple-ticker.js';\nimport type { NewPassengerCars } from '$static/data/examples/new-passenger-cars.js';\n\nexport const getGroupData = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/group-data.json').then((r) => r.json())) as {\n\t\tx: number;\n\t\ty: number;\n\t\tgroup: string;\n\t}[];\n\treturn data;\n});\n\nexport const getAppleStock = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/apple-stock.json').then(async (r) =>\n\t\tparse(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getAppleStockRange = query(\n\tz.object({\n\t\tstart: z.string().optional(),\n\t\tend: z.string().optional(),\n\t\tmaxPoints: z.number().optional().default(300)\n\t}),\n\tasync ({ start, end, maxPoints }) => {\n\t\tconst { fetch } = getRequestEvent();\n\t\tlet data = await fetch('/data/examples/date/apple-stock.json').then(async (r) =>\n\t\t\tparse(await r.text())\n\t\t);\n\n\t\tif (start || end) {\n\t\t\tconst startDate = start ? new Date(start) : undefined;\n\t\t\tconst endDate = end ? new Date(end) : undefined;\n\t\t\tdata = data.filter(\n\t\t\t\t(d) => (!startDate || d.date >= startDate) && (!endDate || d.date <= endDate)\n\t\t\t);\n\t\t}\n\n\t\tif (data.length > maxPoints) {\n\t\t\tconst step = (data.length - 1) / (maxPoints - 1);\n\t\t\tdata = Array.from({ length: maxPoints }, (_, i) => data![Math.round(i * step)]);\n\t\t}\n\n\t\treturn data;\n\t}\n);\n\nexport const getDailyTemperature = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/daily-temperature.json').then(async (r) =>\n\t\tparse<{ date: Date; value: number }[]>(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getDailyTemperatures = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/dailyTemperatures.csv').then(async (r) => {\n\t\treturn csvParse<{ dayOfYear: number; year: number; value: number | 'NA' }>(\n\t\t\tawait r.text(),\n\t\t\t// @ts-expect-error - autoType\n\t\t\tautoType\n\t\t)\n\t\t\t.filter((d) => d.value !== 'NA' && d.dayOfYear <= 365 /* Ignore 366th day */)\n\t\t\t.map((d) => {\n\t\t\t\tconst origDate = new Date(d.year, 0, d.dayOfYear);\n\t\t\t\treturn {\n\t\t\t\t\t...d,\n\t\t\t\t\tdate: new Date(Date.UTC(2000, origDate.getUTCMonth(), origDate.getUTCDate())),\n\t\t\t\t\tvalue: d.value !== 'NA' ? celsiusToFahrenheit(d.value) : 'NA'\n\t\t\t\t};\n\t\t\t});\n\t});\n\treturn data;\n});\n\nexport const getSfoTemperatures = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/sfoTemperatures.csv').then(async (r) => {\n\t\treturn flatGroup(\n\t\t\t// @ts-expect-error - autoType\n\t\t\tcsvParse<{ date: Date; tavg: number; tmax: number; tmin: number }>(await r.text(), autoType),\n\t\t\t(d) => new Date(Date.UTC(2000, d.date.getUTCMonth(), d.date.getUTCDate())) // group by day of year\n\t\t)\n\t\t\t.sort(([a], [b]) => ascending(a, b)) // sort chronologically\n\t\t\t.map(([date, v]) => ({\n\t\t\t\tdate,\n\t\t\t\tavg: mean(v, (d) => d.tavg || NaN),\n\t\t\t\tmin: mean(v, (d) => d.tmin || NaN),\n\t\t\t\tmax: mean(v, (d) => d.tmax || NaN),\n\t\t\t\tminmin: min(v, (d) => d.tmin || NaN),\n\t\t\t\tmaxmax: max(v, (d) => d.tmax || NaN)\n\t\t\t}));\n\t});\n\treturn data;\n});\n\nexport const getPenguins = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/penguins.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as PenguinsData;\n\treturn data;\n});\n\nexport const getFlare = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/hierarchy/flare.json').then((r) => r.json());\n\treturn data;\n});\n\nexport const getSimpleTree = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/hierarchy/simple-tree.json').then((r) => r.json());\n\treturn data;\n});\n\nexport type MetroData = {\n\tMetro: string;\n\tPOP_1980: number;\n\tLPOP_1980: number;\n\tR90_10_1980: number;\n\tPOP_2015: number;\n\tLPOP_2015: number;\n\tR90_10_2015: number;\n\tnyt_display: string;\n\tstate_display: string;\n\thighlight: number;\n};\n\nexport const getMetros = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = csvParse(\n\t\tawait (await fetch('/data/examples/metros.csv')).text(),\n\t\tautoType\n\t) as unknown as MetroData[];\n\treturn data;\n});\n\nexport const getUsSenators = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/us-senators.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as USSenatorsData;\n\treturn data;\n});\n\nexport const getAlphabet = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/alphabet.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { letter: string; frequency: number }[];\n\treturn data;\n});\n\nexport const getOlympians = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/olympians.json').then((r) => r.json())) as {\n\t\tname: string;\n\t\tweight: number;\n\t\theight: number;\n\t}[];\n\treturn data;\n});\n\nexport const getUsEvents = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/us-events.csv').then(async (r) => {\n\t\treturn csvParse(await r.text(), autoType).map((d: any) => {\n\t\t\treturn {\n\t\t\t\tstartDate: new Date(d.startYear, 0, 1),\n\t\t\t\tendDate: new Date(d.endYear, 11, 31),\n\t\t\t\tevent: d.event\n\t\t\t};\n\t\t});\n\t});\n\treturn data;\n});\n\nexport const getCivilizationEvents = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/civilization-timeline.csv').then(async (r) => {\n\t\treturn csvParse(\n\t\t\tawait r.text(),\n\t\t\t// @ts-expect-error - shh\n\t\t\tautoType\n\t\t).sort(sortFunc('start'));\n\t});\n\n\treturn data;\n});\n\nexport type SvelteCount = {\n\tdate: Date;\n\tn: number;\n\tcumsum: number;\n\tcategory: 'svelte' | 'sveltekit';\n};\n\nexport const getSvelteCounts = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/svelte-counts.csv').then(async (r) =>\n\t\t// @ts-expect-error - autoType\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport type SvelteMilestone = {\n\tdate: Date;\n\tcategory: 'svelte' | 'sveltekit' | 'ecosystem';\n\tlabel: string;\n\tx: Date;\n\ty: number;\n};\n\nexport const getSvelteMilestones = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/svelte-milestones.csv').then(async (r) =>\n\t\t// @ts-expect-error - autoType\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport const getAppleTicker = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/date/apple-ticker.json').then(async (r) =>\n\t\tparse(await r.text())\n\t);\n\treturn data;\n});\n\nexport const getCars = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/cars.csv').then(async (r) =>\n\t\t// @ts-expect-error - shh\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport const getNewPassengerCars = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/new-passenger-cars.csv').then(async (r) =>\n\t\t// @ts-expect-error - shh\n\t\tcsvParse(await r.text(), autoType)\n\t);\n\treturn data;\n});\n\nexport const getHydro = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/date/hydro.json').then(async (r) =>\n\t\tparse(await r.text())\n\t)) as HydroData;\n\treturn data;\n});\n\nexport type CountryGdpLifeExpectancy = {\n\ttitle: string;\n\tid: string;\n\tcontinent: string;\n\tx: number;\n\ty: number;\n\tvalue: number;\n};\n\nexport const getCountryGdpLifeExpectancy = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/country-gdp-life-expectancy.json').then((r) =>\n\t\tr.json()\n\t)) as CountryGdpLifeExpectancy[];\n\treturn data;\n});\n\nexport const getForceGroupDots = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/force-group-dots.json').then((r) => r.json())) as {\n\t\tcategory: string;\n\t\tvalue: number;\n\t}[];\n\treturn data;\n});\n\nexport const getWideData = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/wide_data/data.json').then((r) => r.json())) as {\n\t\tepoch: number;\n\t\tidl: number;\n\t\trecv: number;\n\t\tsend: number;\n\t\twrit: number;\n\t\tused: number;\n\t\tfree: number;\n\t}[];\n\treturn data;\n});\n\nexport const getDimensionArrays = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/dimension_arrays/data.json').then((r) =>\n\t\tr.json()\n\t)) as {\n\t\tdate: number[];\n\t\tcpu: number[];\n\t\tram: number[];\n\t\ttcp: number[];\n\t};\n\treturn data;\n});\n\nexport const getSeriesArrays = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/bench/series_arrays/data.json').then((r) =>\n\t\tr.json()\n\t)) as {\n\t\tcpu: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t\tram: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t\ttcp: {\n\t\t\tx: Date;\n\t\t\ty: number;\n\t\t}[];\n\t};\n\treturn data;\n});\n\nexport type VolcanoData = {\n\twidth: number;\n\theight: number;\n\tvalues: number[];\n};\n\nexport const getVolcano = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/volcano.json').then((r) => r.json())) as VolcanoData;\n\treturn data;\n});\n\nexport type WaterVaporData = {\n\twidth: number;\n\theight: number;\n\tvalues: number[];\n};\n\nexport const getWaterVapor = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst rows = csvParseRows(\n\t\tawait fetch('/data/examples/geo/water-vapor.csv').then((r) => r.text())\n\t);\n\treturn {\n\t\twidth: rows[0]?.length ?? 0,\n\t\theight: rows.length,\n\t\tvalues: rows.flat().map((value) => (value === '99999.0' ? NaN : +value))\n\t} satisfies WaterVaporData;\n});\n\nexport type FaithfulData = { eruptions: number; waiting: number };\n\nexport const getFaithful = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/faithful.json').then((r) =>\n\t\tr.json()\n\t)) as FaithfulData[];\n\treturn data;\n});\n\nexport type CategoryBrand = {\n\tdate: Date;\n\tname: string;\n\tcategory: string;\n\tvalue: number;\n};\n\nexport const getCategoryBrands = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/category-brands.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as unknown as CategoryBrand[];\n\n\t// Ensure dates are Date objects\n\tfor (const d of data) {\n\t\td.date = new Date(d.date as unknown as string);\n\t}\n\n\treturn data;\n});\n\nexport type ProgrammingLanguage = {\n\tdate: Date;\n\tname: string;\n\tvalue: number;\n};\n\nexport const getProgrammingLanguages = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/programming-languages.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as unknown as ProgrammingLanguage[];\n\n\tfor (const d of data) {\n\t\td.date = new Date(d.date as unknown as string);\n\t}\n\n\treturn data;\n});\n\nexport const getShapeData = query(z.string().nullable(), async (file) => {\n\tif (!file) return null;\n\tconst { fetch } = getRequestEvent();\n\tconst geojson = await fetch(file).then((r) => r.json());\n\treturn geojson;\n});\n\nexport type TdfStageData = { long: number; lat: number; elev: number }[];\n\nexport const getTdfStage = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/tdf-stage.json').then((r) =>\n\t\tr.json()\n\t)) as TdfStageData;\n\treturn data;\n});\n", "geo.remote.ts": "import { prerender, getRequestEvent } from '$app/server';\n\nimport type { GeometryCollection, Topology } from 'topojson-specification';\nimport { geoCentroid } from 'd3-geo';\nimport { csvParse, autoType } from 'd3-dsv';\nimport { parse } from '@layerstack/utils';\n\nimport type { USStateCapitalsData } from '$static/data/examples/geo/us-state-capitals.js';\nimport type { WorldLinksData } from '$static/data/examples/geo/world-links.js';\n\nexport const getUsStatesTopology = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json').then((r) =>\n\t\tr.json()\n\t)) as Topology<{\n\t\tstates: GeometryCollection<{ name: string }>;\n\t}>;\n\treturn data;\n});\n\nexport const getUsCountiesTopology = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json').then((r) =>\n\t\tr.json()\n\t)) as Topology<{\n\t\tstates: GeometryCollection<{ name: string }>;\n\t\tcounties: GeometryCollection<{ name: string }>;\n\t}>;\n\treturn data;\n});\n\nexport const getUsCountiesAlbersTopology = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch(\n\t\t'https://cdn.jsdelivr.net/npm/us-atlas@3/counties-albers-10m.json'\n\t).then((r) => r.json())) as Topology<{\n\t\tstates: GeometryCollection<{ name: string }>;\n\t\tcounties: GeometryCollection<{ name: string }>;\n\t}>;\n\treturn data;\n});\n\nexport const getCountriesTopology = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst geojson = (await fetch(\n\t\t'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json'\n\t).then((r) => r.json())) as Topology<{\n\t\tcountries: GeometryCollection<{ name: string }>;\n\t\tland: GeometryCollection;\n\t}>;\n\treturn geojson;\n});\n\nexport const getCountriesDetailTopology = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst geojson = (await fetch(\n\t\t'https://cdn.jsdelivr.net/npm/world-atlas@2/countries-50m.json'\n\t).then((r) => r.json())) as Topology<{\n\t\tcountries: GeometryCollection<{ name: string }>;\n\t\tland: GeometryCollection;\n\t}>;\n\treturn geojson;\n});\n\nexport const getEclipses = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/eclipses.json').then(async (r) =>\n\t\tparse(await r.text())\n\t)) as Topology<{\n\t\teclipses: GeometryCollection<{ ID: string; Date: Date }>;\n\t}>;\n\treturn data;\n});\n\nexport const getSubmarineCables = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/geo/submarine-cables.json').then((r) => r.json());\n\treturn data;\n});\n\nexport const getSubmarineCablesLandingPoints = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch('/data/examples/geo/submarine-cables-landing-points.json').then((r) =>\n\t\tr.json()\n\t);\n\treturn data;\n});\n\nexport const getTectonicPlates = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch(\n\t\t'https://raw.githubusercontent.com/fraxen/tectonicplates/master/GeoJSON/PB2002_boundaries.json'\n\t).then((r) => r.json());\n\treturn data;\n});\n\nexport const getEarthquakes = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = await fetch(\n\t\t'https://earthquake.usgs.gov/earthquakes/feed/v1.0/summary/2.5_week.geojson'\n\t)\n\t\t.then((r) => r.json())\n\t\t.then((d: GeoJSON.FeatureCollection) =>\n\t\t\td.features.map((f) => {\n\t\t\t\tconst c = geoCentroid(f);\n\t\t\t\treturn {\n\t\t\t\t\tplace: f.properties.place,\n\t\t\t\t\tmagnitude: f.properties.mag,\n\t\t\t\t\tlongitude: c[0],\n\t\t\t\t\tlatitude: c[1]\n\t\t\t\t};\n\t\t\t})\n\t\t);\n\treturn data;\n});\n\nexport const getTimezones = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/timezones.json').then((r) =>\n\t\tr.json()\n\t)) as Topology<{\n\t\ttimezones: GeometryCollection<{\n\t\t\tobjectid: number;\n\t\t\tscalerank: number;\n\t\t\tfeaturecla: string;\n\t\t\tname: string;\n\t\t\tmap_color6: number;\n\t\t\tmap_color8: number;\n\t\t\tnote: any;\n\t\t\tzone: number;\n\t\t\tutc_format: string;\n\t\t\ttime_zone: string;\n\t\t\tiso_8601: string;\n\t\t\tplaces: string;\n\t\t\tdst_places: any;\n\t\t\ttz_name1st: any;\n\t\t\ttz_namesum: number;\n\t\t}>;\n\t}>;\n\treturn data;\n});\n\nexport const getUsCapitals = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/us-state-capitals.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as USStateCapitalsData;\n\treturn data;\n});\n\nexport const getUsAirports = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/us-airports.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { name: string; latitude: number; longitude: number }[];\n\treturn data;\n});\n\nexport const getWalmarts = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst raw = (await fetch('/data/examples/geo/walmarts.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { long: number; lat: number; opendate: Date; city: string; state: string; type: string }[];\n\tconst data = raw.map((d) => ({\n\t\tlongitude: d.long,\n\t\tlatitude: d.lat,\n\t\tdate: d.opendate,\n\t\tcity: d.city,\n\t\tstate: d.state,\n\t\ttype: d.type\n\t}));\n\treturn data;\n});\n\nexport const getUsCountyPopulation = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/us-county-population-2020.json').then((r) =>\n\t\tr.json()\n\t)) as Array<{\n\t\tstate: string;\n\t\tcounty: string;\n\t\tDP05_0001E: string;\n\t\tDP05_0019E: string;\n\t\tDP05_0019PE: string;\n\t}>;\n\treturn data;\n});\n\nexport const getUsPresidentialElection2020 = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/us-presidential-election-2020.csv').then(\n\t\tasync (r) => csvParse(await r.text(), autoType)\n\t)) as Array<{\n\t\tstate_name: string;\n\t\tcounty_fips: number;\n\t\tcounty_name: string;\n\t\tvotes_gop: number;\n\t\tvotes_dem: number;\n\t\ttotal_votes: number;\n\t\tdiff: number;\n\t\tper_gop: number;\n\t\tper_dem: number;\n\t\tper_point_diff: number;\n\t}>;\n\treturn data;\n});\n\nexport const getWind = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/wind.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as Array<{\n\t\tlongitude: number;\n\t\tlatitude: number;\n\t\tu: number;\n\t\tv: number;\n\t}>;\n\treturn data;\n});\n\nexport const getWorldCapitals = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/world-capitals.json').then(async (r) =>\n\t\tr.json()\n\t)) as { label: string; latitude: number; longitude: number }[];\n\treturn data;\n});\n\nexport const getWorldAirports = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/world-airports.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { name: string; latitude: number; longitude: number }[];\n\treturn data;\n});\n\nexport const getD1FootballTeams = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/d1-football-teams.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as {\n\t\tteam: string;\n\t\tcollege: string;\n\t\tconference: string;\n\t\tcity: string;\n\t\tstate: string;\n\t\tlatitude: number;\n\t\tlongitude: number;\n\t\tespn_id: number;\n\t}[];\n\treturn data;\n});\n\nexport const getBeagleVoyage = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/beagle-voyage.csv').then(async (r) =>\n\t\tcsvParse(await r.text(), autoType)\n\t)) as { longitude: number; latitude: number }[];\n\treturn data;\n});\n\nexport const getWorldLinks = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/geo/world-links.json').then((r) =>\n\t\tr.json()\n\t)) as WorldLinksData;\n\treturn data;\n});\n", "graph.remote.ts": "import { prerender, getRequestEvent, query } from '$app/server';\nimport { range } from 'd3-array';\nimport { randomInteger } from '@layerstack/utils';\nimport { unique } from '@layerstack/utils/array';\nimport { z } from 'zod';\n\nconst alpha = [...Array(26)].map((val, i) => String.fromCharCode(i + 65));\n\nexport const getGraph = query(z.string(), async (name) => {\n\tswitch (name) {\n\t\tcase 'basic': {\n\t\t\treturn getBasicGraph();\n\t\t}\n\t\tcase 'simple': {\n\t\t\treturn getSimpleGraph();\n\t\t}\n\t\tcase 'medium': {\n\t\t\treturn getMediumDag();\n\t\t}\n\t\tcase 'large': {\n\t\t\treturn getLargeDag();\n\t\t}\n\t\tcase 'complex': {\n\t\t\treturn getComplexGraph();\n\t\t}\n\t\tcase 'miserables': {\n\t\t\treturn getMiserablesGraph();\n\t\t}\n\t\tcase 'tcp-state': {\n\t\t\treturn getTcpStateGraph();\n\t\t}\n\t\tcase 'software-user-flow': {\n\t\t\treturn getSoftwareUserFlowGraph();\n\t\t}\n\t\tcase 'cluster': {\n\t\t\treturn getClusterGraph();\n\t\t}\n\t\tcase 'dag-medium': {\n\t\t\treturn getMediumDag();\n\t\t}\n\t\tcase 'dag-large': {\n\t\t\treturn getLargeDag();\n\t\t}\n\t\tcase 'disjoint-graph': {\n\t\t\treturn getDisjointGraph();\n\t\t}\n\t\tcase 'simple-generated': {\n\t\t\treturn getSimpleGeneratedGraph();\n\t\t}\n\t\tcase 'complex-generated': {\n\t\t\treturn getComplexGeneratedGraph();\n\t\t}\n\t\tdefault: {\n\t\t\tthrow new Error(`Unknown graph: ${name}`);\n\t\t}\n\t}\n});\n\nexport const getBasicGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/basic.json').then((r) => r.json())) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getSimpleGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/simple.json').then((r) => r.json())) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getComplexGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/complex.json').then((r) => r.json())) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getMiserablesGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/miserables.json').then((r) => r.json())) as {\n\t\tnodes: { id: string; group: number }[];\n\t\tlinks: { source: string; target: string; value: number }[];\n\t};\n\treturn data;\n});\n\nexport const getTcpStateGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/tcp-state.json').then((r) => r.json())) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string; label: string }[];\n\t};\n\treturn data;\n});\n\nexport const getSoftwareUserFlowGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/software-user-flow.json').then((r) =>\n\t\tr.json()\n\t)) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string; label: string }[];\n\t};\n\treturn data;\n});\n\nexport const getClusterGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/cluster.json').then((r) => r.json())) as {\n\t\tnodes: { id: string; parent?: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getMediumDag = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/dag-medium.json').then((r) => r.json())) as {\n\t\tnodes: { id: string; name: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getLargeDag = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/dag-large.json').then((r) => r.json())) as {\n\t\tnodes: { id: string; name: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n\nexport const getDisjointGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/disjoint-graph.json').then((r) => r.json())) as {\n\t\tnodes: { id: string; group: number }[];\n\t\tlinks: { source: string; target: string; value: number }[];\n\t};\n\treturn data;\n});\n\nexport const getSimpleGeneratedGraph = prerender(async () => {\n\tconst data = {\n\t\tnodes: alpha.map((a) => ({\n\t\t\tid: a\n\t\t})),\n\t\tlinks: alpha.flatMap((a, i) => {\n\t\t\tif (i === 25) {\n\t\t\t\treturn [];\n\t\t\t} else {\n\t\t\t\tconst randomDownstreamId = randomInteger(i + 1, 25);\n\t\t\t\tconst edge = { source: a, target: alpha[randomDownstreamId] };\n\t\t\t\treturn [edge];\n\t\t\t}\n\t\t})\n\t};\n\treturn data;\n});\n\nexport const getComplexGeneratedGraph = prerender(async () => {\n\tfunction getRandomDownstreamIds(index: number) {\n\t\treturn unique(range(randomInteger(1, 3)).map(() => randomInteger(index + 1, 25)));\n\t}\n\n\tconst data = {\n\t\tnodes: alpha.map((a) => ({\n\t\t\tid: a\n\t\t})),\n\t\tlinks: alpha.flatMap((a, i) => {\n\t\t\tif (i === 25) {\n\t\t\t\treturn [];\n\t\t\t} else {\n\t\t\t\treturn getRandomDownstreamIds(i).map((id) => {\n\t\t\t\t\treturn { source: a, target: alpha[id] };\n\t\t\t\t});\n\t\t\t}\n\t\t})\n\t};\n\treturn data;\n});\n\nexport const getGreenhouseGraph = prerender(async () => {\n\tconst { fetch } = getRequestEvent();\n\tconst data = (await fetch('/data/examples/graph/greenhouse.json').then((r) => r.json())) as {\n\t\tnodes: { id: string }[];\n\t\tlinks: { source: string; target: string }[];\n\t};\n\treturn data;\n});\n" } \ No newline at end of file diff --git a/packages/layerchart/package.json b/packages/layerchart/package.json index 508ce4afe..6421723c6 100644 --- a/packages/layerchart/package.json +++ b/packages/layerchart/package.json @@ -139,6 +139,21 @@ "svelte": "./dist/graph.js", "default": "./dist/graph.js" }, + "./svg": { + "types": "./dist/svg.d.ts", + "svelte": "./dist/svg.js", + "default": "./dist/svg.js" + }, + "./canvas": { + "types": "./dist/canvas.d.ts", + "svelte": "./dist/canvas.js", + "default": "./dist/canvas.js" + }, + "./html": { + "types": "./dist/html.d.ts", + "svelte": "./dist/html.js", + "default": "./dist/html.js" + }, "./utils/*": { "types": "./dist/utils/*.d.ts", "svelte": "./dist/utils/*.js", diff --git a/packages/layerchart/src/lib/bench/ComposableLineChart.svelte b/packages/layerchart/src/lib/bench/ComposableLineChart.svelte index 1ab3c5316..926c22e7a 100644 --- a/packages/layerchart/src/lib/bench/ComposableLineChart.svelte +++ b/packages/layerchart/src/lib/bench/ComposableLineChart.svelte @@ -1,9 +1,9 @@
- {#key chartState.isMounted} - - {@const { - domainExtent: _de, - constrain: _uc, - apply: _apply, - scaleExtent: _se, - translateExtent: _te, - ...transformProps - } = transform ?? {}} - - {#if brush} - {#await import('./BrushContext.svelte')} - - - - - - {:then { default: BrushContext }} - - - - - - - - {/await} - {:else} - - - - - {/if} - - {/key} -
-{/if} - - diff --git a/packages/layerchart/src/lib/components/Chart/Chart.base.svelte b/packages/layerchart/src/lib/components/Chart/Chart.base.svelte new file mode 100644 index 000000000..8a02d746d --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.base.svelte @@ -0,0 +1,532 @@ + + + + +{#if ssr === true || typeof window !== 'undefined'} +
+ {#key chartState.isMounted} + {#if transform} + + {@const { + domainExtent: _de, + constrain: _uc, + apply: _apply, + scaleExtent: _se, + translateExtent: _te, + ...transformProps + } = transform} + {#await import('../TransformContext.svelte')} + {@render inner()} + {:then { default: TransformContext }} + + + {@render inner()} + + {/await} + {:else} + {@render inner()} + {/if} + {/key} +
+{/if} + +{#snippet body()} + {#if ChartChildren} + + {:else} + {@render children?.({ context: chartState })} + {/if} +{/snippet} + +{#snippet inner()} + {#if brush} + {#await import('../BrushContext.svelte')} + + + {@render body()} + + {:then { default: BrushContext }} + + + + + {@render body()} + + + {/await} + {:else} + + + {@render body()} + + {/if} +{/snippet} + + diff --git a/packages/layerchart/src/lib/components/Chart/Chart.canvas.svelte b/packages/layerchart/src/lib/components/Chart/Chart.canvas.svelte new file mode 100644 index 000000000..2af82863c --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.canvas.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Chart/Chart.html.svelte b/packages/layerchart/src/lib/components/Chart/Chart.html.svelte new file mode 100644 index 000000000..2a7b01cc2 --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.html.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Chart/Chart.shared.svelte.ts b/packages/layerchart/src/lib/components/Chart/Chart.shared.svelte.ts new file mode 100644 index 000000000..b3934ca1a --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.shared.svelte.ts @@ -0,0 +1,261 @@ +import type { ComponentProps, Snippet } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; +import type { TimeInterval } from 'd3-time'; +import type { HierarchyNode } from 'd3-hierarchy'; +import type { SankeyGraph } from 'd3-sankey'; + +import type { Accessor } from '$lib/utils/common.js'; +import type { MotionProp } from '$lib/utils/motion.svelte.js'; +import type { AnyScale, DomainType } from '$lib/utils/scales.svelte.js'; +import type { + BaseRange, + Nice, + PaddingArray, + Without, + XRangeWithScale, + YRangeWithScale, +} from '$lib/utils/types.js'; +import type { GeoStateProps } from '$lib/states/geo.svelte.js'; +import type { StackLayout } from '$lib/states/series.svelte.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; + +import type TooltipContext from '../tooltip/TooltipContext.svelte'; +import type TransformContext from '../TransformContext.svelte'; +import type BrushContext from '../BrushContext.svelte'; +import type { ChartChildrenProps } from '../ChartChildren/ChartChildren.shared.svelte.js'; +import type { SeriesData } from '../charts/types.js'; + +export type ChartResizeDetail = { + width: number; + height: number; + containerWidth: number; + containerHeight: number; +}; + +export type PreservedChartConfig< + T, + XScale extends AnyScale = AnyScale, + YScale extends AnyScale = AnyScale, +> = Pick< + ChartPropsWithoutHTML, + | 'x' + | 'y' + | 'z' + | 'r' + | 'c' + | 'x1' + | 'y1' + | 'xRange' + | 'yRange' + | 'cDomain' + | 'zDomain' + | 'xDomain' + | 'yDomain' + | 'rDomain' + | 'x1Domain' + | 'y1Domain' + | 'zRange' + | 'rRange' + | 'cRange' + | 'x1Range' + | 'y1Range' +>; + +export type LayerChartInternalMeta = { + /** + * The current chart type. + * The default is `'default'` which is any chart being composed + * that isn't a "simplified chart". + */ + type: + | 'default' + | 'simplified-area' + | 'simplified-bar' + | 'simplified-line' + | 'simplified-pie' + | 'simplified-scatter'; +}; + +export type ChartPropsWithoutHTML< + T, + XScale extends AnyScale = AnyScale, + YScale extends AnyScale = AnyScale, +> = { + /** + * Whether this chart should be rendered server side + * + * @default false + */ + ssr?: boolean; + + /** + * Whether to allow pointer events via CSS. + * Set this to `false` to set `pointer-events: none;` on all components, disabling + * all mouse interactions. + * + * @default true + */ + pointerEvents?: boolean; + + /** + * Determine the positioning of the wrapper div. + * Set this to `'absolute'` when you want to stack layers. + * + * @default 'relative' + */ + position?: string; + + /** + * If `true`, set all scale ranges to `[0, 100]`. + * Ranges reversed via `xReverse`, `yReverse`, or `rReverse` props will + * continue to be reversed as usual. + * @default false + */ + percentRange?: boolean; + + /** + * A bindable reference to the root container element. + */ + ref?: HTMLElement; + + /** + * If `data` is not a flat array of objects and you want to use any of the scales, set a flat + * version of the data via the `flatData` prop. + */ + data?: T[] | readonly T[] | HierarchyNode | SankeyGraph; + + /** A flat version of data. */ + flatData?: T[] | readonly T[] | HierarchyNode | SankeyGraph; + + x?: Accessor; + y?: Accessor; + z?: Accessor; + r?: Accessor; + x1?: Accessor; + y1?: Accessor; + c?: Accessor; + + xDomain?: DomainType; + yDomain?: DomainType; + zDomain?: DomainType; + rDomain?: DomainType; + x1Domain?: DomainType; + y1Domain?: DomainType; + cDomain?: DomainType; + + xNice?: Nice; + yNice?: Nice; + zNice?: Nice; + rNice?: Nice; + + xPadding?: PaddingArray; + yPadding?: PaddingArray; + zPadding?: PaddingArray; + rPadding?: PaddingArray; + + xScale?: XScale; + yScale?: YScale; + zScale?: AnyScale; + rScale?: AnyScale; + x1Scale?: AnyScale; + y1Scale?: AnyScale; + cScale?: AnyScale; + + xRange?: BaseRange; + yRange?: BaseRange; + zRange?: BaseRange; + rRange?: BaseRange; + x1Range?: XRangeWithScale; + y1Range?: YRangeWithScale; + cRange?: string[] | readonly string[]; + + xReverse?: boolean; + yReverse?: boolean; + zReverse?: boolean; + rReverse?: boolean; + + xDomainSort?: boolean; + yDomainSort?: boolean; + zDomainSort?: boolean; + rDomainSort?: boolean; + + padding?: { top?: number; right?: number; bottom?: number; left?: number } | number; + + extents?: { + x?: [min: number, max: number]; + y?: [min: number, max: number]; + r?: [min: number, max: number]; + z?: [min: number, max: number]; + }; + + meta?: Record; + debug?: boolean; + verbose?: boolean; + xBaseline?: number | null; + yBaseline?: number | null; + xInterval?: TimeInterval | null; + yInterval?: TimeInterval | null; + valueAxis?: 'x' | 'y'; + radial?: boolean; + + children?: Snippet<[{ context: ChartState }]>; + + context?: ChartState; + + geo?: Partial; + + tooltipContext?: Partial> | boolean; + + transform?: Partial> & { + apply?: { + rotation?: boolean; + scale?: boolean; + translate?: boolean; + }; + domainExtent?: { + x?: { + min?: number | Date | 'data'; + max?: number | Date | 'data'; + minRange?: number; + }; + y?: { + min?: number | Date | 'data'; + max?: number | Date | 'data'; + minRange?: number; + }; + }; + }; + + brush?: + | (Partial> & { + zoomOnBrush?: boolean; + }) + | boolean; + + series?: SeriesData[]; + + seriesLayout?: StackLayout | 'group'; + + bandPadding?: number; + groupPadding?: number; + + onResize?: (e: ChartResizeDetail) => void; + + clip?: boolean; + + motion?: MotionProp; + + ondragstart?: ComponentProps['ondragstart']; + ondragend?: ComponentProps['ondragend']; + onTransform?: ComponentProps['onTransform']; + + width?: number; + height?: number; +} & ChartChildrenProps; + +export type ChartProps< + T, + XScale extends AnyScale = AnyScale, + YScale extends AnyScale = AnyScale, +> = ChartPropsWithoutHTML & + Without, ChartPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/Chart/Chart.svelte b/packages/layerchart/src/lib/components/Chart/Chart.svelte new file mode 100644 index 000000000..430965d44 --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Chart/Chart.svg.svelte b/packages/layerchart/src/lib/components/Chart/Chart.svg.svelte new file mode 100644 index 000000000..be3ce27f4 --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/Chart.svg.svelte @@ -0,0 +1,28 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Chart/ChartCore.svelte b/packages/layerchart/src/lib/components/Chart/ChartCore.svelte new file mode 100644 index 000000000..6f35ed658 --- /dev/null +++ b/packages/layerchart/src/lib/components/Chart/ChartCore.svelte @@ -0,0 +1,36 @@ + + + + + + diff --git a/packages/layerchart/src/lib/components/ChartChildren.svelte b/packages/layerchart/src/lib/components/ChartChildren.svelte deleted file mode 100644 index bbd61ca8a..000000000 --- a/packages/layerchart/src/lib/components/ChartChildren.svelte +++ /dev/null @@ -1,334 +0,0 @@ - - - - -{#if childrenProp} - {@render childrenProp(snippetProps)} -{:else} - {@render belowContext?.(snippetProps)} - - - {#if typeof grid === 'function'} - {@render grid(snippetProps)} - {:else if grid} - - {/if} - - - {#if annotations.length > 0} - {#await import('./charts/ChartAnnotations.svelte') then { default: ChartAnnotations }} - - {/await} - {/if} - - {@render belowMarks?.(snippetProps)} - {@render marks?.(snippetProps)} - {@render aboveMarks?.(snippetProps)} - - - {#if typeof axis === 'function'} - {@render axis(snippetProps)} - - {#if typeof rule === 'function'} - {@render rule(snippetProps)} - {:else if rule} - - {/if} - {:else if axis} - {#if axis !== 'x'} - - - {/if} - - {#if axis !== 'y'} - - - {/if} - - {#if typeof rule === 'function'} - {@render rule(snippetProps)} - {:else if rule} - - {/if} - {/if} - - - - {#if typeof points === 'function'} - {@render points(snippetProps)} - {:else if points} - {#await import('./Points.svelte') then { default: Points }} - {#each context.series.visibleSeries as s, i (s.key)} - - {/each} - {/await} - {/if} - - {#if typeof labels === 'function'} - {@render labels(snippetProps)} - {:else if labels} - {#await import('./Labels.svelte') then { default: Labels }} - {@const labelSeriesKey = typeof labels === 'object' ? labels.seriesKey : undefined} - {#each context.series.visibleSeries.filter((s) => !labelSeriesKey || s.key === labelSeriesKey) as s, i (s.key)} - - {/each} - {/await} - {/if} - - {#if typeof highlight === 'function'} - {@render highlight(snippetProps)} - {:else if highlight} - - {/if} - - {#if annotations.length > 0} - {#await import('./charts/ChartAnnotations.svelte') then { default: ChartAnnotations }} - - {/await} - {/if} - - - - {@render aboveContext?.(snippetProps)} - - {#if typeof legend === 'function'} - {@render legend(snippetProps)} - {:else if legend} - {#await import('./Legend.svelte') then { default: Legend }} - - {/await} - {/if} - - {#if typeof tooltip === 'function'} - {@render tooltip(snippetProps)} - {:else if tooltipContext} - {#await import('./charts/DefaultTooltip.svelte') then { default: DefaultTooltip }} - - {/await} - {/if} -{/if} diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.base.svelte b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.base.svelte new file mode 100644 index 000000000..74e372510 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.base.svelte @@ -0,0 +1,212 @@ + + + + +{#if childrenProp} + {@render childrenProp(snippetProps)} +{:else} + {@render belowContext?.(snippetProps)} + + + {#if typeof grid === 'function'} + {@render grid(snippetProps)} + {:else if grid} + + {/if} + + + {#if annotations.length > 0} + {#await import('../charts/ChartAnnotations.svelte') then { default: ChartAnnotations }} + + {/await} + {/if} + + {@render belowMarks?.(snippetProps)} + {@render marks?.(snippetProps)} + {@render aboveMarks?.(snippetProps)} + + + {#if typeof axis === 'function'} + {@render axis(snippetProps)} + + {#if typeof rule === 'function'} + {@render rule(snippetProps)} + {:else if rule} + + {/if} + {:else if axis} + {#if axis !== 'x'} + + + {/if} + + {#if axis !== 'y'} + + + {/if} + + {#if typeof rule === 'function'} + {@render rule(snippetProps)} + {:else if rule} + + {/if} + {/if} + + + + {#if typeof points === 'function'} + {@render points(snippetProps)} + {:else if points} + {#await import('../Points/Points.svelte') then { default: Points }} + {#each context.series.visibleSeries as s, i (s.key)} + + {/each} + {/await} + {/if} + + {#if typeof labels === 'function'} + {@render labels(snippetProps)} + {:else if labels} + {#await import('../Labels/Labels.svelte') then { default: Labels }} + {@const labelSeriesKey = typeof labels === 'object' ? labels.seriesKey : undefined} + {#each context.series.visibleSeries.filter((s) => !labelSeriesKey || s.key === labelSeriesKey) as s, i (s.key)} + + {/each} + {/await} + {/if} + + {#if typeof highlight === 'function'} + {@render highlight(snippetProps)} + {:else if highlight} + + {/if} + + {#if annotations.length > 0} + {#await import('../charts/ChartAnnotations.svelte') then { default: ChartAnnotations }} + + {/await} + {/if} + + + + {@render aboveContext?.(snippetProps)} + + {#if typeof legend === 'function'} + {@render legend(snippetProps)} + {:else if legend} + {#await import('../Legend.svelte') then { default: Legend }} + + {/await} + {/if} + + {#if typeof tooltip === 'function'} + {@render tooltip(snippetProps)} + {:else if tooltipContext} + {#await import('../charts/DefaultTooltip.svelte') then { default: DefaultTooltip }} + + {/await} + {/if} +{/if} diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.canvas.svelte b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.canvas.svelte new file mode 100644 index 000000000..248086af6 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.canvas.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.html.svelte b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.html.svelte new file mode 100644 index 000000000..b64d131cd --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.html.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.shared.svelte.ts b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.shared.svelte.ts new file mode 100644 index 000000000..bf750fcc7 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.shared.svelte.ts @@ -0,0 +1,142 @@ +import type { ComponentProps, Snippet } from 'svelte'; + +import type { ChartState } from '$lib/contexts/chart.js'; +import type { AnyScale } from '$lib/utils/scales.svelte.js'; + +import type Axis from '../Axis/Axis.svelte'; +import type Area from '../Area/Area.svelte'; +import type Arc from '../Arc/Arc.svelte'; +import type Bars from '../Bars/Bars.svelte'; +import type BrushContext from '../BrushContext.svelte'; +import type Canvas from '../layers/Canvas.svelte'; +import type Grid from '../Grid/Grid.svelte'; +import type Group from '../Group/Group.svelte'; +import type Highlight from '../Highlight/Highlight.svelte'; +import type Labels from '../Labels/Labels.svelte'; +import type Legend from '../Legend.svelte'; +import type Line from '../Line/Line.svelte'; +import type Pie from '../Pie/Pie.svelte'; +import type Points from '../Points/Points.svelte'; +import type Rule from '../Rule/Rule.svelte'; +import type Spline from '../Spline/Spline.svelte'; +import type Svg from '../layers/Svg.svelte'; +import type TooltipContext from '../tooltip/TooltipContext.svelte'; +import type Tooltip from '../tooltip/Tooltip.svelte'; +import type TooltipHeader from '../tooltip/TooltipHeader.svelte'; +import type TooltipList from '../tooltip/TooltipList.svelte'; +import type TooltipItem from '../tooltip/TooltipItem.svelte'; +import type TooltipSeparator from '../tooltip/TooltipSeparator.svelte'; +import type { ChartAnnotations as ChartAnnotationsType } from '../charts/types.js'; + +// BaseChartProps +export type ChartChildrenProps< + TData, + XScale extends AnyScale = AnyScale, + YScale extends AnyScale = AnyScale, + ChartSnippet = Snippet<[{ context: ChartState }]>, +> = { + /** + * The axis to be used for the chart. + * + * @default true + */ + axis?: ComponentProps | 'x' | 'y' | boolean | ChartSnippet; + + /** + * The grid to be used for the chart. + * + * @default true + */ + grid?: ComponentProps | boolean | ChartSnippet; + + /** + * The labels to be used for the chart. + * + * @default false + */ + labels?: ComponentProps> | boolean | ChartSnippet; + + /** + * The legend to be used for the chart. + * + * @default false + */ + legend?: ComponentProps | boolean | ChartSnippet; + + /** + * The points to be used for the chart. + * + * @default false + */ + points?: ComponentProps | boolean | ChartSnippet; + + /** + * The rule to be used for the chart. + * + * @default true + */ + rule?: ComponentProps | boolean | ChartSnippet; + + /** + * The tooltip snippet to be used for the chart. + */ + tooltip?: ChartSnippet; + + /** + * Tooltip context to be used for the chart. + */ + tooltipContext?: Partial> | boolean; + + highlight?: boolean | Partial> | ChartSnippet; + + /** Annotations to show on chart */ + annotations?: ChartAnnotationsType; + + /** + * Callback when tooltip data is clicked. + */ + onTooltipClick?: (e: MouseEvent, details: { data: any }) => void; + + /** + * Additional props to be passed to the components rendered internally by Chart components. + * This is useful for customizing the behavior of individual components, without having + * to fully override them via snippets. + */ + props?: Partial<{ + area: Partial>; + arc: Partial>; + bars: Partial>; + brush: Partial>; + canvas: Partial>; + grid: Partial>; + group: Partial>; + highlight: Partial>; + labels: Partial>>; + legend: Partial>; + line: Partial>; + pie: Partial>; + spline: Partial>; + points: Partial>; + rule: Partial>; + svg: Partial>; + tooltip: { + context?: Partial>; + root?: Omit>, 'context'>; + header?: Partial>; + list?: Partial>; + item?: Partial>; + separator?: Partial>; + hideTotal?: boolean; + }; + xAxis: Partial>; + yAxis: Partial>; + }>; + + // Snippets + children?: ChartSnippet; + belowContext?: ChartSnippet; + belowMarks?: ChartSnippet; + marks?: ChartSnippet; + aboveMarks?: ChartSnippet; + aboveContext?: ChartSnippet; +}; diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svelte b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svelte new file mode 100644 index 000000000..abba2751b --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svg.svelte b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svg.svelte new file mode 100644 index 000000000..33ef67637 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartChildren/ChartChildren.svg.svelte @@ -0,0 +1,23 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartClipPath.svelte b/packages/layerchart/src/lib/components/ChartClipPath.svelte deleted file mode 100644 index 4a66ec580..000000000 --- a/packages/layerchart/src/lib/components/ChartClipPath.svelte +++ /dev/null @@ -1,45 +0,0 @@ - - - - - diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.base.svelte b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.base.svelte new file mode 100644 index 000000000..976c13dce --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.base.svelte @@ -0,0 +1,33 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.canvas.svelte b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.canvas.svelte new file mode 100644 index 000000000..da13cac4d --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.html.svelte b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.html.svelte new file mode 100644 index 000000000..2460492a4 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.html.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.shared.svelte.ts b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.shared.svelte.ts new file mode 100644 index 000000000..fe240a763 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.shared.svelte.ts @@ -0,0 +1,14 @@ +import type { Snippet } from 'svelte'; +import type { Without } from '$lib/utils/types.js'; +import type { RectClipPathProps } from '../RectClipPath/RectClipPath.shared.svelte.js'; + +export type ChartClipPathPropsWithoutHTML = { + /** Include padding area (ex. axis) @default false */ + full?: boolean; + /** Disable clipping (show all) @default false */ + disabled?: boolean; + children?: Snippet; +}; + +export type ChartClipPathProps = ChartClipPathPropsWithoutHTML & + Without, ChartClipPathPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svelte b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svelte new file mode 100644 index 000000000..02412916a --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svg.svelte b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svg.svelte new file mode 100644 index 000000000..e3a72dd29 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartClipPath/ChartClipPath.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ChartCore.svelte.test.ts b/packages/layerchart/src/lib/components/ChartCore.svelte.test.ts new file mode 100644 index 000000000..b171828d1 --- /dev/null +++ b/packages/layerchart/src/lib/components/ChartCore.svelte.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from 'vitest'; +import { render } from 'vitest-browser-svelte'; +import { tick } from 'svelte'; + +import ChartCoreTestHarness from './tests/ChartCoreTestHarness.svelte'; + +const data = [ + { x: 0, y: 10 }, + { x: 1, y: 20 }, + { x: 2, y: 30 }, +]; + +describe('ChartCore', () => { + it('should render the children snippet content', async () => { + const { container } = render(ChartCoreTestHarness, { + chartProps: { data, x: 'x', y: 'y', height: 200 }, + }); + + await tick(); + + // Children snippet renders a
+ expect(container.querySelector('[data-testid="children-content"]')).not.toBeNull(); + }); + + it('should NOT render ChartChildren-injected marks (Axis, Grid, Rule, Highlight)', async () => { + const { container } = render(ChartCoreTestHarness, { + chartProps: { data, x: 'x', y: 'y', height: 200 }, + }); + + await tick(); + + // Compare to : those classes would be present. ChartCore skips them. + expect(container.querySelector('.lc-axis')).toBeNull(); + expect(container.querySelector('.lc-grid')).toBeNull(); + expect(container.querySelector('.lc-rule')).toBeNull(); + expect(container.querySelector('.lc-highlight-area')).toBeNull(); + }); + + it('should expose the chart context to children', async () => { + let chartContext: any; + + render(ChartCoreTestHarness, { + chartProps: { data, x: 'x', y: 'y', height: 200 }, + oncontext: (ctx: any) => { + chartContext = ctx; + }, + }); + + await tick(); + + expect(chartContext).toBeDefined(); + expect(chartContext.width).toBeGreaterThan(0); + expect(chartContext.height).toBeGreaterThan(0); + }); + + it('should render the root container', async () => { + const { container } = render(ChartCoreTestHarness, { + chartProps: { data, x: 'x', y: 'y', height: 200 }, + }); + + await tick(); + + expect(container.querySelector('.lc-root-container')).not.toBeNull(); + }); +}); diff --git a/packages/layerchart/src/lib/components/Circle.svelte b/packages/layerchart/src/lib/components/Circle.svelte deleted file mode 100644 index 7b99eabda..000000000 --- a/packages/layerchart/src/lib/components/Circle.svelte +++ /dev/null @@ -1,487 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - - {/each} - {:else} - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - {@const resolvedBorderWidth = - resolvedStrokeWidth != null - ? `${resolvedStrokeWidth}px` - : resolvedStroke != null - ? '1px' - : undefined} -
- {/each} - {:else} -
- {@render children?.()} -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Circle/Circle.canvas.svelte b/packages/layerchart/src/lib/components/Circle/Circle.canvas.svelte new file mode 100644 index 000000000..522362950 --- /dev/null +++ b/packages/layerchart/src/lib/components/Circle/Circle.canvas.svelte @@ -0,0 +1,128 @@ + + + diff --git a/packages/layerchart/src/lib/components/Circle/Circle.html.svelte b/packages/layerchart/src/lib/components/Circle/Circle.html.svelte new file mode 100644 index 000000000..cc3b01628 --- /dev/null +++ b/packages/layerchart/src/lib/components/Circle/Circle.html.svelte @@ -0,0 +1,113 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const resolvedBorderWidth = + resolvedStrokeWidth != null + ? `${resolvedStrokeWidth}px` + : resolvedStroke != null + ? '1px' + : undefined} +
+ {/each} +{:else} +
+ {@render children?.()} +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Circle/Circle.shared.svelte.ts b/packages/layerchart/src/lib/components/Circle/Circle.shared.svelte.ts new file mode 100644 index 000000000..4b1bf9029 --- /dev/null +++ b/packages/layerchart/src/lib/components/Circle/Circle.shared.svelte.ts @@ -0,0 +1,288 @@ +import type { Snippet } from 'svelte'; +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { parseDashArray } from '$lib/utils/path.js'; +import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type CirclePropsWithoutHTML = { + /** + * The center x position of the circle. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * + * @default 0 + */ + cx?: DataProp; + + /** + * The initial center x position of the circle (pixel mode only). + * + * @default cx + */ + initialCx?: number; + + /** + * The center y position of the circle. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * + * @default 0 + */ + cy?: DataProp; + + /** + * The initial center y position of the circle (pixel mode only). + * + * @default cy + */ + initialCy?: number; + + /** + * The radius of the circle. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via rScale + * - `function(d)`: accessor called per data item, result passed through rScale + * + * @default 1 + */ + r?: DataProp; + + /** + * The initial radius of the circle (pixel mode only). + * + * @default r + */ + initialR?: number; + + /** + * Data array to iterate over in data mode. + * Falls back to chart context data when not provided. + */ + data?: any[]; + + /** + * Key function for keyed {#each} rendering in data mode. + * + * @default (d, i) => i + */ + key?: (d: any, index: number) => any; + + /** + * A bindable reference to the `` element (pixel mode only). + * + * @bindable + */ + ref?: SVGCircleElement; + + /** Motion configuration (pixel mode only). */ + motion?: MotionProp; + + /** + * Dashed-border pattern. Accepts a number (single dash length), a + * `[dash, gap, ...]` array, or a string (same syntax as SVG + * `stroke-dasharray`). HTML layer approximates via `border-style: dashed`. + */ + dashArray?: number | number[] | string; + + /** Children content to render. Note: Only works for Html layers */ + children?: Snippet; +} & DataDrivenStyleProps; + +export type CircleProps = CirclePropsWithoutHTML & + Without, CirclePropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +/** + * Resolve the cx/cy/r values for a single data item, going through + * either the geo projection or the chart's x/y/r scales. + */ +export function resolveCircle( + d: any, + props: { cx?: DataProp; cy?: DataProp; r?: DataProp }, + chartCtx: ChartState, + geo: GeoState +): { cx: number; cy: number; r: number } { + if (geo.projection) { + const [projX, projY] = resolveGeoDataPair(props.cx, props.cy, d, geo.projection); + return { + cx: projX, + cy: projY, + r: resolveDataProp(props.r, d, chartCtx.rScale, typeof props.r === 'number' ? props.r : 1), + }; + } + return { + cx: resolveDataProp(props.cx, d, chartCtx.xScale, 0), + cy: resolveDataProp(props.cy, d, chartCtx.yScale, 0), + r: resolveDataProp(props.r, d, chartCtx.rScale, typeof props.r === 'number' ? props.r : 1), + }; +} + +/** + * Reactive state shared by every per-layer Circle variant. Instantiate from + * each `Circle.svg.svelte` / `Circle.canvas.svelte` / `Circle.html.svelte` + * component setup, passing a getter for the props. Exposes the derived + * computations + motion sources every layer needs. + * + * Per-layer specific bits (e.g. SVG's `bind:this` ref, HTML's + * `staticBorderWidth`, canvas's `render` function and canvas registration) + * stay in their respective `.svelte` files. + */ +export class CircleState { + // Default initializer so derived class fields below can safely call this + // before the constructor body sets the real getter. + #getProps: () => CircleProps = () => ({}) as CircleProps; + + // Contexts (must be read inside a Svelte component setup, which is where + // CircleState is instantiated). + chartCtx = getChartContext(); + geo = getGeoContext(); + + // Reactive derivations + dashArrayResolved = $derived(parseDashArray(this.#getProps().dashArray)); + dashArrayAttr = $derived( + this.dashArrayResolved ? this.dashArrayResolved.join(' ') : undefined + ); + dataMode = $derived( + hasAnyDataProp(this.#getProps().cx, this.#getProps().cy, this.#getProps().r) + ); + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + // Per-key motion tracking (only created when motion is configured) + #dataMotionMap: ReturnType = null; + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = resolveCircle(d, props, this.chartCtx, this.geo); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + cx: animated?.cx ?? resolved.cx, + cy: animated?.cy ?? resolved.cy, + r: animated?.r ?? resolved.r, + }; + }); + }); + + // Pixel-mode motion sources. Initial values are captured at construction; + // subsequent updates come from the prop getters. + #motionCx!: ReturnType>; + #motionCy!: ReturnType>; + #motionR!: ReturnType>; + + // Static (non-data-driven) values used by SVG/HTML branches in pixel mode. + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' + ? (this.#getProps().opacity as number) + : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + + constructor(getProps: () => CircleProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialCx = + initial.initialCx ?? (typeof initial.cx === 'number' ? initial.cx : 0); + const initialCy = + initial.initialCy ?? (typeof initial.cy === 'number' ? initial.cy : 0); + const initialR = + initial.initialR ?? (typeof initial.r === 'number' ? initial.r : 1); + + this.#motionCx = createMotion( + initialCx, + () => (typeof getProps().cx === 'number' ? (getProps().cx as number) : 0), + initial.motion + ); + this.#motionCy = createMotion( + initialCy, + () => (typeof getProps().cy === 'number' ? (getProps().cy as number) : 0), + initial.motion + ); + this.#motionR = createMotion( + initialR, + () => (typeof getProps().r === 'number' ? (getProps().r as number) : 1), + initial.motion + ); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = resolveCircle(d, props, this.chartCtx, this.geo); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } + + get motionCx() { + return this.#motionCx.current; + } + get motionCy() { + return this.#motionCy.current; + } + get motionR() { + return this.#motionR.current; + } +} + +/** Build the standard `markInfo` payload used by every Circle variant. */ +export function circleMarkInfo(props: CircleProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.cx === 'string' ? props.cx : undefined, + y: typeof props.cy === 'string' ? props.cy : undefined, + color: typeof props.fill === 'string' ? props.fill : undefined, + }; +} diff --git a/packages/layerchart/src/lib/components/Circle/Circle.svelte b/packages/layerchart/src/lib/components/Circle/Circle.svelte new file mode 100644 index 000000000..b613d3e33 --- /dev/null +++ b/packages/layerchart/src/lib/components/Circle/Circle.svelte @@ -0,0 +1,28 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Circle.svelte.test.ts b/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Circle.svelte.test.ts rename to packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts index 140480bb7..06f40377e 100644 --- a/packages/layerchart/src/lib/components/Circle.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Circle/Circle.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Circle from './Circle.svelte'; describe('Circle', () => { diff --git a/packages/layerchart/src/lib/components/Circle/Circle.svg.svelte b/packages/layerchart/src/lib/components/Circle/Circle.svg.svelte new file mode 100644 index 000000000..b0bbb6c3c --- /dev/null +++ b/packages/layerchart/src/lib/components/Circle/Circle.svg.svelte @@ -0,0 +1,91 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + + {/each} +{:else} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/CircleClipPath.svelte b/packages/layerchart/src/lib/components/CircleClipPath.svelte deleted file mode 100644 index fa8e7e762..000000000 --- a/packages/layerchart/src/lib/components/CircleClipPath.svelte +++ /dev/null @@ -1,83 +0,0 @@ - - - - - diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.base.svelte b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.base.svelte new file mode 100644 index 000000000..d800c683b --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.base.svelte @@ -0,0 +1,34 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.canvas.svelte b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.canvas.svelte new file mode 100644 index 000000000..7a0221088 --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.canvas.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.html.svelte b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.html.svelte new file mode 100644 index 000000000..b3d62a77b --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.html.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.shared.svelte.ts b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.shared.svelte.ts new file mode 100644 index 000000000..deab06f61 --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.shared.svelte.ts @@ -0,0 +1,21 @@ +import type { MotionProp } from '$lib/utils/motion.svelte.js'; +import type { ClipPathPropsWithoutHTML } from '../ClipPath/ClipPath.shared.svelte.js'; + +export type CircleClipPathPropsWithoutHTML = { + /** A unique id for the clipPath. */ + id?: string; + /** The center x position of the circle. @default 0 */ + cx?: number; + /** The center y position of the circle. @default 0 */ + cy?: number; + /** The radius of the circle. @required */ + r: number; + /** Whether to disable clipping (show all). @default false */ + disabled?: boolean; + /** Invert the clip — content renders *outside* the circle. @default false */ + invert?: boolean; + /** A bindable reference to the underlying `` element @bindable */ + ref?: SVGCircleElement; + children?: ClipPathPropsWithoutHTML['children']; + motion?: MotionProp; +}; diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svelte b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svelte new file mode 100644 index 000000000..607fb87f9 --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svg.svelte b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svg.svelte new file mode 100644 index 000000000..a776d58f5 --- /dev/null +++ b/packages/layerchart/src/lib/components/CircleClipPath/CircleClipPath.svg.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/ClipPath.svelte b/packages/layerchart/src/lib/components/ClipPath.svelte deleted file mode 100644 index 310499cc7..000000000 --- a/packages/layerchart/src/lib/components/ClipPath.svelte +++ /dev/null @@ -1,150 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - - - {#if clip} - {@render clip({ id })} - {:else if effectivePath} - - {/if} - - {#if useId} - - {/if} - - -{/if} - -{#if children} - {#if disabled} - {@render children({ id, url, useId })} - {:else if layerCtx === 'svg'} - - {@render children({ id, url, useId })} - - {:else if layerCtx === 'html' && effectivePath} -
- {@render children({ id, url, useId })} -
- {:else} - {@render children({ id, url, useId })} - {/if} -{/if} diff --git a/packages/layerchart/src/lib/components/ClipPath/ClipPath.canvas.svelte b/packages/layerchart/src/lib/components/ClipPath/ClipPath.canvas.svelte new file mode 100644 index 000000000..859d53d18 --- /dev/null +++ b/packages/layerchart/src/lib/components/ClipPath/ClipPath.canvas.svelte @@ -0,0 +1,45 @@ + + + + +{#if children} + {@render children({ id, url, useId })} +{/if} diff --git a/packages/layerchart/src/lib/components/ClipPath/ClipPath.html.svelte b/packages/layerchart/src/lib/components/ClipPath/ClipPath.html.svelte new file mode 100644 index 000000000..9be21f902 --- /dev/null +++ b/packages/layerchart/src/lib/components/ClipPath/ClipPath.html.svelte @@ -0,0 +1,42 @@ + + + + +{#if children} + {#if disabled || !c.effectivePath} + {@render children({ id, url, useId })} + {:else} +
+ {@render children({ id, url, useId })} +
+ {/if} +{/if} diff --git a/packages/layerchart/src/lib/components/ClipPath/ClipPath.shared.svelte.ts b/packages/layerchart/src/lib/components/ClipPath/ClipPath.shared.svelte.ts new file mode 100644 index 000000000..825015370 --- /dev/null +++ b/packages/layerchart/src/lib/components/ClipPath/ClipPath.shared.svelte.ts @@ -0,0 +1,59 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; + +export type ClipPathPropsWithoutHTML = { + /** A unique id for the clipPath. */ + id?: string; + + /** Use existing path or shape (by id) for clipPath */ + useId?: string; + + /** Whether to disable clipping (show all). @default false */ + disabled?: boolean; + + /** + * Invert the clip — content renders *outside* the shape instead of inside. + * @default false + */ + invert?: boolean; + + /** + * SVG path `d` string describing the clip shape. Drives all three layers. + */ + path?: string; + + /** A snippet to insert custom SVG content into the ``. */ + clip?: Snippet<[{ id: string }]>; + + /** Children to render. */ + children?: Snippet<[{ id: string; url: string; useId?: string }]>; +}; + +export type ClipPathProps = ClipPathPropsWithoutHTML & + Without, ClipPathPropsWithoutHTML>; + +/** + * Reactive state shared by every per-layer ClipPath variant. + */ +export class ClipPathState { + #getProps: () => ClipPathProps = () => ({}) as ClipPathProps; + + chartCtx: ChartState = getChartContext(); + + // Outer rect covering the chart bounds — combined with the clip shape under + // even-odd fill rule to invert the clip. + outerRect = $derived(`M0,0 H${this.chartCtx.width} V${this.chartCtx.height} H0 Z`); + + /** Effective path used for canvas + html layers when inverting. */ + effectivePath = $derived.by(() => { + const props = this.#getProps(); + return props.invert && props.path ? `${this.outerRect} ${props.path}` : props.path; + }); + + constructor(getProps: () => ClipPathProps) { + this.#getProps = getProps; + } +} diff --git a/packages/layerchart/src/lib/components/ClipPath/ClipPath.svelte b/packages/layerchart/src/lib/components/ClipPath/ClipPath.svelte new file mode 100644 index 000000000..eb454c24b --- /dev/null +++ b/packages/layerchart/src/lib/components/ClipPath/ClipPath.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/ClipPath/ClipPath.svg.svelte b/packages/layerchart/src/lib/components/ClipPath/ClipPath.svg.svelte new file mode 100644 index 000000000..d7dd05e52 --- /dev/null +++ b/packages/layerchart/src/lib/components/ClipPath/ClipPath.svg.svelte @@ -0,0 +1,49 @@ + + + + + + + {#if clip} + {@render clip({ id })} + {:else if c.effectivePath} + + {/if} + + {#if useId} + + {/if} + + + +{#if children} + {#if disabled} + {@render children({ id, url, useId })} + {:else} + + {@render children({ id, url, useId })} + + {/if} +{/if} diff --git a/packages/layerchart/src/lib/components/Contour.svelte b/packages/layerchart/src/lib/components/Contour/Contour.base.svelte similarity index 67% rename from packages/layerchart/src/lib/components/Contour.svelte rename to packages/layerchart/src/lib/components/Contour/Contour.base.svelte index 69f69398d..1ec2adbfd 100644 --- a/packages/layerchart/src/lib/components/Contour.svelte +++ b/packages/layerchart/src/lib/components/Contour/Contour.base.svelte @@ -1,57 +1,13 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Contour/Contour.shared.svelte.ts b/packages/layerchart/src/lib/components/Contour/Contour.shared.svelte.ts new file mode 100644 index 000000000..e7ea61397 --- /dev/null +++ b/packages/layerchart/src/lib/components/Contour/Contour.shared.svelte.ts @@ -0,0 +1,29 @@ +import type { SVGAttributes } from 'svelte/elements'; +import type { CommonStyleProps, Without } from '$lib/utils/types.js'; +import type { Accessor } from '$lib/utils/common.js'; +import type { InterpolateMethod } from '$lib/utils/rasterInterpolate.js'; + +export type ContourPropsWithoutHTML = { + data?: number[] | Float64Array | any[]; + width?: number; + height?: number; + x1?: number; + y1?: number; + x2?: number; + y2?: number; + /** @default 'value' */ + value?: Accessor | ((x: number, y: number) => number); + x?: Accessor; + y?: Accessor; + /** @default 'barycentric' */ + interpolate?: InterpolateMethod; + /** @default 10 */ + thresholds?: number | number[]; + /** @default 0 */ + blur?: number; + /** @default true */ + smooth?: boolean; +} & CommonStyleProps; + +export type ContourProps = ContourPropsWithoutHTML & + Without, ContourPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/Contour/Contour.svelte b/packages/layerchart/src/lib/components/Contour/Contour.svelte new file mode 100644 index 000000000..a2217f189 --- /dev/null +++ b/packages/layerchart/src/lib/components/Contour/Contour.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Contour/Contour.svg.svelte b/packages/layerchart/src/lib/components/Contour/Contour.svg.svelte new file mode 100644 index 000000000..f8f35305c --- /dev/null +++ b/packages/layerchart/src/lib/components/Contour/Contour.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Density.svelte b/packages/layerchart/src/lib/components/Density/Density.base.svelte similarity index 70% rename from packages/layerchart/src/lib/components/Density.svelte rename to packages/layerchart/src/lib/components/Density/Density.base.svelte index 81334a1f5..cf3a90c22 100644 --- a/packages/layerchart/src/lib/components/Density.svelte +++ b/packages/layerchart/src/lib/components/Density/Density.base.svelte @@ -1,25 +1,13 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Density/Density.shared.svelte.ts b/packages/layerchart/src/lib/components/Density/Density.shared.svelte.ts new file mode 100644 index 000000000..ca54048c3 --- /dev/null +++ b/packages/layerchart/src/lib/components/Density/Density.shared.svelte.ts @@ -0,0 +1,17 @@ +import type { SVGAttributes } from 'svelte/elements'; +import type { CommonStyleProps, Without } from '$lib/utils/types.js'; +import type { Accessor } from '$lib/utils/common.js'; + +export type DensityPropsWithoutHTML = { + data?: any[]; + x?: Accessor; + y?: Accessor; + weight?: Accessor; + /** @default 20 */ + bandwidth?: number; + /** @default 20 */ + thresholds?: number; +} & CommonStyleProps; + +export type DensityProps = DensityPropsWithoutHTML & + Without, DensityPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/Density/Density.svelte b/packages/layerchart/src/lib/components/Density/Density.svelte new file mode 100644 index 000000000..67c474f53 --- /dev/null +++ b/packages/layerchart/src/lib/components/Density/Density.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Density/Density.svg.svelte b/packages/layerchart/src/lib/components/Density/Density.svg.svelte new file mode 100644 index 000000000..5d360efd1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Density/Density.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Ellipse.svelte b/packages/layerchart/src/lib/components/Ellipse.svelte deleted file mode 100644 index 1295cf3a4..000000000 --- a/packages/layerchart/src/lib/components/Ellipse.svelte +++ /dev/null @@ -1,485 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - - {/each} - {:else} - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - {@const resolvedBorderWidth = - resolvedStrokeWidth != null - ? `${resolvedStrokeWidth}px` - : resolvedStroke != null - ? '1px' - : undefined} -
- {/each} - {:else} -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.canvas.svelte b/packages/layerchart/src/lib/components/Ellipse/Ellipse.canvas.svelte new file mode 100644 index 000000000..e083ee1d3 --- /dev/null +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.canvas.svelte @@ -0,0 +1,113 @@ + + + diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.html.svelte b/packages/layerchart/src/lib/components/Ellipse/Ellipse.html.svelte new file mode 100644 index 000000000..474e0e072 --- /dev/null +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.html.svelte @@ -0,0 +1,92 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const resolvedBorderWidth = + resolvedStrokeWidth != null + ? `${resolvedStrokeWidth}px` + : resolvedStroke != null + ? '1px' + : undefined} +
+ {/each} +{:else} +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts b/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts new file mode 100644 index 000000000..ed394dde6 --- /dev/null +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.shared.svelte.ts @@ -0,0 +1,221 @@ +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type EllipsePropsWithoutHTML = { + /** The center x position of the ellipse. @default 0 */ + cx?: DataProp; + /** The initial center x position of the ellipse (pixel mode only). @default cx */ + initialCx?: number; + /** The center y position of the ellipse. @default 0 */ + cy?: DataProp; + /** The initial center y position of the ellipse (pixel mode only). @default cy */ + initialCy?: number; + /** The radius of the ellipse on the x-axis. @default 1 */ + rx?: DataProp; + /** The initial radius of the ellipse on the x-axis (pixel mode only). @default rx */ + initialRx?: number; + /** The radius of the ellipse on the y-axis. @default 1 */ + ry?: DataProp; + /** The initial radius of the ellipse on the y-axis (pixel mode only). @default ry */ + initialRy?: number; + /** Data array to iterate over in data mode. */ + data?: any[]; + /** Key function for keyed {#each} rendering in data mode. @default (d, i) => i */ + key?: (d: any, index: number) => any; + /** A bindable reference to the `` element (pixel mode only). @bindable */ + ref?: SVGEllipseElement; + /** Motion configuration (pixel mode only). */ + motion?: MotionProp; +} & DataDrivenStyleProps; + +export type EllipseProps = EllipsePropsWithoutHTML & + Without, EllipsePropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +export function ellipseMarkInfo(props: EllipseProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.cx === 'string' ? props.cx : undefined, + y: typeof props.cy === 'string' ? props.cy : undefined, + color: + typeof props.fill === 'string' + ? props.fill + : typeof props.stroke === 'string' + ? props.stroke + : undefined, + }; +} + +export class EllipseState { + #getProps: () => EllipseProps = () => ({}) as EllipseProps; + + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + dataMode = $derived( + hasAnyDataProp( + this.#getProps().cx, + this.#getProps().cy, + this.#getProps().rx, + this.#getProps().ry + ) + ); + + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.#resolveEllipse(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + cx: animated?.cx ?? resolved.cx, + cy: animated?.cy ?? resolved.cy, + rx: animated?.rx ?? resolved.rx, + ry: animated?.ry ?? resolved.ry, + }; + }); + }); + + #resolveEllipse(d: any) { + const props = this.#getProps(); + if (this.geo.projection) { + const [projX, projY] = resolveGeoDataPair(props.cx, props.cy, d, this.geo.projection); + return { + cx: projX, + cy: projY, + rx: resolveDataProp(props.rx, d, this.chartCtx.rScale, typeof props.rx === 'number' ? props.rx : 1), + ry: resolveDataProp(props.ry, d, this.chartCtx.rScale, typeof props.ry === 'number' ? props.ry : 1), + }; + } + return { + cx: resolveDataProp(props.cx, d, this.chartCtx.xScale, 0), + cy: resolveDataProp(props.cy, d, this.chartCtx.yScale, 0), + rx: resolveDataProp(props.rx, d, this.chartCtx.rScale, typeof props.rx === 'number' ? props.rx : 1), + ry: resolveDataProp(props.ry, d, this.chartCtx.rScale, typeof props.ry === 'number' ? props.ry : 1), + }; + } + + #dataMotionMap: ReturnType = null; + #motionCx!: ReturnType>; + #motionCy!: ReturnType>; + #motionRx!: ReturnType>; + #motionRy!: ReturnType>; + + get motionCx() { + return this.#motionCx.current; + } + get motionCy() { + return this.#motionCy.current; + } + get motionRx() { + return this.#motionRx.current; + } + get motionRy() { + return this.#motionRy.current; + } + + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' + ? (this.#getProps().opacity as number) + : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + staticBorderWidth = $derived.by(() => { + const props = this.#getProps(); + if (typeof props.strokeWidth === 'number') return `${props.strokeWidth}px`; + if (typeof props.stroke === 'string') return '1px'; + return undefined; + }); + + constructor(getProps: () => EllipseProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialCx = initial.initialCx ?? (typeof initial.cx === 'number' ? initial.cx : 0); + const initialCy = initial.initialCy ?? (typeof initial.cy === 'number' ? initial.cy : 0); + const initialRx = initial.initialRx ?? (typeof initial.rx === 'number' ? initial.rx : 1); + const initialRy = initial.initialRy ?? (typeof initial.ry === 'number' ? initial.ry : 1); + + this.#motionCx = createMotion( + initialCx, + () => (typeof getProps().cx === 'number' ? (getProps().cx as number) : 0), + initial.motion + ); + this.#motionCy = createMotion( + initialCy, + () => (typeof getProps().cy === 'number' ? (getProps().cy as number) : 0), + initial.motion + ); + this.#motionRx = createMotion( + initialRx, + () => (typeof getProps().rx === 'number' ? (getProps().rx as number) : 1), + initial.motion + ); + this.#motionRy = createMotion( + initialRy, + () => (typeof getProps().ry === 'number' ? (getProps().ry as number) : 1), + initial.motion + ); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.#resolveEllipse(d); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte new file mode 100644 index 000000000..609bacb60 --- /dev/null +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte @@ -0,0 +1,26 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Ellipse.svelte.test.ts b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Ellipse.svelte.test.ts rename to packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts index 9377502af..cb5a1eac9 100644 --- a/packages/layerchart/src/lib/components/Ellipse.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Ellipse from './Ellipse.svelte'; describe('Ellipse', () => { diff --git a/packages/layerchart/src/lib/components/Ellipse/Ellipse.svg.svelte b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svg.svelte new file mode 100644 index 000000000..c6401e166 --- /dev/null +++ b/packages/layerchart/src/lib/components/Ellipse/Ellipse.svg.svelte @@ -0,0 +1,95 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + + {/each} +{:else} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/Frame.svelte b/packages/layerchart/src/lib/components/Frame/Frame.base.svelte similarity index 56% rename from packages/layerchart/src/lib/components/Frame.svelte rename to packages/layerchart/src/lib/components/Frame/Frame.base.svelte index 3efcbeb3e..c99c31c9a 100644 --- a/packages/layerchart/src/lib/components/Frame.svelte +++ b/packages/layerchart/src/lib/components/Frame/Frame.base.svelte @@ -1,27 +1,24 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Frame/Frame.html.svelte b/packages/layerchart/src/lib/components/Frame/Frame.html.svelte new file mode 100644 index 000000000..493bc44b6 --- /dev/null +++ b/packages/layerchart/src/lib/components/Frame/Frame.html.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Frame/Frame.shared.svelte.ts b/packages/layerchart/src/lib/components/Frame/Frame.shared.svelte.ts new file mode 100644 index 000000000..f718789e1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Frame/Frame.shared.svelte.ts @@ -0,0 +1,12 @@ +import type { Without } from '$lib/utils/types.js'; +import type { RectProps, RectPropsWithoutHTML } from '../Rect/Rect.shared.svelte.js'; + +export type FramePropsWithoutHTML = RectPropsWithoutHTML & { + /** Whether to include padding area @default false */ + full?: boolean; +}; + +export type FrameProps = Omit< + FramePropsWithoutHTML & Without, + 'width' | 'height' +>; diff --git a/packages/layerchart/src/lib/components/Frame/Frame.svelte b/packages/layerchart/src/lib/components/Frame/Frame.svelte new file mode 100644 index 000000000..e128ecd96 --- /dev/null +++ b/packages/layerchart/src/lib/components/Frame/Frame.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Frame/Frame.svg.svelte b/packages/layerchart/src/lib/components/Frame/Frame.svg.svelte new file mode 100644 index 000000000..a68e3b16d --- /dev/null +++ b/packages/layerchart/src/lib/components/Frame/Frame.svg.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Grid.svelte b/packages/layerchart/src/lib/components/Grid.svelte deleted file mode 100644 index 23e178234..000000000 --- a/packages/layerchart/src/lib/components/Grid.svelte +++ /dev/null @@ -1,320 +0,0 @@ - - - - - - {#if x} - {@const splineProps = extractLayerProps(x, 'lc-grid-x-line')} - - - {#each xTickVals as x (x)} - {#if ctx.radial} - {@const [x1, y1] = pointRadial(ctx.xScale(x), ctx.yRange[0])} - {@const [x2, y2] = pointRadial(ctx.xScale(x), ctx.yRange[1])} - - {:else} - - {/if} - {/each} - - - {#if isScaleBand(ctx.xScale) && bandAlign === 'between' && !ctx.radial && xTickVals.length} - {@const x = ctx.xScale(xTickVals[xTickVals.length - 1])! + ctx.xScale.step() + xBandOffset} - - {/if} - - {/if} - - {#if y} - {@const splineProps = extractLayerProps(y, 'lc-grid-y-line')} - - {#each yTickVals as y (y)} - {#if ctx.radial} - {#if radialY === 'circle'} - - {:else} - {#await import('./Spline.svelte') then { default: Spline }} - ({ x, y }))} - x="x" - y="y" - {stroke} - motion={tweenConfig} - curve={curveLinearClosed} - {...splineProps} - class={cls('lc-grid-y-radial-line', classes.line, splineProps?.class)} - /> - {/await} - {/if} - {:else} - - {/if} - {/each} - - - {#if isScaleBand(ctx.yScale) && bandAlign === 'between' && yTickVals.length} - {#if ctx.radial} - - {:else} - {@const y = - ctx.yScale(yTickVals[yTickVals.length - 1])! + ctx.yScale.step() + yBandOffset} - - {/if} - {/if} - - {/if} - - - diff --git a/packages/layerchart/src/lib/components/Grid/Grid.base.svelte b/packages/layerchart/src/lib/components/Grid/Grid.base.svelte new file mode 100644 index 000000000..afc0103fa --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.base.svelte @@ -0,0 +1,221 @@ + + + + + + {#if x} + {@const splineProps = extractLayerProps(x, 'lc-grid-x-line')} + + + {#each c.xTickVals as tick (tick)} + {#if c.ctx.radial} + {@const [x1, y1] = pointRadial(c.ctx.xScale(tick), c.ctx.yRange[0])} + {@const [x2, y2] = pointRadial(c.ctx.xScale(tick), c.ctx.yRange[1])} + + {:else} + + {/if} + {/each} + + + {#if isScaleBand(c.ctx.xScale) && bandAlign === 'between' && !c.ctx.radial && c.xTickVals.length} + + {/if} + + {/if} + + {#if y} + {@const splineProps = extractLayerProps(y, 'lc-grid-y-line')} + + {#each c.yTickVals as tick (tick)} + {#if c.ctx.radial} + {#if radialY === 'circle'} + + {:else} + {#await import('../Spline/Spline.svelte') then { default: Spline }} + ({ x: tx, y: tick }))} + x="x" + y="y" + {stroke} + motion={c.tweenConfig} + curve={curveLinearClosed} + {...splineProps} + class={cls('lc-grid-y-radial-line', classes.line, splineProps?.class)} + /> + {/await} + {/if} + {:else} + + {/if} + {/each} + + + {#if isScaleBand(c.ctx.yScale) && bandAlign === 'between' && c.yTickVals.length} + {#if c.ctx.radial} + + {:else} + {@const yEnd = + c.ctx.yScale(c.yTickVals[c.yTickVals.length - 1])! + c.ctx.yScale.step() + c.yBandOffset} + + {/if} + {/if} + + {/if} + + + diff --git a/packages/layerchart/src/lib/components/Grid/Grid.canvas.svelte b/packages/layerchart/src/lib/components/Grid/Grid.canvas.svelte new file mode 100644 index 000000000..c19fc94f6 --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.canvas.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Grid/Grid.html.svelte b/packages/layerchart/src/lib/components/Grid/Grid.html.svelte new file mode 100644 index 000000000..714498bad --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.html.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts b/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts new file mode 100644 index 000000000..133f8bad1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.shared.svelte.ts @@ -0,0 +1,148 @@ +import { fade } from 'svelte/transition'; +import { cubicIn } from 'svelte/easing'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { Transition, TransitionParams, Without } from '$lib/utils/types.js'; +import { extractTweenConfig, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { isScaleBand } from '$lib/utils/scales.svelte.js'; +import { autoTickVals, type TicksConfig } from '$lib/utils/ticks.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GroupProps } from '../Group/Group.shared.svelte.js'; + +/** Props forwarded onto the underlying grid line (``, ``, or ``). */ +export type GridLineProps = Pick, 'class' | 'style'> & { + stroke?: string; + strokeWidth?: number; + opacity?: number; + /** Dashed-line pattern. See `Line.dashArray`. */ + dashArray?: number | number[] | string; +}; + +export type GridPropsWithoutHTML = { + /** + * Draw a x-axis lines. Pass props (class, style, stroke, strokeWidth, + * opacity, dashArray) to forward onto the underlying line. + * + * @default false + */ + x?: boolean | GridLineProps; + + /** + * Draw a y-axis lines. Pass props (class, style, stroke, strokeWidth, + * opacity, dashArray) to forward onto the underlying line. + * + * @default false + */ + y?: boolean | GridLineProps; + + /** + * Control the number of x-axis ticks + */ + xTicks?: TicksConfig; + + /** + * Control the number of y-axis ticks + * + * @default !isScaleBand(ctx.yScale) ? 4 : undefined + */ + yTicks?: TicksConfig; + + /** + * Line alignment when band scale is used (x or y axis) + * + * @default 'center' + */ + bandAlign?: 'center' | 'between'; + + /** + * Render `y` lines with circles or linear splines + * + * @default 'circle' + */ + radialY?: 'circle' | 'linear'; + + /** + * Stroke color for grid lines. + * Useful for server-side rendering where CSS variables are not available. + */ + stroke?: string; + + /** + * Classes to apply to the rendered elements. + * + * @default {} + */ + classes?: { + root?: string; + line?: string; + }; + + /** + * Transition function for entering elements + * @default defaults to fade if motion is tweened + */ + transitionIn?: In; + + /** + * Parameters for the transitionIn function + * @default { easing: cubicIn } + */ + transitionInParams?: TransitionParams; + + /** + * A reference to the underlying outermost `` element. + * + * @bindable + */ + ref?: SVGGElement; + + motion?: MotionProp; +}; + +export type GridProps = Omit< + GridPropsWithoutHTML & Without>, + 'children' +>; + +/** + * Reactive state shared by every per-layer Grid variant. + */ +export class GridState { + #getProps: () => GridProps = () => ({}) as GridProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => GridProps) { + this.#getProps = getProps; + // Mark as composite so child Splines (radial grid) don't register + this.ctx.registerComponent({ name: 'Grid', kind: 'composite-mark' }); + } + + yTicks = $derived(this.#getProps().yTicks ?? (!isScaleBand(this.ctx.yScale) ? 4 : undefined)); + + tweenConfig = $derived(extractTweenConfig(this.#getProps().motion)); + + defaultTransitionIn = $derived( + (this.#getProps().transitionIn ?? this.tweenConfig?.options) ? fade : () => ({}) + ); + defaultTransitionInParams: TransitionParams = { easing: cubicIn }; + + xTickVals = $derived(autoTickVals(this.ctx.xScale, this.#getProps().xTicks)); + yTickVals = $derived(autoTickVals(this.ctx.yScale, this.yTicks)); + + xBandOffset = $derived.by(() => { + const bandAlign = this.#getProps().bandAlign ?? 'center'; + if (!isScaleBand(this.ctx.xScale)) return 0; + return bandAlign === 'between' + ? -(this.ctx.xScale.padding() * this.ctx.xScale.step()) / 2 + : this.ctx.xScale.step() / 2 - (this.ctx.xScale.padding() * this.ctx.xScale.step()) / 2; + }); + + yBandOffset = $derived.by(() => { + const bandAlign = this.#getProps().bandAlign ?? 'center'; + if (!isScaleBand(this.ctx.yScale)) return 0; + return bandAlign === 'between' + ? -(this.ctx.yScale.padding() * this.ctx.yScale.step()) / 2 + : this.ctx.yScale.step() / 2 - (this.ctx.yScale.padding() * this.ctx.yScale.step()) / 2; + }); +} diff --git a/packages/layerchart/src/lib/components/Grid/Grid.svelte b/packages/layerchart/src/lib/components/Grid/Grid.svelte new file mode 100644 index 000000000..d0fcdbff5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.svelte @@ -0,0 +1,24 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Grid/Grid.svg.svelte b/packages/layerchart/src/lib/components/Grid/Grid.svg.svelte new file mode 100644 index 000000000..fb123bb20 --- /dev/null +++ b/packages/layerchart/src/lib/components/Grid/Grid.svg.svelte @@ -0,0 +1,18 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Group.svelte b/packages/layerchart/src/lib/components/Group.svelte deleted file mode 100644 index f4595cf11..000000000 --- a/packages/layerchart/src/lib/components/Group.svelte +++ /dev/null @@ -1,325 +0,0 @@ - - - - -{#if layerCtx === 'canvas'} - {@render children?.()} -{:else if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - - {@render children?.()} - - {/each} - {:else} - - {@render children?.()} - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} -
- {@render children?.()} -
- {/each} - {:else} -
- {@render children?.()} -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Group/Group.canvas.svelte b/packages/layerchart/src/lib/components/Group/Group.canvas.svelte new file mode 100644 index 000000000..575730c2e --- /dev/null +++ b/packages/layerchart/src/lib/components/Group/Group.canvas.svelte @@ -0,0 +1,38 @@ + + + + +{@render children?.()} diff --git a/packages/layerchart/src/lib/components/Group/Group.html.svelte b/packages/layerchart/src/lib/components/Group/Group.html.svelte new file mode 100644 index 000000000..50b0b00b1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Group/Group.html.svelte @@ -0,0 +1,100 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} +
+ {@render children?.()} +
+ {/each} +{:else} +
+ {@render children?.()} +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts b/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts new file mode 100644 index 000000000..9a5143e4b --- /dev/null +++ b/packages/layerchart/src/lib/components/Group/Group.shared.svelte.ts @@ -0,0 +1,217 @@ +import type { Snippet } from 'svelte'; +import { untrack } from 'svelte'; +import type { HTMLAttributes } from 'svelte/elements'; + +import type { Transition, TransitionParams, Without } from '$lib/utils/types.js'; +import type { DataProp } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { + createMotion, + createDataMotionMap, + extractTweenConfig, + type MotionProp, +} from '$lib/utils/motion.svelte.js'; +import { fade } from 'svelte/transition'; +import { cubicIn } from 'svelte/easing'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type GroupPropsWithoutHTML = { + /** + * Translate x position of the group. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + */ + x?: DataProp; + + /** Initial translate x (pixel mode only). @default x */ + initialX?: number; + + /** + * Translate y position of the group. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + */ + y?: DataProp; + + /** Initial translate y (pixel mode only). @default y */ + initialY?: number; + + /** + * Data array to iterate over in data mode. + * Falls back to chart context data when not provided. + */ + data?: any[]; + + /** + * Key function for keyed {#each} rendering in data mode. + * + * @default (d, i) => i + */ + key?: (d: any, index: number) => any; + + /** Center within chart. @default false */ + center?: boolean | 'x' | 'y'; + + /** + * Prevent `touchmove` default, which can interfere with `pointermove` when + * used with `Tooltip`, for example. + * + * @default false + */ + preventTouchMove?: boolean; + + /** The opacity of the element. (0 to 1) */ + opacity?: number; + + children?: Snippet; + + /** + * A reference to the rendered DOM element, which could be + * either nothing, a `` element (when using ``), or a `
` element + * (when using ``). + * + * @bindable + */ + ref?: Element; + + motion?: MotionProp; + + /** Transition function for entering elements @default fade if motion is tweened */ + transitionIn?: In; + + /** Parameters for the transitionIn function @default { easing: cubicIn } */ + transitionInParams?: TransitionParams; +}; + +export type GroupProps = GroupPropsWithoutHTML & + Without, GroupPropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +/** + * Reactive state shared by every per-layer Group variant. + */ +export class GroupState { + #getProps: () => GroupProps = () => ({}) as GroupProps; + + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + // Data mode detection + dataMode = $derived(hasAnyDataProp(this.#getProps().x, this.#getProps().y)); + + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.#resolveGroup(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + x: animated?.x ?? resolved.x, + y: animated?.y ?? resolved.y, + }; + }); + }); + + #resolveGroup(d: any): { x: number; y: number } { + const props = this.#getProps(); + if (this.geo.projection) { + const [projX, projY] = resolveGeoDataPair(props.x, props.y, d, this.geo.projection); + return { x: projX, y: projY }; + } + return { + x: resolveDataProp(props.x, d, this.chartCtx.xScale, 0), + y: resolveDataProp(props.y, d, this.chartCtx.yScale, 0), + }; + } + + // Pixel-mode position (with center support) + trueX = $derived.by(() => { + const props = this.#getProps(); + if (typeof props.x === 'number') return props.x; + if (props.x == null && (props.center === 'x' || props.center === true)) + return this.chartCtx.width / 2; + return 0; + }); + trueY = $derived.by(() => { + const props = this.#getProps(); + if (typeof props.y === 'number') return props.y; + if (props.y == null && (props.center === 'y' || props.center === true)) + return this.chartCtx.height / 2; + return 0; + }); + + #dataMotionMap: ReturnType = null; + #motionX!: ReturnType>; + #motionY!: ReturnType>; + + get motionX() { + return this.#motionX.current; + } + get motionY() { + return this.#motionY.current; + } + + // Transform string for SVG/HTML pixel mode + transform = $derived.by(() => { + const props = this.#getProps(); + if (props.center || props.x != null || props.y != null) { + return `translate(${this.motionX}px, ${this.motionY}px)`; + } + return undefined; + }); + + // Default transition (fade when motion is tweened) + defaultTransitionIn = $derived( + extractTweenConfig(this.#getProps().motion)?.options ? fade : () => ({}) + ); + defaultTransitionInParams = { easing: cubicIn }; + + constructor(getProps: () => GroupProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialX = initial.initialX ?? (typeof initial.x === 'number' ? initial.x : undefined); + const initialY = initial.initialY ?? (typeof initial.y === 'number' ? initial.y : undefined); + + this.#motionX = createMotion(initialX, () => this.trueX, initial.motion); + this.#motionY = createMotion(initialY, () => this.trueY, initial.motion); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.#resolveGroup(d); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Group/Group.svelte b/packages/layerchart/src/lib/components/Group/Group.svelte new file mode 100644 index 000000000..aafd4bd6c --- /dev/null +++ b/packages/layerchart/src/lib/components/Group/Group.svelte @@ -0,0 +1,26 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Group.svelte.test.ts b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts similarity index 96% rename from packages/layerchart/src/lib/components/Group.svelte.test.ts rename to packages/layerchart/src/lib/components/Group/Group.svelte.test.ts index 3960bbff2..3092f1844 100644 --- a/packages/layerchart/src/lib/components/Group.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Group/Group.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Group from './Group.svelte'; describe('Group', () => { diff --git a/packages/layerchart/src/lib/components/Group/Group.svg.svelte b/packages/layerchart/src/lib/components/Group/Group.svg.svelte new file mode 100644 index 000000000..c3475ca8f --- /dev/null +++ b/packages/layerchart/src/lib/components/Group/Group.svg.svelte @@ -0,0 +1,93 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + + {@render children?.()} + + {/each} +{:else} + + {@render children?.()} + +{/if} diff --git a/packages/layerchart/src/lib/components/Highlight.svelte b/packages/layerchart/src/lib/components/Highlight.svelte deleted file mode 100644 index 09bf3f920..000000000 --- a/packages/layerchart/src/lib/components/Highlight.svelte +++ /dev/null @@ -1,773 +0,0 @@ - - - - -{#if highlightData} - {#if area} - {#if typeof area === 'function'} - {@render area({ area: _area })} - {:else if ctx.radial} - - onAreaClick(e, { data: highlightData }))} - /> - {:else} - onAreaClick(e, { data: highlightData }))} - /> - {/if} - {/if} - - {#if bar} - {#if typeof bar === 'function'} - {@render bar()} - {:else} - {#await import('./Bar.svelte') then { default: Bar }} - onBarClick(e, { data: highlightData }))} - /> - {/await} - {/if} - {/if} - - {#if linesProp} - {#if typeof linesProp === 'function'} - {@render linesProp({ lines: _lines })} - {:else} - {#each _lines as line} - - {/each} - {/if} - {/if} - - {#if points} - {#if typeof points === 'function'} - {@render points({ points: _points })} - {:else} - {#each _points as point} - {@const pointOpacity = - opacity ?? - (point.seriesKey - ? ctx.series.isHighlighted(point.seriesKey, true) - ? 1 - : 0.1 - : undefined)} - { - // Do not propagate `pointerdown` event to `BrushContext` if `onclick` is provided - e.stopPropagation(); - })} - onclick={onPointClick && ((e) => onPointClick(e, { point, data: highlightData }))} - onpointerenter={(e) => { - if (onPointClick) { - asAny(e.target).style.cursor = 'pointer'; - } - if (point.seriesKey) { - ctx.series.highlightKey = point.seriesKey; - } - onPointEnter?.(e, { point, data: highlightData }); - }} - onpointerleave={(e) => { - if (onPointClick) { - asAny(e.target).style.cursor = 'default'; - } - if (point.seriesKey) { - ctx.series.highlightKey = null; - } - onPointLeave?.(e, { point, data: highlightData }); - }} - /> - {/each} - {/if} - {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.base.svelte b/packages/layerchart/src/lib/components/Highlight/Highlight.base.svelte new file mode 100644 index 000000000..d43e8fc4d --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.base.svelte @@ -0,0 +1,199 @@ + + + + +{#if c.highlightData} + {#if area} + {#if typeof area === 'function'} + {@render area({ area: c.area })} + {:else if c.ctx.radial} + onAreaClick(e, { data: c.highlightData }))} + /> + {:else} + onAreaClick(e, { data: c.highlightData }))} + /> + {/if} + {/if} + + {#if bar} + {#if typeof bar === 'function'} + {@render bar()} + {:else} + {#await import('../Bar/Bar.svelte') then { default: Bar }} + onBarClick(e, { data: c.highlightData }))} + /> + {/await} + {/if} + {/if} + + {#if linesProp} + {#if typeof linesProp === 'function'} + {@render linesProp({ lines: c.lines })} + {:else} + {#each c.lines as line} + + {/each} + {/if} + {/if} + + {#if points} + {#if typeof points === 'function'} + {@render points({ points: c.points })} + {:else} + {#each c.points as point} + {@const pointOpacity = + opacity ?? + (point.seriesKey + ? c.ctx.series.isHighlighted(point.seriesKey, true) + ? 1 + : 0.1 + : undefined)} + { + e.stopPropagation(); + })} + onclick={onPointClick && + ((e: MouseEvent) => onPointClick(e, { point, data: c.highlightData }))} + onpointerenter={(e: PointerEvent) => { + if (onPointClick) { + asAny(e.target).style.cursor = 'pointer'; + } + if (point.seriesKey) { + c.ctx.series.highlightKey = point.seriesKey; + } + onPointEnter?.(e, { point, data: c.highlightData }); + }} + onpointerleave={(e: PointerEvent) => { + if (onPointClick) { + asAny(e.target).style.cursor = 'default'; + } + if (point.seriesKey) { + c.ctx.series.highlightKey = null; + } + onPointLeave?.(e, { point, data: c.highlightData }); + }} + /> + {/each} + {/if} + {/if} +{/if} + + diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.canvas.svelte b/packages/layerchart/src/lib/components/Highlight/Highlight.canvas.svelte new file mode 100644 index 000000000..683e7d57a --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.canvas.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.html.svelte b/packages/layerchart/src/lib/components/Highlight/Highlight.html.svelte new file mode 100644 index 000000000..329fa11f6 --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.html.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts new file mode 100644 index 000000000..9909fe83e --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.shared.svelte.ts @@ -0,0 +1,520 @@ +import type { ComponentProps, Snippet } from 'svelte'; +import { max, min } from 'd3-array'; +import { pointRadial, type Series, type SeriesPoint } from 'd3-shape'; +import { notNull } from '@layerstack/utils'; + +import type Bar from '../Bar/Bar.svelte'; +import type Circle from '../Circle/Circle.svelte'; +import type Line from '../Line/Line.svelte'; +import type Rect from '../Rect/Rect.svelte'; +import { accessor, type Accessor } from '$lib/utils/common.js'; +import { isScaleBand, isScaleTime } from '$lib/utils/scales.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { MotionProp } from '$lib/utils/motion.svelte.js'; + +export type HighlightPointData = { x: any; y: any }; +export type HighlightPoint = { + x: number; + y: number; + r?: number; + fill: string; + data: HighlightPointData; + seriesKey?: string; +}; + +export type HighlightLineSegment = { x1: number; y1: number; x2: number; y2: number }; +export type HighlightArea = { x: number; y: number; width: number; height: number }; + +export type HighlightPropsWithoutHTML = { + /** Highlight specific data (annotate), espeically uses tooltip data */ + data?: any; + /** Override `x` from context */ + x?: Accessor; + /** Override `y` from context */ + y?: Accessor; + /** + * Use the chart's radius scale for highlight point size. + * When `true`, uses the `r` config from the chart context. + * When an accessor is provided, uses it to read the radius value from the data. + */ + r?: boolean | Accessor; + + axis?: 'x' | 'y' | 'both' | 'none'; + + /** + * Show points and pass props to Circles + * @default false + */ + points?: + | boolean + | Partial> + | Snippet< + [ + { + points: { + x: number; + y: number; + fill: string; + data: HighlightPointData; + }[]; + }, + ] + >; + + /** + * Show lines and pass props to Lines + * @default false + */ + lines?: + | boolean + | Partial> + | Snippet<[{ lines: HighlightLineSegment[] }]>; + + /** + * Show area and pass props to Rect + * @default false + */ + area?: + | boolean + | Partial> + | Snippet<[{ area: HighlightArea }]>; + + /** + * Show bar and pass props to Rect + * + * @default false + */ + bar?: boolean | Partial> | Snippet; + + /** + * Set to false to disable spring transitions + * + * @default true + */ + motion?: MotionProp; + + /** The opacity of the element. (0 to 1) */ + opacity?: number; + + onAreaClick?: (e: MouseEvent, detail: { data: any }) => void; + onBarClick?: (e: MouseEvent, detail: { data: any }) => void; + + onPointClick?: (e: MouseEvent, detail: { point: HighlightPoint; data: any }) => void; + onPointEnter?: (e: MouseEvent, detail: { point: HighlightPoint; data: any }) => void; + onPointLeave?: (e: MouseEvent, detail: { point: HighlightPoint; data: any }) => void; +}; + +export type HighlightProps = HighlightPropsWithoutHTML; + +/** + * Reactive state shared by every per-layer Highlight variant. Holds the + * derived `lines`, `area`, and `points` arrays the template renders, plus + * helpers like `axis` and `getPointRadius`. + */ +export class HighlightState { + #getProps: () => HighlightProps = () => ({}) as HighlightProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => HighlightProps) { + this.#getProps = getProps; + } + + x = $derived(accessor(this.#getProps().x ?? this.ctx.x)); + y = $derived(accessor(this.#getProps().y ?? this.ctx.y)); + + highlightData = $derived(this.#getProps().data ?? this.ctx.tooltip.data); + + xValue = $derived(this.x(this.highlightData)); + xCoord = $derived( + Array.isArray(this.xValue) + ? this.xValue.map((v) => this.ctx.xScale(v)) + : this.ctx.xScale(this.xValue) + ); + xOffset = $derived( + isScaleBand(this.ctx.xScale) && !this.ctx.radial ? this.ctx.xScale.bandwidth() / 2 : 0 + ); + xCoordScalar = $derived( + Array.isArray(this.xCoord) + ? (this.xCoord[0] + this.xCoord[this.xCoord.length - 1]) / 2 + : this.xCoord + ); + + yValue = $derived(this.y(this.highlightData)); + yCoord = $derived( + Array.isArray(this.yValue) + ? this.yValue.map((v) => this.ctx.yScale(v)) + : this.ctx.yScale(this.yValue) + ); + yOffset = $derived( + isScaleBand(this.ctx.yScale) && !this.ctx.radial ? this.ctx.yScale.bandwidth() / 2 : 0 + ); + yCoordScalar = $derived( + Array.isArray(this.yCoord) + ? (this.yCoord[0] + this.yCoord[this.yCoord.length - 1]) / 2 + : this.yCoord + ); + + axis = $derived.by(() => { + const axisProp = this.#getProps().axis; + return axisProp == null + ? isScaleBand(this.ctx.yScale) || isScaleTime(this.ctx.yScale) || this.ctx.valueAxis === 'x' + ? 'y' + : 'x' + : axisProp; + }); + + /** Resolve radius for a data item using the chart's rScale */ + getPointRadius(d: any): number | undefined { + const rProp = this.#getProps().r; + if (!rProp || !d) return undefined; + if (rProp === true) { + return this.ctx.config.r ? this.ctx.rGet(d) : undefined; + } + const value = accessor(rProp)(d); + return value != null ? this.ctx.rScale(value) : undefined; + } + + lines = $derived.by(() => { + let tmpLines: HighlightLineSegment[] = []; + if (!this.highlightData) return tmpLines; + const axis = this.axis; + if (axis === 'x' || axis === 'both') { + if (Array.isArray(this.xCoord)) { + tmpLines = [ + ...tmpLines, + ...this.xCoord.filter(notNull).map((xItem) => ({ + x1: xItem + this.xOffset, + y1: min(this.ctx.yRange) as unknown as number, + x2: xItem + this.xOffset, + y2: max(this.ctx.yRange) as unknown as number, + })), + ]; + } else if (this.xCoord != null) { + tmpLines = [ + ...tmpLines, + { + x1: this.xCoord + this.xOffset, + y1: min(this.ctx.yRange) as unknown as number, + x2: this.xCoord + this.xOffset, + y2: max(this.ctx.yRange) as unknown as number, + }, + ]; + } + } + + if (axis === 'y' || axis === 'both') { + if (Array.isArray(this.yCoord)) { + tmpLines = [ + ...tmpLines, + ...this.yCoord.filter(notNull).map((yItem) => ({ + x1: min(this.ctx.xRange) as unknown as number, + y1: yItem + this.yOffset, + x2: max(this.ctx.xRange) as unknown as number, + y2: yItem + this.yOffset, + })), + ]; + } else if (this.yCoord != null) { + tmpLines = [ + ...tmpLines, + { + x1: min(this.ctx.xRange) as unknown as number, + y1: this.yCoord + this.yOffset, + x2: max(this.ctx.xRange) as unknown as number, + y2: this.yCoord + this.yOffset, + }, + ]; + } + } + + if (this.ctx.radial) { + tmpLines = tmpLines.map((l) => { + const [x1, y1] = pointRadial(l.x1, l.y1); + const [x2, y2] = pointRadial(l.x2, l.y2); + return { ...l, x1, y1, x2, y2 }; + }); + } + + return tmpLines; + }); + + area = $derived.by(() => { + const tmpArea: HighlightArea = { x: 0, y: 0, width: 0, height: 0 }; + if (!this.highlightData) return tmpArea; + const axis = this.axis; + + if (axis === 'x' || axis === 'both') { + if (Array.isArray(this.xCoord)) { + tmpArea.x = min(this.xCoord)!; + tmpArea.width = (max(this.xCoord)! - min(this.xCoord)!) as number; + } else if (isScaleBand(this.ctx.xScale)) { + tmpArea.x = + (this.xCoord as number) - (this.ctx.xScale.padding() * this.ctx.xScale.step()) / 2; + tmpArea.width = this.ctx.xScale.step(); + } else if (this.ctx.xInterval) { + const start = this.ctx.xInterval.floor(this.xValue); + const end = this.ctx.xInterval.offset(start); + const xStart = this.ctx.xScale(start); + const xEnd = this.ctx.xScale(end); + tmpArea.x = Math.min(xStart, xEnd); + tmpArea.width = Math.abs(xEnd - xStart); + } else { + const index = this.ctx.flatData.findIndex( + (d) => Number(this.x(d)) === Number(this.x(this.highlightData)) + ); + const isLastPoint = index + 1 === this.ctx.flatData.length; + const nextDataPoint = isLastPoint + ? max(this.ctx.xDomain) + : this.x(this.ctx.flatData[index + 1]); + tmpArea.x = this.xCoord as number; + tmpArea.width = (this.ctx.xScale(nextDataPoint) ?? 0) - ((this.xCoord as number) ?? 0); + } + + if (axis === 'x') { + tmpArea.y = min(this.ctx.yRange) as unknown as number; + tmpArea.height = (max(this.ctx.yRange) - min(this.ctx.yRange)) as unknown as number; + } + } + + if (axis === 'y' || axis === 'both') { + if (Array.isArray(this.yCoord)) { + tmpArea.y = min(this.yCoord)!; + tmpArea.height = (max(this.yCoord)! - min(this.yCoord)!) as number; + } else if (isScaleBand(this.ctx.yScale)) { + tmpArea.y = + (this.yCoord as number) - (this.ctx.yScale.padding() * this.ctx.yScale.step()) / 2; + tmpArea.height = this.ctx.yScale.step(); + } else if (this.ctx.yInterval) { + const start = this.ctx.yInterval.floor(this.yValue); + const end = this.ctx.yInterval.offset(start); + const yStart = this.ctx.yScale(start); + const yEnd = this.ctx.yScale(end); + tmpArea.y = Math.min(yStart, yEnd); + tmpArea.height = Math.abs(yEnd - yStart); + } else { + const index = this.ctx.flatData.findIndex( + (d) => Number(this.y(d)) === Number(this.y(this.highlightData)) + ); + const isLastPoint = index + 1 === this.ctx.flatData.length; + const nextDataPoint = isLastPoint + ? max(this.ctx.yDomain) + : this.y(this.ctx.flatData[index + 1]); + tmpArea.y = this.yCoord as number; + tmpArea.height = (this.ctx.yScale(nextDataPoint) ?? 0) - ((this.yCoord as number) ?? 0); + } + + if (axis === 'y') { + tmpArea.width = max(this.ctx.xRange) as unknown as number; + } + } + return tmpArea; + }); + + points = $derived.by(() => { + let tmpPoints: HighlightPoint[] = []; + if (!this.highlightData) return tmpPoints; + const props = this.#getProps(); + + if (props.data === undefined && this.ctx.tooltip.series.length > 0) { + tmpPoints = this.ctx.tooltip.series + .flatMap((seriesInfo) => { + if (!seriesInfo.visible) return []; + + let pointX: number; + let pointY: number; + let dataX: any; + let dataY: any; + + if (this.ctx.series.isStacked) { + const matchingData = this.ctx.flatData.find((d) => this.x(d) === this.xValue); + const stackValue = matchingData + ? this.ctx.series.getStackValue(seriesInfo.key, matchingData) + : null; + const stackedY1 = stackValue + ? this.ctx.series.stackLayout === 'stackDiverging' && stackValue[1] <= 0 + ? stackValue[0] + : stackValue[1] + : 0; + + if (this.ctx.valueAxis === 'x') { + pointX = this.ctx.xScale(stackedY1) + this.xOffset; + pointY = (this.yCoordScalar as number) + this.yOffset; + dataX = stackedY1; + dataY = this.yValue; + } else { + pointX = (this.xCoordScalar as number) + this.xOffset; + pointY = this.ctx.yScale(stackedY1) + this.yOffset; + dataX = this.xValue; + dataY = stackedY1; + } + } else { + const seriesValue = seriesInfo.value; + + if (seriesValue == null) { + return []; + } + + if (Array.isArray(seriesValue)) { + if (this.ctx.valueAxis === 'x') { + return seriesValue.map((sv) => ({ + x: this.ctx.xScale(sv) + this.xOffset, + y: (this.yCoordScalar as number) + this.yOffset, + fill: seriesInfo.color ?? '', + data: { x: sv, y: this.yValue }, + seriesKey: seriesInfo.key, + })); + } else { + return seriesValue.map((sv) => ({ + x: (this.xCoordScalar as number) + this.xOffset, + y: this.ctx.yScale(sv) + this.yOffset, + fill: seriesInfo.color ?? '', + data: { x: this.xValue, y: sv }, + seriesKey: seriesInfo.key, + })); + } + } + + if (this.ctx.valueAxis === 'x') { + pointX = this.ctx.xScale(seriesValue) + this.xOffset; + pointY = (this.yCoordScalar as number) + this.yOffset; + dataX = seriesValue; + dataY = this.yValue; + } else { + pointX = (this.xCoordScalar as number) + this.xOffset; + pointY = this.ctx.yScale(seriesValue) + this.yOffset; + dataX = this.xValue; + dataY = seriesValue; + } + } + + return { + x: pointX, + y: pointY, + fill: seriesInfo.color ?? '', + data: { x: dataX, y: dataY }, + seriesKey: seriesInfo.key, + }; + }) + .filter(notNull); + } else if (Array.isArray(this.xCoord)) { + if (Array.isArray(this.highlightData)) { + const highlightSeriesPoint = this.highlightData as SeriesPoint; + if (Array.isArray(this.ctx.data)) { + const seriesPointsData = (this.ctx.data as any[]) + .map((series: Series) => ({ + series, + point: series.find((d) => this.y(d) === this.y(highlightSeriesPoint))!, + })) + .filter((d) => d.point); + + tmpPoints = seriesPointsData + .map((seriesPoint) => { + const fill = this.ctx.config.c ? this.ctx.cGet(seriesPoint.series) : null; + return { + x: this.ctx.xScale(seriesPoint.point[1]) + this.xOffset, + y: (this.yCoordScalar as number) + this.yOffset, + fill, + data: { x: seriesPoint.point[1], y: this.yValue }, + seriesKey: undefined, + }; + }) + .filter(notNull) as HighlightPoint[]; + } + } else { + tmpPoints = this.xCoord + .map((xItem, i) => { + if (xItem == null) return null; + // @ts-expect-error - TODO: fix type + const _key = this.ctx.config.x?.[i]; + + const fill = this.ctx.config.c + ? this.ctx.cGet({ ...this.highlightData, $key: _key }) + : null; + + return { + x: xItem + this.xOffset, + y: (this.yCoordScalar as number) + this.yOffset, + fill, + data: { x: this.xValue, y: this.yValue }, + seriesKey: _key, + }; + }) + .filter(notNull) as HighlightPoint[]; + } + } else if (Array.isArray(this.yCoord)) { + if (Array.isArray(this.highlightData)) { + const highlightSeriesPoint = this.highlightData as SeriesPoint; + if (Array.isArray(this.ctx.data)) { + const seriesPointsData = (this.ctx.data as any[]) + .map((series: Series) => ({ + series, + point: series.find((d) => this.x(d) === this.x(highlightSeriesPoint))!, + })) + .filter((d) => d.point); + + tmpPoints = seriesPointsData + .map((seriesPoint) => { + const fill = this.ctx.config.c ? this.ctx.cGet(seriesPoint.series) : null; + return { + x: (this.xCoord as number) + this.xOffset, + y: this.ctx.yScale(seriesPoint.point[1]) + this.yOffset, + fill, + data: { x: this.xValue, y: seriesPoint.point[1] }, + seriesKey: undefined, + }; + }) + .filter(notNull) as HighlightPoint[]; + } + } else { + tmpPoints = this.yCoord + .map((yItem, i) => { + if (yItem == null) return null; + // @ts-expect-error - TODO: fix type + const _key = this.ctx.config.y[i]; + + const fill = this.ctx.config.c + ? this.ctx.cGet({ ...this.highlightData, $key: _key }) + : null; + + return { + x: (this.xCoord as number) + this.xOffset, + y: yItem + this.yOffset, + fill, + data: { x: this.xValue, y: this.yValue }, + seriesKey: _key, + }; + }) + .filter(notNull) as HighlightPoint[]; + } + } else if (this.xCoord != null && this.yCoord != null) { + const fill = this.ctx.config.c ? this.ctx.cGet(this.highlightData) : null; + tmpPoints = [ + { + x: (this.xCoord as number) + this.xOffset, + y: (this.yCoord as number) + this.yOffset, + fill: fill as string, + data: { x: this.xValue, y: this.yValue }, + seriesKey: undefined, + }, + ]; + } else { + tmpPoints = []; + } + + if (this.ctx.radial) { + tmpPoints = tmpPoints.map((p) => { + const [x, y] = pointRadial(p.x, p.y); + return { ...p, x, y }; + }); + } + + if (props.r) { + const pointR = this.getPointRadius(this.highlightData); + if (pointR != null) { + tmpPoints = tmpPoints.map((p) => ({ ...p, r: pointR })); + } + } + + return tmpPoints; + }); +} diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.svelte b/packages/layerchart/src/lib/components/Highlight/Highlight.svelte new file mode 100644 index 000000000..a0570413e --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Highlight/Highlight.svg.svelte b/packages/layerchart/src/lib/components/Highlight/Highlight.svg.svelte new file mode 100644 index 000000000..1f65cbbaf --- /dev/null +++ b/packages/layerchart/src/lib/components/Highlight/Highlight.svg.svelte @@ -0,0 +1,17 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Hull.svelte b/packages/layerchart/src/lib/components/Hull/Hull.base.svelte similarity index 56% rename from packages/layerchart/src/lib/components/Hull.svelte rename to packages/layerchart/src/lib/components/Hull/Hull.base.svelte index aabb52cde..d883c699d 100644 --- a/packages/layerchart/src/lib/components/Hull.svelte +++ b/packages/layerchart/src/lib/components/Hull/Hull.base.svelte @@ -1,55 +1,13 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts b/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts new file mode 100644 index 000000000..a178943d8 --- /dev/null +++ b/packages/layerchart/src/lib/components/Hull/Hull.shared.svelte.ts @@ -0,0 +1,29 @@ +import type { Without, CommonStyleProps } from '$lib/utils/types.js'; +import type { SVGAttributes } from 'svelte/elements'; +import type { ComponentProps } from 'svelte'; +import type { Delaunay } from 'd3-delaunay'; +import type Spline from '../Spline/Spline.svelte'; +import type { GroupProps } from '../Group/Group.shared.svelte.js'; + +export type HullPropsWithoutHTML = { + data?: any; + /** @default curveLinearClosed */ + curve?: ComponentProps['curve']; + classes?: { + root?: string; + path?: string; + }; + onpointermove?: ( + e: PointerEvent, + details: { points: [number, number][]; polygon: Delaunay.Polygon } + ) => void; + onclick?: ( + e: MouseEvent, + details: { points: [number, number][]; polygon: Delaunay.Polygon } + ) => void; + onpointerleave?: (e: PointerEvent) => void; + /** @bindable */ + ref?: SVGGElement; +} & CommonStyleProps; + +export type HullProps = HullPropsWithoutHTML & Without; diff --git a/packages/layerchart/src/lib/components/Hull/Hull.svelte b/packages/layerchart/src/lib/components/Hull/Hull.svelte new file mode 100644 index 000000000..3317db8ca --- /dev/null +++ b/packages/layerchart/src/lib/components/Hull/Hull.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Hull/Hull.svg.svelte b/packages/layerchart/src/lib/components/Hull/Hull.svg.svelte new file mode 100644 index 000000000..fca61f8ea --- /dev/null +++ b/packages/layerchart/src/lib/components/Hull/Hull.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Image.svelte b/packages/layerchart/src/lib/components/Image.svelte deleted file mode 100644 index 00bc45ef4..000000000 --- a/packages/layerchart/src/lib/components/Image.svelte +++ /dev/null @@ -1,558 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedItems as item, i (item.key)} - {@const resolvedHrefValue = resolveHref(item.d)} - {@const renderX = item.x - item.width / 2} - {@const renderY = item.y - item.height / 2} - {#if item.r !== undefined} - - - - - - {/if} - - {/each} - {:else} - {#if pixelR !== undefined} - - - - - - {/if} - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedHrefValue = resolveHref(item.d)} - - {/each} - {:else} - - {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Image/Image.canvas.svelte b/packages/layerchart/src/lib/components/Image/Image.canvas.svelte new file mode 100644 index 000000000..a02594c80 --- /dev/null +++ b/packages/layerchart/src/lib/components/Image/Image.canvas.svelte @@ -0,0 +1,132 @@ + + + diff --git a/packages/layerchart/src/lib/components/Image/Image.html.svelte b/packages/layerchart/src/lib/components/Image/Image.html.svelte new file mode 100644 index 000000000..171b27a05 --- /dev/null +++ b/packages/layerchart/src/lib/components/Image/Image.html.svelte @@ -0,0 +1,114 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedHrefValue = c.resolveHref(item.d)} + + {/each} +{:else} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts b/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts new file mode 100644 index 000000000..49cb3784c --- /dev/null +++ b/packages/layerchart/src/lib/components/Image/Image.shared.svelte.ts @@ -0,0 +1,265 @@ +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import { get } from '@layerstack/utils'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataProp } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { + createMotion, + createDataMotionMap, + parseMotionProp, + type MotionProp, + type MotionOptions, +} from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type ImagePropsWithoutHTML = { + /** + * Image URL. In data mode, resolved per item. + */ + href?: string | ((d: any) => string); + x?: DataProp; + initialX?: number; + y?: DataProp; + initialY?: number; + width?: DataProp; + initialWidth?: number; + height?: DataProp; + initialHeight?: number; + /** Circular clip radius. When set, overrides width/height to 2*r. */ + r?: DataProp; + /** Rotation in degrees clockwise. */ + rotate?: DataProp; + /** SVG preserveAspectRatio attribute. @default 'xMidYMid meet' */ + preserveAspectRatio?: string; + /** CORS attribute for the image. */ + crossOrigin?: '' | 'anonymous' | 'use-credentials'; + /** Image rendering quality. */ + imageRendering?: string; + /** The opacity of the image. (0 to 1) */ + opacity?: number; + /** Data array to iterate over in data mode. */ + data?: any[]; + /** Key function for keyed {#each} rendering in data mode. @default (d, i) => i */ + key?: (d: any, index: number) => any; + /** A bindable reference to the `` element (pixel mode only). @bindable */ + ref?: SVGImageElement; + /** Motion configuration (pixel mode only). */ + motion?: MotionProp<'x' | 'y' | 'width' | 'height'>; +}; + +export type ImageProps = ImagePropsWithoutHTML & + Without, ImagePropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +export function imageMarkInfo(props: ImageProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.x === 'string' ? props.x : undefined, + y: typeof props.y === 'string' ? props.y : undefined, + }; +} + +export class ImageState { + #getProps: () => ImageProps = () => ({}) as ImageProps; + + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + dataMode = $derived( + hasAnyDataProp( + this.#getProps().x, + this.#getProps().y, + this.#getProps().width, + this.#getProps().height, + this.#getProps().r + ) || typeof this.#getProps().href === 'function' + ); + + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolveImage(d: any) { + const props = this.#getProps(); + const resolvedR = + props.r !== undefined ? resolveDataProp(props.r, d, null, 0) : undefined; + const defaultSize = resolvedR !== undefined ? resolvedR * 2 : 16; + const resolvedWidth = + props.width !== undefined + ? resolveDataProp(props.width, d, null, defaultSize) + : defaultSize; + const resolvedHeight = + props.height !== undefined + ? resolveDataProp(props.height, d, null, defaultSize) + : defaultSize; + + let resolvedX: number, resolvedY: number; + if (this.geo.projection) { + [resolvedX, resolvedY] = resolveGeoDataPair(props.x, props.y, d, this.geo.projection); + } else { + resolvedX = resolveDataProp(props.x, d, this.chartCtx.xScale, 0); + resolvedY = resolveDataProp(props.y, d, this.chartCtx.yScale, 0); + } + + return { + x: resolvedX, + y: resolvedY, + width: resolvedWidth, + height: resolvedHeight, + r: resolvedR, + rotate: props.rotate !== undefined ? resolveDataProp(props.rotate, d, null, 0) : undefined, + }; + } + + resolveHref(d: any): string | undefined { + const href = this.#getProps().href; + if (!href) return undefined; + if (typeof href === 'function') return href(d); + const dataValue = get(d, href); + if (dataValue !== undefined) return String(dataValue); + return href; + } + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.resolveImage(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + x: animated?.x ?? resolved.x, + y: animated?.y ?? resolved.y, + width: animated?.width ?? resolved.width, + height: animated?.height ?? resolved.height, + r: resolved.r, + rotate: resolved.rotate, + }; + }); + }); + + // Pixel-mode helpers + defaultSize = $derived( + typeof this.#getProps().r === 'number' ? (this.#getProps().r as number) * 2 : 16 + ); + resolvedPixelWidth = $derived( + typeof this.#getProps().width === 'number' + ? (this.#getProps().width as number) + : this.defaultSize + ); + resolvedPixelHeight = $derived( + typeof this.#getProps().height === 'number' + ? (this.#getProps().height as number) + : this.defaultSize + ); + pixelR = $derived( + typeof this.#getProps().r === 'number' ? (this.#getProps().r as number) : undefined + ); + pixelRotate = $derived( + typeof this.#getProps().rotate === 'number' ? (this.#getProps().rotate as number) : undefined + ); + + #dataMotionMap: ReturnType = null; + #motionX!: ReturnType>; + #motionY!: ReturnType>; + #motionWidth!: ReturnType>; + #motionHeight!: ReturnType>; + + get motionX() { + return this.#motionX.current; + } + get motionY() { + return this.#motionY.current; + } + get motionWidth() { + return this.#motionWidth.current; + } + get motionHeight() { + return this.#motionHeight.current; + } + + constructor(getProps: () => ImageProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialX = initial.initialX ?? (typeof initial.x === 'number' ? initial.x : 0); + const initialY = initial.initialY ?? (typeof initial.y === 'number' ? initial.y : 0); + const initialWidth = + initial.initialWidth ?? + (typeof initial.width === 'number' + ? initial.width + : typeof initial.r === 'number' + ? initial.r * 2 + : 16); + const initialHeight = + initial.initialHeight ?? + (typeof initial.height === 'number' + ? initial.height + : typeof initial.r === 'number' + ? initial.r * 2 + : 16); + const motion = initial.motion; + + this.#motionX = createMotion( + initialX, + () => (typeof getProps().x === 'number' ? (getProps().x as number) : 0), + motion === undefined ? undefined : parseMotionProp(motion, 'x') + ); + this.#motionY = createMotion( + initialY, + () => (typeof getProps().y === 'number' ? (getProps().y as number) : 0), + motion === undefined ? undefined : parseMotionProp(motion, 'y') + ); + this.#motionWidth = createMotion( + initialWidth, + () => this.resolvedPixelWidth, + motion === undefined ? undefined : parseMotionProp(motion, 'width') + ); + this.#motionHeight = createMotion( + initialHeight, + () => this.resolvedPixelHeight, + motion === undefined ? undefined : parseMotionProp(motion, 'height') + ); + + this.#dataMotionMap = createDataMotionMap(motion as MotionOptions | undefined); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.resolveImage(d); + untrack(() => + motionMap.update(key, { + x: resolved.x, + y: resolved.y, + width: resolved.width, + height: resolved.height, + }) + ); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Image/Image.svelte b/packages/layerchart/src/lib/components/Image/Image.svelte new file mode 100644 index 000000000..76aba8b02 --- /dev/null +++ b/packages/layerchart/src/lib/components/Image/Image.svelte @@ -0,0 +1,26 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Image.svelte.test.ts b/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Image.svelte.test.ts rename to packages/layerchart/src/lib/components/Image/Image.svelte.test.ts index 138e74bb6..12d97ce59 100644 --- a/packages/layerchart/src/lib/components/Image.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Image/Image.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Image from './Image.svelte'; describe('Image', () => { diff --git a/packages/layerchart/src/lib/components/Image/Image.svg.svelte b/packages/layerchart/src/lib/components/Image/Image.svg.svelte new file mode 100644 index 000000000..a9d247ec1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Image/Image.svg.svelte @@ -0,0 +1,138 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item, i (item.key)} + {@const resolvedHrefValue = c.resolveHref(item.d)} + {@const renderX = item.x - item.width / 2} + {@const renderY = item.y - item.height / 2} + {#if item.r !== undefined} + + + + + + {/if} + + {/each} +{:else} + {#if c.pixelR !== undefined} + + + + + + {/if} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/Labels.svelte b/packages/layerchart/src/lib/components/Labels.svelte deleted file mode 100644 index 51a1d461e..000000000 --- a/packages/layerchart/src/lib/components/Labels.svelte +++ /dev/null @@ -1,313 +0,0 @@ - - - - - - - {#snippet children({ points })} - {#each points as point, i (key(point.data, i))} - {@const baseProps = getTextProps(point, points, i)} - {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} - {#if childrenProp} - {@render childrenProp({ data: point, textProps })} - {:else} - - {/if} - {/each} - {/snippet} - - - - diff --git a/packages/layerchart/src/lib/components/Labels/Labels.base.svelte b/packages/layerchart/src/lib/components/Labels/Labels.base.svelte new file mode 100644 index 000000000..4301a897c --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.base.svelte @@ -0,0 +1,92 @@ + + + + + + + {#snippet children({ points }: { points: Point[] })} + {#each points as point, i (key(point.data, i))} + {@const baseProps = c.getTextProps(point, points, i)} + {@const textProps = extractLayerProps(baseProps, 'lc-labels-text')} + {#if childrenProp} + {@render childrenProp({ data: point, textProps })} + {:else} + + {/if} + {/each} + {/snippet} + + + + diff --git a/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte b/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte new file mode 100644 index 000000000..12755e7f9 --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.canvas.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Labels/Labels.html.svelte b/packages/layerchart/src/lib/components/Labels/Labels.html.svelte new file mode 100644 index 000000000..2cc55dd51 --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.html.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts new file mode 100644 index 000000000..9aa81434e --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.shared.svelte.ts @@ -0,0 +1,225 @@ +import type { ComponentProps, Snippet } from 'svelte'; +import { format as formatValue, type FormatType, type FormatConfig } from '@layerstack/utils'; + +import type { Without } from '$lib/utils/types.js'; +import { accessor, type Accessor } from '$lib/utils/common.js'; +import { isScaleBand } from '$lib/utils/scales.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import { createDimensionGetter } from '$lib/utils/rect.svelte.js'; +import type { TextProps } from '../Text/Text.shared.svelte.js'; +import type { Point } from '../Points/Points.shared.svelte.js'; + +export type LabelsPropsWithoutHTML = { + /** Override data instead of using context */ + data?: T; + /** Override display value accessor. By default, uses `y` unless yScale is band scale */ + value?: Accessor; + /** The fill color of the label, string or accessor */ + fill?: string | Accessor; + /** Override `x` accessor from Chart context */ + x?: Accessor; + /** Override `y` accessor from Chart context */ + y?: Accessor; + /** Series key to use for accessor. */ + seriesKey?: string; + /** @default 'outside' */ + placement?: 'inside' | 'outside' | 'middle' | 'center' | 'smart'; + /** @default placement === 'center' || placement === 'middle' ? 0 : 4 */ + offset?: number; + /** The format of the label */ + format?: FormatType | FormatConfig; + /** @default (d, index) => index */ + key?: (d: T, index: number) => any; + children?: Snippet<[{ data: Point; textProps: TextProps }]>; +}; + +export type LabelsProps = LabelsPropsWithoutHTML & + Without>; + +/** + * Reactive state shared by every per-layer Labels variant. Holds the + * `getTextProps(point, points, i)` helper that computes per-point label + * positioning + opacity, plus the resolved opacity. + */ +export class LabelsState { + #getProps: () => LabelsProps = () => ({}) as LabelsProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => LabelsProps) { + this.#getProps = getProps; + this.ctx.registerComponent({ name: 'Labels', kind: 'composite-mark' }); + } + + getDimensions = $derived( + createDimensionGetter(this.ctx, () => ({ + x: this.#getProps().x, + y: this.#getProps().y, + })) + ); + + series = $derived.by(() => { + const seriesKey = this.#getProps().seriesKey; + return seriesKey ? this.ctx.series.series.find((s) => s.key === seriesKey) : undefined; + }); + + derivedOpacity = $derived.by(() => { + const opacity = (this.#getProps() as any).opacity as number | undefined; + return ( + opacity ?? + (this.series?.key == null || + this.ctx.series.visibleSeries.length <= 1 || + this.ctx.series.isHighlighted(this.series.key, true) + ? 1 + : 0.1) + ); + }); + + getTextProps(point: Point, points?: Point[], i?: number): TextProps { + const props = this.#getProps(); + const placement = props.placement ?? 'outside'; + const offset = props.offset ?? (placement === 'center' || placement === 'middle' ? 0 : 4); + + const pointValue = isScaleBand(this.ctx.yScale) ? point.xValue : point.yValue; + const isLowEdge = point.edgeIndex != null ? point.edgeIndex === 0 : pointValue < 0; + + const fillValue = + typeof props.fill === 'function' ? accessor(props.fill)(point.data) : props.fill; + + const displayValue = props.value + ? accessor(props.value)(point.data) + : isScaleBand(this.ctx.yScale) + ? point.xValue + : point.yValue; + + const formattedValue = formatValue( + displayValue, + // @ts-expect-error - improve types + props.format ?? + (props.value + ? undefined + : isScaleBand(this.ctx.yScale) + ? this.ctx.xScale.tickFormat?.() + : this.ctx.yScale.tickFormat?.()) + ); + + let result: TextProps; + + if (isScaleBand(this.ctx.yScale)) { + if (placement === 'center') { + const dims = + this.getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 }; + result = { + value: formattedValue, + fill: fillValue, + x: dims.x + dims.width / 2, + y: dims.y + dims.height / 2, + textAnchor: 'middle', + verticalAnchor: 'middle', + capHeight: '.6rem', + } as TextProps; + } else if (isLowEdge) { + result = { + value: formattedValue, + fill: fillValue, + x: point.x + (placement === 'outside' ? -offset : offset), + y: point.y, + textAnchor: + placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start', + verticalAnchor: 'middle', + capHeight: '.6rem', + } as TextProps; + } else { + result = { + value: formattedValue, + fill: fillValue, + x: point.x + (placement === 'outside' ? offset : -offset), + y: point.y, + textAnchor: + placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end', + verticalAnchor: 'middle', + capHeight: '.6rem', + } as TextProps; + } + } else { + if (placement === 'center') { + const dims = + this.getDimensions(point.data) ?? { x: point.x, y: point.y, width: 0, height: 0 }; + result = { + value: formattedValue, + fill: fillValue, + x: dims.x + dims.width / 2, + y: dims.y + dims.height / 2, + capHeight: '.6rem', + textAnchor: 'middle', + verticalAnchor: 'middle', + } as TextProps; + } else if (isLowEdge) { + result = { + value: formattedValue, + fill: fillValue, + x: point.x, + y: point.y + (placement === 'outside' ? offset : -offset), + capHeight: '.6rem', + textAnchor: 'middle', + verticalAnchor: + placement === 'middle' ? 'middle' : placement === 'outside' ? 'start' : 'end', + } as TextProps; + } else { + result = { + value: formattedValue, + fill: fillValue, + x: point.x, + y: point.y + (placement === 'outside' ? -offset : offset), + capHeight: '.6rem', + textAnchor: 'middle', + verticalAnchor: + placement === 'middle' ? 'middle' : placement === 'outside' ? 'end' : 'start', + } as TextProps; + } + } + + if (placement === 'smart' && points != null && i != null) { + const getValue = (p: Point): number => + isScaleBand(this.ctx.yScale) ? p.xValue : p.yValue; + const curr = getValue(point); + const prev = i > 0 ? getValue(points[i - 1]) : curr; + const next = i < points.length - 1 ? getValue(points[i + 1]) : curr; + + const xPrevTight = Math.abs(prev - curr) < offset; + const xNextTight = Math.abs(curr - next) < offset; + const isPeak = (prev <= curr && curr >= next) || (xPrevTight && xNextTight); + const isTrough = (prev >= curr && curr <= next) || (xPrevTight && xNextTight); + const isRising = !isPeak && !isTrough && prev < curr; + const isFalling = !isPeak && !isTrough && prev >= curr; + + return { + ...result, + x: point.x, + y: point.y, + dx: isRising + ? xPrevTight + ? offset + : -offset + : isFalling + ? xNextTight + ? -offset + : offset + : 0, + dy: isPeak ? -offset : isTrough ? offset : 0, + textAnchor: isRising + ? xPrevTight + ? 'start' + : 'end' + : isFalling + ? xNextTight + ? 'end' + : 'start' + : 'middle', + verticalAnchor: isPeak ? 'end' : isTrough ? 'start' : 'middle', + } as TextProps; + } + + return result; + } +} diff --git a/packages/layerchart/src/lib/components/Labels/Labels.svelte b/packages/layerchart/src/lib/components/Labels/Labels.svelte new file mode 100644 index 000000000..464af18da --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte b/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte new file mode 100644 index 000000000..854d4a545 --- /dev/null +++ b/packages/layerchart/src/lib/components/Labels/Labels.svg.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Line.svelte b/packages/layerchart/src/lib/components/Line.svelte deleted file mode 100644 index 6f3d17cc2..000000000 --- a/packages/layerchart/src/lib/components/Line.svelte +++ /dev/null @@ -1,509 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - - - - - {#each resolvedItems as item (item.key)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - - {/each} - {:else} - - - - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - {@const { angle, length } = pointsToAngleAndLength( - { x: item.x1, y: item.y1 }, - { x: item.x2, y: item.y2 } - )} -
- {/each} - {:else} - {@const { angle, length } = pointsToAngleAndLength( - { x: motionX1.current, y: motionY1.current }, - { x: motionX2.current, y: motionY2.current } - )} - -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Line/Line.canvas.svelte b/packages/layerchart/src/lib/components/Line/Line.canvas.svelte new file mode 100644 index 000000000..2d9484171 --- /dev/null +++ b/packages/layerchart/src/lib/components/Line/Line.canvas.svelte @@ -0,0 +1,120 @@ + + + diff --git a/packages/layerchart/src/lib/components/Line/Line.html.svelte b/packages/layerchart/src/lib/components/Line/Line.html.svelte new file mode 100644 index 000000000..d72a986e4 --- /dev/null +++ b/packages/layerchart/src/lib/components/Line/Line.html.svelte @@ -0,0 +1,88 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const { angle, length } = pointsToAngleAndLength( + { x: item.x1, y: item.y1 }, + { x: item.x2, y: item.y2 } + )} +
+ {/each} +{:else} + {@const { angle, length } = pointsToAngleAndLength( + { x: c.motionX1, y: c.motionY1 }, + { x: c.motionX2, y: c.motionY2 } + )} + +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts b/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts new file mode 100644 index 000000000..cf1b6d9c5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Line/Line.shared.svelte.ts @@ -0,0 +1,304 @@ +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { parseDashArray } from '$lib/utils/path.js'; +import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; +import type { MarkerOptions } from '../MarkerWrapper.svelte'; + +export type LinePropsWithoutHTML = { + /** + * The x-coordinate of the line's starting point. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * + * @required + */ + x1: DataProp; + + /** The initial x-coordinate of the line's starting point (pixel mode only). @default x1 */ + initialX1?: number; + + /** + * The y-coordinate of the line's starting point. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * + * @required + */ + y1: DataProp; + + /** The initial y-coordinate of the line's starting point (pixel mode only). @default y1 */ + initialY1?: number; + + /** + * The x-coordinate of the line's ending point. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * + * @required + */ + x2: DataProp; + + /** The initial x-coordinate of the line's ending point (pixel mode only). @default x2 */ + initialX2?: number; + + /** + * The y-coordinate of the line's ending point. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * + * @default y2 + */ + y2: DataProp; + + /** The initial y-coordinate of the line's ending point (pixel mode only). @default y2 */ + initialY2?: number; + + /** + * Data array to iterate over in data mode. + * Falls back to chart context data when not provided. + */ + data?: any[]; + + /** + * Key function for keyed {#each} rendering in data mode. + * + * @default (d, i) => i + */ + key?: (d: any, index: number) => any; + + /** Marker to attach to both start and end points of the line */ + marker?: MarkerOptions; + + /** Marker to attach to the start point of the line */ + markerStart?: MarkerOptions; + + /** Marker to attach to the mid point of the line */ + markerMid?: MarkerOptions; + + /** Marker to attach to the end point of the line */ + markerEnd?: MarkerOptions; + + /** Motion configuration (pixel mode only). */ + motion?: MotionProp; + + /** + * Dashed-line pattern. Accepts a number (single dash length), a + * `[dash, gap, ...]` array, or a string (same syntax as SVG + * `stroke-dasharray`). Works across ``, ``, and `` + * layers — HTML approximates the pattern via `repeating-linear-gradient`. + */ + dashArray?: number | number[] | string; +} & DataDrivenStyleProps; + +export type LineProps = LinePropsWithoutHTML & + Without, LinePropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +/** Build the standard `markInfo` payload used by every Line variant. */ +export function lineMarkInfo(props: LineProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: + typeof props.x1 === 'string' + ? props.x1 + : typeof props.x2 === 'string' + ? props.x2 + : undefined, + y: + typeof props.y1 === 'string' + ? props.y1 + : typeof props.y2 === 'string' + ? props.y2 + : undefined, + color: + typeof props.stroke === 'string' + ? props.stroke + : typeof props.fill === 'string' + ? props.fill + : undefined, + }; +} + +/** + * Reactive state shared by every per-layer Line variant. + */ +export class LineState { + #getProps: () => LineProps = () => ({}) as LineProps; + + // Contexts + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + // Data mode detection + dataMode = $derived( + hasAnyDataProp( + this.#getProps().x1, + this.#getProps().y1, + this.#getProps().x2, + this.#getProps().y2 + ) + ); + + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.#resolveLine(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + x1: animated?.x1 ?? resolved.x1, + y1: animated?.y1 ?? resolved.y1, + x2: animated?.x2 ?? resolved.x2, + y2: animated?.y2 ?? resolved.y2, + }; + }); + }); + + #resolveLine(d: any): { x1: number; y1: number; x2: number; y2: number } { + const props = this.#getProps(); + if (this.geo.projection) { + const [projX1, projY1] = resolveGeoDataPair(props.x1, props.y1, d, this.geo.projection); + const [projX2, projY2] = resolveGeoDataPair(props.x2, props.y2, d, this.geo.projection); + return { x1: projX1, y1: projY1, x2: projX2, y2: projY2 }; + } + return { + x1: resolveDataProp(props.x1, d, this.chartCtx.xScale, 0), + y1: resolveDataProp(props.y1, d, this.chartCtx.yScale, 0), + x2: resolveDataProp(props.x2, d, this.chartCtx.xScale, 0), + y2: resolveDataProp(props.y2, d, this.chartCtx.yScale, 0), + }; + } + + // Dash array + dashArrayResolved = $derived(parseDashArray(this.#getProps().dashArray)); + dashArrayAttr = $derived(this.dashArrayResolved ? this.dashArrayResolved.join(' ') : undefined); + + // Pixel-mode motion sources + #dataMotionMap: ReturnType = null; + #motionX1!: ReturnType>; + #motionY1!: ReturnType>; + #motionX2!: ReturnType>; + #motionY2!: ReturnType>; + + get motionX1() { + return this.#motionX1.current; + } + get motionY1() { + return this.#motionY1.current; + } + get motionX2() { + return this.#motionX2.current; + } + get motionY2() { + return this.#motionY2.current; + } + + // Static (non-data-driven) values for SVG/HTML pixel mode + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' + ? (this.#getProps().opacity as number) + : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + // For HTML rendering: stroke-width fallback as div height + staticHeight = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? `${this.#getProps().strokeWidth}px` + : '1px' + ); + + constructor(getProps: () => LineProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialX1 = initial.initialX1 ?? (typeof initial.x1 === 'number' ? initial.x1 : 0); + const initialY1 = initial.initialY1 ?? (typeof initial.y1 === 'number' ? initial.y1 : 0); + const initialX2 = initial.initialX2 ?? (typeof initial.x2 === 'number' ? initial.x2 : 0); + const initialY2 = initial.initialY2 ?? (typeof initial.y2 === 'number' ? initial.y2 : 0); + + this.#motionX1 = createMotion( + initialX1, + () => (typeof getProps().x1 === 'number' ? (getProps().x1 as number) : 0), + initial.motion + ); + this.#motionY1 = createMotion( + initialY1, + () => (typeof getProps().y1 === 'number' ? (getProps().y1 as number) : 0), + initial.motion + ); + this.#motionX2 = createMotion( + initialX2, + () => (typeof getProps().x2 === 'number' ? (getProps().x2 as number) : 0), + initial.motion + ); + this.#motionY2 = createMotion( + initialY2, + () => (typeof getProps().y2 === 'number' ? (getProps().y2 as number) : 0), + initial.motion + ); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.#resolveLine(d); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Line/Line.svelte b/packages/layerchart/src/lib/components/Line/Line.svelte new file mode 100644 index 000000000..0b06ec61e --- /dev/null +++ b/packages/layerchart/src/lib/components/Line/Line.svelte @@ -0,0 +1,27 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Line.svelte.test.ts b/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Line.svelte.test.ts rename to packages/layerchart/src/lib/components/Line/Line.svelte.test.ts index 3b13a26f3..67f0e9d7e 100644 --- a/packages/layerchart/src/lib/components/Line.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Line/Line.svelte.test.ts @@ -3,7 +3,7 @@ import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; import type { Component } from 'svelte'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Line from './Line.svelte'; describe('Line', () => { diff --git a/packages/layerchart/src/lib/components/Line/Line.svg.svelte b/packages/layerchart/src/lib/components/Line/Line.svg.svelte new file mode 100644 index 000000000..5e38add01 --- /dev/null +++ b/packages/layerchart/src/lib/components/Line/Line.svg.svelte @@ -0,0 +1,122 @@ + + + + +{#if c.dataMode} + + + + + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + + {/each} +{:else} + + + + +{/if} + + diff --git a/packages/layerchart/src/lib/components/LinearGradient.svelte b/packages/layerchart/src/lib/components/LinearGradient.svelte deleted file mode 100644 index 57d3bcb08..000000000 --- a/packages/layerchart/src/lib/components/LinearGradient.svelte +++ /dev/null @@ -1,232 +0,0 @@ - - - - -{#if layerCtx === 'canvas'} - - {@render children?.({ id, gradient: asAny(canvasGradient) })} -{:else if layerCtx === 'svg'} - - - {#if stopsContent} - {@render stopsContent?.()} - {:else if stops} - {#each stops as stop, i} - {#if Array.isArray(stop)} - - {:else} - - {/if} - {/each} - {/if} - - - - {@render children?.({ id, gradient: `url(#${id})` })} -{:else if layerCtx === 'html'} - {@render children?.({ id, gradient: createCSSGradient() })} -{/if} diff --git a/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.canvas.svelte b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.canvas.svelte new file mode 100644 index 000000000..9c0a95d57 --- /dev/null +++ b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.canvas.svelte @@ -0,0 +1,75 @@ + + + + +{@render children?.({ id, gradient: asAny(canvasGradient) })} diff --git a/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.html.svelte b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.html.svelte new file mode 100644 index 000000000..38f6f88ab --- /dev/null +++ b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.html.svelte @@ -0,0 +1,51 @@ + + + + +{@render children?.({ id, gradient: createCSSGradient() })} diff --git a/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.shared.svelte.ts b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.shared.svelte.ts new file mode 100644 index 000000000..17d36a7ae --- /dev/null +++ b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.shared.svelte.ts @@ -0,0 +1,56 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; + +export type LinearGradientPropsWithoutHTML = { + /** Unique id for linearGradient */ + id?: string; + + /** + * Array of strings (colors) distributed equally from 0-100%, or + * `[offset, color]` tuples. + * + * @default `['var(--tw-gradient-from)', 'var(--tw-gradient-to)']` + */ + stops?: string[] | [string | number, string][]; + + /** Apply color stops top-to-bottom (true) or left-to-right (false). @default false */ + vertical?: boolean; + + /** @default '0%' */ + x1?: string; + + /** @default '0%' */ + y1?: string; + + /** @default vertical ? '0%' : '100%' */ + x2?: string; + + /** @default vertical ? '100%' : '0%' */ + y2?: string; + + /** Rotate the gradient by a given angle in degrees */ + rotate?: number; + + /** + * Define the coordinate system for attributes (i.e. gradientUnits) + * + * @default 'objectBoundingBox' + */ + units?: 'objectBoundingBox' | 'userSpaceOnUse'; + + /** + * A bindable reference to the underlying `` element + * + * @bindable + */ + ref?: SVGLinearGradientElement; + + /** Render as a child of the gradient and will opt out of the default stops being rendered. */ + stopsContent?: Snippet; + + children?: Snippet<[{ id: string; gradient: string }]>; +}; + +export type LinearGradientProps = LinearGradientPropsWithoutHTML & + Without, LinearGradientPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svelte b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svelte new file mode 100644 index 000000000..9073f8da2 --- /dev/null +++ b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svelte @@ -0,0 +1,26 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svg.svelte b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svg.svelte new file mode 100644 index 000000000..f87a395c4 --- /dev/null +++ b/packages/layerchart/src/lib/components/LinearGradient/LinearGradient.svg.svelte @@ -0,0 +1,73 @@ + + + + + + + {#if stopsContent} + {@render stopsContent?.()} + {:else if stops} + {#each stops as stop, i} + {#if Array.isArray(stop)} + + {:else} + + {/if} + {/each} + {/if} + + + +{@render children?.({ id, gradient: `url(#${id})` })} diff --git a/packages/layerchart/src/lib/components/Link.svelte b/packages/layerchart/src/lib/components/Link/Link.base.svelte similarity index 61% rename from packages/layerchart/src/lib/components/Link.svelte rename to packages/layerchart/src/lib/components/Link/Link.base.svelte index 683cb4986..ff21c4012 100644 --- a/packages/layerchart/src/lib/components/Link.svelte +++ b/packages/layerchart/src/lib/components/Link/Link.base.svelte @@ -1,121 +1,16 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Month/Month.html.svelte b/packages/layerchart/src/lib/components/Month/Month.html.svelte new file mode 100644 index 000000000..e4093ce38 --- /dev/null +++ b/packages/layerchart/src/lib/components/Month/Month.html.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Month/Month.shared.svelte.ts b/packages/layerchart/src/lib/components/Month/Month.shared.svelte.ts new file mode 100644 index 000000000..dcf7cadd6 --- /dev/null +++ b/packages/layerchart/src/lib/components/Month/Month.shared.svelte.ts @@ -0,0 +1,37 @@ +import type { ComponentProps, Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; +import type Text from '../Text/Text.svelte'; +import type { RectPropsWithoutHTML } from '../Rect/Rect.shared.svelte.js'; + +export type MonthCell = { + x: number; + y: number; + color: any; + data: any; + date: Date; +}; + +export type MonthPropsWithoutHTML = { + start: Date; + end: Date; + /** @default 25 */ + cellSize?: number; + monthsPerRow?: number; + /** @default 1.2 */ + monthPadding?: number; + /** @default 8 */ + rowSpacing?: number; + /** @default true */ + showDayNumber?: boolean; + monthLabel?: boolean | Partial>; + dayNumberProps?: Partial>; + tooltip?: boolean; + children?: Snippet<[{ cells: MonthCell[]; cellSize: number }]>; +} & Omit< + RectPropsWithoutHTML, + 'children' | 'x' | 'y' | 'width' | 'height' | 'fill' | 'onpointermove' | 'onpointerleave' +>; + +export type MonthProps = MonthPropsWithoutHTML & + Without, MonthPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/Month/Month.svelte b/packages/layerchart/src/lib/components/Month/Month.svelte new file mode 100644 index 000000000..445bc2cf5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Month/Month.svelte @@ -0,0 +1,27 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Month/Month.svg.svelte b/packages/layerchart/src/lib/components/Month/Month.svg.svelte new file mode 100644 index 000000000..ec5235d56 --- /dev/null +++ b/packages/layerchart/src/lib/components/Month/Month.svg.svelte @@ -0,0 +1,19 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/MonthPath.svelte b/packages/layerchart/src/lib/components/MonthPath.svelte index 769cc1f55..0f899d7d7 100644 --- a/packages/layerchart/src/lib/components/MonthPath.svelte +++ b/packages/layerchart/src/lib/components/MonthPath.svelte @@ -38,7 +38,7 @@ import { timeWeek } from 'd3-time'; import { cls } from '@layerstack/tailwind'; import { endOfInterval } from '@layerstack/utils'; - import Path, { type PathPropsWithoutHTML } from './Path.svelte'; + import Path, { type PathPropsWithoutHTML } from './Path/Path.svelte'; let { date, diff --git a/packages/layerchart/src/lib/components/Path.svelte b/packages/layerchart/src/lib/components/Path.svelte deleted file mode 100644 index ff8ecb0de..000000000 --- a/packages/layerchart/src/lib/components/Path.svelte +++ /dev/null @@ -1,381 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#key key} - - - - - - {#if startContent && startPoint} - - {@render startContent({ - point: startPoint, - value: { - x: ctx.xScale?.invert?.(startPoint.x), - y: ctx.yScale?.invert?.(startPoint.y), - }, - })} - - {/if} - - {#if endContent && endPoint.current} - - {@render endContent({ - point: endPoint.current, - value: { - x: ctx.xScale?.invert?.(endPoint.current.x), - y: ctx.yScale?.invert?.(endPoint.current.y), - }, - })} - - {/if} - {/key} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Path/Path.canvas.svelte b/packages/layerchart/src/lib/components/Path/Path.canvas.svelte new file mode 100644 index 000000000..d958a6e50 --- /dev/null +++ b/packages/layerchart/src/lib/components/Path/Path.canvas.svelte @@ -0,0 +1,88 @@ + + + diff --git a/packages/layerchart/src/lib/components/Path/Path.shared.svelte.ts b/packages/layerchart/src/lib/components/Path/Path.shared.svelte.ts new file mode 100644 index 000000000..7e1113978 --- /dev/null +++ b/packages/layerchart/src/lib/components/Path/Path.shared.svelte.ts @@ -0,0 +1,127 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { CommonStyleProps, Without } from '$lib/utils/types.js'; +import type { MarkerOptions } from '../MarkerWrapper.svelte'; + +import { interpolatePath } from 'd3-interpolate-path'; +import { flattenPathData } from '$lib/utils/path.js'; +import { + createMotion, + extractTweenConfig, + type MotionProp, + type ResolvedMotion, +} from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; + +import type { draw as _drawTransition } from 'svelte/transition'; + +export type PathPropsWithoutHTML = { + /** + * Pass `` explicitly instead of calculating + * from data / context + */ + pathData?: string | undefined | null; + + /** + * Whether to animate the drawing of the path over time. + * Pass either `true` or an object with transition options to + * enable the transition. + * + * Works best with `tweened` disabled. + */ + draw?: boolean | Parameters[1]; + + /** Marker to attach to both start and end points of the line */ + marker?: MarkerOptions; + + /** Marker to attach to the middle point of the line */ + markerMid?: MarkerOptions; + + /** Marker to attach to the start point of the line */ + markerStart?: MarkerOptions; + + /** Marker to attach to the end point of the line */ + markerEnd?: MarkerOptions; + + /** + * Add additional content at the start of the line. + * Receives `{ point: DOMPoint; value: { x: number; y: number } }` as a snippet prop. + */ + startContent?: Snippet<[{ point: DOMPoint; value: { x: number; y: number } }]>; + + /** + * Add additional content at the end of the line. + * Receives `{ point: DOMPoint; value: { x: number; y: number } }` as a snippet prop. + */ + endContent?: Snippet<[{ point: DOMPoint; value: { x: number; y: number } }]>; + + /** + * A reference to the `` element. + * + * @bindable + */ + pathRef?: SVGPathElement; + + motion?: MotionProp; +} & CommonStyleProps; + +export type PathProps = PathPropsWithoutHTML & + Without, PathPropsWithoutHTML>; + +/** + * Reactive state shared by every per-layer Path variant. + */ +export class PathState { + #getProps: () => PathProps = () => ({}) as PathProps; + + // Contexts + chartCtx: ChartState = getChartContext(); + + // Path data tween source — the actual `d` attribute / canvas render input + #tweenedState!: ReturnType>; + + get tweenedPathData() { + return this.#tweenedState.current; + } + + // Re-key trigger for draw transitions + drawKey = $state(Symbol()); + + constructor(getProps: () => PathProps) { + this.#getProps = getProps; + + const initial = getProps(); + const extractedTween = extractTweenConfig(initial.motion); + const tweenedOptions: ResolvedMotion | undefined = extractedTween + ? { + type: extractedTween.type, + options: { interpolate: interpolatePath, ...extractedTween.options }, + } + : undefined; + + // Provide initial `0` baseline; only set on initial mount + const defaultPathData = (() => { + if (!tweenedOptions) { + // Fast initial render when not tweened + return ''; + } else if (initial.pathData) { + return flattenPathData( + initial.pathData, + Math.min(this.chartCtx.yScale(0) ?? this.chartCtx.yRange[0], this.chartCtx.yRange[0]) + ); + } + return ''; + })(); + + this.#tweenedState = createMotion(defaultPathData, () => getProps().pathData, tweenedOptions); + + // Re-trigger draw transition when path data changes + $effect(() => { + if (!getProps().draw) return; + // Touch dependency + void getProps().pathData; + this.drawKey = Symbol(); + }); + } +} diff --git a/packages/layerchart/src/lib/components/Path/Path.svelte b/packages/layerchart/src/lib/components/Path/Path.svelte new file mode 100644 index 000000000..efcec068f --- /dev/null +++ b/packages/layerchart/src/lib/components/Path/Path.svelte @@ -0,0 +1,21 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Path/Path.svg.svelte b/packages/layerchart/src/lib/components/Path/Path.svg.svelte new file mode 100644 index 000000000..2f0cbb233 --- /dev/null +++ b/packages/layerchart/src/lib/components/Path/Path.svg.svelte @@ -0,0 +1,167 @@ + + + + +{#key c.drawKey} + + + + + + {#if startContent && startPoint} + + {@render startContent({ + point: startPoint, + value: { + x: c.chartCtx.xScale?.invert?.(startPoint.x), + y: c.chartCtx.yScale?.invert?.(startPoint.y), + }, + })} + + {/if} + + {#if endContent && endPoint.current} + + {@render endContent({ + point: endPoint.current, + value: { + x: c.chartCtx.xScale?.invert?.(endPoint.current.x), + y: c.chartCtx.yScale?.invert?.(endPoint.current.y), + }, + })} + + {/if} +{/key} + + diff --git a/packages/layerchart/src/lib/components/Pattern.svelte b/packages/layerchart/src/lib/components/Pattern.svelte deleted file mode 100644 index fea7beb07..000000000 --- a/packages/layerchart/src/lib/components/Pattern.svelte +++ /dev/null @@ -1,398 +0,0 @@ - - - - -{#if layerCtx === 'canvas'} - {@render children?.({ id, pattern: asAny(canvasPattern) })} -{:else if layerCtx === 'svg'} - - - {#if patternContent} - {@render patternContent?.()} - {:else} - {#if background} - - {/if} - - {#each shapes.filter((shape) => shape.type === 'line') as line} - - {/each} - - {#each shapes.filter((shape) => shape.type === 'circle') as circle} - - {/each} - {/if} - - - - {@render children?.({ id, pattern: `url(#${id})` })} -{:else if layerCtx === 'html'} - {@render children?.({ id, pattern: createCSSPattern() })} -{/if} diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte new file mode 100644 index 000000000..b689b8e31 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.canvas.svelte @@ -0,0 +1,48 @@ + + + + +{@render children?.({ id, pattern: asAny(canvasPattern) })} diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.html.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.html.svelte new file mode 100644 index 000000000..500aa4701 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.html.svelte @@ -0,0 +1,107 @@ + + + + +{@render children?.({ id, pattern: createCSSPattern() })} diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts b/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts new file mode 100644 index 000000000..0939d025a --- /dev/null +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.shared.svelte.ts @@ -0,0 +1,152 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; + +export type PatternLineDef = { + /** The width of the line @default 1 */ + width?: string; + /** The rotation of the line @default 0 */ + rotate?: number; + /** The color of the line @default 'var(--color-surface-content)' */ + color?: string; + /** The opacity of the line @default 1 */ + opacity?: number; +}; + +export type PatternCircleDef = { + /** The radius of the circle @default 1 */ + radius?: number; + /** Stagger the circle layout @default false */ + stagger?: boolean; + /** The color of the circle @default 'var(--color-surface-content)' */ + color?: string; + /** The opacity of the circle @default 1 */ + opacity?: number; +}; + +export type PatternPropsWithoutHTML = { + /** The id of the pattern */ + id?: string; + + /** The size of the pattern (sets `width` and `height` as same value). */ + size?: number; + + /** The width of the pattern for custom patterns (set by `lines`, etc) */ + width?: number; + + /** The height of the pattern for custom patterns (set by `lines`, etc) */ + height?: number; + + /** The number of lines to render */ + lines?: boolean | PatternLineDef | PatternLineDef[]; + + /** The number of circles to render */ + circles?: boolean | PatternCircleDef | PatternCircleDef[]; + + /** The background color of the pattern */ + background?: string; + + /** Render as a child of the pattern. Note: only supported on the `` layer. */ + patternContent?: Snippet; + + children?: Snippet<[{ id: string; pattern: string }]>; +}; + +export type PatternProps = PatternPropsWithoutHTML & + Without, PatternPropsWithoutHTML>; + +export type CircleShape = { + type: 'circle'; + cx: number; + cy: number; + r: number; + fill: string; + opacity: number; +}; +export type LineShape = { + type: 'line'; + path: string; + stroke: string; + strokeWidth: string | number; + opacity: number; +}; +export type PatternShape = CircleShape | LineShape; + +/** + * Build the SVG/canvas shape descriptors for a pattern's lines/circles. + * Pure function — no reactivity. + */ +export function buildPatternShapes( + linesProp: PatternPropsWithoutHTML['lines'], + circlesProp: PatternPropsWithoutHTML['circles'], + size: number, + width: number, + height: number +): PatternShape[] { + const shapes: PatternShape[] = []; + + if (linesProp) { + const lineDefs = Array.isArray(linesProp) ? linesProp : linesProp === true ? [{}] : [linesProp]; + for (const line of lineDefs) { + const stroke = line.color ?? 'var(--color-surface-content, currentColor)'; + const strokeWidth = line.width ?? 1; + const opacity = line.opacity ?? 1; + + let rotate = Math.round(line.rotate ?? 0) % 360; + if (rotate > 180) rotate = rotate - 360; + else if (rotate > 90) rotate = rotate - 180; + else if (rotate < -180) rotate = rotate + 360; + else if (rotate < -90) rotate = rotate + 180; + + let path = ''; + if (rotate === 0) { + path = ` + M 0 0 L ${width} 0 + M 0 ${height} L ${width} ${height} + `; + } else if (rotate === 90) { + path = ` + M 0 0 L 0 ${height} + M ${width} 0 L ${width} ${height} + `; + } else if (rotate > 0) { + path = ` + M 0 ${-height} L ${width * 2} ${height} + M ${-width} ${-height} L ${width} ${height} + M ${-width} 0 L ${width} ${height * 2} + `; + } else { + path = ` + M ${-width} ${height} L ${width} ${-height} + M ${-width} ${height * 2} L ${width * 2} ${-height} + M 0 ${height * 2} L ${width * 2} 0 + `; + } + + shapes.push({ type: 'line', path, stroke, strokeWidth, opacity }); + } + } + + if (circlesProp) { + const circleDefs = Array.isArray(circlesProp) + ? circlesProp + : circlesProp === true + ? [{}] + : [circlesProp]; + for (const circle of circleDefs) { + const fill = circle.color ?? 'var(--color-surface-content, currentColor)'; + const opacity = circle.opacity ?? 1; + const r = circle.radius ?? 1; + if (circle.stagger) { + shapes.push( + { type: 'circle', cx: size / 4, cy: size / 4, r, fill, opacity }, + { type: 'circle', cx: (size * 3) / 4, cy: (size * 3) / 4, r, fill, opacity } + ); + } else { + shapes.push({ type: 'circle', cx: size / 2, cy: size / 2, r, fill, opacity }); + } + } + } + + return shapes; +} diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.svelte new file mode 100644 index 000000000..ad79bd88d --- /dev/null +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.svelte @@ -0,0 +1,29 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte b/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte new file mode 100644 index 000000000..4535091a1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pattern/Pattern.svg.svelte @@ -0,0 +1,69 @@ + + + + + + + {#if patternContent} + {@render patternContent?.()} + {:else} + {#if background} + + {/if} + + {#each shapes.filter((s) => s.type === 'line') as line} + + {/each} + + {#each shapes.filter((s) => s.type === 'circle') as circle} + + {/each} + {/if} + + + +{@render children?.({ id, pattern: `url(#${id})` })} diff --git a/packages/layerchart/src/lib/components/Pie.svelte b/packages/layerchart/src/lib/components/Pie.svelte deleted file mode 100644 index ba3641409..000000000 --- a/packages/layerchart/src/lib/components/Pie.svelte +++ /dev/null @@ -1,156 +0,0 @@ - - - - -{#if children} - {@render children({ arcs })} -{:else} - {#each arcs as arc} - - {/each} -{/if} diff --git a/packages/layerchart/src/lib/components/Pie/Pie.base.svelte b/packages/layerchart/src/lib/components/Pie/Pie.base.svelte new file mode 100644 index 000000000..db8bac235 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pie/Pie.base.svelte @@ -0,0 +1,69 @@ + + + + +{#if children} + {@render children({ arcs: c.arcs })} +{:else} + {#each c.arcs as arc} + + {/each} +{/if} diff --git a/packages/layerchart/src/lib/components/Pie/Pie.canvas.svelte b/packages/layerchart/src/lib/components/Pie/Pie.canvas.svelte new file mode 100644 index 000000000..6dc9145a3 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pie/Pie.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Pie/Pie.shared.svelte.ts b/packages/layerchart/src/lib/components/Pie/Pie.shared.svelte.ts new file mode 100644 index 000000000..0283de0be --- /dev/null +++ b/packages/layerchart/src/lib/components/Pie/Pie.shared.svelte.ts @@ -0,0 +1,93 @@ +import type { Snippet } from 'svelte'; +import { pie as d3pie, type PieArcDatum } from 'd3-shape'; +import { min, max } from 'd3-array'; + +import { degreesToRadians } from '$lib/utils/math.js'; +import { createMotion, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; + +export type PiePropsWithoutHTML = { + data?: any[]; + /** Range [min,max] in degrees. See also startAngle/endAngle @default [0, 360] */ + range?: [number, number] | number[]; + /** Start angle in radians */ + startAngle?: number; + /** End angle in radians */ + endAngle?: number; + /** + * Define innerRadius. + * value >= 1: discrete value + * value > 0: percent of `outerRadius` + * value < 0: offset of `outerRadius` + * default: yRange min + */ + innerRadius?: number; + /** Define outerRadius. Defaults to yRange max/2 (ie. chart height / 2) */ + outerRadius?: number; + /** @default 0 */ + cornerRadius?: number; + /** @default 0 */ + padAngle?: number; + /** @default 0 */ + offset?: number; + /** Setup pointer events to show tooltip for related data */ + tooltip?: boolean; + /** Sort function to sort the arcs */ + sort?: ((a: any, b: any) => number) | null; + children?: Snippet<[{ arcs: PieArcDatum[] }]>; + motion?: MotionProp; +}; + +export type PieProps = PiePropsWithoutHTML; + +/** + * Reactive state shared by every per-layer Pie variant. Builds the + * d3-pie generator and resolved `arcs` array. + */ +export class PieState { + #getProps: () => PieProps = () => ({}) as PieProps; + ctx: ChartState = getChartContext(); + + #motionEndAngle!: ReturnType>; + + constructor(getProps: () => PieProps) { + this.#getProps = getProps; + this.#motionEndAngle = createMotion(0, () => this.endAngle, getProps().motion); + } + + range = $derived(this.#getProps().range ?? ([0, 360] as [number, number])); + + endAngle = $derived.by(() => { + const props = this.#getProps(); + return ( + props.endAngle ?? + degreesToRadians( + (this.ctx.config.xRange ? max(this.ctx.config.xRange as number[]) : max(this.range))! + ) + ); + }); + + pie = $derived.by(() => { + const props = this.#getProps(); + let _pie = d3pie() + .startAngle( + props.startAngle ?? + degreesToRadians( + (this.ctx.config.xRange ? min(this.ctx.config.xRange as number[]) : min(this.range))! + ) + ) + .endAngle(this.#motionEndAngle.current) + .padAngle(props.padAngle ?? 0) + .value(this.ctx.x); + + if (props.sort === null) { + _pie = _pie.sort(null); + } else if (props.sort) { + _pie = _pie.sort(props.sort); + } + return _pie; + }); + + arcs = $derived(this.pie(this.#getProps().data ?? (Array.isArray(this.ctx.data) ? this.ctx.data : []))); +} diff --git a/packages/layerchart/src/lib/components/Pie/Pie.svelte b/packages/layerchart/src/lib/components/Pie/Pie.svelte new file mode 100644 index 000000000..ea5e3c103 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pie/Pie.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Pie/Pie.svg.svelte b/packages/layerchart/src/lib/components/Pie/Pie.svg.svelte new file mode 100644 index 000000000..a3c0cca65 --- /dev/null +++ b/packages/layerchart/src/lib/components/Pie/Pie.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Points.svelte b/packages/layerchart/src/lib/components/Points.svelte deleted file mode 100644 index 8c9da6e74..000000000 --- a/packages/layerchart/src/lib/components/Points.svelte +++ /dev/null @@ -1,200 +0,0 @@ - - - - -{#if children} - {@render children({ points })} -{:else} - {#each points as point} - - {/each} -{/if} diff --git a/packages/layerchart/src/lib/components/Points/Points.base.svelte b/packages/layerchart/src/lib/components/Points/Points.base.svelte new file mode 100644 index 000000000..71fe38549 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.base.svelte @@ -0,0 +1,75 @@ + + + + +{#if children} + {@render children({ points: c.points })} +{:else} + {#each c.points as point} + + {/each} +{/if} diff --git a/packages/layerchart/src/lib/components/Points/Points.canvas.svelte b/packages/layerchart/src/lib/components/Points/Points.canvas.svelte new file mode 100644 index 000000000..d54e705f3 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Points/Points.html.svelte b/packages/layerchart/src/lib/components/Points/Points.html.svelte new file mode 100644 index 000000000..a40c67226 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.html.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts new file mode 100644 index 000000000..4c9ef5b10 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.shared.svelte.ts @@ -0,0 +1,170 @@ +import type { Snippet } from 'svelte'; +import { pointRadial } from 'd3-shape'; + +import type { CommonStyleProps, Without } from '$lib/utils/types.js'; +import { isScaleBand, type AnyScale } from '$lib/utils/scales.svelte.js'; +import { accessor, type Accessor } from '$lib/utils/common.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { CircleProps } from '../Circle/Circle.shared.svelte.js'; + +export type Point = { + x: number; + y: number; + r: number; + xValue: any; + yValue: any; + data: any; + /** Index within array accessor (0 = start/low edge, 1 = end/high edge). Undefined for single-value points. */ + edgeIndex?: number; +}; + +type Offset = number | ((value: number, context: any) => number) | undefined; + +export type PointsPropsWithoutHTML = { + /** Override data instead of using context */ + data?: any; + /** Override `x` accessor from Chart context */ + x?: Accessor; + /** Override `y` accessor from Chart context */ + y?: Accessor; + /** Series key to use for accessor. */ + seriesKey?: string; + /** Override `r` accessor from Chart context @default 5 */ + r?: number; + /** The offset of the point in the x direction */ + offsetX?: Offset; + /** The offset of the point in the y direction */ + offsetY?: Offset; + children?: Snippet<[{ points: Point[] }]>; +} & CommonStyleProps; + +export type PointsProps = PointsPropsWithoutHTML & + Omit, 'ref'>; + +/** + * Reactive state shared by every per-layer Points variant. Holds the + * computed `points` array from the chart context. + */ +export class PointsState { + #getProps: () => PointsProps = () => ({}) as PointsProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => PointsProps) { + this.#getProps = getProps; + this.ctx.registerComponent({ + name: 'Points', + kind: 'mark', + markInfo: () => { + const p = getProps(); + return { + data: p.data, + x: p.x, + y: p.y, + seriesKey: p.seriesKey, + color: (p.fill ?? p.stroke) as string | undefined, + }; + }, + }); + } + + series = $derived( + this.ctx.series.series.find((s) => s.key === this.#getProps().seriesKey) + ); + seriesAccessor = $derived( + this.series?.value ?? (this.series?.data ? undefined : this.series?.key) + ); + + stackAccessors = $derived.by(() => { + const seriesKey = this.#getProps().seriesKey; + return seriesKey && this.ctx.series.isStacked + ? this.ctx.series.getStackAccessors(seriesKey) + : null; + }); + + xAccessor = $derived( + accessor( + this.#getProps().x ?? + (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? + this.ctx.x + ) + ); + + yAccessor = $derived.by(() => { + const props = this.#getProps(); + if (props.y) return accessor(props.y); + if (this.stackAccessors) return this.stackAccessors.y1; + if (Array.isArray(this.seriesAccessor) && this.ctx.valueAxis === 'y') { + return accessor(this.seriesAccessor[1]); + } + return accessor((this.ctx.valueAxis === 'y' ? this.seriesAccessor : undefined) ?? this.ctx.y); + }); + + pointsData = $derived(this.#getProps().data ?? this.series?.data ?? this.ctx.data); + + #getOffset( + value: any, + offset: Offset, + scale: AnyScale, + subScale?: AnyScale + ): number { + const seriesKey = this.#getProps().seriesKey; + if (typeof offset === 'function') { + return offset(value, this.ctx); + } else if (offset != null) { + return offset; + } else if (subScale && seriesKey) { + return subScale(seriesKey) + (subScale.bandwidth?.() ?? 0) / 2; + } else if (isScaleBand(scale) && !this.ctx.radial) { + return scale.bandwidth() / 2; + } + return 0; + } + + #getPointObject(xVal: number, yVal: number, d: any, edgeIndex?: number): Point { + const props = this.#getProps(); + const scaledX: number = this.ctx.xScale(xVal); + const scaledY: number = this.ctx.yScale(yVal); + + const x = + scaledX + + this.#getOffset(scaledX, props.offsetX, this.ctx.xScale, this.ctx.x1Scale ?? undefined); + const y = + scaledY + + this.#getOffset(scaledY, props.offsetY, this.ctx.yScale, this.ctx.y1Scale ?? undefined); + + const radialPoint = pointRadial(x, y); + + return { + x: this.ctx.radial ? radialPoint[0] : x, + y: this.ctx.radial ? radialPoint[1] : y, + r: this.ctx.config.r ? this.ctx.rGet(d) : props.r ?? 5, + xValue: xVal, + yValue: yVal, + data: d, + edgeIndex, + }; + } + + points = $derived.by(() => { + return this.pointsData.flatMap((d: any) => { + const xValue: number | number[] = this.xAccessor(d); + const yValue: number | number[] = this.yAccessor(d); + + if (Array.isArray(xValue)) { + return xValue + .filter(Boolean) + .map((xVal: number, i: number) => + this.#getPointObject(xVal, yValue as number, d, i) + ); + } else if (Array.isArray(yValue)) { + return yValue + .filter(Boolean) + .map((yVal: number, i: number) => this.#getPointObject(xValue as number, yVal, d, i)); + } else if (xValue != null && yValue != null) { + return this.#getPointObject(xValue as number, yValue as number, d); + } + return []; + }); + }); +} diff --git a/packages/layerchart/src/lib/components/Points/Points.svelte b/packages/layerchart/src/lib/components/Points/Points.svelte new file mode 100644 index 000000000..e42f5b5a8 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Points/Points.svg.svelte b/packages/layerchart/src/lib/components/Points/Points.svg.svelte new file mode 100644 index 000000000..8c4599f86 --- /dev/null +++ b/packages/layerchart/src/lib/components/Points/Points.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Polygon.svelte b/packages/layerchart/src/lib/components/Polygon.svelte deleted file mode 100644 index d25238195..000000000 --- a/packages/layerchart/src/lib/components/Polygon.svelte +++ /dev/null @@ -1,538 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedData as d, i (keyFn(d, i))} - {@const pathData = resolvePolygon(d)} - {@const resolvedFill = resolveColorProp(fill, d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, d)} - {@const resolvedOpacity = resolveStyleProp(opacity, d)} - {@const resolvedClass = resolveStyleProp(className, d)} - - {/each} - {:else} - - {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Polygon/Polygon.canvas.svelte b/packages/layerchart/src/lib/components/Polygon/Polygon.canvas.svelte new file mode 100644 index 000000000..08508646d --- /dev/null +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.canvas.svelte @@ -0,0 +1,112 @@ + + + diff --git a/packages/layerchart/src/lib/components/Polygon/Polygon.shared.svelte.ts b/packages/layerchart/src/lib/components/Polygon/Polygon.shared.svelte.ts new file mode 100644 index 000000000..9833bb6ea --- /dev/null +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.shared.svelte.ts @@ -0,0 +1,301 @@ +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import { interpolatePath } from 'd3-interpolate-path'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { polygon } from '$lib/utils/shape.js'; +import { roundedPolygonPath } from '$lib/utils/path.js'; +import { + createMotion, + createDataMotionMap, + extractTweenConfig, + type MotionProp, + type ResolvedMotion, +} from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type PolygonPropsWithoutHTML = { + cx?: DataProp; + initialCx?: number; + cy?: DataProp; + initialCy?: number; + r?: DataProp; + initialR?: number; + data?: any[]; + key?: (d: any, index: number) => any; + /** The number of points or explicit points to create the polygon. @default 4 */ + points?: number | { x: number; y: number }[]; + /** The radius of the curve for rounded corners. @default 0 */ + cornerRadius?: number; + /** The rotation of the polygon. @default 0 */ + rotate?: number; + /** The percent to inset the odd points of the star (<1 inset, >1 outset). @default 0 */ + inset?: number; + /** The horizontal stretch factor of the polygon. @default 1 */ + scaleX?: number; + /** The vertical stretch factor of the polygon. @default 1 */ + scaleY?: number; + /** The skew angle in degrees along the X axis. @default 0 */ + skewX?: number; + /** The skew angle in degrees along the Y axis. @default 0 */ + skewY?: number; + /** The tilt factor for x-coordinates. @default 1 */ + tiltX?: number; + /** The tilt factor for y-coordinates. @default 1 */ + tiltY?: number; + /** A bindable reference to the `` element. @bindable */ + ref?: SVGPathElement; + motion?: MotionProp; +} & DataDrivenStyleProps; + +export type PolygonProps = PolygonPropsWithoutHTML & + Without, PolygonPropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +export function polygonMarkInfo(props: PolygonProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.cx === 'string' ? props.cx : undefined, + y: typeof props.cy === 'string' ? props.cy : undefined, + color: + typeof props.fill === 'string' + ? props.fill + : typeof props.stroke === 'string' + ? props.stroke + : undefined, + }; +} + +export class PolygonState { + #getProps: () => PolygonProps = () => ({}) as PolygonProps; + + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + dataMode = $derived( + hasAnyDataProp(this.#getProps().cx, this.#getProps().cy, this.#getProps().r) + ); + + resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.resolvedData.map((d, i) => { + const key = keyFn(d, i); + let resolvedCx: number, resolvedCy: number; + if (this.geo.projection) { + [resolvedCx, resolvedCy] = resolveGeoDataPair(props.cx, props.cy, d, this.geo.projection); + } else { + resolvedCx = resolveDataProp(props.cx, d, this.chartCtx.xScale, 0); + resolvedCy = resolveDataProp(props.cy, d, this.chartCtx.yScale, 0); + } + const resolvedR = resolveDataProp( + props.r, + d, + this.chartCtx.rScale, + typeof props.r === 'number' ? props.r : 1 + ); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + cx: animated?.cx ?? resolvedCx, + cy: animated?.cy ?? resolvedCy, + r: animated?.r ?? resolvedR, + }; + }); + }); + + /** Resolve a single data item to a polygon path string. */ + resolvePolygonPath(d: any): string { + const props = this.#getProps(); + let resolvedCx: number, resolvedCy: number; + if (this.geo.projection) { + [resolvedCx, resolvedCy] = resolveGeoDataPair(props.cx, props.cy, d, this.geo.projection); + } else { + resolvedCx = resolveDataProp(props.cx, d, this.chartCtx.xScale, 0); + resolvedCy = resolveDataProp(props.cy, d, this.chartCtx.yScale, 0); + } + const resolvedR = resolveDataProp( + props.r, + d, + this.chartCtx.rScale, + typeof props.r === 'number' ? props.r : 1 + ); + + const pts = + typeof props.points === 'number' || props.points === undefined + ? polygon({ + cx: resolvedCx, + cy: resolvedCy, + count: typeof props.points === 'number' ? props.points : 4, + radius: resolvedR, + rotate: props.rotate ?? 0, + inset: props.inset ?? 0, + scaleX: props.scaleX ?? 1, + scaleY: props.scaleY ?? 1, + skewX: props.skewX ?? 0, + skewY: props.skewY ?? 0, + tiltX: props.tiltX ?? 0, + tiltY: props.tiltY ?? 0, + }) + : props.points; + + return roundedPolygonPath(pts, props.cornerRadius ?? 0); + } + + // Pixel-mode motion sources + #dataMotionMap: ReturnType = null; + #motionCx!: ReturnType>; + #motionCy!: ReturnType>; + #motionR!: ReturnType>; + #tweenedState!: ReturnType>; + + get motionCx() { + return this.#motionCx.current; + } + get motionCy() { + return this.#motionCy.current; + } + get motionR() { + return this.#motionR.current; + } + get tweenedPathData() { + return this.#tweenedState.current; + } + + // Pixel-mode polygon path string (depends on motion + transform props) + pixelPathData = $derived.by(() => { + const props = this.#getProps(); + const pts = + typeof props.points === 'number' || props.points === undefined + ? polygon({ + cx: this.motionCx, + cy: this.motionCy, + count: typeof props.points === 'number' ? props.points : 4, + radius: this.motionR, + rotate: props.rotate ?? 0, + inset: props.inset ?? 0, + scaleX: props.scaleX ?? 1, + scaleY: props.scaleY ?? 1, + skewX: props.skewX ?? 0, + skewY: props.skewY ?? 0, + tiltX: props.tiltX ?? 0, + tiltY: props.tiltY ?? 0, + }) + : props.points; + return roundedPolygonPath(pts, props.cornerRadius ?? 0); + }); + + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' + ? (this.#getProps().opacity as number) + : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + + constructor(getProps: () => PolygonProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialCx = initial.initialCx ?? (typeof initial.cx === 'number' ? initial.cx : 0); + const initialCy = initial.initialCy ?? (typeof initial.cy === 'number' ? initial.cy : 0); + const initialR = initial.initialR ?? (typeof initial.r === 'number' ? initial.r : 1); + + this.#motionCx = createMotion( + initialCx, + () => (typeof getProps().cx === 'number' ? (getProps().cx as number) : 0), + initial.motion + ); + this.#motionCy = createMotion( + initialCy, + () => (typeof getProps().cy === 'number' ? (getProps().cy as number) : 0), + initial.motion + ); + this.#motionR = createMotion( + initialR, + () => (typeof getProps().r === 'number' ? (getProps().r as number) : 1), + initial.motion + ); + + const extractedTween = extractTweenConfig(initial.motion); + const tweenedOptions: ResolvedMotion | undefined = extractedTween + ? { + type: extractedTween.type, + options: { interpolate: interpolatePath, ...extractedTween.options }, + } + : undefined; + this.#tweenedState = createMotion( + null, + () => this.pixelPathData, + tweenedOptions + ); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.resolvedData.length; i++) { + const d = this.resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + let resolvedCx: number, resolvedCy: number; + if (this.geo.projection) { + [resolvedCx, resolvedCy] = resolveGeoDataPair(props.cx, props.cy, d, this.geo.projection); + } else { + resolvedCx = resolveDataProp(props.cx, d, this.chartCtx.xScale, 0); + resolvedCy = resolveDataProp(props.cy, d, this.chartCtx.yScale, 0); + } + const resolvedR = resolveDataProp( + props.r, + d, + this.chartCtx.rScale, + typeof props.r === 'number' ? props.r : 1 + ); + untrack(() => + motionMap.update(key, { cx: resolvedCx, cy: resolvedCy, r: resolvedR }) + ); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Polygon/Polygon.svelte b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte new file mode 100644 index 000000000..b3fa98b00 --- /dev/null +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Polygon.svelte.test.ts b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts similarity index 97% rename from packages/layerchart/src/lib/components/Polygon.svelte.test.ts rename to packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts index 4d58e1cf6..8d4a1ffda 100644 --- a/packages/layerchart/src/lib/components/Polygon.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Polygon from './Polygon.svelte'; describe('Polygon', () => { diff --git a/packages/layerchart/src/lib/components/Polygon/Polygon.svg.svelte b/packages/layerchart/src/lib/components/Polygon/Polygon.svg.svelte new file mode 100644 index 000000000..b57c2699a --- /dev/null +++ b/packages/layerchart/src/lib/components/Polygon/Polygon.svg.svelte @@ -0,0 +1,82 @@ + + + + +{#if c.dataMode} + {#each c.resolvedData as d, i (rest.key ? rest.key(d, i) : i)} + {@const pathData = c.resolvePolygonPath(d)} + {@const resolvedFill = resolveColorProp(rest.fill, d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, d)} + {@const resolvedClass = resolveStyleProp(rest.class, d)} + + {/each} +{:else} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/RadialGradient.svelte b/packages/layerchart/src/lib/components/RadialGradient.svelte deleted file mode 100644 index 741ee3615..000000000 --- a/packages/layerchart/src/lib/components/RadialGradient.svelte +++ /dev/null @@ -1,190 +0,0 @@ - - - - -{#if layerCtx === 'canvas'} - {@render children?.({ id, gradient: canvasGradient as any })} -{:else if layerCtx === 'svg'} - - - {#if stopsContent} - {@render stopsContent()} - {:else if stops} - {@const stopClass = cls('lc-radial-gradient-stop', className)} - {#each stops as stop, i} - {#if Array.isArray(stop)} - - {:else} - - {/if} - {/each} - {/if} - - - - {@render children?.({ id, gradient: `url(#${id})` })} -{/if} diff --git a/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.canvas.svelte b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.canvas.svelte new file mode 100644 index 000000000..aa97ecfb1 --- /dev/null +++ b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.canvas.svelte @@ -0,0 +1,69 @@ + + + + +{@render children?.({ id, gradient: canvasGradient as any })} diff --git a/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.shared.svelte.ts b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.shared.svelte.ts new file mode 100644 index 000000000..fd8502ab7 --- /dev/null +++ b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.shared.svelte.ts @@ -0,0 +1,57 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; + +export type RadialGradientPropsWithoutHTML = { + /** Unique id for radialGradient */ + id?: string; + + /** + * Array of strings (colors), distributed equally from 0-100%. + * If array of tuples, will use first value as the offset, and second as color. + * + * @default ['var(--tw-gradient-from)', 'var(--tw-gradient-to)'] + */ + stops?: string[] | [string | number, string][]; + + /** The x coordinate of the center of the gradient @default '50%' */ + cx?: string; + + /** The y coordinate of the center of the gradient @default '50%' */ + cy?: string; + + /** The x coordinate of the focal point of the gradient @default cx */ + fx?: string; + + /** The y coordinate of the focal point of the gradient @default cy */ + fy?: string; + + /** The radius of the gradient */ + r?: string; + + /** + * Indicates how the gradient behaves if it starts or ends inside the bounds + * of the shape containing the gradient + * + * @default 'pad' + */ + spreadMethod?: 'pad' | 'reflect' | 'repeat'; + + /** Transform attribute for the gradient */ + transform?: string | null; + + /** + * Define the coordinate system for attributes (i.e. gradientUnits) + * + * @default 'objectBoundingBox' + */ + units?: 'objectBoundingBox' | 'userSpaceOnUse'; + + children?: Snippet<[{ id: string; gradient: string }]>; + + /** Render as a child of the gradient and will opt out of the default stops being rendered. */ + stopsContent?: Snippet; +}; + +export type RadialGradientProps = RadialGradientPropsWithoutHTML & + Without, RadialGradientPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svelte b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svelte new file mode 100644 index 000000000..4fbd017d5 --- /dev/null +++ b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svg.svelte b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svg.svelte new file mode 100644 index 000000000..638e38528 --- /dev/null +++ b/packages/layerchart/src/lib/components/RadialGradient/RadialGradient.svg.svelte @@ -0,0 +1,66 @@ + + + + + + + {#if stopsContent} + {@render stopsContent()} + {:else if stops} + {@const stopClass = cls('lc-radial-gradient-stop', className)} + {#each stops as stop, i} + {#if Array.isArray(stop)} + + {:else} + + {/if} + {/each} + {/if} + + + +{@render children?.({ id, gradient: `url(#${id})` })} diff --git a/packages/layerchart/src/lib/components/Raster.svelte b/packages/layerchart/src/lib/components/Raster/Raster.base.svelte similarity index 70% rename from packages/layerchart/src/lib/components/Raster.svelte rename to packages/layerchart/src/lib/components/Raster/Raster.base.svelte index 576d6be67..80cddf15b 100644 --- a/packages/layerchart/src/lib/components/Raster.svelte +++ b/packages/layerchart/src/lib/components/Raster/Raster.base.svelte @@ -1,58 +1,12 @@
- {/each} - {:else} - - -
- {@render children?.()} -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Rect/Rect.canvas.svelte b/packages/layerchart/src/lib/components/Rect/Rect.canvas.svelte new file mode 100644 index 000000000..f5df99395 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rect/Rect.canvas.svelte @@ -0,0 +1,156 @@ + + + diff --git a/packages/layerchart/src/lib/components/Rect/Rect.html.svelte b/packages/layerchart/src/lib/components/Rect/Rect.html.svelte new file mode 100644 index 000000000..a9751a1ad --- /dev/null +++ b/packages/layerchart/src/lib/components/Rect/Rect.html.svelte @@ -0,0 +1,103 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const resolvedBorderWidth = + resolvedStrokeWidth != null + ? `${resolvedStrokeWidth}px` + : resolvedStroke != null + ? '1px' + : undefined} + + +
+ {/each} +{:else} + + +
+ {@render children?.()} +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts b/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts new file mode 100644 index 000000000..915c5fd72 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rect/Rect.shared.svelte.ts @@ -0,0 +1,436 @@ +import type { Snippet } from 'svelte'; +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { CommonEvents, Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + hasAnyDataProp, + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { + resolveCorners, + cornersUniform, + resolveInsets, + type Corners, + type Insets, +} from '$lib/utils/rect.svelte.js'; +import { roundedRectPath, parseDashArray } from '$lib/utils/path.js'; +import { + createMotion, + createDataMotionMap, + parseMotionProp, + type MotionProp, + type MotionOptions, +} from '$lib/utils/motion.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +export type RectPropsWithoutHTML = { + /** + * The x position of the rectangle. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * + * @default 0 + */ + x?: DataProp; + + /** + * The initial x position (pixel mode only). + * + * @default x + */ + initialX?: number; + + /** + * The y position of the rectangle. + * - `number`: pixel value (direct) + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * + * @default 0 + */ + y?: DataProp; + + /** + * The initial y position (pixel mode only). + * + * @default y + */ + initialY?: number; + + /** + * The width of the rectangle (pixels). + * + * @default 0 + */ + width?: number; + initialWidth?: number; + + /** + * The height of the rectangle (pixels). + * + * @default 0 + */ + height?: number; + initialHeight?: number; + + /** + * Left/start x edge (data mode). + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * - `number`: pixel value + */ + x0?: DataProp; + + /** + * Right/end x edge (data mode). + * - `string`: data property name, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * - `number`: pixel value + */ + x1?: DataProp; + + /** + * Top/start y edge (data mode). + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * - `number`: pixel value + */ + y0?: DataProp; + + /** + * Bottom/end y edge (data mode). + * - `string`: data property name, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * - `number`: pixel value + */ + y1?: DataProp; + + /** + * Insets to shrink the rendered rectangle. + * Supports `all`, `x`, `y`, `left`, `right`, `top`, `bottom`. + */ + insets?: Insets; + + /** + * Data array to iterate over in data mode. + * Falls back to chart context data when not provided. + */ + data?: any[]; + + /** + * Key function for keyed {#each} rendering in data mode. + * + * @default (d, i) => i + */ + key?: (d: any, index: number) => any; + + /** + * Underlying `` tag when using . Useful for bindings (pixel mode only). + * + * @bindable + */ + ref?: SVGRectElement; + + /** Motion configuration (pixel mode only). */ + motion?: MotionProp<'x' | 'y' | 'width' | 'height'>; + + /** + * Dashed-border pattern. Accepts a number (single dash length), a + * `[dash, gap, ...]` array, or a string (same syntax as SVG + * `stroke-dasharray`). HTML layer approximates via `border-style: dashed`. + */ + dashArray?: number | number[] | string; + + /** + * Per-corner radii. Accepts a number (all corners equal — same as `rx`), + * a `[tl, tr, br, bl]` tuple, or `{ topLeft, topRight, bottomRight, bottomLeft }`. + * Takes precedence over `rx`/`ry` when corners differ. + */ + corners?: Corners; + + /** Children content to render. Note: Only works for Html layers */ + children?: Snippet; +} & DataDrivenStyleProps; + +export type RectProps = RectPropsWithoutHTML & + Without, RectPropsWithoutHTML> & + CommonEvents; + +const defaultKey = (_: any, i: number) => i; + +/** Build the standard `markInfo` payload used by every Rect variant. */ +export function rectMarkInfo(props: RectProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.x === 'string' ? props.x : undefined, + y: typeof props.y === 'string' ? props.y : undefined, + color: + typeof props.fill === 'string' + ? props.fill + : typeof props.stroke === 'string' + ? props.stroke + : undefined, + }; +} + +/** + * Reactive state shared by every per-layer Rect variant. + */ +export class RectState { + #getProps: () => RectProps = () => ({}) as RectProps; + + // Contexts + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + // Data mode detection + hasEdgeProps = $derived( + hasAnyDataProp( + this.#getProps().x0, + this.#getProps().y0, + this.#getProps().x1, + this.#getProps().y1 + ) + ); + dataMode = $derived( + hasAnyDataProp(this.#getProps().x, this.#getProps().y) || this.hasEdgeProps + ); + + // Data resolution + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.#resolveRect(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + x: animated?.x ?? resolved.x, + y: animated?.y ?? resolved.y, + width: animated?.width ?? resolved.width, + height: animated?.height ?? resolved.height, + }; + }); + }); + + #resolveRect(d: any): { x: number; y: number; width: number; height: number } { + const props = this.#getProps(); + const resolvedInsets = resolveInsets(props.insets); + + if (this.hasEdgeProps) { + let rx0: number, rx1p: number, ry0: number, ry1p: number; + if (this.geo.projection) { + [rx0, ry0] = resolveGeoDataPair(props.x0, props.y0, d, this.geo.projection); + [rx1p, ry1p] = resolveGeoDataPair(props.x1, props.y1, d, this.geo.projection); + } else { + rx0 = resolveDataProp(props.x0, d, this.chartCtx.xScale, 0); + rx1p = resolveDataProp(props.x1, d, this.chartCtx.xScale, 0); + ry0 = resolveDataProp(props.y0, d, this.chartCtx.yScale, 0); + ry1p = resolveDataProp(props.y1, d, this.chartCtx.yScale, 0); + } + + const left = Math.min(rx0, rx1p) + resolvedInsets.left; + const right = Math.max(rx0, rx1p) - resolvedInsets.right; + const top = Math.min(ry0, ry1p) + resolvedInsets.top; + const bottom = Math.max(ry0, ry1p) - resolvedInsets.bottom; + + return { + x: left, + y: top, + width: Math.max(0, right - left), + height: Math.max(0, bottom - top), + }; + } else { + let resolvedX: number, resolvedY: number; + if (this.geo.projection) { + [resolvedX, resolvedY] = resolveGeoDataPair(props.x, props.y, d, this.geo.projection); + } else { + resolvedX = resolveDataProp(props.x, d, this.chartCtx.xScale, 0); + resolvedY = resolveDataProp(props.y, d, this.chartCtx.yScale, 0); + } + return { + x: resolvedX + resolvedInsets.left, + y: resolvedY + resolvedInsets.top, + width: Math.max(0, (props.width ?? 0) - resolvedInsets.left - resolvedInsets.right), + height: Math.max(0, (props.height ?? 0) - resolvedInsets.top - resolvedInsets.bottom), + }; + } + } + + // Dash array + dashArrayResolved = $derived(parseDashArray(this.#getProps().dashArray)); + dashArrayAttr = $derived(this.dashArrayResolved ? this.dashArrayResolved.join(' ') : undefined); + + // Corners + cornersUniformValue = $derived.by(() => { + const corners = this.#getProps().corners; + if (corners === undefined) return undefined; + if (typeof corners === 'number') return corners; + const resolved = resolveCorners(corners, Infinity, Infinity); + return cornersUniform(resolved) ? resolved[0] : undefined; + }); + cornersNonUniform = $derived( + this.#getProps().corners !== undefined && this.cornersUniformValue === undefined + ); + + // Normalize rx/ry: if only one provided, use for both (SVG behavior) + rx = $derived( + Number( + (this.#getProps() as any).rx ?? (this.#getProps() as any).ry ?? this.cornersUniformValue + ) || 0 + ); + ry = $derived( + Number( + (this.#getProps() as any).ry ?? (this.#getProps() as any).rx ?? this.cornersUniformValue + ) || 0 + ); + + // Pixel-mode motion sources + #dataMotionMap: ReturnType = null; + #motionX!: ReturnType>; + #motionY!: ReturnType>; + #motionWidth!: ReturnType>; + #motionHeight!: ReturnType>; + + get motionX() { + return this.#motionX.current; + } + get motionY() { + return this.#motionY.current; + } + get motionWidth() { + return this.#motionWidth.current; + } + get motionHeight() { + return this.#motionHeight.current; + } + + // Resolved per-corner radii (clamped to current bounds) + resolvedCorners = $derived.by(() => { + const corners = this.#getProps().corners; + if (corners === undefined) return undefined; + return resolveCorners(corners, this.motionWidth, this.motionHeight); + }); + + borderRadiusStyle = $derived( + this.resolvedCorners ? this.resolvedCorners.map((c) => `${c}px`).join(' ') : undefined + ); + + pixelPathData = $derived.by(() => { + if (this.resolvedCorners && this.cornersNonUniform) { + return roundedRectPath( + this.motionX, + this.motionY, + this.motionWidth, + this.motionHeight, + this.resolvedCorners + ); + } + return undefined; + }); + + // Static (non-data-driven) values for SVG/HTML pixel mode + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeOpacity = $derived( + typeof this.#getProps().strokeOpacity === 'number' + ? (this.#getProps().strokeOpacity as number) + : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' + ? (this.#getProps().opacity as number) + : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + // Match SVG's implicit `stroke-width: 1` default + staticBorderWidth = $derived.by(() => { + const props = this.#getProps(); + if (typeof props.strokeWidth === 'number') return `${props.strokeWidth}px`; + if (typeof props.stroke === 'string') return '1px'; + return undefined; + }); + + constructor(getProps: () => RectProps) { + this.#getProps = getProps; + + const initial = getProps(); + const initialX = initial.initialX ?? (typeof initial.x === 'number' ? initial.x : 0); + const initialY = initial.initialY ?? (typeof initial.y === 'number' ? initial.y : 0); + const initialWidth = initial.initialWidth ?? initial.width ?? 0; + const initialHeight = initial.initialHeight ?? initial.height ?? 0; + const motion = initial.motion; + + this.#motionX = createMotion( + initialX, + () => (typeof getProps().x === 'number' ? (getProps().x as number) : 0), + motion === undefined ? undefined : parseMotionProp(motion, 'x') + ); + this.#motionY = createMotion( + initialY, + () => (typeof getProps().y === 'number' ? (getProps().y as number) : 0), + motion === undefined ? undefined : parseMotionProp(motion, 'y') + ); + this.#motionWidth = createMotion( + initialWidth, + () => getProps().width ?? 0, + motion === undefined ? undefined : parseMotionProp(motion, 'width') + ); + this.#motionHeight = createMotion( + initialHeight, + () => getProps().height ?? 0, + motion === undefined ? undefined : parseMotionProp(motion, 'height') + ); + + this.#dataMotionMap = createDataMotionMap(motion as MotionOptions | undefined); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.#resolveRect(d); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} diff --git a/packages/layerchart/src/lib/components/Rect/Rect.svelte b/packages/layerchart/src/lib/components/Rect/Rect.svelte new file mode 100644 index 000000000..1c6df75ff --- /dev/null +++ b/packages/layerchart/src/lib/components/Rect/Rect.svelte @@ -0,0 +1,27 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Rect.svelte.test.ts b/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Rect.svelte.test.ts rename to packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts index 9d3b1ad9f..e8a3ff5ac 100644 --- a/packages/layerchart/src/lib/components/Rect.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Rect/Rect.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Rect from './Rect.svelte'; describe('Rect', () => { diff --git a/packages/layerchart/src/lib/components/Rect/Rect.svg.svelte b/packages/layerchart/src/lib/components/Rect/Rect.svg.svelte new file mode 100644 index 000000000..61ccf3f32 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rect/Rect.svg.svelte @@ -0,0 +1,136 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedStrokeOpacity = resolveStyleProp(rest.strokeOpacity, item.d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + + {/each} +{:else if c.pixelPathData} + + + } + d={c.pixelPathData} + fill={c.staticFill} + fill-opacity={c.staticFillOpacity} + stroke={c.staticStroke} + stroke-opacity={c.staticStrokeOpacity} + stroke-width={c.staticStrokeWidth} + opacity={c.staticOpacity} + stroke-dasharray={c.dashArrayAttr} + class={cls('lc-rect', c.staticClassName)} + /> +{:else} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/RectClipPath.svelte b/packages/layerchart/src/lib/components/RectClipPath.svelte deleted file mode 100644 index f789b5a33..000000000 --- a/packages/layerchart/src/lib/components/RectClipPath.svelte +++ /dev/null @@ -1,97 +0,0 @@ - - - - - - {#snippet children({ url })} - {@render childrenProp?.({ id, url })} - {/snippet} - diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.base.svelte b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.base.svelte new file mode 100644 index 000000000..91e6b39b2 --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.base.svelte @@ -0,0 +1,36 @@ + + + + + + {#snippet children({ url }: { url: string })} + {@render childrenProp?.({ id, url })} + {/snippet} + diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.canvas.svelte b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.canvas.svelte new file mode 100644 index 000000000..678ba49a6 --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.html.svelte b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.html.svelte new file mode 100644 index 000000000..5448ba7bd --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.html.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.shared.svelte.ts b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.shared.svelte.ts new file mode 100644 index 000000000..9c95db231 --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.shared.svelte.ts @@ -0,0 +1,32 @@ +import type { CommonEvents, Without } from '$lib/utils/types.js'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Snippet } from 'svelte'; +import type { RectPropsWithoutHTML } from '../Rect/Rect.shared.svelte.js'; +import type { MotionProp } from '$lib/utils/motion.svelte.js'; + +export type BaseRectClipPathPropsWithoutHTML = { + /** A unique id for the clipPath. */ + id?: string; + /** The x position of the clipPath. @default 0 */ + x?: number; + /** The y position of the clipPath. @default 0 */ + y?: number; + /** The width of the clipPath. @required */ + width: number; + /** The height of the clipPath. @required */ + height: number; + /** Whether to disable clipping (show all). @default false */ + disabled?: boolean; + /** Invert the clip — content renders *outside* the rect. @default false */ + invert?: boolean; + /** The default children snippet which provides the id and url for the clipPath. */ + children?: Snippet<[{ id: string; url: string }]>; + motion?: MotionProp<'x' | 'y' | 'width' | 'height'>; +}; + +export type RectClipPathPropsWithoutHTML = BaseRectClipPathPropsWithoutHTML & + Without; + +export type RectClipPathProps = RectClipPathPropsWithoutHTML & + Without, RectClipPathPropsWithoutHTML> & + CommonEvents; diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svelte b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svelte new file mode 100644 index 000000000..88080fedf --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svg.svelte b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svg.svelte new file mode 100644 index 000000000..2625022e5 --- /dev/null +++ b/packages/layerchart/src/lib/components/RectClipPath/RectClipPath.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Rule.svelte b/packages/layerchart/src/lib/components/Rule.svelte deleted file mode 100644 index f7d1a2353..000000000 --- a/packages/layerchart/src/lib/components/Rule.svelte +++ /dev/null @@ -1,249 +0,0 @@ - - - - - - {#each lines as line} - {@const stroke = line.stroke ?? strokeProp} - - {#if ctx.radial} - {#if line.axis === 'x'} - {@const [x1, y1] = pointRadial(line.x1, line.y1)} - {@const [x2, y2] = pointRadial(line.x2, line.y2)} - - {:else if line.axis === 'y'} - - {/if} - {:else} - - {/if} - {/each} - - - diff --git a/packages/layerchart/src/lib/components/Rule/Rule.base.svelte b/packages/layerchart/src/lib/components/Rule/Rule.base.svelte new file mode 100644 index 000000000..2cb2e44a1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.base.svelte @@ -0,0 +1,108 @@ + + + + + + {#each c.lines as line} + {@const stroke = line.stroke ?? strokeProp} + + {#if c.ctx.radial} + {#if line.axis === 'x'} + {@const [x1, y1] = pointRadial(line.x1, line.y1)} + {@const [x2, y2] = pointRadial(line.x2, line.y2)} + + {:else if line.axis === 'y'} + + {/if} + {:else} + + {/if} + {/each} + + + diff --git a/packages/layerchart/src/lib/components/Rule/Rule.canvas.svelte b/packages/layerchart/src/lib/components/Rule/Rule.canvas.svelte new file mode 100644 index 000000000..538016512 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.canvas.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Rule/Rule.html.svelte b/packages/layerchart/src/lib/components/Rule/Rule.html.svelte new file mode 100644 index 000000000..d9c590939 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.html.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts b/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts new file mode 100644 index 000000000..6503d7630 --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.shared.svelte.ts @@ -0,0 +1,202 @@ +import type { SVGAttributes } from 'svelte/elements'; + +import { extent } from 'd3-array'; + +import type { Without } from '$lib/utils/types.js'; +import { accessor, chartDataArray, type Accessor } from '$lib/utils/common.js'; +import { isScaleBand, isScaleNumeric } from '$lib/utils/scales.svelte.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { LinePropsWithoutHTML } from '../Line/Line.shared.svelte.js'; + +export type BaseRulePropsWithoutHTML = { + /** + * Override the data from the context. + */ + data?: any; + + /** + * Create a vertical `x` line + * - If true or 'left', will draw at chart left (xRange[0]) + * - If 'right', will draw at chart right (xRange[1]) + * - Use `0` for baseline (yScale(0)) + * - Use number | Date value for annotation (yScale(value)) + * + * @default false + */ + x?: number | Date | boolean | '$left' | '$right' | Accessor; + + /** + * Pixel offset to apply to `x` coordinate + * + * @default 0 + */ + xOffset?: number; + + /** + * Create a horizontal `y` line + * - If true or 'bottom', will draw at chart bottom (yRange[0]) + * - If 'top', will draw at chart top (yRange[1]) + * - Use `0` for baseline (xScale(0)) + * - Use number | Date value for annotation (xScale(value)) + * + * @default false + */ + y?: number | Date | boolean | '$top' | '$bottom' | Accessor; + + /** + * Pixel offset to apply to `y` coordinate + * @default 0 + */ + yOffset?: number; +}; + +export type RulePropsWithoutHTML = BaseRulePropsWithoutHTML & + Without, BaseRulePropsWithoutHTML>; + +export type RuleProps = RulePropsWithoutHTML & + Without, RulePropsWithoutHTML>; + +export type RuleLineSegment = { + x1: number; + y1: number; + x2: number; + y2: number; + axis: 'x' | 'y'; + stroke?: string | null; +}; + +/** + * Reactive state shared by every per-layer Rule variant. + */ +export class RuleState { + #getProps: () => RuleProps = () => ({}) as RuleProps; + ctx: ChartState = getChartContext(); + + constructor(getProps: () => RuleProps) { + this.#getProps = getProps; + this.ctx.registerComponent({ name: 'Rule', kind: 'composite-mark' }); + } + + data = $derived(chartDataArray(this.#getProps().data ?? this.ctx.data)); + + singleX = $derived.by(() => { + const x = this.#getProps().x; + return ( + typeof x === 'number' || + x instanceof Date || + x === true || + x === '$left' || + x === '$right' || + (isScaleBand(this.ctx.xScale) && this.ctx.xDomain.includes(x as any)) + ); + }); + + singleY = $derived.by(() => { + const y = this.#getProps().y; + return ( + typeof y === 'number' || + y instanceof Date || + y === true || + y === '$bottom' || + y === '$top' || + (isScaleBand(this.ctx.yScale) && this.ctx.yDomain.includes(y as any)) + ); + }); + + xRangeMinMax = $derived(extent(this.ctx.xRange)); + yRangeMinMax = $derived(extent(this.ctx.yRange)); + + lines = $derived.by(() => { + const props = this.#getProps(); + const x = props.x ?? false; + const y = props.y ?? false; + const xOffset = props.xOffset ?? 0; + const yOffset = props.yOffset ?? 0; + const strokeProp = props.stroke; + + const result: RuleLineSegment[] = []; + + // Single x line + if (this.singleX) { + const _x = + x === true || x === '$left' + ? this.xRangeMinMax[0]! + : x === '$right' + ? this.xRangeMinMax[1]! + : this.ctx.xScale(x as any) + xOffset; + + result.push({ + x1: _x, + y1: this.ctx.yRange[0] || 0, + x2: _x, + y2: this.ctx.yRange[1] || 0, + axis: 'x', + }); + } + + // Single y line + if (this.singleY) { + const _y = + y === true || y === '$bottom' + ? this.yRangeMinMax[1]! + : y === '$top' + ? this.yRangeMinMax[0]! + : this.ctx.yScale(y as any) + yOffset; + + result.push({ + x1: this.ctx.xRange[0] || 0, + y1: _y, + x2: this.ctx.xRange[1] || 0, + y2: _y, + axis: 'y', + }); + } + + // Data driven lines + if (!this.singleX && !this.singleY) { + const xAccessor = x !== false ? accessor(x as Accessor) : this.ctx.x; + const yAccessor = y !== false ? accessor(y as Accessor) : this.ctx.y; + + const xBandOffset = isScaleBand(this.ctx.xScale) ? this.ctx.xScale.bandwidth() / 2 : 0; + const yBandOffset = isScaleBand(this.ctx.yScale) ? this.ctx.yScale.bandwidth() / 2 : 0; + + for (const d of this.data) { + const xValue = xAccessor(d); + const yValue = yAccessor(d); + + const x1Value = Array.isArray(xValue) + ? xValue[0] + : isScaleNumeric(this.ctx.xScale) + ? 0 + : xValue; + const x2Value = Array.isArray(xValue) ? xValue[1] : xValue; + const y1Value = Array.isArray(yValue) + ? yValue[0] + : isScaleNumeric(this.ctx.yScale) + ? 0 + : yValue; + const y2Value = Array.isArray(yValue) ? yValue[1] : yValue; + + result.push({ + x1: this.ctx.xScale(x1Value) + xBandOffset + xOffset, + y1: this.ctx.yScale(y1Value) + yBandOffset + yOffset, + x2: this.ctx.xScale(x2Value) + xBandOffset + xOffset, + y2: this.ctx.yScale(y2Value) + yBandOffset + yOffset, + axis: Array.isArray(yValue) || isScaleBand(this.ctx.xScale) ? 'x' : 'y', + stroke: (strokeProp ?? this.ctx.config.c) ? this.ctx.cGet(d) : null, + }); + } + } + + // Remove lines if out of range of chart (non-0 baseline, brushing, etc) + return result.filter((line) => { + return ( + line.x1 >= this.xRangeMinMax[0]! && + line.x2 <= this.xRangeMinMax[1]! && + line.y1 >= this.yRangeMinMax[0]! && + line.y2 <= this.yRangeMinMax[1]! + ); + }); + }); +} diff --git a/packages/layerchart/src/lib/components/Rule/Rule.svelte b/packages/layerchart/src/lib/components/Rule/Rule.svelte new file mode 100644 index 000000000..15e55cebb --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.svelte @@ -0,0 +1,23 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Rule/Rule.svg.svelte b/packages/layerchart/src/lib/components/Rule/Rule.svg.svelte new file mode 100644 index 000000000..7d3e6494f --- /dev/null +++ b/packages/layerchart/src/lib/components/Rule/Rule.svg.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Spline.svelte b/packages/layerchart/src/lib/components/Spline.svelte deleted file mode 100644 index eb22a2ed0..000000000 --- a/packages/layerchart/src/lib/components/Spline.svelte +++ /dev/null @@ -1,318 +0,0 @@ - - - - - - - -{#if segments} - {#each segments as seg, i (i)} - - {/each} -{:else} - -{/if} diff --git a/packages/layerchart/src/lib/components/Spline/Spline.base.svelte b/packages/layerchart/src/lib/components/Spline/Spline.base.svelte new file mode 100644 index 000000000..68a5a97f5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.base.svelte @@ -0,0 +1,56 @@ + + + + +{#if c.segments} + {#each c.segments as seg, i (i)} + + {/each} +{:else} + +{/if} diff --git a/packages/layerchart/src/lib/components/Spline/Spline.canvas.svelte b/packages/layerchart/src/lib/components/Spline/Spline.canvas.svelte new file mode 100644 index 000000000..a5e0195c7 --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts new file mode 100644 index 000000000..9ab2f8bc7 --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.shared.svelte.ts @@ -0,0 +1,272 @@ +import type { SVGAttributes } from 'svelte/elements'; +import type { CurveFactory, CurveFactoryLineOnly, Line } from 'd3-shape'; +import { line as d3Line, lineRadial } from 'd3-shape'; +import { geoPath as d3GeoPath } from 'd3-geo'; +import { max } from 'd3-array'; +import { interpolatePath } from 'd3-interpolate-path'; + +import { accessor, type Accessor } from '$lib/utils/common.js'; +import { isScaleBand } from '$lib/utils/scales.svelte.js'; +import { + createMotion, + extractTweenConfig, + type MotionProp, +} from '$lib/utils/motion.svelte.js'; +import { resolveColorProp, resolveStyleProp } from '$lib/utils/dataProp.js'; +import type { ColorProp, StyleProp } from '$lib/utils/dataProp.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; +import type { Without } from '$lib/utils/types.js'; +import type { PathProps } from '../Path/Path.shared.svelte.js'; + +export type SplinePropsWithoutHTML = { + /** Override data instead of using context */ + data?: any; + /** Override `x` accessor from Chart context */ + x?: Accessor; + /** Override `y` accessor from Chart context */ + y?: Accessor; + /** Series key to use for accessor. Only applicable if `` uses `series` and `x`/`y` are not set. */ + seriesKey?: string; + /** Function to determine if a point is defined */ + defined?: Parameters['defined']>[0]; + /** Curve of path drawn. Imported via d3-shape. */ + curve?: CurveFactory | CurveFactoryLineOnly; + /** Stroke color or function returning stroke per data point. */ + stroke?: ColorProp; + /** Fill color or function returning fill per data point. */ + fill?: ColorProp; + /** Opacity or function returning opacity per data point. */ + opacity?: StyleProp; + /** Whether to animate the path using tweened interpolation. */ + motion?: MotionProp; +} & Omit; + +export type SplineProps = SplinePropsWithoutHTML & + Without, SplinePropsWithoutHTML>; + +export type SplineSegment = { + stroke?: string; + fill?: string; + opacity?: number; + d: string; +}; + +/** + * Reactive state shared by every per-layer Spline variant. Holds the + * computed `d` (path data), `segments` (per-style-grouped paths when + * stroke/fill/opacity are functions), and tween state. + */ +export class SplineState { + #getProps: () => SplineProps = () => ({}) as SplineProps; + ctx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + #tweenState!: ReturnType>; + + constructor(getProps: () => SplineProps) { + this.#getProps = getProps; + + const initial = getProps(); + this.ctx.registerComponent({ + name: 'Spline', + kind: 'mark', + markInfo: () => { + const p = getProps(); + return { + data: p.data, + x: p.x, + y: p.y, + seriesKey: p.seriesKey, + color: typeof p.stroke === 'string' ? p.stroke : undefined, + }; + }, + }); + + this.#tweenState = createMotion(this.#defaultPathData(), () => this.d, { + type: 'tween', + interpolate: interpolatePath, + }); + } + + #getScaleValue( + data: any, + scale: typeof this.ctx.xScale | typeof this.ctx.yScale, + accessorFn: Function + ) { + let value = accessorFn(data); + if (Array.isArray(value)) value = max(value); + if (scale.domain().length) return scale(value); + return value; + } + + series = $derived( + this.ctx.series.series.find((s) => s.key === this.#getProps().seriesKey) + ); + seriesAccessor = $derived( + this.series?.value ?? (this.series?.data ? undefined : this.series?.key) + ); + + xAccessor = $derived( + accessor( + this.#getProps().x ?? + (this.ctx.valueAxis === 'x' ? this.seriesAccessor : undefined) ?? + this.ctx.x + ) + ); + yAccessor = $derived( + accessor( + this.#getProps().y ?? + (this.ctx.valueAxis === 'y' ? this.seriesAccessor : undefined) ?? + this.ctx.y + ) + ); + + xOffset = $derived(isScaleBand(this.ctx.xScale) ? this.ctx.xScale.bandwidth() / 2 : 0); + yOffset = $derived(isScaleBand(this.ctx.yScale) ? this.ctx.yScale.bandwidth() / 2 : 0); + + #buildPath(resolvedData: any[]): string { + const props = this.#getProps(); + const path = this.ctx.radial + ? lineRadial() + .angle((d) => this.#getScaleValue(d, this.ctx.xScale, this.xAccessor) + 0) + .radius((d) => this.#getScaleValue(d, this.ctx.yScale, this.yAccessor) + this.yOffset) + : d3Line() + .x((d) => this.#getScaleValue(d, this.ctx.xScale, this.xAccessor) + this.xOffset) + .y((d) => this.#getScaleValue(d, this.ctx.yScale, this.yAccessor) + this.yOffset); + + path.defined( + props.defined ?? ((d) => this.xAccessor(d) != null && this.yAccessor(d) != null) + ); + if (props.curve) path.curve(props.curve); + + return path(resolvedData) ?? ''; + } + + hasAnyStyleFn = $derived.by(() => { + const p = this.#getProps(); + return ( + typeof p.stroke === 'function' || + typeof p.fill === 'function' || + typeof p.opacity === 'function' + ); + }); + + d = $derived.by(() => { + const props = this.#getProps(); + if (this.hasAnyStyleFn && !this.geo.projection) return ''; + + const resolvedData = props.data ?? this.series?.data ?? this.ctx.data; + + if (this.geo.projection) { + const coordinates = resolvedData + .filter((d: any) => { + if (props.defined) return props.defined(d, 0, resolvedData); + return this.xAccessor(d) != null && this.yAccessor(d) != null; + }) + .map((d: any) => [this.xAccessor(d), this.yAccessor(d)]); + + const lineString = { type: 'LineString' as const, coordinates }; + return d3GeoPath(this.geo.projection)(lineString) ?? ''; + } + + return this.#buildPath(resolvedData); + }); + + segments = $derived.by(() => { + if (!this.hasAnyStyleFn) return null; + const props = this.#getProps(); + const resolvedData = props.data ?? this.series?.data ?? this.ctx.data; + if (this.geo.projection) return null; + + const groups = groupConsecutive(resolvedData, (d, i, arr) => { + const s = resolveColorProp(props.stroke, d, this.ctx.cScale, i, arr); + const f = resolveColorProp(props.fill, d, this.ctx.cScale, i, arr); + const o = resolveStyleProp(props.opacity, d, i, arr); + return { key: `${s}\0${f}\0${o}`, style: { stroke: s, fill: f, opacity: o } }; + }); + + return groups.map((group) => ({ + ...group.style, + d: this.#buildPath(group.data), + })); + }); + + #defaultPathData(): string { + const props = this.#getProps(); + if (!extractTweenConfig(props.motion)) return ''; + + if (this.ctx.config.x) { + const resolvedData = props.data ?? this.series?.data ?? this.ctx.data; + const baseline = Math.min(this.ctx.yScale(0) ?? this.ctx.yRange[0], this.ctx.yRange[0]); + + const path = this.ctx.radial + ? lineRadial() + .angle((d) => this.#getScaleValue(d, this.ctx.xScale, this.xAccessor) + 0) + .radius(() => baseline) + : d3Line() + .x((d) => this.#getScaleValue(d, this.ctx.xScale, this.xAccessor) + this.xOffset) + .y(() => baseline); + + path.defined( + props.defined ?? ((d) => this.xAccessor(d) != null && this.yAccessor(d) != null) + ); + if (props.curve) path.curve(props.curve); + + return path(resolvedData) ?? ''; + } + + return ''; + } + + isTweened = $derived(extractTweenConfig(this.#getProps().motion) != null); + + get tweenedPath() { + return this.#tweenState.current; + } + + seriesOpacity = $derived.by(() => { + if ( + this.series?.key == null || + this.ctx.series.visibleSeries.length <= 1 || + this.ctx.series.isHighlighted(this.series.key, true) + ) { + return 1; + } + return 0.1; + }); +} + +type SegmentStyle = { stroke?: string; fill?: string; opacity?: number }; + +/** + * Groups consecutive data points by a composite key derived from function-valued style props. + * The key at index `i` determines the style for the segment from point `i` to point `i+1`. + * Each group includes an overlap of 1 point at boundaries for curve continuity. + */ +function groupConsecutive( + data: any[], + keyFn: (d: any, index: number, data: any[]) => { key: string; style: SegmentStyle } +): Array<{ style: SegmentStyle; data: any[] }> { + if (data.length < 2) return []; + + const groups: Array<{ style: SegmentStyle; data: any[] }> = []; + let current = keyFn(data[0], 0, data); + let startIdx = 0; + + for (let i = 1; i < data.length; i++) { + const next = keyFn(data[i], i, data); + if (next.key !== current.key) { + groups.push({ style: current.style, data: data.slice(startIdx, i + 1) }); + startIdx = i; + current = next; + } + } + if (data.length - startIdx >= 2) { + groups.push({ style: current.style, data: data.slice(startIdx) }); + } + + return groups; +} diff --git a/packages/layerchart/src/lib/components/Spline/Spline.svelte b/packages/layerchart/src/lib/components/Spline/Spline.svelte new file mode 100644 index 000000000..3c140dc69 --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Spline/Spline.svg.svelte b/packages/layerchart/src/lib/components/Spline/Spline.svg.svelte new file mode 100644 index 000000000..0a6682713 --- /dev/null +++ b/packages/layerchart/src/lib/components/Spline/Spline.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Text.svelte b/packages/layerchart/src/lib/components/Text.svelte deleted file mode 100644 index bde410c13..000000000 --- a/packages/layerchart/src/lib/components/Text.svelte +++ /dev/null @@ -1,959 +0,0 @@ - - - - -{#if layerCtx === 'svg'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const text = resolveTextValue(item.d)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedStroke = resolveColorProp(stroke, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedStrokeWidth = resolveStyleProp(strokeWidth, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - {@const dataRotateTransform = rotate ? `rotate(${rotate}, ${item.x}, ${item.y})` : ''} - - - - {text} - - - - {/each} - {:else} - - - - {#if path} - - {#key path} - - {/key} - - - - {wordsByLines.map((line) => line.words.join(' ')).join()} - - - {:else if isValidXOrY(typeof x === 'function' ? undefined : x) && isValidXOrY(typeof y === 'function' ? undefined : y)} - - {#if segments} - {#each segments as segment, index (index)} - - {segment.value} - - {/each} - {:else} - {#each wordsByLines as line, index (index)} - - {line.words.join(' ')} - - {/each} - {/if} - - {/if} - - {/if} -{:else if layerCtx === 'html'} - {#if dataMode} - {#each resolvedItems as item (item.key)} - {@const text = resolveTextValue(item.d)} - {@const resolvedFill = resolveColorProp(fill, item.d, chartCtx.cScale)} - {@const resolvedFillOpacity = resolveStyleProp(fillOpacity, item.d)} - {@const resolvedOpacity = resolveStyleProp(opacity, item.d)} - {@const resolvedClass = resolveStyleProp(className, item.d)} - {@const translateX = textAnchor === 'middle' ? '-50%' : textAnchor === 'end' ? '-100%' : '0%'} - {@const translateY = - verticalAnchor === 'middle' ? '-50%' : verticalAnchor === 'end' ? '-100%' : '0%'} -
- {text} -
- {/each} - {:else} - {@const translateX = textAnchor === 'middle' ? '-50%' : textAnchor === 'end' ? '-100%' : '0%'} - {@const translateY = - verticalAnchor === 'middle' ? '-50%' : verticalAnchor === 'end' ? '-100%' : '0%'} -
- {#if segments} - {#each segments as segment} - {segment.value} - {/each} - {:else} - {textValue} - {/if} -
- {/if} -{/if} - - diff --git a/packages/layerchart/src/lib/components/Text/Text.canvas.svelte b/packages/layerchart/src/lib/components/Text/Text.canvas.svelte new file mode 100644 index 000000000..5f4d860fd --- /dev/null +++ b/packages/layerchart/src/lib/components/Text/Text.canvas.svelte @@ -0,0 +1,204 @@ + + + diff --git a/packages/layerchart/src/lib/components/Text/Text.html.svelte b/packages/layerchart/src/lib/components/Text/Text.html.svelte new file mode 100644 index 000000000..adf3c9570 --- /dev/null +++ b/packages/layerchart/src/lib/components/Text/Text.html.svelte @@ -0,0 +1,106 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const text = c.resolveTextValue(item.d)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const textAnchor = rest.textAnchor ?? 'start'} + {@const verticalAnchor = rest.verticalAnchor ?? 'end'} + {@const translateX = textAnchor === 'middle' ? '-50%' : textAnchor === 'end' ? '-100%' : '0%'} + {@const translateY = + verticalAnchor === 'middle' ? '-50%' : verticalAnchor === 'end' ? '-100%' : '0%'} +
+ {text} +
+ {/each} +{:else} + {@const textAnchor = rest.textAnchor ?? 'start'} + {@const verticalAnchor = rest.verticalAnchor ?? 'end'} + {@const translateX = textAnchor === 'middle' ? '-50%' : textAnchor === 'end' ? '-100%' : '0%'} + {@const translateY = + verticalAnchor === 'middle' ? '-50%' : verticalAnchor === 'end' ? '-100%' : '0%'} +
+ {#if rest.segments} + {#each rest.segments as segment} + {segment.value} + {/each} + {:else} + {c.textValue} + {/if} +
+{/if} + + diff --git a/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts new file mode 100644 index 000000000..081b726f5 --- /dev/null +++ b/packages/layerchart/src/lib/components/Text/Text.shared.svelte.ts @@ -0,0 +1,599 @@ +import { untrack } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; + +import type { Without } from '$lib/utils/types.js'; +import type { DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import { + resolveDataProp, + resolveGeoDataPair, +} from '$lib/utils/dataProp.js'; +import { chartDataArray } from '$lib/utils/common.js'; +import { createMotion, createDataMotionMap, type MotionProp } from '$lib/utils/motion.svelte.js'; +import { get } from '@layerstack/utils'; +import { format as formatValue, type FormatType, type FormatConfig } from '@layerstack/utils'; +import { getStringWidth, truncateText, type TruncateTextOptions } from '$lib/utils/string.js'; +import { getChartContext } from '$lib/contexts/chart.js'; +import { getGeoContext } from '$lib/contexts/geo.js'; +import type { ChartState } from '$lib/states/chart.svelte.js'; +import type { GeoState } from '$lib/states/geo.svelte.js'; + +/** + * Check if a string looks like a CSS/SVG value (percentage, em, px, etc.) + * rather than a data property accessor. + */ +function isCSSValue(value: string): boolean { + return /^-?[\d.]+(%|em|rem|px|pt|cm|mm|in)?$/.test(value); +} + +/** + * Check if a Text prop value is a data-space prop. + * Functions are always data props. + * Strings are data props unless they look like CSS values (e.g. "50%", "1em"). + */ +export function isTextDataProp(value: any): boolean { + if (typeof value === 'function') return true; + if (typeof value === 'string' && !isCSSValue(value)) return true; + return false; +} + +export type TextSegment = { + value: string | number; + class?: string; +}; + +export type TextPropsWithoutHTML = { + /** + * Text value to render. + * - `number`: direct value + * - `string`: in data mode, treated as a data property name (e.g. `"label"` → `d.label`); + * in pixel mode, used as literal text + * - `function(d)`: accessor called per data item to get the text value + * + * @default 0 + */ + value?: string | number | ((d: any) => string | number); + + /** + * Array of styled text segments for inline mixed styling. + * Each segment has its own value and optional class. + * Mutually exclusive with `value`. + */ + segments?: TextSegment[]; + + /** + * Maximum width to occupy (approximate as words are not split) + */ + width?: number; + + /** + * x position of the text. + * - `number`: pixel value (direct) + * - `string` (CSS value like "50%"): SVG position value + * - `string` (property name like "date"): data property, resolved via xScale + * - `function(d)`: accessor called per data item, result passed through xScale + * + * @default 0 + */ + x?: string | number | ((d: any) => any); + + /** + * Initial x position of the text (pixel mode only). + * + * @default x + */ + initialX?: string | number; + + /** + * y position of the text. + * - `number`: pixel value (direct) + * - `string` (CSS value like "50%"): SVG position value + * - `string` (property name like "value"): data property, resolved via yScale + * - `function(d)`: accessor called per data item, result passed through yScale + * + * @default 0 + */ + y?: string | number | ((d: any) => any); + + /** + * Initial y position of the text (pixel mode only). + * + * @default y + */ + initialY?: string | number; + + /** + * dx offset of the text + * + * @default 0 + */ + dx?: string | number; + + /** + * dy offset of the text + * + * @default 0 + */ + dy?: string | number; + + /** + * Desired "line height" of the text, implemented as y offsets + * + * @default "1em" + */ + lineHeight?: string; + + /** + * Cap height of the text + * @default '0.71em' + */ + capHeight?: string; + + /** + * Whether to scale the fontSize to accommodate the specified width + * + * @default false + */ + scaleToFit?: boolean; + + /** + * Horizontal text anchor + * + * @default 'start' + */ + textAnchor?: 'start' | 'middle' | 'end' | 'inherit'; + + /** + * Vertical text anchor + * + * @default 'end' + */ + verticalAnchor?: 'start' | 'middle' | 'end' | 'inherit'; + + /** + * The dominant baseline of the text. Useful for aligning text to the baseline of the axis. + * + * @default 'auto' + */ + dominantBaseline?: + | 'auto' + | 'text-before-edge' + | 'text-after-edge' + | 'middle' + | 'hanging' + | 'ideographic' + | 'mathematical'; + + /** + * Rotational angle of the text + */ + rotate?: number; + + /** + * A bindable reference to the wrapping `` element. + * + * @bindable + */ + svgRef?: SVGElement; + + /** + * Props to pass to the wrapping `` element. + */ + svgProps?: Omit, 'children'>; + + /** + * A bindable reference to the inner `` element + * + * @bindable + */ + ref?: SVGTextElement; + + /** + * Format the displayed value. When set with `motion` and a numeric `value`, + * the number will tween smoothly and be formatted for display. + */ + format?: FormatType | FormatConfig; + + /** Motion configuration (pixel mode only). */ + motion?: MotionProp; + + /** + * Whether to enable text truncation + */ + truncate?: boolean | TruncateTextOptions; + + /** + * A unique identifier for the SVG path element. + * One is generated by default if not provided. + * + */ + pathId?: string; + + /** + * The path to render the text along. + */ + path?: string | null; + + /** + * Specify the offset for the start of the text along the path. + * Can be a percentage ('50%') or a length value. + * + * @default '0%' + */ + startOffset?: string | number; + + /** + * Data array to iterate over in data mode. + * Falls back to chart context data when not provided. + */ + data?: any[]; + + /** + * Key function for keyed {#each} rendering in data mode. + * + * @default (d, i) => i + */ + key?: (d: any, index: number) => any; +} & DataDrivenStyleProps; + +export type TextProps = TextPropsWithoutHTML & + Without, TextPropsWithoutHTML>; + +const defaultKey = (_: any, i: number) => i; + +export function getPathLength(pathRef: SVGPathElement | undefined) { + if (pathRef && typeof pathRef.getTotalLength === 'function') { + try { + return pathRef.getTotalLength(); + } catch (e) { + console.error('Error getting path length:', e); + return 0; + } + } + return 0; +} + +/** + * Convert css value to pixel value (ex. 0.71em => 11.36) + */ +export function getPixelValue(cssValue: number | string) { + // TODO: Properly measure pixel values using DOM (handle inherited font size, zoom, etc) + if (typeof cssValue === 'number') return cssValue; + const result = cssValue.match(/([\d.]+)(\D+)/); + const number = Number(result?.[1]); + switch (result?.[2]) { + case 'px': + return number; + case 'em': + case 'rem': + return number * 16; + default: + return 0; + } +} + +export function isValidXOrY(xOrY: string | number | undefined) { + return ( + (typeof xOrY === 'number' && Number.isFinite(xOrY)) || + typeof xOrY === 'string' + ); +} + +/** Build the standard `markInfo` payload used by every Text variant. */ +export function textMarkInfo(props: TextProps, dataMode: boolean) { + if (!dataMode) return {}; + return { + data: props.data, + x: typeof props.x === 'string' ? props.x : undefined, + y: typeof props.y === 'string' ? props.y : undefined, + color: typeof props.fill === 'string' ? props.fill : undefined, + }; +} + +/** + * Reactive state shared by every per-layer Text variant. Instantiate from + * each `Text.svg.svelte` / `Text.canvas.svelte` / `Text.html.svelte` + * component setup, passing a getter for the props. + * + * Per-layer specific bits (SVG `bind:this` refs, canvas's `render` function, + * canvas-specific style measurement) stay in their respective `.svelte` files. + */ +export class TextState { + #getProps: () => TextProps = () => ({}) as TextProps; + + // Contexts + chartCtx: ChartState = getChartContext(); + geo: GeoState = getGeoContext(); + + // Path measurement (only meaningful for SVG layer where the textPath element exists) + pathRef = $state(); + + // Data mode detection + dataMode = $derived(isTextDataProp(this.#getProps().x) || isTextDataProp(this.#getProps().y)); + + // Data resolution + #resolvedData: any[] = $derived( + this.dataMode ? (this.#getProps().data ?? chartDataArray(this.chartCtx.data)) : [] + ); + + resolvedItems = $derived.by(() => { + if (!this.dataMode) return []; + const props = this.#getProps(); + const keyFn = props.key ?? defaultKey; + return this.#resolvedData.map((d, i) => { + const key = keyFn(d, i); + const resolved = this.resolveTextPosition(d); + const animated = this.#dataMotionMap?.get(key); + return { + d, + key, + x: animated?.x ?? resolved.x, + y: animated?.y ?? resolved.y, + }; + }); + }); + + resolveTextPosition(d: any): { x: number; y: number } { + const props = this.#getProps(); + if (this.geo.projection) { + const [projX, projY] = resolveGeoDataPair( + props.x as any, + props.y as any, + d, + this.geo.projection + ); + return { x: projX, y: projY }; + } + return { + x: resolveDataProp(props.x as any, d, this.chartCtx.xScale, 0), + y: resolveDataProp(props.y as any, d, this.chartCtx.yScale, 0), + }; + } + + resolveTextValue(d: any): string { + const value = this.#getProps().value; + if (typeof value === 'function') { + const v = value(d); + return v != null ? String(v) : ''; + } + if (typeof value === 'string') { + const v = get(d, value); + return v != null ? String(v) : ''; + } + return value != null ? String(value) : ''; + } + + #dataMotionMap: ReturnType = null; + + // Pixel-mode motion sources + #motionX!: ReturnType>; + #motionY!: ReturnType>; + #motionValue!: ReturnType>; + + get motionX() { + return this.#motionX.current; + } + get motionY() { + return this.#motionY.current; + } + + // Resolved width: for path text, defer to the (SVG-bound) pathRef length + resolvedWidth = $derived(this.#getProps().path ? getPathLength(this.pathRef) : this.#getProps().width); + + #defaultTruncateOptions: TruncateTextOptions = $derived({ + maxChars: undefined, + position: 'end', + maxWidth: this.resolvedWidth, + }); + + truncateConfig: TruncateTextOptions | boolean = $derived.by(() => { + const truncate = this.#getProps().truncate; + if (typeof truncate === 'boolean') { + if (truncate) return this.#defaultTruncateOptions; + return false; + } + return { ...this.#defaultTruncateOptions, ...(truncate ?? {}) }; + }); + + // Numeric value tweening + rawText = $derived.by(() => { + const value = this.#getProps().value; + const motion = this.#getProps().motion; + const format = this.#getProps().format; + if (typeof value === 'function' || value == null) return ''; + if (typeof value === 'number' && motion) { + const v = this.#motionValue.current; + // @ts-expect-error - improve format types + return format ? formatValue(v, format) : String(v); + } + // @ts-expect-error - improve format types + const text = format ? formatValue(value, format) : value.toString(); + return text.replace(/\\n/g, '\n'); + }); + + textValue = $derived.by(() => { + const cfg = this.truncateConfig; + if (!cfg || cfg === true) return this.rawText; + return truncateText(this.rawText, cfg); + }); + + // Word-wrapping (depends on style measurement; SVG/canvas-only) + // Note: `style` is a placeholder — never assigned in the original component + // either, so spaceWidth always falls back to 0. Preserved for behavior parity. + #spaceWidth = $derived(getStringWidth(' ', undefined as any) || 0); + + wordsByLines = $derived.by(() => { + const props = this.#getProps(); + const width = props.width; + const scaleToFit = props.scaleToFit ?? false; + const lines = this.textValue.split('\n'); + + return lines.flatMap((line) => { + const words = line.split(/(?:(?! +)\s+)/); + if (width == null) { + return [{ words }]; + } + return words.reduce((result: { words: string[]; width?: number }[], item) => { + const currentLine = result[result.length - 1]; + const itemWidth = getStringWidth(item, undefined as any) || 0; + + if ( + currentLine && + (width == null || + scaleToFit || + (currentLine.width || 0) + itemWidth + this.#spaceWidth < width) + ) { + currentLine.words.push(item); + currentLine.width = currentLine.width || 0; + currentLine.width += itemWidth + this.#spaceWidth; + } else { + const newLine = { words: [item], width: itemWidth }; + result.push(newLine); + } + return result; + }, []); + }); + }); + + lineCount = $derived(this.wordsByLines.length); + + // Vertical positioning + startDy = $derived.by(() => { + const props = this.#getProps(); + const verticalAnchor = props.verticalAnchor ?? 'end'; + const lineHeight = props.lineHeight ?? '1em'; + const capHeight = props.capHeight ?? '0.71em'; + if (verticalAnchor === 'start') { + return getPixelValue(lineHeight); + } else if (verticalAnchor === 'middle') { + return ((this.lineCount - 1) / 2) * -getPixelValue(lineHeight) + getPixelValue(capHeight) / 2; + } + return (this.lineCount - 1) * -getPixelValue(lineHeight) - getPixelValue(capHeight) / 2; + }); + + dataModeStartDy = $derived.by(() => { + const props = this.#getProps(); + const verticalAnchor = props.verticalAnchor ?? 'end'; + const lineHeight = props.lineHeight ?? '1em'; + const capHeight = props.capHeight ?? '0.71em'; + if (verticalAnchor === 'start') return getPixelValue(lineHeight); + if (verticalAnchor === 'middle') return getPixelValue(capHeight) / 2; + return -getPixelValue(capHeight) / 2; + }); + + scaleTransform = $derived.by(() => { + const props = this.#getProps(); + const x = props.x; + const y = props.y; + const width = props.width; + const scaleToFit = props.scaleToFit ?? false; + if ( + scaleToFit && + this.lineCount > 0 && + typeof x === 'number' && + typeof y === 'number' && + typeof width === 'number' + ) { + const lineWidth = this.wordsByLines[0].width || 1; + const sx = width / lineWidth; + const sy = sx; + const originX = x - sx * x; + const originY = y - sy * y; + return `matrix(${sx}, 0, 0, ${sy}, ${originX}, ${originY})`; + } + return ''; + }); + + rotateTransform = $derived.by(() => { + const props = this.#getProps(); + return props.rotate ? `rotate(${props.rotate}, ${props.x}, ${props.y})` : ''; + }); + + transform = $derived( + (this.#getProps().transform as string | undefined) ?? + `${this.scaleTransform} ${this.rotateTransform}` + ); + + // Static (non-data-driven) values + staticFill = $derived( + typeof this.#getProps().fill === 'string' ? (this.#getProps().fill as string) : undefined + ); + staticFillOpacity = $derived( + typeof this.#getProps().fillOpacity === 'number' + ? (this.#getProps().fillOpacity as number) + : undefined + ); + staticStroke = $derived( + typeof this.#getProps().stroke === 'string' ? (this.#getProps().stroke as string) : undefined + ); + staticStrokeWidth = $derived( + typeof this.#getProps().strokeWidth === 'number' + ? (this.#getProps().strokeWidth as number) + : undefined + ); + staticOpacity = $derived( + typeof this.#getProps().opacity === 'number' ? (this.#getProps().opacity as number) : undefined + ); + staticClassName = $derived( + typeof this.#getProps().class === 'string' ? (this.#getProps().class as string) : undefined + ); + + constructor(getProps: () => TextProps) { + this.#getProps = getProps; + + const initial = getProps(); + const _initialX: string | number = + initial.initialX ?? (typeof initial.x === 'function' ? 0 : (initial.x ?? 0)); + const _initialY: string | number = + initial.initialY ?? (typeof initial.y === 'function' ? 0 : (initial.y ?? 0)); + + this.#motionX = createMotion( + _initialX, + () => { + const x = getProps().x; + return typeof x === 'number' || typeof x === 'string' ? x : 0; + }, + initial.motion + ); + this.#motionY = createMotion( + _initialY, + () => { + const y = getProps().y; + return typeof y === 'number' || typeof y === 'string' ? y : 0; + }, + initial.motion + ); + + // Tween numeric values when motion is configured + this.#motionValue = createMotion( + typeof initial.value === 'number' ? initial.value : 0, + () => (typeof getProps().value === 'number' ? (getProps().value as number) : 0), + typeof initial.value === 'number' && initial.motion + ? typeof initial.motion === 'object' && 'type' in initial.motion + ? initial.motion + : undefined + : undefined + ); + + this.#dataMotionMap = createDataMotionMap(initial.motion); + if (this.#dataMotionMap) { + const motionMap = this.#dataMotionMap; + $effect(() => { + if (!this.dataMode) return; + const props = getProps(); + const keyFn = props.key ?? defaultKey; + const activeKeys = new Set(); + for (let i = 0; i < this.#resolvedData.length; i++) { + const d = this.#resolvedData[i]; + const key = keyFn(d, i); + activeKeys.add(key); + const resolved = this.resolveTextPosition(d); + untrack(() => motionMap.update(key, resolved)); + } + untrack(() => motionMap.cleanup(activeKeys)); + }); + } + } +} + +// Re-export for per-layer files that need it +export type { TruncateTextOptions } from '$lib/utils/string.js'; diff --git a/packages/layerchart/src/lib/components/Text/Text.svelte b/packages/layerchart/src/lib/components/Text/Text.svelte new file mode 100644 index 000000000..8b81f96ec --- /dev/null +++ b/packages/layerchart/src/lib/components/Text/Text.svelte @@ -0,0 +1,29 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{:else if layerCtx === 'html'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Text.svelte.test.ts b/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts similarity index 98% rename from packages/layerchart/src/lib/components/Text.svelte.test.ts rename to packages/layerchart/src/lib/components/Text/Text.svelte.test.ts index f8f9a5bbd..15cc4df91 100644 --- a/packages/layerchart/src/lib/components/Text.svelte.test.ts +++ b/packages/layerchart/src/lib/components/Text/Text.svelte.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'; import { render } from 'vitest-browser-svelte'; import { page } from 'vitest/browser'; -import TestHarness, { componentTestId } from './tests/TestHarness.svelte'; +import TestHarness, { componentTestId } from '../tests/TestHarness.svelte'; import Text from './Text.svelte'; describe('Text', () => { diff --git a/packages/layerchart/src/lib/components/Text/Text.svg.svelte b/packages/layerchart/src/lib/components/Text/Text.svg.svelte new file mode 100644 index 000000000..8ce83bb32 --- /dev/null +++ b/packages/layerchart/src/lib/components/Text/Text.svg.svelte @@ -0,0 +1,193 @@ + + + + +{#if c.dataMode} + {#each c.resolvedItems as item (item.key)} + {@const text = c.resolveTextValue(item.d)} + {@const resolvedFill = resolveColorProp(rest.fill, item.d, c.chartCtx.cScale)} + {@const resolvedStroke = resolveColorProp(rest.stroke, item.d, c.chartCtx.cScale)} + {@const resolvedFillOpacity = resolveStyleProp(rest.fillOpacity, item.d)} + {@const resolvedStrokeWidth = resolveStyleProp(rest.strokeWidth, item.d)} + {@const resolvedOpacity = resolveStyleProp(rest.opacity, item.d)} + {@const resolvedClass = resolveStyleProp(rest.class, item.d)} + {@const dataRotateTransform = rotate + ? `rotate(${rotate}, ${item.x}, ${item.y})` + : ''} + + + + {text} + + + + {/each} +{:else} + + {#if rest.path} + + {#key rest.path} + + {/key} + + + + {c.wordsByLines.map((line) => line.words.join(' ')).join()} + + + {:else} + + + {#if rest.segments} + {#each rest.segments as segment, index (index)} + + {segment.value} + + {/each} + {:else} + {#each c.wordsByLines as line, index (index)} + + {line.words.join(' ')} + + {/each} + {/if} + + {/if} + +{/if} + + diff --git a/packages/layerchart/src/lib/components/Threshold.svelte b/packages/layerchart/src/lib/components/Threshold.svelte deleted file mode 100644 index 79b470ae7..000000000 --- a/packages/layerchart/src/lib/components/Threshold.svelte +++ /dev/null @@ -1,75 +0,0 @@ - - - - - -{#key curve} - - {#snippet clip()} - ctx.y(d)[1]} y1={(d) => max(ctx.yDomain)} {curve} {defined} /> - {/snippet} - {@render above?.({ curve, defined })} - - - - {#snippet clip()} - min(ctx.yDomain)} y1={(d) => ctx.y(d)[1]} {curve} {defined} /> - {/snippet} - - {@render below?.({ curve, defined })} - - - {@render children?.({ curve, defined })} -{/key} diff --git a/packages/layerchart/src/lib/components/Threshold/Threshold.base.svelte b/packages/layerchart/src/lib/components/Threshold/Threshold.base.svelte new file mode 100644 index 000000000..79beafcf2 --- /dev/null +++ b/packages/layerchart/src/lib/components/Threshold/Threshold.base.svelte @@ -0,0 +1,42 @@ + + + + +{#key curve} + + {#snippet clip()} + ctx.y(d)[1]} y1={(d: any) => max(ctx.yDomain)} {curve} {defined} /> + {/snippet} + {@render above?.({ curve, defined })} + + + + {#snippet clip()} + min(ctx.yDomain)} y1={(d: any) => ctx.y(d)[1]} {curve} {defined} /> + {/snippet} + + {@render below?.({ curve, defined })} + + + {@render children?.({ curve, defined })} +{/key} diff --git a/packages/layerchart/src/lib/components/Threshold/Threshold.canvas.svelte b/packages/layerchart/src/lib/components/Threshold/Threshold.canvas.svelte new file mode 100644 index 000000000..2a723d7cf --- /dev/null +++ b/packages/layerchart/src/lib/components/Threshold/Threshold.canvas.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Threshold/Threshold.shared.svelte.ts b/packages/layerchart/src/lib/components/Threshold/Threshold.shared.svelte.ts new file mode 100644 index 000000000..0f24b8f60 --- /dev/null +++ b/packages/layerchart/src/lib/components/Threshold/Threshold.shared.svelte.ts @@ -0,0 +1,20 @@ +import type { ComponentProps, Snippet } from 'svelte'; +import type { CurveFactory } from 'd3-shape'; +import type Area from '../Area/Area.svelte'; + +export type ThresholdSnippetProps = { + curve?: CurveFactory; + defined?: ComponentProps['defined']; +}; + +export type ThresholdProps = { + /** The curve factory to use for the area. */ + curve?: CurveFactory; + /** Function to determine if a point is defined. */ + defined?: ComponentProps['defined']; + /** Content to render above the threshold area. */ + above?: Snippet<[ThresholdSnippetProps]>; + /** Content to render below the threshold area. */ + below?: Snippet<[ThresholdSnippetProps]>; + children?: Snippet<[ThresholdSnippetProps]>; +}; diff --git a/packages/layerchart/src/lib/components/Threshold/Threshold.svelte b/packages/layerchart/src/lib/components/Threshold/Threshold.svelte new file mode 100644 index 000000000..46fb550c2 --- /dev/null +++ b/packages/layerchart/src/lib/components/Threshold/Threshold.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Threshold/Threshold.svg.svelte b/packages/layerchart/src/lib/components/Threshold/Threshold.svg.svelte new file mode 100644 index 000000000..8d1e0d9b1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Threshold/Threshold.svg.svelte @@ -0,0 +1,14 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Trail.svelte b/packages/layerchart/src/lib/components/Trail/Trail.base.svelte similarity index 53% rename from packages/layerchart/src/lib/components/Trail.svelte rename to packages/layerchart/src/lib/components/Trail/Trail.base.svelte index 48cb8f3a5..db58b6b35 100644 --- a/packages/layerchart/src/lib/components/Trail.svelte +++ b/packages/layerchart/src/lib/components/Trail/Trail.base.svelte @@ -1,107 +1,12 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Vector/Vector.shared.svelte.ts b/packages/layerchart/src/lib/components/Vector/Vector.shared.svelte.ts new file mode 100644 index 000000000..0b5e62a6b --- /dev/null +++ b/packages/layerchart/src/lib/components/Vector/Vector.shared.svelte.ts @@ -0,0 +1,38 @@ +import type { Snippet } from 'svelte'; +import type { SVGAttributes } from 'svelte/elements'; +import type { Without } from '$lib/utils/types.js'; +import type { DataProp, DataDrivenStyleProps } from '$lib/utils/dataProp.js'; +import type { VectorAnchor } from '$lib/utils/path.js'; +import type { MotionProp } from '$lib/utils/motion.svelte.js'; + +export type VectorShape = 'arrow' | 'arrow-filled' | 'spike'; + +export type VectorPropsWithoutHTML = { + /** @default 0 */ + x?: DataProp; + /** @default x */ + initialX?: number; + /** @default 0 */ + y?: DataProp; + /** @default y */ + initialY?: number; + /** @default 12 */ + length?: DataProp; + /** @default length */ + initialLength?: number; + /** @default 0 */ + rotate?: DataProp; + /** @default 'arrow' */ + shape?: VectorShape; + anchor?: VectorAnchor; + /** Width of the vector shape in pixels. */ + width?: number; + data?: any[]; + /** @default (d, i) => i */ + key?: (d: any, index: number) => any; + motion?: MotionProp; + children?: Snippet<[{ length: number; d?: any }]>; +} & DataDrivenStyleProps; + +export type VectorProps = VectorPropsWithoutHTML & + Without, VectorPropsWithoutHTML>; diff --git a/packages/layerchart/src/lib/components/Vector/Vector.svelte b/packages/layerchart/src/lib/components/Vector/Vector.svelte new file mode 100644 index 000000000..955c33dd1 --- /dev/null +++ b/packages/layerchart/src/lib/components/Vector/Vector.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Vector/Vector.svg.svelte b/packages/layerchart/src/lib/components/Vector/Vector.svg.svelte new file mode 100644 index 000000000..6ca68bbfc --- /dev/null +++ b/packages/layerchart/src/lib/components/Vector/Vector.svg.svelte @@ -0,0 +1,13 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Violin.svelte b/packages/layerchart/src/lib/components/Violin/Violin.base.svelte similarity index 69% rename from packages/layerchart/src/lib/components/Violin.svelte rename to packages/layerchart/src/lib/components/Violin/Violin.base.svelte index 194c87926..fd8ee0912 100644 --- a/packages/layerchart/src/lib/components/Violin.svelte +++ b/packages/layerchart/src/lib/components/Violin/Violin.base.svelte @@ -1,73 +1,23 @@ + + + + diff --git a/packages/layerchart/src/lib/components/Violin/Violin.shared.svelte.ts b/packages/layerchart/src/lib/components/Violin/Violin.shared.svelte.ts new file mode 100644 index 000000000..1aafb0fea --- /dev/null +++ b/packages/layerchart/src/lib/components/Violin/Violin.shared.svelte.ts @@ -0,0 +1,25 @@ +import type { SVGAttributes } from 'svelte/elements'; +import type { CurveFactory } from 'd3-shape'; +import type { CommonStyleProps, CommonEvents, Without } from '$lib/utils/types.js'; +import type { Accessor } from '$lib/utils/common.js'; + +export type ViolinPropsWithoutHTML = { + data: Object; + density?: Accessor; + values?: Accessor; + bandwidth?: number; + /** @default 50 */ + thresholds?: number; + width?: number; + /** @default curveCardinal */ + curve?: CurveFactory; + /** @default false */ + median?: boolean; + /** @default false */ + box?: boolean | { width?: number }; + tooltip?: boolean; +} & CommonStyleProps; + +export type ViolinProps = ViolinPropsWithoutHTML & + Without, ViolinPropsWithoutHTML> & + CommonEvents; diff --git a/packages/layerchart/src/lib/components/Violin/Violin.svelte b/packages/layerchart/src/lib/components/Violin/Violin.svelte new file mode 100644 index 000000000..263158a8e --- /dev/null +++ b/packages/layerchart/src/lib/components/Violin/Violin.svelte @@ -0,0 +1,20 @@ + + + + +{#if layerCtx === 'svg'} + +{:else if layerCtx === 'canvas'} + +{/if} diff --git a/packages/layerchart/src/lib/components/Violin/Violin.svg.svelte b/packages/layerchart/src/lib/components/Violin/Violin.svg.svelte new file mode 100644 index 000000000..dbfee2473 --- /dev/null +++ b/packages/layerchart/src/lib/components/Violin/Violin.svg.svelte @@ -0,0 +1,16 @@ + + + + + diff --git a/packages/layerchart/src/lib/components/Voronoi.svelte b/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte similarity index 51% rename from packages/layerchart/src/lib/components/Voronoi.svelte rename to packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte index 0a1033be7..5bc5aef01 100644 --- a/packages/layerchart/src/lib/components/Voronoi.svelte +++ b/packages/layerchart/src/lib/components/Voronoi/Voronoi.base.svelte @@ -1,89 +1,34 @@
ChartCore children
+ {/snippet} + diff --git a/packages/layerchart/src/lib/components/tests/TestHarness.svelte b/packages/layerchart/src/lib/components/tests/TestHarness.svelte index c20727727..3e93564af 100644 --- a/packages/layerchart/src/lib/components/tests/TestHarness.svelte +++ b/packages/layerchart/src/lib/components/tests/TestHarness.svelte @@ -5,7 +5,7 @@