Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
210d6f3
feat: Add `@layerchart/3d` for true 3d charts
techniq Oct 31, 2026
a86d0a8
feat(3d): Add surfaces, rasters, maps, globes, lights, and more 3D marks
techniq Nov 1, 2026
69bc68b
feat: Move the 3D camera to Chart `view` (`perspective`/`orthographic…
techniq Nov 1, 2026
bbfa53f
refactor: Rename `@layerchart/3d` to `@layerchart/webgl` and its `Sce…
techniq Nov 1, 2026
4f85d15
also here (transition the surface when changing functions
techniq Nov 2, 2026
8004afd
feat(docs): Population density for every US state, and use `Bin` in t…
techniq Nov 2, 2026
21056f3
feat(webgl): Add point lights and glowing, image-wrapped points, with…
techniq Nov 2, 2026
59d7c28
feat: Consolidate WebGL docs into one guide with per-component pages …
techniq Nov 2, 2026
05d5bad
feat(webgl): Match core's Text, Rect, Link, Axis, and Grid, zoom flat…
techniq Nov 2, 2026
b79588e
fix(TooltipContext): Settle a quadtree tooltip only when just the sca…
techniq Nov 3, 2026
98b13da
fix(Axis): Space ticks the same along every axis of a chart with a `v…
techniq Nov 3, 2026
5adc034
docs: Remove the grab cursor from pan / zoom examples
techniq Nov 3, 2026
6a1ffe1
feat(webgl): Fit 3D views inside the chart's `padding`, as `isometric…
techniq Nov 3, 2026
0697428
feat(webgl): Align marks with LayerChart's: `class` styling, `GeoRast…
techniq Nov 3, 2026
15e900f
feat(webgl): Add `Rule`, `Vector`, and `Highlight`, and camera and la…
techniq Nov 3, 2026
476bbe2
docs: Link each component's LayerChart and WebGL pages to each other
techniq Nov 3, 2026
5cf6c63
docs: Add a Views guide comparing `isometric`, `orthographic`, and `p…
techniq Nov 3, 2026
c34ef2f
fix(Chart): Turn a tall `isometric` view about the middle of its box,…
techniq Nov 3, 2026
c97c3ed
docs: Drag to turn the isometric treemap
techniq Nov 3, 2026
f970661
docs: Cover the flat default and geo projections in the Views guide
techniq Nov 3, 2026
a05fa3c
docs: Match the WebGL page link to the layer toggle and move it after…
techniq Nov 3, 2026
e5b1da2
docs: Base WebGL component descriptions on their LayerChart namesakes'
techniq Nov 3, 2026
8e624a5
docs: Mark `@layerchart/webgl` pages and examples as WebGL in search …
techniq Nov 3, 2026
25e1c26
Render 3D vectors with tilt and scaled z positions
techniq Nov 3, 2026
1e1a85d
Add glowing additive-blended WebGL points and galaxy example
techniq Nov 3, 2026
e6396f6
improve changesets
techniq Nov 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"$schema": "https://unpkg.com/@changesets/config@2.3.1/schema.json",
"changelog": ["@svitejs/changesets-changelog-github-compact", { "repo": "techniq/layerchart" }],
"commit": false,
"fixed": [],
"fixed": [["layerchart", "@layerchart/webgl"]],
"linked": [],
"access": "public",
"baseBranch": "main",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/drag-dblclick.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(TransformContext): Don't zoom in on the double click two quick drags make
5 changes: 5 additions & 0 deletions .changeset/highlight-domain-motion.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Highlight): Move with a chart's `domain` pan or zoom rather than springing after it (`motion` now defaults to none on a `domain` transform)
5 changes: 5 additions & 0 deletions .changeset/isometric-axis-tick-spacing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Axis): Space ticks the same along every axis of a chart with a `view` (ex. `isometric`), rather than wider along `x`, so a square floor's axes get as many ticks each
5 changes: 5 additions & 0 deletions .changeset/isometric-pivot.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Chart): Dragging to turn a panned `isometric` view turns it about what's in the middle, rather than the floor's center
5 changes: 5 additions & 0 deletions .changeset/layerchart-webgl.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@layerchart/webgl': minor
---

feat: New `@layerchart/webgl` package for rendering with WebGL including support for data dense 2d charts and unique 3d features such as perspective, lights/shadows, and 3D surfaces.
5 changes: 5 additions & 0 deletions .changeset/mark-z-extent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(Chart): Add `MarkInfo.zExtent` to let a mark widen an automatic `zDomain` with heights from its own values
5 changes: 5 additions & 0 deletions .changeset/quadtree-domain-settle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(TooltipContext): Build a `quadtree` tooltip's tree once a `domain` pan or zoom rests, rather than at every step of it
5 changes: 5 additions & 0 deletions .changeset/shared-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat: Export `binCells`, `binGrid`, and `isScaleLinear()` for drawing marks in other ways (ex. `@layerchart/webgl`)
5 changes: 5 additions & 0 deletions .changeset/shift-scroll-zoom.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(TransformContext): Zoom on a `shift` scroll that's a little off straight (ex. a trackpad swipe), rather than panning a `domain` far sideways
5 changes: 5 additions & 0 deletions .changeset/tooltip-reappear.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': patch
---

fix(Tooltip): Appear where the pointer is when shown again (ex. after a drag), rather than gliding over from where it was last seen
5 changes: 5 additions & 0 deletions .changeset/transform-rotation-extent.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(TransformContext): Add `rotationExtent` (or `rotateExtent` / `tiltExtent`) to limit how far a drag turns a chart's `view` or a globe projection, and pass a view's turned `rotation` to `constrain`
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ bench-results/
.live-code/

# Generated API documentation
**/generated/api/*.json
**/generated/api/**/*.json

# Generated StackBlitz files
**/static/stackblitz-files.json
Expand Down
2 changes: 1 addition & 1 deletion docs/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ vite.config.ts.timestamp-*
.content-collections

# Generated API documentation
generated/api/*.json
generated/api/**/*.json

# Generated StackBlitz files
static/stackblitz-files.json
8 changes: 6 additions & 2 deletions docs/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,9 @@
"test": "pnpm test:unit -- --run",
"test:visual": "node scripts/visual-snapshot.mjs capture snapshots/current.json --repeat 1 && node scripts/visual-snapshot.mjs compare snapshots/baseline.json snapshots/current.json",
"test:visual:update": "node scripts/visual-snapshot.mjs capture snapshots/baseline.json --repeat 3",
"generate:api": "layerstack-docs generate-api ../packages/layerchart/src/lib/components generated/api",
"generate:api": "layerstack-docs generate-api ../packages/layerchart/src/lib/components generated/api && layerstack-docs generate-api ../packages/webgl/src/lib/components generated/api/webgl",
"generate:screenshots": "layerstack-docs generate-screenshots src/examples/components static/screenshots",
"generate:catalog": "layerstack-docs generate-catalog ../packages/layerchart/src/lib/components src/examples/components src/examples/catalog",
"generate:catalog": "layerstack-docs generate-catalog ../packages/layerchart/src/lib/components src/examples/components src/examples/catalog && layerstack-docs generate-catalog ../packages/webgl/src/lib/components src/examples/components/webgl src/examples/catalog/webgl",
"generate:stackblitz": "layerstack-docs generate-stackblitz src static/stackblitz-files.json static/remote-sources.json --source src/lib/utils/data.ts=lib/utils/data.ts --remote data.remote.ts=lib/data.remote.ts --remote geo.remote.ts=lib/geo.remote.ts --remote graph.remote.ts=lib/graph.remote.ts",
"generate:releases": "layerstack-docs generate-releases techniq/layerchart generated/releases",
"generate:all": "pnpm generate:api && pnpm generate:screenshots && pnpm generate:catalog && pnpm generate:stackblitz && pnpm generate:releases"
Expand All @@ -33,6 +33,7 @@
"@iconify-json/lucide": "^1.2.111",
"@iconify-json/simple-icons": "^1.2.85",
"@iconify-json/vscode-icons": "^1.2.53",
"@layerchart/webgl": "workspace:*",
"@layerstack/docs": "1.0.0-next.6",
"@layerstack/svelte-actions": "1.0.1-next.20",
"@layerstack/svelte-state": "0.1.0-next.25",
Expand All @@ -44,6 +45,7 @@
"@sveltejs/kit": "^2.62.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@threlte/core": "^8.6.1",
"@types/d3-array": "^3.2.2",
"@types/d3-color": "^3.1.3",
"@types/d3-dsv": "^3.0.7",
Expand All @@ -59,6 +61,7 @@
"@types/d3-time": "^3.0.4",
"@types/node": "^25.9.1",
"@types/shapefile": "^0.6.4",
"@types/three": "^0.187.0",
"@types/topojson-client": "^3.1.5",
"@types/topojson-server": "^3.0.4",
"@types/topojson-simplify": "^3.0.3",
Expand Down Expand Up @@ -98,6 +101,7 @@
"svelte-check": "^4.5.0",
"svelte-ux": "2.0.0-next.22",
"tailwindcss": "^4.3.0",
"three": "^0.187.0",
"topojson-client": "^3.1.0",
"topojson-server": "^3.0.1",
"topojson-simplify": "^3.0.3",
Expand Down
151 changes: 151 additions & 0 deletions docs/scripts/kontur-population.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Builds the people in each state for the `WebGL/state-population` example from Kontur Population
* (November 2023, CC BY 4.0): 400m H3 hexagons, which for the whole US is a 300MB download and an
* 840MB GeoPackage — too much to commit or fetch on every build.
*
* node scripts/kontur-population.mjs <kontur_population_US_20231101.gpkg>
*
* Download and unzip it first:
* https://geodata-eu-central-1-kontur-public.s3.amazonaws.com/kontur_datasets/kontur_population_US_20231101.gpkg.gz
*
* Each hexagon's people go to the 0.01° cell its center is in, and each state's cells (clipped to
* us-atlas' 1:10m outlines) are summed again into about `COLUMNS` cells across the state. Its
* largest places, from Natural Earth's populated places (public domain), are kept to label.
* Writes `static/data/examples/geo/kontur-population/<state>.json`: the cells as whole steps from
* the state's southwest corner, and the places as `[name, longitude, latitude]`,
* `{ origin, step, cells: [i, j, people, ...], cities }`.
*/
import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { geoBounds, geoContains, geoDistance } from 'd3-geo';
import { feature } from 'topojson-client';

/** us-atlas' territories, which Kontur's US population leaves out */
const TERRITORIES = [
'American Samoa',
'Commonwealth of the Northern Mariana Islands',
'Guam',
'Puerto Rico',
'United States Virgin Islands'
];
/** How many places to label in each state, and how far apart, as a share of its size */
const CITIES = 6;
const CITY_SPACING = 0.12;
/** About how many cells across each state, so its hexagons have a few each */
const COLUMNS = 160;
/** The cells hexagons are first summed into, in degrees */
const FINE = 0.01;
const ATLAS_URL = 'https://cdn.jsdelivr.net/npm/us-atlas@3/states-10m.json';
const PLACES_URL =
'https://raw.githubusercontent.com/nvkelso/natural-earth-vector/v5.1.2/geojson/ne_10m_populated_places_simple.geojson';
const OUT = 'static/data/examples/geo/kontur-population';

const [source] = process.argv.slice(2);
if (!source) {
console.error('Usage: node scripts/kontur-population.mjs <kontur_population_US.gpkg>');
process.exit(1);
}

const topology = await fetch(ATLAS_URL).then((r) => r.json());
const states = feature(topology, topology.objects.states).features.filter(
(f) => !TERRITORIES.includes(f.properties.name)
);
const places = (await fetch(PLACES_URL).then((r) => r.json())).features
.map((f) => f.properties)
.filter((p) => p.adm0_a3 === 'USA');

// Web Mercator (the GeoPackage's EPSG:3857) to and from longitude / latitude
const R = 20037508.342789244;
const toLonLat = (x, y) => [
(x / R) * 180,
(2 * Math.atan(Math.exp((y / R) * Math.PI)) - Math.PI / 2) * (180 / Math.PI)
];

/** People in each fine cell, by `i,j` */
const fine = new Map();
const db = new DatabaseSync(source, { readOnly: true });
for (const { population, geom } of db
.prepare('select population, geom from population')
.iterate()) {
// A GeoPackage geometry's header: "GP", version, flags, SRS id, then (as its flags say) its
// envelope — here `[minx, maxx, miny, maxy]`, whose middle is the hexagon's center
const flags = geom[3];
if (((flags >> 1) & 7) === 0) continue;
const view = new DataView(geom.buffer, geom.byteOffset + 8, 32);
const little = (flags & 1) === 1;
const [minx, maxx, miny, maxy] = [0, 8, 16, 24].map((o) => view.getFloat64(o, little));
const [lon, lat] = toLonLat((minx + maxx) / 2, (miny + maxy) / 2);
const key = `${Math.round(lon / FINE)},${Math.round(lat / FINE)}`;
fine.set(key, (fine.get(key) ?? 0) + population);
}
const rows = Array.from(fine, ([key, people]) => {
const [i, j] = key.split(',').map(Number);
return [+(i * FINE).toFixed(2), +(j * FINE).toFixed(2), Math.round(people)];
});

mkdirSync(OUT, { recursive: true });
for (const state of states) {
const [[w, s], [e, n]] = geoBounds(state);
// Alaska's Aleutians cross the antimeridian, so its west edge is east of its east edge
const wraps = w > e;
const width = wraps ? e - w + 360 : e - w;
const inside = (lon) => (wraps ? lon >= w || lon <= e : lon >= w && lon <= e);
const lat = (s + n) / 2;
// Cells about square on the ground: a degree of longitude is shorter than one of latitude
const across = Math.cos((lat * Math.PI) / 180);
const step = +(Math.max(width * across, n - s) / COLUMNS).toPrecision(2);
const stepLon = +(step / across).toPrecision(2);

const cells = new Map();
let people = 0;
for (const [lon, la, count] of rows) {
if (!inside(lon) || la < s || la > n || !geoContains(state, [lon, la])) continue;
const key = `${Math.round(lon / stepLon)},${Math.round(la / step)}`;
cells.set(key, (cells.get(key) ?? 0) + count);
people += count;
}
const entries = Array.from(cells, ([key, count]) => [
...key.split(',').map(Number),
Math.round(count)
]).filter((d) => d[2] > 0);
const i0 = Math.min(...entries.map((d) => d[0]));
const j0 = Math.min(...entries.map((d) => d[1]));
const data = {
origin: [+(i0 * stepLon).toFixed(4), +(j0 * step).toFixed(4)],
step: [stepLon, step],
cells: entries.flatMap(([i, j, count]) => [i - i0, j - j0, count])
};
// The largest places, each a little way from those already kept
const spacing = geoDistance([w, s], [w + width, n]) * CITY_SPACING;
const cities = [];
const candidates = places
.filter((p) => p.adm1name === state.properties.name)
.sort((a, b) => b.pop_max - a.pop_max);
for (const p of candidates) {
if (cities.length >= CITIES) break;
const at = [p.longitude, p.latitude];
if (cities.some(([, lon, la]) => geoDistance(at, [lon, la]) < spacing)) continue;
cities.push([p.name, +p.longitude.toFixed(3), +p.latitude.toFixed(3)]);
}
data.cities = cities;

const slug = state.properties.name.toLowerCase().replace(/ /g, '-');
writeFileSync(join(OUT, `${slug}.json`), JSON.stringify(data));
console.log(
`${slug}: ${entries.length} cells, ${Math.round(people).toLocaleString()} people, ${cities.map((c) => c[0]).join(', ')}`
);
}

// Every state there's a file for, by its file and name, for the example's list (and prerendering)
writeFileSync(
join(OUT, 'states.json'),
JSON.stringify(
states
.map((state) => ({
value: state.properties.name.toLowerCase().replace(/ /g, '-'),
label: state.properties.name
}))
.sort((a, b) => a.label.localeCompare(b.label))
)
);
21 changes: 21 additions & 0 deletions docs/src/content/components/webgl/Arc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
description: 'Primitive component which draws a curved segment on a chart to represent portions of a whole or highlight specific data ranges. In a WebGL layer, it stands up by its z.'
category: WebGL marks
related: [Arc, guides/webgl]
components:
- { name: webgl/Arc, label: Arc }
---

::tip
Part of [`@layerchart/webgl`](/docs/guides/webgl), drawn in a `WebGL` layer. See the [WebGL guide](/docs/guides/webgl) to get started.
::

## Usage

An arc with LayerChart's [Arc](/docs/components/Arc) props: from `startAngle` to `endAngle` (ex. a slice from `d3.pie()`), or to a `value` through its `domain` and `range`, over a `track`. Radii are pixels, fractions of the chart's radius, or offsets from it, as LayerChart's are, and `class` styles it as on any LayerChart mark. It's centered in the plot (where LayerChart's draws inside a centered `Layer` or `Group`); `x` / `y` move it. `z` stands it up, as on an `isometric` chart; without one it lies flat.

:example{ name="pie" }

LayerChart's concentric gauges, unchanged but for `z`:

:example{ name="concentric" }
19 changes: 19 additions & 0 deletions docs/src/content/components/webgl/Area.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
description: 'Marking component which shades the space under a line on a chart to emphasize the magnitude and trend of data over a range. In a WebGL layer, it lies on the floor, or rises as a curtain with a z.'
category: WebGL marks
related: [Area, guides/webgl]
components:
- { name: webgl/Area, label: Area }
---

::tip
Part of [`@layerchart/webgl`](/docs/guides/webgl), drawn in a `WebGL` layer. See the [WebGL guide](/docs/guides/webgl) to get started.
::

## Usage

A curtain from the floor (or `z0`) up to `z` along the rows, one per group.

:example{ name="areas" }

:example{ name="ridgeline" }
35 changes: 35 additions & 0 deletions docs/src/content/components/webgl/Axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
---
description: "Commonly used component displays the scale and reference lines on a chart, providing context for interpreting data values. In a WebGL layer, it runs along the plot box's edges: bottom for x, left for y, and back for z."
category: WebGL
related: [Axis, guides/webgl]
components:
- { name: webgl/Axis, label: Axis }
---

::tip
Part of [`@layerchart/webgl`](/docs/guides/webgl), drawn in a `WebGL` layer. See the [WebGL guide](/docs/guides/webgl) to get started.
::

## Usage

Tick labels along an edge: `bottom` for `x`, `left` for `y`, and `back` for `z` (up the corner behind the plot). They keep to the edges in front as the view turns.

Labels are drawn on the plot box, turning with it: the floor's (`bottom`, `left`) lie on it, as on an [isometric](/docs/guides/isometric#text) chart, and the height axis' (`back`) stand on the back wall beside its edge. `tickLabelProps={{ viewport: true }}` and `labelProps={{ viewport: true }}` draw them facing the viewer instead, over the scene, upright and crisp however the view turns.

```svelte
<WebGL>
<Axis placement="bottom" label="x" />
<Axis
placement="left"
label="y"
tickLabelProps={{ viewport: true }}
labelProps={{ viewport: true }}
/>
<Axis placement="back" label="z" />
<Points />
</WebGL>
```

:example{ component="webgl/Points" name="scatter-3d" }

:example{ component="webgl/Spline" name="temperatures" }
Loading
Loading