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

geometries and projections #1111

Merged
merged 43 commits into from
Nov 26, 2022
Merged

geometries and projections #1111

merged 43 commits into from
Nov 26, 2022

Conversation

mbostock
Copy link
Member

@mbostock mbostock commented Nov 23, 2022

Screen Shot 2022-11-23 at 12 21 03 PM

Plot.plot({
  width: 960,
  height: 600,
  projection: "albers-usa",
  color: {
    scheme: "purd",
    domain: [1, 10],
    type: "quantize",
    n: 9,
    unknown: "#ccc",
    legend: true,
    label: "Unemployment (%)"
  },
  marks: [
    Plot.geometry(counties, {fill: d => unemployment.get(d.id), title: d => d.properties.name}),
    Plot.geometry(statemesh, {stroke: "white"})
  ]
})

TODO:

  • Pass x and y channels through the projection (e.g., for Plot.text labels in lat/long).
  • Disable the x and y scales when a non-default projection is used
  • Make it work with initializers
  • More named projections
  • Configurable rotation
  • Variable-size point geometries using the r scale
  • Test graticule and sphere marks
  • Test the null projection
  • Documentation

Potential future enhancements (but not now):

  • Combine (linear) projection with x and y scales (for geometry in abstract coordinates)?
  • Shorthand for GeoJSON properties (e.g., "name" becomes d => d.properties["name"])?
  • Automatically fit a projection to the geometry?

Fixes #1043.

@mbostock mbostock requested a review from Fil November 23, 2022 17:25
@@ -206,7 +207,7 @@ export function ScaleQuantize(
if (min instanceof Date) thresholds = thresholds.map((x) => new Date(x)); // preserve date types
}
if (order(arrayify(domain)) < 0) thresholds.reverse(); // preserve descending domain
return ScaleThreshold(key, channels, {domain: thresholds, range, reverse});
return ScaleThreshold(key, channels, {domain: thresholds, range, reverse, unknown});
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes an (unfiled) bug where the quantize scale didn’t respect the unknown option.

