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
16 changes: 16 additions & 0 deletions .changeset/geo-choropleth-map-chart.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@chart-io/core": minor
"@chart-io/react": minor
---

Added `<Geo>`, a map chart supporting Choropleth (shaded region) maps as well as points, pie glyphs, flow arcs and tracked routes plotted onto a shared projection.

`<Geo>` is a composition root like `<XYChart>` - it's self-contained (wraps `<Chart>` directly, so no need to nest it yourself), but rather than a single fixed plot it composes any combination of new layers as `children`, all sharing one `d3-geo` projection fitted to the plot area:

- `<Choropleth>` shades each region of a GeoJSON/TopoJSON `features` geography by a quantized color scale, joined against the chart's data by a region key - e.g. population by country/state.
- `<GeoPoints>` plots a marker per row at its `lat`/`lon`, optionally sized by a value field.
- `<GeoPie>` plots a small Pie/Donut glyph per location, summarizing that location's rows by category - e.g. the energy mix for every country on a world map.
- `<GeoArcs>` draws a great-circle flow line between a `source`/`target` `lat`/`lon` pair per row - e.g. migration between regions.
- `<GeoPaths>` connects each group's rows (e.g. one tracked animal's individual position readings) into a single ordered route across the map - e.g. bird migration tracks.

Any combination of layers can be composed onto the same `<Geo>`, e.g. a Choropleth with `<GeoPoints>` markers on top. Supports both GeoJSON and TopoJSON (via the new `topojson-client` dependency) for `features`, any of `d3-geo`'s standard projections (or a custom projection factory), and Canvas rendering via `useCanvas` alongside a new `renderGeoPath` Canvas primitive (registered under the `"geo"` path type) shared by every layer that draws an arbitrary projected shape.
5 changes: 5 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"d3-color": "^3.1.0",
"d3-ease": "^3.0.1",
"d3-format": "^3.1.0",
"d3-geo": "^3.1.1",
"d3-hierarchy": "^3.1.2",
"d3-sankey": "^0.12.3",
"d3-scale": "^4.0.2",
Expand All @@ -48,6 +49,7 @@
"d3-transition": "^3.0.1",
"lodash": "^4.17.21",
"proxy-memoize": "^2.0.4",
"topojson-client": "^3.1.0",
"uuid": "^9.0.0"
},
"devDependencies": {
Expand All @@ -58,6 +60,7 @@
"@types/d3-color": "^3.1.0",
"@types/d3-ease": "^3.0.0",
"@types/d3-format": "^3.0.1",
"@types/d3-geo": "^3.1.0",
"@types/d3-hierarchy": "^3.1.7",
"@types/d3-sankey": "^0.12.4",
"@types/d3-scale": "^4.0.3",
Expand All @@ -67,6 +70,8 @@
"@types/d3-time-format": "^4.0.0",
"@types/d3-timer": "^3.0.0",
"@types/d3-transition": "^3.0.3",
"@types/geojson": "^7946.0.14",
"@types/topojson-client": "^3.1.4",
"@swc/core": "^1.3.36",
"@swc/jest": "^0.2.24",
"@testing-library/jest-dom": "^5.16.4",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/canvas/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from "./canvasRenderLoop";
export * from "./progressiveCanvasRenderLoop";
export * from "./renderArc";
export * from "./renderCircle";
export * from "./renderGeoPath";
export * from "./renderLink";
export * from "./renderLinkRadial";
export * from "./renderPolygon";
Expand Down
5 changes: 5 additions & 0 deletions packages/core/src/canvas/renderElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { IColor } from "../types";

import { renderArc } from "./renderArc";
import { renderCircle } from "./renderCircle";
import { renderGeoPath } from "./renderGeoPath";
import { renderLink } from "./renderLink";
import { renderLinkRadial } from "./renderLinkRadial";
import { renderPolygon } from "./renderPolygon";
Expand Down Expand Up @@ -66,6 +67,10 @@ export function renderElements(
renderArc(context, node, overrideColor);
break;

case "geo":
renderGeoPath(context, node, overrideColor);
break;

case "link":
renderLink(context, node, overrideColor);
break;
Expand Down
89 changes: 89 additions & 0 deletions packages/core/src/canvas/renderGeoPath.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { d3 } from "../d3";
import type { IColor } from "../types";

// `d3.geoPath()` (with no `pointRadius`, which none of `<Geo>`'s layers set) only ever emits `M`
// (moveto), `L` (lineto) and `Z` (closepath) commands - never curves - so a full SVG path parser
// isn't needed, just enough to walk each of a (possibly multi-ring, e.g. a country with islands or
// a hole) shape's subpaths back into Canvas moveTo/lineTo/closePath calls
const COMMAND_REGEX = /[MLZ][^MLZ]*/g;

/**
* Replays an SVG `d` path string (as produced by `d3.geoPath()`) onto a Canvas 2D context
* @param context The Canvas context object to draw the path into
* @param d The `d` attribute value to replay
*/
function drawPath(context: CanvasRenderingContext2D, d: string) {
const commands: string[] = d.match(COMMAND_REGEX) ?? [];

commands.forEach((command) => {
const type = command[0];

if (type === "Z") {
context.closePath();
return;
}

const [x, y] = command.slice(1).split(",").map(Number);

if (type === "M") {
context.moveTo(x, y);
} else {
context.lineTo(x, y);
}
});
}

/**
* Renders an arbitrary geographic path (e.g. a projected region, flow arc or tracked route drawn by
* `<Geo>`'s layers) to the canvas. Registered with `renderElements` under the `"geo"` path type.
* Unlike `renderLink`/`renderArc`, which reconstruct their geometry from a handful of numeric
* `data-*` attributes, a geo path can be an arbitrarily complex multi-ring shape (e.g. a country with
* enclaves or islands) - so rather than re-deriving that geometry, this replays the `d` attribute
* D3 already computed via `d3.geoPath()` directly
* @param context The Canvas context object to render to
* @param node The virtual DOM node that represents this element
* @param overrideColor A custom color to override the node color which is used for the virtual canvas
*/
export function renderGeoPath(context: CanvasRenderingContext2D, node: Element, overrideColor?: IColor) {
const selection = d3.select(node);
const d = selection.attr("d");

if (!d) {
return;
}

const fill = selection.style("fill");
const fillOpacity = selection.style("fill-opacity");
const opacity = Number(selection.style("opacity")) || 1;
const stroke = selection.style("stroke");
const strokeWidth = Number(selection.style("stroke-width")) || 1;

context.beginPath();
drawPath(context, d);

if (overrideColor) {
// We apply this as both the fill and stroke so that hovering anywhere within (or right on
// the edge/along the line of) the shape maps back to this element
context.globalAlpha = 1;
context.fillStyle = `${overrideColor}`;
context.fill();
context.strokeStyle = `${overrideColor}`;
context.lineWidth = Math.max(strokeWidth, 6);
context.stroke();

return;
}

if (fill && fill !== "none") {
context.globalAlpha = (Number(fillOpacity) || 1) * opacity;
context.fillStyle = fill;
context.fill();
}

if (stroke && stroke !== "none") {
context.globalAlpha = opacity;
context.strokeStyle = stroke;
context.lineWidth = strokeWidth;
context.stroke();
}
}
1 change: 1 addition & 0 deletions packages/core/src/d3/barrel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * from "d3-chord";
export * from "d3-color";
export * from "d3-ease";
export * from "d3-format";
export * from "d3-geo";
export * from "d3-hierarchy";
export * from "d3-sankey";
export * from "d3-scale";
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/utils/geo/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./normalizeGeoFeatures";
56 changes: 56 additions & 0 deletions packages/core/src/utils/geo/normalizeGeoFeatures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { feature as topojsonFeature } from "topojson-client";
import type { Feature, FeatureCollection, Geometry } from "geojson";

/** A bare TopoJSON topology - typed loosely since `topojson-specification` isn't a direct dependency */
export interface ITopology {
type: "Topology";
objects: Record<string, unknown>;
[key: string]: unknown;
}

/**
* The geography a `<Geo>` chart can be given - either plain GeoJSON (a single `Feature` or a
* `FeatureCollection`, e.g. hand-authored or already converted), or a TopoJSON `Topology` (e.g.
* `world-atlas`/`us-atlas`), which is far more common for basemaps since it's typically an order of
* magnitude smaller on the wire than the equivalent GeoJSON
*/
export type IGeoFeatures = Feature | FeatureCollection | ITopology;

/**
* Normalizes any of the shapes `<Geo>` accepts for its `features` prop down to a single GeoJSON
* `FeatureCollection`, so every layer (`<Choropleth>`, the projection fit, an optional sphere/graticule
* outline, ...) can consume one consistent shape regardless of what was actually passed in
* @param features The geography to normalize, or `undefined` if none was provided
* @param object Which TopoJSON object to extract, if `features` is a `Topology`. Defaults to
* the first object on the topology
* @return The normalized `FeatureCollection`, or `undefined` if no `features` were given
*/
export function normalizeGeoFeatures(features: IGeoFeatures | undefined, object?: string): FeatureCollection | undefined {
if (!features) {
return undefined;
}

if (features.type === "Topology") {
const objectKey = object ?? Object.keys(features.objects)[0];
const geometry = features.objects[objectKey];

// istanbul ignore next: only reachable by passing a Topology with no objects, or an unknown key
if (!geometry) {
return { type: "FeatureCollection", features: [] };
}

// `topojson-client`'s `feature()` returns a `FeatureCollection` for a GeometryCollection
// object, or a single `Feature` for anything else - normalize both to the same shape
const extracted = topojsonFeature(features as any, geometry as any) as
| FeatureCollection<Geometry>
| Feature<Geometry>;

return extracted.type === "FeatureCollection" ? extracted : { type: "FeatureCollection", features: [extracted] };
}

if (features.type === "FeatureCollection") {
return features;
}

return { type: "FeatureCollection", features: [features] };
}
123 changes: 123 additions & 0 deletions packages/core/src/utils/geo/normalizeGeoFeatures.unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { FeatureCollection } from "geojson";

import { ITopology, normalizeGeoFeatures } from "./normalizeGeoFeatures";

describe("normalizeGeoFeatures", () => {
it("returns undefined when no features are given", () => {
expect(normalizeGeoFeatures(undefined)).toBeUndefined();
});

it("passes a FeatureCollection through unchanged", () => {
const featureCollection: FeatureCollection = {
type: "FeatureCollection",
features: [{ type: "Feature", properties: { id: "A" }, geometry: { type: "Point", coordinates: [0, 0] } }],
};

expect(normalizeGeoFeatures(featureCollection)).toBe(featureCollection);
});

it("wraps a single Feature in a FeatureCollection", () => {
const feature = { type: "Feature" as const, properties: { id: "A" }, geometry: { type: "Point" as const, coordinates: [0, 0] } };

expect(normalizeGeoFeatures(feature)).toEqual({
type: "FeatureCollection",
features: [feature],
});
});

it("converts a TopoJSON Topology's default object into a FeatureCollection", () => {
const topology: ITopology = {
type: "Topology",
objects: {
states: {
type: "GeometryCollection",
geometries: [
{
type: "Polygon",
properties: { name: "A" },
arcs: [[0]],
},
],
},
},
arcs: [
[
[0, 0],
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
],
],
};

const result = normalizeGeoFeatures(topology);

expect(result.type).toBe("FeatureCollection");
expect(result.features).toHaveLength(1);
expect(result.features[0].properties).toEqual({ name: "A" });
});

it("converts a named TopoJSON object when `object` is given", () => {
const topology: ITopology = {
type: "Topology",
objects: {
countries: {
type: "GeometryCollection",
geometries: [],
},
states: {
type: "GeometryCollection",
geometries: [
{
type: "Polygon",
properties: { name: "A" },
arcs: [[0]],
},
],
},
},
arcs: [
[
[0, 0],
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
],
],
};

const result = normalizeGeoFeatures(topology, "states");

expect(result.features).toHaveLength(1);
expect(result.features[0].properties).toEqual({ name: "A" });
});

it("wraps a single extracted Feature (a non-GeometryCollection object) in a FeatureCollection", () => {
const topology: ITopology = {
type: "Topology",
objects: {
outline: {
type: "Polygon",
properties: { name: "outline" },
arcs: [[0]],
},
},
arcs: [
[
[0, 0],
[1, 0],
[0, 1],
[-1, 0],
[0, -1],
],
],
};

const result = normalizeGeoFeatures(topology);

expect(result.type).toBe("FeatureCollection");
expect(result.features).toHaveLength(1);
});
});
1 change: 1 addition & 0 deletions packages/core/src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ export * from "./linkStores";
export * from "./formatters";
export * from "./logger";
export * from "./getBandwidthAndOffset";
export * from "./geo";
export * from "./wordCloud";
export { exportImage } from "./exportImage";
export * from "./downloadFile";
1 change: 1 addition & 0 deletions packages/react/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@
"@testing-library/user-event": "^13.5.0",
"@types/d3-scale": "^4.0.3",
"@types/d3-selection": "^3.0.4",
"@types/geojson": "^7946.0.14",
"@types/jest": "^29.4.0",
"@types/mdx": "^2.0.3",
"@types/node": "^18.13.0",
Expand Down
Loading
Loading