Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

The geometry channels accepts a channelSpec #1831

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
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: 3 additions & 2 deletions src/channel.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,10 @@ export interface Channel {
* scale will not be used if the associated values met certain criteria; for
* example, a channel that would normally be associated with the *color* scale
* will not if all values are valid CSS color strings. if the scale is
* specified as false or null, the channel values will not be scaled.
* specified as false or null, the channel values will not be scaled. The
* *projection* scale is used by the *geometry* channel.
*/
scale?: ScaleName | "auto" | boolean | null;
scale?: ScaleName | "auto" | "projection" | boolean | null;

/**
* The required scale type, if any. Marks may require a certain scale type;
Expand Down
10 changes: 10 additions & 0 deletions src/channel.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,16 @@ export function valueObject(channels, scales) {
// Note: mutates channel!
export function inferChannelScale(name, channel) {
const {scale, value} = channel;
if (name === "geometry") {
if (scale === "projection") return channel;
if (scale === false || scale === null) return {...channel, scale: null};
if (scale === undefined || scale === true || scale === "auto")
return {
...channel,
scale: "projection"
};
throw new Error(`invalid projection scale: ${scale}`);
}
if (scale === true || scale === "auto") {
switch (name) {
case "fill":
Expand Down
3 changes: 2 additions & 1 deletion src/mark.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import {channelDomain, createChannels, valueObject} from "./channel.js";
import {channelDomain, createChannels, valueObject, inferChannelScale} from "./channel.js";
import {defined} from "./defined.js";
import {maybeFacetAnchor} from "./facet.js";
import {maybeNamed, maybeValue} from "./options.js";
Expand Down Expand Up @@ -50,6 +50,7 @@ export class Mark {
const {value, label = channel.label, scale = channel.scale} = channel.value;
channel = {...channel, label, scale, value};
}
if (name === "geometry") channel = inferChannelScale("geometry", maybeValue(channel));
if (data === singleton && typeof channel.value === "string") {
// convert field names to singleton values for decoration marks (e.g., frame)
const {value} = channel;
Expand Down
4 changes: 2 additions & 2 deletions src/marks/geo.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type {GeoPermissibleObjects} from "d3";
import type {ChannelValue, ChannelValueSpec} from "../channel.js";
import type {ChannelValueSpec} from "../channel.js";
import type {Data, MarkOptions, RenderableMark} from "../mark.js";

/** Options for the geo mark. */
Expand All @@ -8,7 +8,7 @@ export interface GeoOptions extends MarkOptions {
* A required channel for the geometry to render; defaults to identity,
* assuming *data* is a GeoJSON object or an iterable of GeoJSON objects.
*/
geometry?: ChannelValue;
geometry?: ChannelValueSpec;

/**
* The size of Point and MultiPoint geometries, defaulting to a constant 3
Expand Down
6 changes: 3 additions & 3 deletions src/marks/geo.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export class Geo extends Mark {
super(
data,
{
geometry: {value: options.geometry, scale: "projection"},
geometry: {value: options.geometry, scale: "auto"},
r: {value: vr, scale: "r", filter: positive, optional: true}
},
withDefaultSort(options),
Expand All @@ -31,8 +31,8 @@ export class Geo extends Mark {
this.r = cr;
}
render(index, scales, channels, dimensions, context) {
const {geometry: G, r: R} = channels;
const path = geoPath(context.projection ?? scaleProjection(scales));
const {geometry: G, r: R, channels: {geometry: {scale}}} = channels; // prettier-ignore
const path = geoPath(scale === "projection" ? context.projection ?? scaleProjection(scales) : null);
const {r} = this;
if (negative(r)) index = [];
else if (r !== undefined) path.pointRadius(r);
Expand Down
5 changes: 3 additions & 2 deletions src/transforms/centroid.d.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type {ChannelTransform, ChannelValue} from "../channel.js";
import type {ChannelTransform} from "../channel.js";
import type {Initialized} from "./basic.js";
import type {GeoOptions} from "../marks/geo.js";

/** Options for the centroid and geoCentroid transforms. */
export interface CentroidOptions {
/**
* A channel supplying GeoJSON geometry; defaults to the identity transform,
* assuming that the data is GeoJSON geometry.
*/
geometry?: ChannelValue;
geometry?: GeoOptions["geometry"];
}

/**
Expand Down
13 changes: 8 additions & 5 deletions src/transforms/centroid.js
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
import {geoCentroid as GeoCentroid, geoPath} from "d3";
import {identity, valueof} from "../options.js";
import {identity, valueof, maybeValue} from "../options.js";
import {initializer} from "./basic.js";
import {inferChannelScale} from "../channel.js";

export function centroid({geometry = identity, ...options} = {}) {
// Suppress defaults for x and y since they will be computed by the initializer.
return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, {projection}) => {
const G = valueof(data, geometry);
return initializer({...options, x: null, y: null}, (data, facets, channels, scales, dimensions, context) => {
const {value, scale} = inferChannelScale("geometry", maybeValue(geometry));
const G = valueof(data, value);
const n = G.length;
const X = new Float64Array(n);
const Y = new Float64Array(n);
const projection = scale === "projection" ? context.projection : null;
const path = geoPath(projection);
for (let i = 0; i < n; ++i) [X[i], Y[i]] = path.centroid(G[i]);
return {
data,
facets,
channels: {
x: {value: X, scale: projection == null ? "x" : null, source: null},
y: {value: Y, scale: projection == null ? "y" : null, source: null}
x: {value: X, scale: null, source: null},
y: {value: Y, scale: null, source: null}
}
};
});
Expand Down
1 change: 1 addition & 0 deletions test/data/counties-albers-10m.json

Large diffs are not rendered by default.

3,166 changes: 3,166 additions & 0 deletions test/output/usCounties.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3,166 changes: 3,166 additions & 0 deletions test/output/usCountiesPreprojected.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions test/plots/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,7 @@ export * from "./us-congress-age-color-explicit.js";
export * from "./us-congress-age-gender.js";
export * from "./us-congress-age-symbol-explicit.js";
export * from "./us-congress-age.js";
export * from "./us-counties.js";
export * from "./us-county-choropleth.js";
export * from "./us-county-spikes.js";
export * from "./us-population-state-age-dots.js";
Expand Down
36 changes: 36 additions & 0 deletions test/plots/us-counties.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import * as Plot from "@observablehq/plot";
import * as d3 from "d3";
import {feature, mesh} from "topojson-client";

export async function usCounties() {
const [nation, statemesh, counties] = await d3
.json<any>("data/us-counties-10m.json")
.then((us) => [feature(us, us.objects.nation), mesh(us, us.objects.states), feature(us, us.objects.counties)]);
return Plot.plot({
width: 975,
height: 610,
projection: "albers-usa",
marks: [
Plot.geo(nation, {fill: "#dedede"}),
Plot.geo(statemesh, {stroke: "#fff"}),
Plot.dot(counties.features, Plot.centroid())
]
});
}

// Tests an explicit null scale override for geometries, when the projection is set at the top-level.
export async function usCountiesPreprojected() {
const [nation, statemesh, counties] = await d3
.json<any>("data/counties-albers-10m.json")
.then((us) => [feature(us, us.objects.nation), mesh(us, us.objects.states), feature(us, us.objects.counties)]);
return Plot.plot({
width: 975,
height: 610,
projection: "albers-usa",
marks: [
Plot.geo(nation, {fill: "#dedede", geometry: {value: Plot.identity, scale: null}}),
Plot.geo(statemesh, {stroke: "#fff", geometry: {value: Plot.identity, scale: null}}),
Plot.dot(counties.features, Plot.centroid({geometry: {value: Plot.identity, scale: null}}))
]
});
}
36 changes: 35 additions & 1 deletion test/scales/scales-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2235,11 +2235,45 @@ it("mark(data, {channels}) respects a scale set to true or false", () => {

it("mark(data, {channels}) rejects unknown scales", () => {
assert.throws(
() => Plot.dot([], {channels: {fill: {value: (d) => d, scale: "neo"}}}).initialize().channels.fill.scale,
() => Plot.dot([], {channels: {fill: {value: (d) => d, scale: "neo"}}}).initialize(),
/^Error: unknown scale: neo$/
);
});

it("geo(data, {geometry: {scale}}) rejects invalid scales", () => {
assert.ok(Plot.geo([], {geometry: {value: Plot.identity, scale: "projection"}}));
assert.ok(Plot.geo([], {geometry: {value: Plot.identity, scale: "auto"}}));
assert.ok(Plot.geo([], {geometry: {value: Plot.identity, scale: true}}));
assert.ok(Plot.geo([], {geometry: {value: Plot.identity, scale: null}}));
assert.ok(Plot.geo([], {geometry: {value: Plot.identity}}));
assert.ok(Plot.geo([], {geometry: {value: Plot.identity, scale: false}}));
assert.throws(
() => Plot.geo([], {geometry: {value: Plot.identity, scale: "x"}}),
/^Error: invalid projection scale: x$/
);
assert.throws(
() => Plot.geo([], {geometry: {value: Plot.identity, scale: "neo"}}),
/^Error: invalid projection scale: neo$/
);
});

it("centroid(data, {geometry: {scale}}) rejects invalid scales", () => {
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: "projection"}})).plot());
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: "auto"}})).plot());
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: true}})).plot());
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: null}})).plot());
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity}})).plot());
assert.ok(Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: false}})).plot());
assert.throws(
() => Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: "x"}})).plot(),
/^Error: invalid projection scale: x$/
);
assert.throws(
() => Plot.text([], Plot.centroid({geometry: {value: Plot.identity, scale: "neo"}})).plot(),
/^Error: invalid projection scale: neo$/
);
});

// Given a plot specification (or, as shorthand, an array of marks or a single
// mark), asserts that the given named scales, when materialized from the first
// plot and used to produce a second plot, produce the same output and the same
Expand Down