@mbostock mbostock changed the title geometry mark geometries and projections Nov 23, 2022
@@ -140,7 +141,7 @@ function applyMultilineText(selection, {monospace, lineAnchor, lineHeight, lineW
selection.each(function (i) {
const lines = linesof(formatDefault(T[i]));
const n = lines.length;
const y = lineAnchor === "top" ? 0.71 : lineAnchor === "bottom" ? -0.29 - n : (164 - n * 100) / 200;
const y = lineAnchor === "top" ? 0.71 : lineAnchor === "bottom" ? 1 - n : (164 - n * 100) / 200;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This reverts #1061.

const {x: X, y: Y, rotate: R, text: T, fontSize: FS} = channels;
const {rotate} = this;
const [cx, cy] = applyFrameAnchor(this, dimensions);
return create("svg:g", context)
.call(applyIndirectStyles, this, scales, dimensions)
.call(applyIndirectTextStyles, this, T, dimensions)
.call(applyTransform, this, scales)
.call(applyTransform, this, {x: X && x, y: Y && y})
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixes #1043.

@Fil
Copy link
Contributor

Fil commented Nov 23, 2022

Would it make sense to call it Plot.geo rather than Plot.geometries? Shorter and more evocative of maps.

@mbostock
Copy link
Member Author

Would it make sense to call it Plot.geo rather than Plot.geometries? Shorter and more evocative of maps.

That’d be fine, especially since it’s inherently tied to GeoJSON.

@mbostock
Copy link
Member Author

Example using normal marks (dot, text) with a projection:

Screen Shot 2022-11-24 at 10 05 14 AM

Plot.plot({
  width: 960,
  height: 600,
  projection: "albers-usa",
  marks: [
    Plot.geometry(states, {fill: "#ccc"}),
    Plot.geometry(statemesh, {stroke: "white"}),
    Plot.dot(capitals, {x: "longitude", y: "latitude", fill: "currentColor"}),
    Plot.text(capitals, {
      x: "longitude",
      y: "latitude",
      frameAnchor: "bottom",
      text: (d) => `${d.capital}\n${d.state}`,
      dy: -6
    })
  ]
})

@mbostock
Copy link
Member Author

Example using hexbin initializer with a projection:

Screen Shot 2022-11-24 at 10 06 37 AM

Plot.plot({
  width: 960,
  height: 600,
  projection: "albers-usa",
  color: {
    legend: true,
    label: "First year opened",
    scheme: "spectral"
  },
  r: {
    range: [0, 20]
  },
  marks: [
    Plot.geometry(statemesh, {strokeOpacity: 0.25, strokeWidth: 1}),
    Plot.dot(
      walmarts,
      Plot.hexbin(
        {r: "count", fill: "min", title: "min"},
        {x: "longitude", y: "latitude", fill: "date", stroke: "white", title: "date"}
      )
    )
  ]
})

@mbostock mbostock marked this pull request as ready for review November 24, 2022 18:04
@Fil
Copy link
Contributor

Fil commented Nov 24, 2022

One thing that comes to mind is we'll want to expose the projection definition so we can share it between different maps, and make it easier to augment the current map. Maybe this could follow the same pattern as scales: chart.scale("projection") would expose the projection's parameters, and apply and invert functions.

@mbostock
Copy link
Member Author

One thing that comes to mind is we'll want to expose the projection definition so we can share it between different maps

Yeah, we could do that. I’m not sure if we should call the projection a scale but they are certainly similar. For now I don’t think this is particularly urgent because there is little internal configuration of the projection (the only thing that affects it is the width and height of the chart); as long as you specify the same projection type you’ll get the same behavior across charts.

@Fil
Copy link
Contributor

Fil commented Nov 25, 2022

I've tried to port all my maps from the Plot.carto 0.5 notebook to this branch, and took notes. It works generally very well, and is a painless transition: see https://observablehq.com/@fil/plot-geometries-1111

Only two things didn't work:

  • pointRadius. We should support the r channel (or constant) as setting the path's pointRadius. I'll add a PR for this feature today.

  • Clipping. Some projections need an option to "clip to sphere" (e.g. armadillo). (Not urgent, but probably not difficult.)

Beyond these, porting the maps was for the most part a no-brainer. One issue was slowing me down consistently though: scaling.

In general adjusting the scale to fit the map to the frame is hard—and sometimes it might be impossible to compute in advance (for example when the frame's size depends on the number of facets);

  • the question might be "what to fit in the frame" (Plot.Carto defaults to the first feature or features, but we could imagine having an explicit option. We would also need to respect the padding, margins, etc, as well as frameAnchor to position the map in the direction that has some freedom)
  • We need to make it easier for D3 projections—they (generally) expect a 960600 frame, and Plot defaults to 640400; I want to call projection: {type: d3.geoBertin1953(), scale:"auto"} or something.
  • projection{type:"identity"} should accept scale/translate

Usage

(As I already mentioned), repeatedly having to type Plot.geometry was tripping me up. I hesitated with Plot.geometries (since we sometimes have one feature, or several features to draw). Having Plot.geo instead would be a no-brainer.

I was very happy with the fact that sending a single feature as data, instead of an array, worked. But I'm not sure if this should be done on all the marks? i.e. Plot.mark(Object, options)?

Using the x and y channels for longitude and latitude in Plot.dot is a bit disconcerting at first (in particular if your map is not "horizontal"). However it's better than any alternative I've considered, in particular because it makes transitioning a chart for "rectangular" to "projected" a one-line business.

Ideas for future iterations:

More projections. The default list of projections is a bit restricted. I'm not advocating to embed all of d3-geo-projections and d3-geo-polygon… but I'd like it to be easier to extend… maybe with some kind of plugin that would register projection names. (Maybe related to theming-extending color schemes #630.)

Exposing a projection as a scale. (As already mentioned,) we'll have to think of a way to expose/share a scale's definition. If we push people to define projections with D3, it might be hard to create the object that allows to recreate the same projection somewhere else. If we create a small DSL for projections, it should cover more than the handful that is known to d3-geo (see also https://vega.github.io/vega-lite/docs/projection.html#projection-types).

More graticule options. Add graticule (step) options in Plot.graticule.

Random remarks

Default dimensions. Why do Plot.geometry(land).plot() and Plot.geometry(land).plot({ projection: "identity" }) differ on the default size? The technical answer is that the first is not setting x and y, but one could argue that as soon as we have the Plot.geometry mark, a projection is implied.

@Fil
Copy link
Contributor

Fil commented Nov 25, 2022

Addendum: I like the idea of string accessors for properties, but I'm not sure how we would disambiguate between "Population" as an accessor for d => d.properties.Population and "id" for d => d.id.

@mbostock
Copy link
Member Author

I implemented the error on marks requiring band scales in d0e41cb.

We could throw a similar error for marks that define channels bound to either the x or y scale, but don’t provide exactly the x and y channels.

@mbostock
Copy link
Member Author

Added another error for non-point marks in cf8476e.

@mbostock
Copy link
Member Author

So, the only thing left is whether we call it Plot.geo or Plot.geometry? 😅

Copy link
Contributor

@Fil Fil left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

I'm with geo, but it's your call ultimately :)

@mbostock
Copy link
Member Author

Do you have an opinion on the channel name being “geo” or “geometry”?

@Fil
Copy link
Contributor

Fil commented Nov 26, 2022

Most of the time we won't have to use it, so I don't mind if it's longer to type or look up. Having a geo mark and a geometry channel might help disambiguate?

@mbostock
Copy link
Member Author

What do you think of calling it “geojson” (for both the mark and the channel)? Is that better or worse than “geometry”? E.g.,

Plot.geojson(statemesh, {strokeOpacity: 0.3})

@Fil
Copy link
Contributor

Fil commented Nov 26, 2022

yes, geojson would be fine.

@espinielli
Copy link
Contributor

Amazing development!

@mbostock mbostock merged commit 785d03e into main Nov 26, 2022
@mbostock mbostock deleted the mbostock/geo branch November 26, 2022 22:11
@Fil
Copy link
Contributor

Fil commented Nov 27, 2022

I love how f63a8eb skips invisible points (e.g. on the other side of the orthographic projection).

@Fil Fil added the geo Maps and projections label Dec 1, 2022
@Fil Fil mentioned this pull request Aug 22, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
geo Maps and projections
Projects
None yet
Development

Successfully merging this pull request may close these issues.

frameAnchor: bottom is not properly aligned
3 participants