Skip to content

Commit

Permalink
geometries and projections (#1111)
Browse files Browse the repository at this point in the history
* geometry mark

* pRetTIEr

* project x & y; re-fix #1043

* more projections, and options

* add missing test

* PretTier

* projection + hexbin

* projection.stream for x and y

* test null projection

* another comment

* remove TODO’s

* simplify walmarts

* Update README

* sphere, graticule

* update graticule snapshot

* Walmart data provenance

* r channel for geometries; sort by R.

* Use the facets dimensions, and respect margins. Note that this changes the test plots by 1px (due to margins being 0.5-offset).

* clip: "sphere"

* allow projection configuration function

* revert dimensions changes

* withDefaultSort

* tweak faceted walmarts test

* fix auto height for pre-projected geometry

* more projections

* projection + density

* all marks can clip to sphere

* test voronoi clip to sphere

* error if sphere clip lacks projection

* error if projection and band-requiring channels

* Update README.md

Co-authored-by: Philippe Rivière <[email protected]>

* Update README.md

Co-authored-by: Philippe Rivière <[email protected]>

* pReTTIER

* projection requires x and y channels

* Update README

* Update README

* Update README

* Update README

* a more interesting voronoi map

* geo

* use fitExtent for armadillo

* more data provenance

Co-authored-by: Philippe Rivière <[email protected]>
  • Loading branch information
mbostock and Fil authored Nov 26, 2022
1 parent 21dc1c7 commit 785d03e
Show file tree
Hide file tree
Showing 61 changed files with 22,620 additions and 107 deletions.
71 changes: 68 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,37 @@ Plot automatically generates axes for position scales. You can configure these a

Top-level options are also supported as shorthand: **grid** (for *x* and *y* only; see [facet.grid](#facet-options)), **label**, **axis**, **inset**, **round**, **align**, and **padding**.

### Projection options

The top-level **projection** option applies a two-dimensional (often geographic) projection in place of *x* and *y* scales. It is typically used in conjunction with a [geo mark](#geo) to produce a map, but can be used with any mark that supports *x* and *y* channels, such as [dot](#dot) and [text](#text). The following built-in named projections are supported:

* *equirectangular* - the equirectangular, or *plate carrée*, projection
* *orthographic* - the orthographic projection
* *stereographic* - the stereographic projection
* *mercator* - the Mercator projection
* *equal-earth* - the [Equal Earth projection](https://en.wikipedia.org/wiki/Equal_Earth_projection) by Šavrič *et al.*
* *natural-earth* - the [Natural Earth projection](https://en.wikipedia.org/wiki/Natural_Earth_projection) by Patterson
* *azimuthal-equal-area* - the azimuthal equal-area projection
* *azimuthal-equidistant* - the azimuthal equidistant projection
* *conic-conformal* - the conic conformal projection
* *conic-equal-area* - the conic equal-area projection
* *conic-equidistant* - the conic equidistant projection
* *gnomonic* - the gnomonic projection
* *transverse-mercator* - the transverse Mercator projection
* *albers* - the Albers’ conic equal-area projection
* *albers-usa* - a composite Albers conic equal-area projection suitable for the United States
* *identity* or null (default) - the identity projection for pre-projected geometry

In addition to these named projections, the **projection** option may be specified as a [D3 projection](https://github.com/d3/d3-geo/blob/main/README.md#projections), or any custom projection that implements [*projection*.stream](https://github.com/d3/d3-geo/blob/main/README.md#projection_stream), or a function that receives a configuration object ({width, height, marginTop, marginRight, marginBottom, marginLeft, ...options}) and returns such a projection.

If the **projection** option is specified as an object, the following additional projection options are supported:

* projection.**type** - one of the projection names above
* projection.**center** - the [projection’s center of reference](https://github.com/d3/d3-geo/blob/main/README.md#projection_center)
* projection.**parallels** - the [standard parallels](https://github.com/d3/d3-geo/blob/main/README.md#conic_parallels) (for conic projections only)
* projection.**precision** - the [sampling threshold](https://github.com/d3/d3-geo/blob/main/README.md#projection_precision)
* projection.**rotate** - a two- or three- element array of Euler angles to rotate the sphere

### Color options

The normal scale types—*linear*, *sqrt*, *pow*, *log*, *symlog*, and *ordinal*—can be used to encode color. In addition, Plot supports special scale types for color:
Expand Down Expand Up @@ -1209,12 +1240,46 @@ Equivalent to [Plot.dot](#plotdotdata-options) except that the **symbol** option
<!-- jsdocEnd hexagon -->
### Geo
[Source](./src/marks/geo.js) · [Examples](https://observablehq.com/@observablehq/plot-geo) · Draws polygons, lines, points, and other GeoJSON geometry, often in conjunction with a [geographic projection](#projection-options) to produce a thematic map. The **geometry** channel specifies the geometry (GeoJSON object) to draw; if not specified, the mark’s *data* is assumed to be GeoJSON.
#### Plot.geo(*data*, *options*)
```js
Plot.geo(counties, {fill: d => d.properties.rate})
```
Returns a new geo mark with the given *data* and *options*. If *data* is a GeoJSON feature collection, then the mark’s data is *data*.features; if *data* is a GeoJSON geometry collection, then the mark’s data is *data*.geometries; if *data* is some other GeoJSON object, then the mark’s data is the single-element array [*data*]. If the **geometry** option is not specified, *data* is assumed to be a GeoJSON object or an iterable of GeoJSON objects.
In addition to the [standard mark options](#marks), the **r** option controls the size of Point and MultiPoint geometries. It can be specified as either a channel or constant. When **r** is specified as a number, it is interpreted as a constant radius in pixels; otherwise it is interpreted as a channel and the effective radius is controlled by the *r* scale. (As with [dots](#dot), the *r* scale defaults to a *sqrt* scale such that the visual area of a point is proportional to its associated value.) If the **r** option is not specified it defaults to 3 pixels. Geometries with a nonpositive radius are not drawn. If **r** is a channel, geometries will be sorted by descending radius by default.
#### Plot.sphere(*options*)
```js
Plot.sphere()
```
Returns a new geo mark with a *Sphere* geometry object and the given *options*.
#### Plot.graticule(*options*)
```js
Plot.graticule()
```
Returns a new geo mark with a [default 10° global graticule](https://github.com/d3/d3-geo/blob/main/README.md#geoGraticule10) geometry object and the given *options*.
### Hexgrid
The hexgrid mark can be used to support marks using the [hexbin](#hexbin) layout.
#### Plot.hexgrid(*options*)
```js
Plot.hexgrid()
```
<!-- jsdoc hexgrid -->
The **binWidth** option specifies the distance between the centers of neighboring hexagons, in pixels (defaults to 20). The **clip** option defaults to true, clipping the mark to the frame’s dimensions.
Expand Down Expand Up @@ -2355,7 +2420,7 @@ The supported stack options are:
The following **order** methods are supported:
- null - input order (default)
- null (default) - input order
- *value* - ascending value order (or descending with **reverse**)
- *sum* - order series by their total value
- *appearance* - order series by the position of their maximum value
Expand All @@ -2369,7 +2434,7 @@ The stack transform supports diverging stacks: negative values are stacked below
After all values have been stacked from zero, an optional **offset** can be applied to translate or scale the stacks. The following **offset** methods are supported:
- null - a zero baseline (default)
- null (default) - a zero baseline
- *expand* (or *normalize*) - rescale each stack to fill [0, 1]
- *center* (or *silhouette*) - align the centers of all stacks
- *wiggle* - translate stacks to minimize apparent movement
Expand Down Expand Up @@ -2769,7 +2834,7 @@ A [marker](https://developer.mozilla.org/en-US/docs/Web/SVG/Element/marker) defi
The following named markers are supported:
* *none* - no marker (default)
* *none* (default) - no marker
* *arrow* - an arrowhead
* *dot* - a filled *circle* without a stroke and 2.5px radius
* *circle*, equivalent to *circle-fill* - a filled circle with a white stroke and 3px radius
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@typescript-eslint/eslint-plugin": "^5.25.0",
"@typescript-eslint/parser": "^5.25.0",
"canvas": "2",
"d3-geo-projection": "^4.0.0",
"eslint": "^8.16.0",
"eslint-config-prettier": "^8.5.0",
"get-tsconfig": "^4.1.0",
Expand All @@ -65,6 +66,7 @@
"prettier": "^2.7.1",
"rollup": "2",
"rollup-plugin-terser": "7",
"topojson-client": "^3.1.0",
"tsx": "^3.8.0",
"typescript": "^4.6.4",
"typescript-module-alias": "^1.0.2",
Expand Down
31 changes: 28 additions & 3 deletions src/channel.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {ascending, descending, rollup, sort} from "d3";
import {first, isIterable, labelof, map, maybeValue, range, valueof} from "./options.js";
import {applyProjection} from "./projection.js";
import {registry} from "./scales/index.js";
import {maybeReduce} from "./transforms/group.js";

Expand All @@ -24,13 +25,37 @@ export function Channels(descriptors, data) {
}

// TODO Use Float64Array for scales with numeric ranges, e.g. position?
export function valueObject(channels, scales) {
return Object.fromEntries(
export function valueObject(channels, scales, {projection}) {
let x, y; // names of channels bound to x and y scale

const values = Object.fromEntries(
Object.entries(channels).map(([name, {scale: scaleName, value}]) => {
const scale = scales[scaleName];
let scale;
if (scaleName !== undefined) {
if (scaleName === "x") x = x === undefined ? name : "*";
else if (scaleName === "y") y = y === undefined ? name : "*";
scale = scales[scaleName];
}
return [name, scale === undefined ? value : map(value, scale)];
})
);

// If there is a projection, and there are both x and y channels, and those x
// and y channels are associated with the x and y scale respectively (and not
// already in screen coordinates as with an initializer), then apply the
// projection, replacing the x and y values. Note that the x and y scales
// themselves don’t exist if there is a projection, but whether the channels
// are associated with scales still determines whether the projection should
// apply; think of the projection as a combination xy-scale.
if (projection) {
if (x === "x" && y === "y") {
applyProjection(values, projection);
} else if (x || y) {
throw new Error("projection requires x and y channels");
}
}

return values;
}

// Note: mutates channel.domain! This is set to a function so that it is lazily
Expand Down
8 changes: 6 additions & 2 deletions src/context.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import {creator, select} from "d3";
import {maybeProjection} from "./projection.js";

export function Context({document = window.document} = {}) {
return {document};
export function Context(
{document = typeof window !== "undefined" ? window.document : undefined, projection} = {},
dimensions
) {
return {document, projection: maybeProjection(projection, dimensions)};
}

export function create(name, {document}) {
Expand Down
11 changes: 7 additions & 4 deletions src/dimensions.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import {isProjection} from "./projection.js";
import {isOrdinalScale} from "./scales.js";
import {offset} from "./style.js";

export function Dimensions(
scales,
geometry,
{x: {axis: xAxis} = {}, y: {axis: yAxis} = {}, fx: {axis: fxAxis} = {}, fy: {axis: fyAxis} = {}},
{
projection,
width = 640,
height = autoHeight(scales),
height = autoHeight(scales, geometry || isProjection(projection)),
facet: {
margin: facetMargin,
marginTop: facetMarginTop = facetMargin !== undefined ? facetMargin : fxAxis === "top" ? 30 : 0,
Expand Down Expand Up @@ -43,8 +46,8 @@ export function Dimensions(
};
}

function autoHeight({y, fy, fx}) {
function autoHeight({y, fy, fx}, geometry) {
const nfy = fy ? fy.scale.domain().length : 1;
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : 1;
return !!(y || fy) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
const ny = y ? (isOrdinalScale(y) ? y.scale.domain().length : Math.max(7, 17 / nfy)) : geometry ? 17 : 1;
return !!(y || fy || geometry) * Math.max(1, Math.min(60, ny * nfy)) * 20 + !!fx * 30 + 60;
}
1 change: 1 addition & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {delaunayLink, delaunayMesh, hull, voronoi, voronoiMesh} from "./marks/de
export {Density, density} from "./marks/density.js";
export {Dot, dot, dotX, dotY, circle, hexagon} from "./marks/dot.js";
export {Frame, frame} from "./marks/frame.js";
export {Geo, geo, sphere, graticule} from "./marks/geo.js";
export {Hexgrid, hexgrid} from "./marks/hexgrid.js";
export {Image, image} from "./marks/image.js";
export {Line, line, lineX, lineY} from "./marks/line.js";
Expand Down
2 changes: 1 addition & 1 deletion src/marks/area.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ export class Area extends Mark {
render(index, scales, channels, dimensions, context) {
const {x1: X1, y1: Y1, x2: X2 = X1, y2: Y2 = Y1} = channels;
return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, scales, 0, 0)
.call((g) =>
g
Expand Down
2 changes: 1 addition & 1 deletion src/marks/arrow.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class Arrow extends Mark {
const wingScale = headLength / 1.5;

return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, scales)
.call((g) =>
g
Expand Down
2 changes: 1 addition & 1 deletion src/marks/bar.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export class AbstractBar extends Mark {
render(index, scales, channels, dimensions, context) {
const {rx, ry} = this;
return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(this._transform, this, scales)
.call((g) =>
g
Expand Down
15 changes: 9 additions & 6 deletions src/marks/delaunay.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class DelaunayLink extends Mark {
markers(this, options);
}
render(index, scales, channels, dimensions, context) {
const {x, y} = scales;
const {x: X, y: Y, z: Z} = channels;
const {curve} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
Expand Down Expand Up @@ -123,8 +124,8 @@ class DelaunayLink extends Mark {
}

return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyTransform, this, scales)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, {x: X && x, y: Y && y})
.call(
Z
? (g) =>
Expand Down Expand Up @@ -155,6 +156,7 @@ class AbstractDelaunayMark extends Mark {
);
}
render(index, scales, channels, dimensions, context) {
const {x, y} = scales;
const {x: X, y: Y, z: Z} = channels;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const xi = X ? (i) => X[i] : constant(cx);
Expand All @@ -172,8 +174,8 @@ class AbstractDelaunayMark extends Mark {
}

return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyTransform, this, scales)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, {x: X && x, y: Y && y})
.call(
Z
? (g) =>
Expand Down Expand Up @@ -223,6 +225,7 @@ class Voronoi extends Mark {
);
}
render(index, scales, channels, dimensions, context) {
const {x, y} = scales;
const {x: X, y: Y, z: Z} = channels;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const xi = X ? (i) => X[i] : constant(cx);
Expand All @@ -242,8 +245,8 @@ class Voronoi extends Mark {
}

return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyTransform, this, scales)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, {x: X && x, y: Y && y})
.call(
Z
? (g) =>
Expand Down
26 changes: 20 additions & 6 deletions src/marks/density.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {contourDensity, create, geoPath} from "d3";
import {identity, isTypedArray, maybeTuple, maybeZ, valueof} from "../options.js";
import {valueObject} from "../channel.js";
import {isTypedArray, maybeTuple, maybeZ} from "../options.js";
import {Mark} from "../plot.js";
import {coerceNumbers} from "../scales.js";
import {
Expand Down Expand Up @@ -49,8 +50,8 @@ export class Density extends Mark {
const {contours} = channels;
const path = geoPath();
return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyTransform, this, scales)
.call(applyIndirectStyles, this, scales, dimensions, context)
.call(applyTransform, this, {})
.call((g) =>
g
.selectAll()
Expand Down Expand Up @@ -84,15 +85,28 @@ function densityInitializer(options, fillDensity, strokeDensity) {
: typeof thresholds?.[Symbol.iterator] === "function"
? coerceNumbers(thresholds)
: +thresholds;
return initializer(options, function (data, facets, channels, scales, dimensions) {
const X = channels.x ? coerceNumbers(valueof(channels.x.value, scales[channels.x.scale] || identity)) : null;
const Y = channels.y ? coerceNumbers(valueof(channels.y.value, scales[channels.y.scale] || identity)) : null;
return initializer(options, function (data, facets, channels, scales, dimensions, context) {
const W = channels.weight ? coerceNumbers(channels.weight.value) : null;
const Z = channels.z?.value;
const {z} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
const {width, height} = dimensions;

// Extract the scaled (or projected!) values for the x and y channels.
let {x: X, y: Y} = valueObject(
{
...(channels.x && {x: channels.x}),
...(channels.y && {y: channels.y})
},
scales,
context
);

// Coerce the x and y channels to numbers (so that null is properly treated
// as an undefined value rather than being coerced to zero).
if (X) X = coerceNumbers(X);
if (Y) Y = coerceNumbers(Y);

// Group any of the input channels according to the first index associated
// with each z-series or facet. Drop any channels not be needed for
// rendering after the contours are computed.
Expand Down
Loading

0 comments on commit 785d03e

Please sign in to comment.