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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bin-exports.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(Bin): Export `binCells` and `binGrid`, to draw bins in other ways (ex. `@layerchart/webgl`'s `Bin`)
5 changes: 5 additions & 0 deletions .changeset/chart-3d-views.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'layerchart': minor
---

feat(Chart): `perspective` and `orthographic` views for 3D layers (`@layerchart/webgl`), turned, panned, and zoomed by `transform` as `isometric` is
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/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 charts drawn with WebGL (three.js): a `WebGL` layer drawing through the chart's `perspective` / `orthographic` view (or flat, for many marks in 2D), with marks for points, bars, bins, lines, areas, surfaces, rasters, maps, and globes, and lights with shadows
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): `MarkInfo.zExtent` lets a mark widen an automatic `zDomain` with heights from its own values
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))
)
);
17 changes: 17 additions & 0 deletions docs/src/content/components/webgl/Arc.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
description: 'Marking component drawing an extruded slice per row in a WebGL chart, ex. from d3.pie().'
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 extruded slice per row, from `startAngle` / `endAngle` and `innerRadius` / `outerRadius` (ex. from `d3.pie()`), `z` tall.

:example{ name="pie" }
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 drawing a curtain from the floor up to z along the rows in a WebGL chart, one per group.'
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" }
28 changes: 28 additions & 0 deletions docs/src/content/components/webgl/Axis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
description: "Tick labels along an edge of a WebGL chart's plot box: bottom for x, left for y, 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). Labels are drawn over the scene, upright and crisp however the view turns, and keep to the edges in front.

```svelte
<WebGL>
<Axis placement="bottom" label="x" />
<Axis placement="left" label="y" />
<Axis placement="back" label="z" />
<Points />
</WebGL>
```

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

:example{ component="webgl/Spline" name="temperatures" }
45 changes: 45 additions & 0 deletions docs/src/content/components/webgl/Bars.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
description: 'Marking component drawing a box per row in a WebGL chart, standing on its band cell as tall as its z, stacking along z.'
category: WebGL marks
related: [Bars, guides/webgl]
components:
- { name: webgl/Bars, label: Bars }
---

::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 box per row, standing on its `x` × `y` band cell, as tall as its `z`.

```svelte
<Chart
{data}
x="week"
xScale={scaleBand()}
y="day"
yScale={scaleBand()}
z="value"
view={perspective}
>
<WebGL>
<Bars tooltip />
</WebGL>
</Chart>
```

:example{ name="columns" }

## Stacked

With series and `valueAxis="z"`, bars stack up off the floor.

:example{ name="stacked-columns" }

## Heatmap and calendar

:example{ name="heatmap" }

:example{ name="calendar" }
Loading
Loading