From 15c000901c6af6c2edd568b7a02e5cf774de568a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 19:25:40 +0000 Subject: [PATCH] Add a Geo/map chart with Choropleth, points, pie, arc and path layers Adds , a composition root (like ) for map charts. 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: - shades each region of a GeoJSON/TopoJSON geography by a quantized color scale, joined against the chart's data by a region key. - plots a marker per row at its lat/lon, optionally sized by a value field. - plots a small Pie/Donut glyph per location, summarizing that location's rows by category (e.g. energy mix per country). - draws a great-circle flow line between a source/target lat/lon pair per row (e.g. migration or trade between regions). - connects each group's rows into a single ordered route across the map (e.g. a tracked animal's migration path). Any combination of layers can be composed onto one . Supports both GeoJSON and TopoJSON (via the new topojson-client dependency) for `features`, any standard d3-geo projection or a custom 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. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01SHs98Edsbh6eZLEKKhJMS3 --- .changeset/geo-choropleth-map-chart.md | 16 + packages/core/package.json | 5 + packages/core/src/canvas/index.ts | 1 + packages/core/src/canvas/renderElements.ts | 5 + packages/core/src/canvas/renderGeoPath.ts | 89 +++++ packages/core/src/d3/barrel.ts | 1 + packages/core/src/utils/geo/index.ts | 1 + .../src/utils/geo/normalizeGeoFeatures.ts | 56 ++++ .../utils/geo/normalizeGeoFeatures.unit.ts | 123 +++++++ packages/core/src/utils/index.ts | 1 + packages/react/package.json | 1 + packages/react/src/data/world_map_dataset.js | 189 +++++++++++ .../react/src/lib/components/Plots/Geo.mdx | 104 ++++++ .../src/lib/components/Plots/Geo.stories.tsx | 207 ++++++++++++ .../Plots/Geo/Choropleth/Choropleth.tsx | 204 ++++++++++++ .../components/Plots/Geo/Choropleth/index.ts | 1 + .../src/lib/components/Plots/Geo/Geo.tsx | 127 ++++++++ .../src/lib/components/Plots/Geo/Geo.unit.tsx | 282 ++++++++++++++++ .../components/Plots/Geo/GeoArcs/GeoArcs.tsx | 209 ++++++++++++ .../lib/components/Plots/Geo/GeoArcs/index.ts | 1 + .../lib/components/Plots/Geo/GeoContext.ts | 35 ++ .../Plots/Geo/GeoPaths/GeoPaths.tsx | 247 ++++++++++++++ .../components/Plots/Geo/GeoPaths/index.ts | 1 + .../components/Plots/Geo/GeoPie/GeoPie.tsx | 29 ++ .../Plots/Geo/GeoPie/GeoPieBase.tsx | 307 ++++++++++++++++++ .../lib/components/Plots/Geo/GeoPie/index.ts | 1 + .../Plots/Geo/GeoPoints/GeoPoints.tsx | 188 +++++++++++ .../components/Plots/Geo/GeoPoints/index.ts | 1 + ...leth-to-canvas-without-throwing-1-snap.png | Bin 0 -> 2337 bytes .../src/lib/components/Plots/Geo/index.ts | 7 + .../components/Plots/Geo/resolveProjection.ts | 35 ++ .../components/Plots/Geo/useGeoProjection.ts | 77 +++++ .../src/lib/components/Plots/ShapesPlot.tsx | 174 ++++++++++ .../react/src/lib/components/Plots/index.ts | 1 + pnpm-lock.yaml | 55 ++++ 35 files changed, 2781 insertions(+) create mode 100644 .changeset/geo-choropleth-map-chart.md create mode 100644 packages/core/src/canvas/renderGeoPath.ts create mode 100644 packages/core/src/utils/geo/index.ts create mode 100644 packages/core/src/utils/geo/normalizeGeoFeatures.ts create mode 100644 packages/core/src/utils/geo/normalizeGeoFeatures.unit.ts create mode 100644 packages/react/src/data/world_map_dataset.js create mode 100644 packages/react/src/lib/components/Plots/Geo.mdx create mode 100644 packages/react/src/lib/components/Plots/Geo.stories.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/Choropleth/Choropleth.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/Choropleth/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/Geo.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/Geo.unit.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoArcs/GeoArcs.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoArcs/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoContext.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPaths/GeoPaths.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPaths/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPie.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPieBase.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPie/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPoints/GeoPoints.tsx create mode 100644 packages/react/src/lib/components/Plots/Geo/GeoPoints/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/__image_snapshots__/geo-unit-tsx-geo-using-canvas-should-render-a-choropleth-to-canvas-without-throwing-1-snap.png create mode 100644 packages/react/src/lib/components/Plots/Geo/index.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/resolveProjection.ts create mode 100644 packages/react/src/lib/components/Plots/Geo/useGeoProjection.ts create mode 100644 packages/react/src/lib/components/Plots/ShapesPlot.tsx diff --git a/.changeset/geo-choropleth-map-chart.md b/.changeset/geo-choropleth-map-chart.md new file mode 100644 index 000000000..c3d03a7fd --- /dev/null +++ b/.changeset/geo-choropleth-map-chart.md @@ -0,0 +1,16 @@ +--- +"@chart-io/core": minor +"@chart-io/react": minor +--- + +Added ``, a map chart supporting Choropleth (shaded region) maps as well as points, pie glyphs, flow arcs and tracked routes plotted onto a shared projection. + +`` is a composition root like `` - it's self-contained (wraps `` 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: + +- `` 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. +- `` plots a marker per row at its `lat`/`lon`, optionally sized by a value field. +- `` 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. +- `` draws a great-circle flow line between a `source`/`target` `lat`/`lon` pair per row - e.g. migration between regions. +- `` 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 ``, e.g. a Choropleth with `` 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. diff --git a/packages/core/package.json b/packages/core/package.json index c06699fcc..8b1c7c3dd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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": { @@ -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", @@ -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", diff --git a/packages/core/src/canvas/index.ts b/packages/core/src/canvas/index.ts index 49b55024f..c6db47d87 100644 --- a/packages/core/src/canvas/index.ts +++ b/packages/core/src/canvas/index.ts @@ -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"; diff --git a/packages/core/src/canvas/renderElements.ts b/packages/core/src/canvas/renderElements.ts index cf3b4be31..a3748275e 100644 --- a/packages/core/src/canvas/renderElements.ts +++ b/packages/core/src/canvas/renderElements.ts @@ -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"; @@ -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; diff --git a/packages/core/src/canvas/renderGeoPath.ts b/packages/core/src/canvas/renderGeoPath.ts new file mode 100644 index 000000000..9076883c7 --- /dev/null +++ b/packages/core/src/canvas/renderGeoPath.ts @@ -0,0 +1,89 @@ +import { d3 } from "../d3"; +import type { IColor } from "../types"; + +// `d3.geoPath()` (with no `pointRadius`, which none of ``'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 + * ``'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(); + } +} diff --git a/packages/core/src/d3/barrel.ts b/packages/core/src/d3/barrel.ts index c362bb6d3..7279fb353 100644 --- a/packages/core/src/d3/barrel.ts +++ b/packages/core/src/d3/barrel.ts @@ -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"; diff --git a/packages/core/src/utils/geo/index.ts b/packages/core/src/utils/geo/index.ts new file mode 100644 index 000000000..4920eb950 --- /dev/null +++ b/packages/core/src/utils/geo/index.ts @@ -0,0 +1 @@ +export * from "./normalizeGeoFeatures"; diff --git a/packages/core/src/utils/geo/normalizeGeoFeatures.ts b/packages/core/src/utils/geo/normalizeGeoFeatures.ts new file mode 100644 index 000000000..11597e1e1 --- /dev/null +++ b/packages/core/src/utils/geo/normalizeGeoFeatures.ts @@ -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; + [key: string]: unknown; +} + +/** + * The geography a `` 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 `` accepts for its `features` prop down to a single GeoJSON + * `FeatureCollection`, so every layer (``, 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 + | Feature; + + return extracted.type === "FeatureCollection" ? extracted : { type: "FeatureCollection", features: [extracted] }; + } + + if (features.type === "FeatureCollection") { + return features; + } + + return { type: "FeatureCollection", features: [features] }; +} diff --git a/packages/core/src/utils/geo/normalizeGeoFeatures.unit.ts b/packages/core/src/utils/geo/normalizeGeoFeatures.unit.ts new file mode 100644 index 000000000..05b048b5e --- /dev/null +++ b/packages/core/src/utils/geo/normalizeGeoFeatures.unit.ts @@ -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); + }); +}); diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts index 01a5a5e1d..fca45ef11 100644 --- a/packages/core/src/utils/index.ts +++ b/packages/core/src/utils/index.ts @@ -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"; diff --git a/packages/react/package.json b/packages/react/package.json index d7a42af62..5426061cc 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -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", diff --git a/packages/react/src/data/world_map_dataset.js b/packages/react/src/data/world_map_dataset.js new file mode 100644 index 000000000..476aa5d5d --- /dev/null +++ b/packages/react/src/data/world_map_dataset.js @@ -0,0 +1,189 @@ +// Illustrative, deliberately low-resolution "world map" used by the family of stories - +// simplified bounding-box regions rather than real coastlines, so the Storybook/Chromatic build has +// no external basemap dependency. GeoJSON requires an exterior ring to be wound clockwise when +// plotted with longitude as x and latitude as y (north up) - see each ring below + +const continent_regions = { + type: "FeatureCollection", + features: [ + { + type: "Feature", + id: "North America", + properties: { continent: "North America" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-170, 10], + [-170, 75], + [-50, 75], + [-50, 10], + [-170, 10], + ], + ], + }, + }, + { + type: "Feature", + id: "South America", + properties: { continent: "South America" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-80, -55], + [-80, 10], + [-35, 10], + [-35, -55], + [-80, -55], + ], + ], + }, + }, + { + type: "Feature", + id: "Europe", + properties: { continent: "Europe" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-10, 35], + [-10, 70], + [40, 70], + [40, 35], + [-10, 35], + ], + ], + }, + }, + { + type: "Feature", + id: "Africa", + properties: { continent: "Africa" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [-20, -35], + [-20, 37], + [50, 37], + [50, -35], + [-20, -35], + ], + ], + }, + }, + { + type: "Feature", + id: "Asia", + properties: { continent: "Asia" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [45, 5], + [45, 70], + [150, 70], + [150, 5], + [45, 5], + ], + ], + }, + }, + { + type: "Feature", + id: "Oceania", + properties: { continent: "Oceania" }, + geometry: { + type: "Polygon", + coordinates: [ + [ + [110, -45], + [110, -10], + [180, -10], + [180, -45], + [110, -45], + ], + ], + }, + }, + ], +}; + +// Approximate 2023 population, in millions +const continent_population = [ + { continent: "North America", population: 596 }, + { continent: "South America", population: 436 }, + { continent: "Europe", population: 743 }, + { continent: "Africa", population: 1460 }, + { continent: "Asia", population: 4753 }, + { continent: "Oceania", population: 45 }, +]; + +const major_cities = [ + { city: "New York", continent: "North America", lat: 40.7, lon: -74.0, population: 8.3 }, + { city: "Mexico City", continent: "North America", lat: 19.4, lon: -99.1, population: 9.2 }, + { city: "São Paulo", continent: "South America", lat: -23.5, lon: -46.6, population: 12.3 }, + { city: "Buenos Aires", continent: "South America", lat: -34.6, lon: -58.4, population: 3.1 }, + { city: "London", continent: "Europe", lat: 51.5, lon: -0.1, population: 8.9 }, + { city: "Paris", continent: "Europe", lat: 48.9, lon: 2.4, population: 2.1 }, + { city: "Cairo", continent: "Africa", lat: 30.0, lon: 31.2, population: 10.0 }, + { city: "Lagos", continent: "Africa", lat: 6.5, lon: 3.4, population: 15.4 }, + { city: "Tokyo", continent: "Asia", lat: 35.7, lon: 139.7, population: 14.0 }, + { city: "Mumbai", continent: "Asia", lat: 19.1, lon: 72.9, population: 12.4 }, + { city: "Sydney", continent: "Oceania", lat: -33.9, lon: 151.2, population: 5.3 }, +]; + +// Each city's illustrative electricity generation mix, for the story +const ENERGY_MIX_BY_CITY = { + "New York": [35, 45, 20], + "Mexico City": [25, 65, 10], + "São Paulo": [70, 25, 5], + "Buenos Aires": [30, 60, 10], + London: [45, 35, 20], + Paris: [25, 15, 60], + Cairo: [15, 80, 5], + Lagos: [10, 90, 0], + Tokyo: [30, 55, 15], + Mumbai: [20, 75, 5], + Sydney: [40, 60, 0], +}; + +const city_energy_mix = major_cities.flatMap(({ city, continent, lat, lon }) => { + const [renewables, fossilFuels, nuclear] = ENERGY_MIX_BY_CITY[city]; + + return [ + { city, continent, lat, lon, source: "Renewables", value: renewables }, + { city, continent, lat, lon, source: "Fossil fuels", value: fossilFuels }, + { city, continent, lat, lon, source: "Nuclear", value: nuclear }, + ]; +}); + +// Approximate continent centroids, used to plot `trade_flows_dataset` (see ./trade_flows_dataset) +// as flow arcs on a layer +const continent_centroids = { + "North America": { lat: 45, lon: -100 }, + "South America": { lat: -15, lon: -60 }, + Europe: { lat: 50, lon: 15 }, + Africa: { lat: 5, lon: 20 }, + Asia: { lat: 35, lon: 90 }, + Oceania: { lat: -25, lon: 140 }, +}; + +// Simulated GPS fixes for two migrating birds, for the story +const bird_migration_dataset = [ + { bird: "Osprey 14", day: 1, lat: 60, lon: 15 }, + { bird: "Osprey 14", day: 5, lat: 50, lon: 10 }, + { bird: "Osprey 14", day: 10, lat: 35, lon: 5 }, + { bird: "Osprey 14", day: 15, lat: 15, lon: 0 }, + { bird: "Osprey 14", day: 20, lat: -5, lon: 10 }, + { bird: "Osprey 14", day: 25, lat: -25, lon: 20 }, + { bird: "Curlew 7", day: 1, lat: 65, lon: -20 }, + { bird: "Curlew 7", day: 6, lat: 55, lon: -15 }, + { bird: "Curlew 7", day: 12, lat: 40, lon: -12 }, + { bird: "Curlew 7", day: 18, lat: 20, lon: -16 }, + { bird: "Curlew 7", day: 24, lat: 0, lon: -10 }, +]; + +export { continent_regions, continent_population, major_cities, city_energy_mix, continent_centroids, bird_migration_dataset }; diff --git a/packages/react/src/lib/components/Plots/Geo.mdx b/packages/react/src/lib/components/Plots/Geo.mdx new file mode 100644 index 000000000..47b53fc25 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo.mdx @@ -0,0 +1,104 @@ +import { Story, Canvas, Meta } from "@storybook/blocks"; +import * as GeoStories from "./Geo.stories" + + + +# Geo/Map Plots + +`` is a map chart. It's a composition root like `` - self-contained (no need to wrap it in +another chart component), but rather than a single fixed plot, it composes any combination of layers as +`children`, all sharing one [`d3-geo`](https://d3js.org/d3-geo) projection fitted to the plot area: + +- **``** shades each region of a geography by a quantized color scale, joined against the + chart's data by a region key - e.g. population by country/state. +- **``** plots a marker per row at its latitude/longitude, optionally sized by a value. +- **``** plots a small Pie/Donut glyph per location, summarizing that location's rows by category. +- **``** draws a great-circle flow line between a source/target latitude/longitude pair per + row - e.g. migration or trade between regions. +- **``** connects each group's rows into a single ordered route across the map - e.g. a + tracked animal's individual GPS fixes stitched into its migration path. + +Any combination of layers can be composed onto the same `` - see [Composing layers](#composing-layers) below. + + + +## `` Component + +### Props + +| Prop | Type | Default | Note | +| ------------ | --------------------------------- | -------------- | ------------------------------------------------------------------------------------------------------------------------ | +| `features` | GeoJSON or TopoJSON | `undefined` | The geography to render/fit the map to. Optional - a map of scattered points/arcs/paths doesn't need any region geometry, and fits the whole globe/plane instead. | +| `object` | `string` | First object | Which TopoJSON object to extract, if `features` is a `Topology` with more than one (e.g. `"states"` vs `"counties"`). | +| `projection` | `string \| () => GeoProjection` | `"equalEarth"` | One of `"equalEarth"`, `"mercator"`, `"naturalEarth1"`, `"orthographic"`, `"albersUsa"`, `"albers"`, `"azimuthalEqualArea"`, or a factory for a custom `d3-geo` projection. | +| `rotate` | `[number, number, number]` | `undefined` | An optional `[lambda, phi, gamma]` rotation, in degrees. | + +`features` accepts either plain GeoJSON (a `Feature` or `FeatureCollection`) or a TopoJSON `Topology` - +e.g. from [`world-atlas`](https://www.npmjs.com/package/world-atlas) or +[`us-atlas`](https://www.npmjs.com/package/us-atlas), which are usually a much smaller download than the +equivalent GeoJSON. + +## `` Component + +### Props + +| Prop | Type | Default | Note | +| -------------- | ---------------------------- | --------------------------- | ----------------------------------------------------------------------------------------------------------- | +| **`regionKey`\*** | `string` | `null` | The key of the field, on each row of data, that identifies which region it belongs to. | +| **`value`\*** | `string` | `null` | The key of the numeric field used to color each region. | +| `featureKey` | `(feature) => string` | `feature.id`/`.properties.id` | Returns the ID for a GeoJSON feature, matched against `regionKey`. | +| `colors` | `string[]` | A 6-shade blue palette | The sequence of colors to quantize `value` into. | +| `domain` | `[number, number]` | Extent of `value` | Overrides the `[min, max]` domain colors are quantized across. | +| `noDataColor` | `string` | `theme.background` | The fill color for a region with no matching row of data. | +| `stroke` | `string` | `theme.background` | The border color drawn between regions. | +| `interactive` | `boolean` | `true` | Whether the plot should be interactive. | +| `showInLegend` | `boolean` | `true` | Whether a legend item is added per quantized color bucket. | + +### Using Canvas + +Like other plots, a `` chart can be rendered using an HTML Canvas instead of SVG by setting `useCanvas`. + + + +## `` Component + +Plots a circle marker at every row's `lat`/`lon`. Set `value` and a `radius` `[min, max]` range for +area-proportional sizing, and/or `category` to color points categorically. + + + +## `` Component + +Plots a small Pie glyph (or Donut, via `innerRadius`) at every distinct `lat`/`lon` (or `group`), +summarizing that location's rows by `category`/`value` - e.g. a city's electricity generation mix. + + + +## `` Component + +Draws a curved flow line for every row, from a `sourceLat`/`sourceLon` to a `targetLat`/`targetLon` - +e.g. trade or migration between regions. Set `value` and a `strokeWidth` `[min, max]` range to scale +each arc by its flow's magnitude. + + + +## `` Component + +Connects every `group`'s rows (e.g. one tracked animal's individual position readings) into a single +route, ordered by `order` if given - e.g. a bird's migration track. + + + +## Composing layers + +Any combination of layers can be composed together inside a single ``, sharing one projection and +one dataset - e.g. a Choropleth with `` markers on top: + + + +```jsx + + + + +``` diff --git a/packages/react/src/lib/components/Plots/Geo.stories.tsx b/packages/react/src/lib/components/Plots/Geo.stories.tsx new file mode 100644 index 000000000..1de87b69a --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo.stories.tsx @@ -0,0 +1,207 @@ +import { themes } from "@chart-io/core"; + +import type { Meta } from "@storybook/react"; +import { fn } from "@storybook/test"; +import React from "react"; + +import { + bird_migration_dataset, + city_energy_mix, + continent_centroids, + continent_population, + continent_regions, + major_cities, +} from "../../../data/world_map_dataset"; +import { trade_flows_dataset } from "../../../data/trade_flows_dataset"; +import { argTypes } from "../../../storybook/argTypes"; +import { createSVGTest } from "../../testUtils"; +import { Choropleth } from "./Geo/Choropleth"; +import { Geo } from "./Geo/Geo"; +import { GeoArcs } from "./Geo/GeoArcs"; +import { GeoPaths } from "./Geo/GeoPaths"; +import { GeoPie } from "./Geo/GeoPie"; +import { GeoPoints } from "./Geo/GeoPoints"; + +const { width, height, margin, useCanvas, theme } = argTypes; + +export default { + title: "Charts/Geo/Geo", + component: Geo, + parameters: { + docs: { + transformSource: (src) => { + src = src.replaceAll(/undefined,?/g, ""); + src = src.replace(/^\s*\n/gm, ""); + return src; + }, + }, + chromatic: { delay: 300 }, + }, + args: { + onClick: fn(), + onMouseOver: fn(), + onMouseOut: fn(), + }, + argTypes: { + useCanvas, + width, + height, + theme, + leftMargin: margin, + rightMargin: margin, + topMargin: margin, + bottomMargin: margin, + }, +} as Meta; + +const GeoTemplate = (args) => ( + + {args.children} + +); + +const DEFAULT_ARGS = { + useCanvas: false, + width: 700, + height: 450, + animationDuration: 250, + theme: themes.light, + leftMargin: 20, + rightMargin: 20, + topMargin: 20, + bottomMargin: 20, +}; + +export const Basic = { + name: "Choropleth", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: continent_population, + features: continent_regions, + children: , + }, + play: createSVGTest("path.choropleth-region", { clientX: 150, clientY: 200 }), +}; + +export const Canvas = { + name: "Using Canvas", + render: GeoTemplate, + args: { + ...Basic.args, + useCanvas: true, + }, +}; + +export const Points = { + name: "GeoPoints", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: major_cities, + features: continent_regions, + children: , + }, + play: createSVGTest("circle.geo-point", { clientX: 150, clientY: 200 }), +}; + +export const Pie = { + name: "GeoPie", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: city_energy_mix, + features: continent_regions, + children: , + }, + play: createSVGTest("path.geo-pie-slice", { clientX: 150, clientY: 200 }), +}; + +// Combine a handful of the illustrative continent-to-continent `trade_flows_dataset` routes (see +// Chord/Sankey) with each continent's approximate centroid, so they can be plotted as flow arcs. +// Only a few routes are used so each arc's curve stays legible - the full dataset has 14 routes, +// several long-haul enough (e.g. North America/Asia) that their great circles cross near the +// antimeridian, which this projection (fitted to the visible continents, not the Pacific) can't show +const TRADE_ROUTES_TO_SHOW = [ + ["North America", "Europe"], + ["Europe", "Africa"], + ["Africa", "Asia"], + ["Asia", "Oceania"], +]; + +const tradeFlowRoutes = TRADE_ROUTES_TO_SHOW.map(([from, to]) => { + const { trade } = trade_flows_dataset.find((route) => route.from === from && route.to === to); + + return { + from, + to, + trade, + fromLat: continent_centroids[from].lat, + fromLon: continent_centroids[from].lon, + toLat: continent_centroids[to].lat, + toLon: continent_centroids[to].lon, + }; +}); + +export const Arcs = { + name: "GeoArcs", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: tradeFlowRoutes, + features: continent_regions, + children: , + }, + play: createSVGTest("path.geo-arc", { clientX: 150, clientY: 200 }), +}; + +export const Paths = { + name: "GeoPaths", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: bird_migration_dataset, + features: continent_regions, + children: , + }, + play: createSVGTest("path.geo-route", { clientX: 150, clientY: 200 }), +}; + +// A single dataset shared by both layers: the city rows carry `lat`/`lon` for , and the +// continent rows (deliberately listed last, so they win Choropleth's region lookup for any continent +// that also has a city) carry the `population` colors regions by +const composedData = [...major_cities, ...continent_population]; + +export const Composed = { + name: "Composing Multiple Layers", + render: GeoTemplate, + args: { + ...DEFAULT_ARGS, + data: composedData, + features: continent_regions, + children: ( + + + + + ), + }, + play: createSVGTest("path.choropleth-region", { clientX: 150, clientY: 200 }), +}; diff --git a/packages/react/src/lib/components/Plots/Geo/Choropleth/Choropleth.tsx b/packages/react/src/lib/components/Plots/Geo/Choropleth/Choropleth.tsx new file mode 100644 index 000000000..c7160ed09 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/Choropleth/Choropleth.tsx @@ -0,0 +1,204 @@ +import { chartSelectors, d3, formatNumber, IState } from "@chart-io/core"; +import type { IColor, IData, IDatum, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import type { Feature } from "geojson"; + +import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { useDatumContextMenu, useLegendItems } from "../../../../hooks"; +import { withCanvas, withSVG } from "../../../../hoc"; + +import { ShapesPlot, IShapesPlotProps } from "../../ShapesPlot"; +import { useFocused } from "../../useFocused"; +import { useTooltip } from "../../useTooltip"; +import { useGeoContext } from "../GeoContext"; + +const DEFAULT_COLORS: IColor[] = ["#eff3ff", "#bdd7e7", "#9ecae1", "#6baed6", "#3182bd", "#08519c"]; + +interface IRegion { + id: string; + feature: Feature; + datum?: IDatum; + d: string; +} + +const CanvasRegionsPlot = withCanvas>(ShapesPlot, "plot choropleth-region"); +const SVGRegionsPlot = withSVG>(ShapesPlot, "plot choropleth-region"); + +export interface IChoroplethProps { + /** + * Should Canvas be used instead of SVG? + */ + useCanvas?: boolean; + /** + * The key of the field, on each row of data, that identifies which region it belongs to - joined + * against `featureKey(feature)` to color that region + */ + regionKey: string; + /** + * The key of the numeric field used to color each region + */ + value: string; + /** + * Returns the ID for a GeoJSON feature, matched against each row's `regionKey` field to find the + * data for that region + * @default (feature) => `${feature.id ?? feature.properties?.id}` + */ + featureKey?: (feature: Feature) => string; + /** + * Returns the display name for a GeoJSON feature, used in the tooltip + * @default (feature) => `${feature.properties?.name ?? featureKey(feature)}` + */ + featureName?: (feature: Feature) => string; + /** + * The sequence of colors to quantize `value` into - e.g. a 3-color array buckets every region + * into a "low"/"medium"/"high" shade + * @default A 6-shade sequential blue palette + */ + colors?: IColor[]; + /** + * Overrides the `[min, max]` domain colors are quantized across. Defaults to the extent of + * `value` across the data + */ + domain?: [number, number]; + /** + * The fill color for a region with no matching row of data + * @default theme.background + */ + noDataColor?: IColor; + /** + * The border color drawn between regions + * @default theme.background + */ + stroke?: IColor; + /** + * The width, in pixels, of the border between regions + * @default 1 + */ + strokeWidth?: number; + /** + * Should the plot be interactive and be able to trigger tooltips? + * @default true + */ + interactive?: boolean; + /** + * Should this series feature in the Legend? + * @default true + */ + showInLegend?: boolean; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +const defaultFeatureKey = (feature: Feature) => `${feature.id ?? (feature.properties as IDatum)?.id}`; + +/** + * Represents a Choropleth layer, shading each region of ``'s `features` by a quantized color + * scale of `value`, joined against the chart's data via `regionKey`/`featureKey`. Used inside a + * `` chart alongside any other layer (``, ``, ``, ``) - + * not a standalone chart itself + * @param props The set of React properties + * @return The Choropleth component + */ +export function Choropleth({ + useCanvas = false, + regionKey, + value, + featureKey = defaultFeatureKey, + featureName, + colors, + domain, + noDataColor, + stroke, + strokeWidth = 1, + interactive = true, + showInLegend = true, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IChoroplethProps) { + const data = useSelector((s: IState) => chartSelectors.data(s)); + const theme = useSelector((s: IState) => chartSelectors.theme(s)); + const { features, path } = useGeoContext("Choropleth"); + + const palette = colors ?? DEFAULT_COLORS; + const nameFor = featureName ?? ((feature: Feature) => `${(feature.properties as IDatum)?.name ?? featureKey(feature)}`); + + const onTooltip = useTooltip(); + const onFocus = useFocused(theme); + const onDatumContextMenu = useDatumContextMenu(); + + const { regions, colorScale } = useMemo(() => { + const byRegion = new Map(); + (data as IData).forEach((datum) => byRegion.set(`${datum[regionKey]}`, datum)); + + const extent = domain ?? (d3.extent((data as IData).map((datum) => Number(datum[value]))) as [number, number]); + const colorScale = d3.scaleQuantize().domain(extent).range(palette); + + const regions: IRegion[] = (features?.features ?? []).map((feature) => { + const id = featureKey(feature); + return { id, feature, datum: byRegion.get(id), d: path(feature) ?? "" }; + }); + + return { regions, colorScale }; + }, [data, features, regionKey, value, domain, palette, featureKey, path]); + + const legendColors = colorScale.range(); + const legendKeys = legendColors.map((color) => { + const [min, max] = colorScale.invertExtent(color); + return `${formatNumber(min)} - ${formatNumber(max)}`; + }); + + useLegendItems(legendKeys, "square", showInLegend, legendColors); + + const colorFor = (region: IRegion) => + region.datum ? colorScale(Number(region.datum[value])).toString() : (noDataColor ?? theme.background).toString(); + + const handleMouseOver = (region: IRegion, element: Element, event: MouseEvent) => { + const { datum } = region; + const color = colorFor(region) as IColor; + + onMouseOver && onMouseOver(datum, element, event); + onFocus && onFocus({ element, event, datum }); + onTooltip && datum && onTooltip({ datum, event, name: nameFor(region.feature), value: datum[value], color }); + }; + + const handleMouseOut = (region: IRegion, element: Element, event: MouseEvent) => { + onMouseOut && onMouseOut(region.datum, element, event); + onFocus && onFocus(null); + onTooltip && onTooltip(null); + }; + + const handleClick = (region: IRegion, element: Element, event: MouseEvent) => { + onClick && onClick(region.datum, element, event); + region.datum && onDatumContextMenu(region.datum, event); + }; + + const Regions = useCanvas ? CanvasRegionsPlot : SVGRegionsPlot; + + return ( + region.id} + d={(region) => region.d} + fill={colorFor} + fillOpacity={theme.series.opacity} + stroke={(stroke ?? theme.background).toString()} + strokeWidth={strokeWidth} + cursor={() => (interactive ? "pointer" : "default")} + interactive={interactive} + onMouseOver={handleMouseOver} + onMouseOut={handleMouseOut} + onClick={handleClick} + /> + ); +} diff --git a/packages/react/src/lib/components/Plots/Geo/Choropleth/index.ts b/packages/react/src/lib/components/Plots/Geo/Choropleth/index.ts new file mode 100644 index 000000000..1b90cb2b0 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/Choropleth/index.ts @@ -0,0 +1 @@ +export * from "./Choropleth"; diff --git a/packages/react/src/lib/components/Plots/Geo/Geo.tsx b/packages/react/src/lib/components/Plots/Geo/Geo.tsx new file mode 100644 index 000000000..d50a50c19 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/Geo.tsx @@ -0,0 +1,127 @@ +import { d3 } from "@chart-io/core"; +import type { IGeoFeatures, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import React, { forwardRef } from "react"; + +import { Chart, IChartProps, IChartRef } from "../../Chart"; +import { LegendOverlay } from "../../LegendOverlay"; +import { TooltipOverlay } from "../../TooltipOverlay"; +import { extendChildrenProps } from "../../../utils"; + +import { GeoContext } from "./GeoContext"; +import type { IGeoProjectionType } from "./resolveProjection"; +import { useGeoProjection } from "./useGeoProjection"; + +export interface IGeoProps extends Omit { + /** + * The layers to render onto the map, e.g. any combination of ``, ``, + * ``, `` and `` + */ + children?: JSX.Element | JSX.Element[]; + /** + * The geography to render/fit the map to - GeoJSON (a `Feature` or `FeatureCollection`) or a + * TopoJSON `Topology` (e.g. from `world-atlas`/`us-atlas`) - see `object`. Consumed directly by + * `` for its regions, and used by every layer to fit the shared projection. + * Optional - a map of scattered points/arcs/paths doesn't need any region geometry, and will + * fit the projection to the whole globe/plane instead + */ + features?: IGeoFeatures; + /** + * Which object to extract, if `features` is a TopoJSON `Topology` with more than one (e.g. + * `"states"` vs `"counties"`). Defaults to the first object on the topology + */ + object?: string; + /** + * The projection to use - either one of the built-in presets (`"equalEarth"`, `"mercator"`, + * `"naturalEarth1"`, `"orthographic"`, `"albersUsa"`, `"albers"`, `"azimuthalEqualArea"`), or a + * factory function for full control over a `d3-geo` projection of your own + * @default "equalEarth" + */ + projection?: IGeoProjectionType | (() => d3.GeoProjection); + /** + * An optional `[lambda, phi, gamma]` rotation, in degrees, applied to the projection - e.g. to + * recenter a world map on a different meridian + */ + rotate?: [number, number, number]; +} + +/** + * Represents a Geo/map chart. A self-contained chart: no need to wrap it in another chart component + * yourself. Renders no basemap of its own - compose in whichever layers you need as `children`: + * `` for shaded regions, ``/`` for markers/pie glyphs at + * locations, and ``/`` for flows between locations or tracked routes through + * them - any combination can be layered together onto the same projection + * @param props The set of React properties + * @return The Geo component + */ +export const Geo = forwardRef(({ children, features, object, projection, rotate, ...chartProps }, ref) => { + return ( + + + {children} + + + + + ); +}); + +Geo.displayName = "Geo"; + +interface IGeoLayerProps { + children?: JSX.Element | JSX.Element[]; + features?: IGeoFeatures; + object?: string; + projection?: IGeoProjectionType | (() => d3.GeoProjection); + rotate?: [number, number, number]; + /** + * The following are provided by ``/`` cloning them onto their direct + * children - `` is that direct child, so it re-forwards them onto its own children + * (the actual layers), which is where they're really needed + */ + useCanvas?: boolean; + animationDuration?: number; + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +/** + * Computes the projection shared by every layer (see `useGeoProjection`) and provides it via + * `GeoContext`, so ``/``/``/``/`` can project their + * data into the same pixel space without each recomputing their own. Also the direct child of + * `` that actually carries the `isPlot`/`requiresVirtualCanvas` flags `` and + * `` look for - so it re-forwards the props they clone onto it down to its own children + * @param props The set of React properties + * @return The GeoLayer component + */ +function GeoLayer({ + children, + features, + object, + projection, + rotate, + useCanvas, + animationDuration, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IGeoLayerProps) { + const context = useGeoProjection({ features, object, projection, rotate }); + + const childrenWithProps = extendChildrenProps(children, { + useCanvas, + animationDuration, + renderVirtualCanvas, + ...(onMouseOver && { onMouseOver }), + ...(onMouseOut && { onMouseOut }), + ...(onClick && { onClick }), + }); + + return {childrenWithProps}; +} + +GeoLayer.requiresVirtualCanvas = true; +GeoLayer.isPlot = true; diff --git a/packages/react/src/lib/components/Plots/Geo/Geo.unit.tsx b/packages/react/src/lib/components/Plots/Geo/Geo.unit.tsx new file mode 100644 index 000000000..6b973bc8f --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/Geo.unit.tsx @@ -0,0 +1,282 @@ +import { chartSelectors } from "@chart-io/core"; + +import { toMatchImageSnapshot } from "jest-image-snapshot"; +import React from "react"; +import { render } from "@testing-library/react"; + +import { getBuffer, wait } from "../../../testUtils"; + +import { Choropleth } from "./Choropleth"; +import { Geo } from "./Geo"; +import { GeoArcs } from "./GeoArcs"; +import { GeoPaths } from "./GeoPaths"; +import { GeoPie } from "./GeoPie"; +import { GeoPoints } from "./GeoPoints"; + +expect.extend({ toMatchImageSnapshot }); + +// GeoJSON (RFC 7946) requires an exterior ring to be wound clockwise when plotted with longitude as +// x and latitude as y (north up) - d3-geo relies on this to tell "the inside of this ring" from "the +// rest of the sphere", so a counterclockwise ring here would render as almost the whole globe with +// this shape cut out of it, not the shape itself +const features = { + type: "FeatureCollection" as const, + features: [ + { + type: "Feature" as const, + properties: { id: "north" }, + geometry: { + type: "Polygon" as const, + coordinates: [ + [ + [-10, 10], + [-10, 80], + [10, 80], + [10, 10], + [-10, 10], + ], + ], + }, + }, + { + type: "Feature" as const, + properties: { id: "south" }, + geometry: { + type: "Polygon" as const, + coordinates: [ + [ + [-10, -80], + [-10, -10], + [10, -10], + [10, -80], + [-10, -80], + ], + ], + }, + }, + ], +}; + +describe("Geo", () => { + describe("Choropleth", () => { + const data = [ + { region: "north", population: 10 }, + { region: "south", population: 90 }, + ]; + + it("should render a region for every feature", async () => { + const { container } = render( + + + , + ); + + await wait(); + + expect(container.querySelectorAll("path.choropleth-region").length).toBe(2); + }); + + it("should color a region without a matching row of data using noDataColor", async () => { + const noMatch = [{ region: "north", population: 10 }]; + + const { container } = render( + + + , + ); + + await wait(); + + const regions = container.querySelectorAll("path.choropleth-region"); + const southRegion = Array.from(regions).find((region) => region.getAttribute("d") !== regions[0].getAttribute("d")); + + expect((southRegion as SVGPathElement).style.fill).toBe("rgb(1, 2, 3)"); + }); + + it("should add a legend item for every quantized color bucket", async () => { + let capturedStore; + + render( + (capturedStore = store)} + > + + , + ); + + await wait(); + + expect(chartSelectors.legend.items(capturedStore.getState())).toHaveLength(2); + }); + }); + + describe("GeoPoints", () => { + const data = [ + { city: "A", lat: 40, lon: -20, value: 5 }, + { city: "B", lat: -40, lon: 20, value: 15 }, + ]; + + it("should render a point for every row of data", async () => { + const { container } = render( + + + , + ); + + await wait(); + + const points = container.querySelectorAll("circle.geo-point"); + expect(points.length).toBe(2); + + points.forEach((point) => { + expect(Number(point.getAttribute("cx"))).toBeGreaterThanOrEqual(0); + expect(Number(point.getAttribute("cx"))).toBeLessThanOrEqual(200); + expect(Number(point.getAttribute("cy"))).toBeGreaterThanOrEqual(0); + expect(Number(point.getAttribute("cy"))).toBeLessThanOrEqual(200); + }); + }); + + it("should scale radius by value when radius is a [min, max] range", async () => { + const { container } = render( + + + , + ); + + await wait(); + + const points = Array.from(container.querySelectorAll("circle.geo-point")); + const radii = points.map((point) => Number(point.getAttribute("r"))); + + expect(Math.min(...radii)).toBeCloseTo(2, 0); + expect(Math.max(...radii)).toBeCloseTo(20, 0); + }); + }); + + describe("GeoArcs", () => { + const data = [ + { fromLat: 10, fromLon: -10, toLat: 40, toLon: 30, migrants: 100 }, + { fromLat: -40, fromLon: -20, toLat: -10, toLon: 10, migrants: 50 }, + ]; + + it("should render an arc for every row of data", async () => { + const { container } = render( + + + , + ); + + await wait(); + + const arcs = container.querySelectorAll("path.geo-arc"); + expect(arcs.length).toBe(2); + arcs.forEach((arc) => expect(arc.getAttribute("d")).toMatch(/^M/)); + }); + }); + + describe("GeoPaths", () => { + const data = [ + { bird: "A", lat: 10, lon: -10, t: 1 }, + { bird: "A", lat: 15, lon: -5, t: 2 }, + { bird: "A", lat: 20, lon: 0, t: 3 }, + { bird: "B", lat: -10, lon: 10, t: 1 }, + { bird: "B", lat: -15, lon: 15, t: 2 }, + ]; + + it("should render one route per group, and a waypoint per row", async () => { + const { container } = render( + + + , + ); + + await wait(); + + expect(container.querySelectorAll("path.geo-route").length).toBe(2); + expect(container.querySelectorAll("circle.geo-waypoint").length).toBe(5); + }); + + it("should not render waypoints when showPoints is false", async () => { + const { container } = render( + + + , + ); + + await wait(); + + expect(container.querySelectorAll("circle.geo-waypoint").length).toBe(0); + }); + }); + + describe("GeoPie", () => { + const data = [ + { city: "A", lat: 40, lon: -20, source: "Solar", value: 30 }, + { city: "A", lat: 40, lon: -20, source: "Wind", value: 70 }, + { city: "B", lat: -40, lon: 20, source: "Solar", value: 50 }, + { city: "B", lat: -40, lon: 20, source: "Wind", value: 50 }, + ]; + + it("should render a slice per row, grouped by location", async () => { + const { container } = render( + + + , + ); + + await wait(); + + expect(container.querySelectorAll("path.geo-pie-slice").length).toBe(4); + }); + }); + + describe("composing multiple layers together", () => { + it("should render every layer's elements onto the same map, sharing one projection", async () => { + // A single shared dataset - the Choropleth reads `region`/`population`, GeoPoints reads + // `lat`/`lon` - each layer simply ignores the fields it doesn't need + const data = [ + { region: "north", population: 10, lat: 40, lon: -5 }, + { region: "south", population: 90, lat: -40, lon: 5 }, + ]; + + const { container } = render( + + + + , + ); + + await wait(); + + expect(container.querySelectorAll("path.choropleth-region").length).toBe(2); + expect(container.querySelectorAll("circle.geo-point").length).toBe(2); + }); + }); + + describe("using Canvas", () => { + it("should render a Choropleth to Canvas without throwing", async () => { + const data = [ + { region: "north", population: 10 }, + { region: "south", population: 90 }, + ]; + + const { container } = render( + + + , + ); + + await wait(300); + + const canvases = container.querySelectorAll(".canvas"); + expect(canvases.length).toBe(1); + + const buffer = getBuffer(canvases[0] as HTMLCanvasElement); + expect(buffer).toMatchImageSnapshot(); + }); + }); +}); diff --git a/packages/react/src/lib/components/Plots/Geo/GeoArcs/GeoArcs.tsx b/packages/react/src/lib/components/Plots/Geo/GeoArcs/GeoArcs.tsx new file mode 100644 index 000000000..0f8f6297c --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoArcs/GeoArcs.tsx @@ -0,0 +1,209 @@ +import { chartSelectors, d3, IState } from "@chart-io/core"; +import type { IColor, IData, IDatum, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { useLegendItems } from "../../../../hooks"; +import { withCanvas, withSVG } from "../../../../hoc"; + +import { ShapesPlot, IShapesPlotProps } from "../../ShapesPlot"; +import { useFocused } from "../../useFocused"; +import { useTooltip } from "../../useTooltip"; +import { useGeoContext } from "../GeoContext"; + +// How many points to sample the great-circle interpolation between source and target at - enough +// to look smoothly curved once projected, without generating an excessively long `d` string +const INTERPOLATION_STEPS = 64; + +interface IArc { + key: string; + datum: IDatum; + d: string; +} + +const CanvasArcsPlot = withCanvas>(ShapesPlot, "plot geo-arcs"); +const SVGArcsPlot = withSVG>(ShapesPlot, "plot geo-arcs"); + +export interface IGeoArcsProps { + /** + * Should Canvas be used instead of SVG? + */ + useCanvas?: boolean; + /** + * The key of the field holding each row's origin latitude + */ + sourceLat: string; + /** + * The key of the field holding each row's origin longitude + */ + sourceLon: string; + /** + * The key of the field holding each row's destination latitude + */ + targetLat: string; + /** + * The key of the field holding each row's destination longitude + */ + targetLon: string; + /** + * The key of a numeric field to scale each arc's stroke width by (e.g. flow magnitude, like the + * size of a migration between two regions). Arcs are drawn at a fixed `strokeWidth` if omitted + */ + value?: string; + /** + * A fixed stroke width for every arc, or the `[min, max]` pixel range to scale `value` into + * @default 1.5 + */ + strokeWidth?: number | [number, number]; + /** + * The key of a field used to color each arc categorically. Every arc uses `color`/the theme's + * first series color if omitted + */ + category?: string; + /** + * A fixed color for every arc, used when `category` isn't given + */ + color?: IColor; + /** + * The set of colors to use for each `category`. Defaults to the theme's series colors + */ + colors?: IColor[]; + strokeOpacity?: number; + /** + * Should the plot be interactive and be able to trigger tooltips? + * @default true + */ + interactive?: boolean; + /** + * Should this series feature in the Legend? + * @default true + */ + showInLegend?: boolean; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +/** + * Represents a GeoArcs layer, drawing a great-circle flow line between a `source`/`target` + * lat/lon pair for every row of data - e.g. migration between regions, or any other origin/destination + * flow. Used inside a `` chart alongside any other layer - not a standalone chart itself + * @param props The set of React properties + * @return The GeoArcs component + */ +export function GeoArcs({ + useCanvas = false, + sourceLat, + sourceLon, + targetLat, + targetLon, + value, + strokeWidth = 1.5, + category, + color, + colors, + strokeOpacity = 0.75, + interactive = true, + showInLegend = true, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IGeoArcsProps) { + const data = useSelector((s: IState) => chartSelectors.data(s)); + const theme = useSelector((s: IState) => chartSelectors.theme(s)); + const { path } = useGeoContext("GeoArcs"); + + const palette = colors ?? theme.series.colors; + const categories = useMemo( + () => (category ? Array.from(new Set((data as IData).map((d) => `${d[category]}`))) : []), + [data, category], + ); + const legendColors = useMemo(() => categories.map((_, index) => palette[index % palette.length]), [categories, palette]); + + useLegendItems(categories, "line", showInLegend && Boolean(category), legendColors); + + const onTooltip = useTooltip(); + const onFocus = useFocused(theme); + + const { arcs, colorFor, strokeWidthFor } = useMemo(() => { + const arcs: IArc[] = (data as IData) + .map((datum, index) => { + const source: [number, number] = [Number(datum[sourceLon]), Number(datum[sourceLat])]; + const target: [number, number] = [Number(datum[targetLon]), Number(datum[targetLat])]; + if (![...source, ...target].every(Number.isFinite)) return null; + + const interpolate = d3.geoInterpolate(source, target); + const coordinates = d3.range(0, INTERPOLATION_STEPS + 1).map((step) => interpolate(step / INTERPOLATION_STEPS)); + const d = path({ type: "LineString", coordinates }); + + return d ? { key: `${index}`, datum, d } : null; + }) + .filter((arc): arc is IArc => arc !== null); + + // @ts-ignore: TODO: Not sure how to fix this + const colorScale = category ? d3.scaleOrdinal().domain(categories).range(palette) : null; + const colorFor = (arc: IArc) => (colorScale ? colorScale(`${arc.datum[category]}`).toString() : (color ?? palette[0]).toString()); + + const strokeWidthFor = Array.isArray(strokeWidth) + ? (() => { + const extent = d3.extent((data as IData).map((datum) => Number(datum[value]))) as [number, number]; + const scale = d3.scaleSqrt().domain(extent).range(strokeWidth); + return (arc: IArc) => scale(Number(arc.datum[value])); + })() + : () => strokeWidth; + + return { arcs, colorFor, strokeWidthFor }; + }, [data, sourceLat, sourceLon, targetLat, targetLon, value, strokeWidth, category, categories, palette, color, path]); + + const handleMouseOver = (arc: IArc, element: Element, event: MouseEvent) => { + const { datum } = arc; + const color = colorFor(arc) as IColor; + + onMouseOver && onMouseOver(datum, element, event); + onFocus && onFocus({ element, event, datum }); + onTooltip && + onTooltip({ + datum, + event, + name: `${datum[sourceLat]}, ${datum[sourceLon]} → ${datum[targetLat]}, ${datum[targetLon]}`, + value: value ? datum[value] : undefined, + color, + }); + }; + + const handleMouseOut = (arc: IArc, element: Element, event: MouseEvent) => { + onMouseOut && onMouseOut(arc.datum, element, event); + onFocus && onFocus(null); + onTooltip && onTooltip(null); + }; + + const handleClick = (arc: IArc, element: Element, event: MouseEvent) => { + onClick && onClick(arc.datum, element, event); + }; + + const Arcs = useCanvas ? CanvasArcsPlot : SVGArcsPlot; + + return ( + arc.key} + d={(arc) => arc.d} + stroke={colorFor} + strokeOpacity={strokeOpacity} + strokeWidth={strokeWidthFor} + cursor={() => (interactive ? "pointer" : "default")} + interactive={interactive} + onMouseOver={handleMouseOver} + onMouseOut={handleMouseOut} + onClick={handleClick} + /> + ); +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoArcs/index.ts b/packages/react/src/lib/components/Plots/Geo/GeoArcs/index.ts new file mode 100644 index 000000000..789f1e68b --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoArcs/index.ts @@ -0,0 +1 @@ +export * from "./GeoArcs"; diff --git a/packages/react/src/lib/components/Plots/Geo/GeoContext.ts b/packages/react/src/lib/components/Plots/Geo/GeoContext.ts new file mode 100644 index 000000000..a9698dc12 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoContext.ts @@ -0,0 +1,35 @@ +import { d3, logAndThrowError } from "@chart-io/core"; + +import type { FeatureCollection } from "geojson"; + +import { createContext, useContext } from "react"; + +export interface IGeoContextValue { + /** The projection every layer's coordinates are projected through, fitted to the plot area */ + projection: d3.GeoProjection; + /** A `d3.geoPath` bound to `projection`, turning a GeoJSON geometry into an SVG `d` string */ + path: d3.GeoPath; + /** Projects a `[longitude, latitude]` pair into `[x, y]` pixels, or `null` if it's not visible */ + project: (coordinates: [number, number]) => [number, number] | null; + /** The normalized geography passed to ``, if any - consumed by `` */ + features?: FeatureCollection; +} + +export const GeoContext = createContext(null); + +/** + * Reads the projection set up by an ancestor `` - used by every one of its layers + * (``, ``, ``, ``, ``) to project their + * longitude/latitude data into the same shared pixel space + * @param componentName The name of the calling layer component, used in the error if it's missing + * @return The projection context + */ +export function useGeoContext(componentName: string): IGeoContextValue { + const context = useContext(GeoContext); + + if (!context) { + logAndThrowError("E009", `<${componentName}> must be rendered inside a chart`); + } + + return context; +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPaths/GeoPaths.tsx b/packages/react/src/lib/components/Plots/Geo/GeoPaths/GeoPaths.tsx new file mode 100644 index 000000000..3c9d44791 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPaths/GeoPaths.tsx @@ -0,0 +1,247 @@ +import { chartSelectors, d3, IState } from "@chart-io/core"; +import type { IColor, IData, IDatum, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import { groupBy, sortBy } from "lodash"; +import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { useLegendItems } from "../../../../hooks"; +import { withCanvas, withSVG } from "../../../../hoc"; + +import { INodesPlotProps, NodesPlot } from "../../NodesPlot"; +import { ShapesPlot, IShapesPlotProps } from "../../ShapesPlot"; +import { useFocused } from "../../useFocused"; +import { useTooltip } from "../../useTooltip"; +import { useGeoContext } from "../GeoContext"; + +interface IRoute { + key: string; + group: string; + rows: IData; + d: string; +} + +interface IWaypoint { + key: string; + group: string; + datum: IDatum; + cx: number; + cy: number; +} + +const CanvasRoutesPlot = withCanvas>(ShapesPlot, "plot geo-route"); +const SVGRoutesPlot = withSVG>(ShapesPlot, "plot geo-route"); +const CanvasWaypointsPlot = withCanvas>(NodesPlot, "plot geo-waypoint"); +const SVGWaypointsPlot = withSVG>(NodesPlot, "plot geo-waypoint"); + +export interface IGeoPathsProps { + /** + * Should Canvas be used instead of SVG? + */ + useCanvas?: boolean; + /** + * The key of the field holding each row's latitude + */ + lat: string; + /** + * The key of the field holding each row's longitude + */ + lon: string; + /** + * The key of the field identifying which route/track each row belongs to - e.g. a tracked + * animal's ID, joining up all of its individual position readings into a single route + */ + group: string; + /** + * The key of a field to sort each route's rows by before connecting them - e.g. a timestamp. + * Uses the data's existing order if omitted + */ + order?: string; + /** + * The key of a field used to color each route categorically. Every route uses `color`/the + * theme's series colors (cycled by route) if omitted + */ + category?: string; + /** + * A fixed color for every route, used when `category` isn't given + */ + color?: IColor; + /** + * The set of colors to cycle through for each route/`category`. Defaults to the theme's series colors + */ + colors?: IColor[]; + strokeWidth?: number; + strokeOpacity?: number; + /** + * Should a marker be drawn at every waypoint along each route? + * @default true + */ + showPoints?: boolean; + /** + * The radius, in pixels, of each waypoint marker + * @default 2.5 + */ + pointRadius?: number; + /** + * Should the plot be interactive and be able to trigger tooltips? + * @default true + */ + interactive?: boolean; + /** + * Should this series feature in the Legend? + * @default true + */ + showInLegend?: boolean; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +/** + * Represents a GeoPaths layer, connecting every route/`group`'s rows of data (e.g. one tracked + * animal's individual position readings) into a single route across the map, ordered by `order` if + * given. Used inside a `` chart alongside any other layer - not a standalone chart itself + * @param props The set of React properties + * @return The GeoPaths component + */ +export function GeoPaths({ + useCanvas = false, + lat, + lon, + group, + order, + category, + color, + colors, + strokeWidth = 1.5, + strokeOpacity = 1, + showPoints = true, + pointRadius = 2.5, + interactive = true, + showInLegend = true, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IGeoPathsProps) { + const data = useSelector((s: IState) => chartSelectors.data(s)); + const theme = useSelector((s: IState) => chartSelectors.theme(s)); + const { project } = useGeoContext("GeoPaths"); + + const palette = colors ?? theme.series.colors; + const groups = useMemo(() => Object.keys(groupBy(data as IData, group)), [data, group]); + const legendKeys = category ? Array.from(new Set((data as IData).map((d) => `${d[category]}`))) : groups; + const legendColors = useMemo(() => legendKeys.map((_, index) => palette[index % palette.length]), [legendKeys, palette]); + + useLegendItems(legendKeys, "line", showInLegend, legendColors); + + const onTooltip = useTooltip(); + const onFocus = useFocused(theme); + + const { routes, waypoints, colorFor } = useMemo(() => { + const byGroup = groupBy(data as IData, group); + + // @ts-ignore: TODO: Not sure how to fix this + const colorScale = d3.scaleOrdinal().domain(legendKeys).range(palette); + const colorFor = (groupKey: string, datum: IDatum) => { + if (color) return color.toString(); + const key = category ? `${datum[category]}` : groupKey; + return colorScale(key).toString(); + }; + + const projectRow = (datum: IDatum): [number, number] | null => { + const longitude = Number(datum[lon]); + const latitude = Number(datum[lat]); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null; + + return project([longitude, latitude]); + }; + + const routes: IRoute[] = groups.map((groupKey) => { + const rows = order ? sortBy(byGroup[groupKey], order) : byGroup[groupKey]; + const coordinates = rows.map(projectRow).filter((point): point is [number, number] => point !== null); + + return { key: groupKey, group: groupKey, rows, d: d3.line()(coordinates) ?? "" }; + }); + + const waypoints: IWaypoint[] = showPoints + ? groups.flatMap((groupKey) => + (order ? sortBy(byGroup[groupKey], order) : byGroup[groupKey]) + .map((datum, index) => { + const projected = projectRow(datum); + return projected ? { key: `${groupKey}:${index}`, group: groupKey, datum, cx: projected[0], cy: projected[1] } : null; + }) + .filter((waypoint): waypoint is IWaypoint => waypoint !== null), + ) + : []; + + return { routes, waypoints, colorFor }; + }, [data, group, groups, order, lat, lon, category, legendKeys, palette, color, project, showPoints]); + + const handleMouseOver = (item: IRoute | IWaypoint, element: Element, event: MouseEvent) => { + const datum = "datum" in item ? item.datum : item.rows[item.rows.length - 1]; + const routeColor = colorFor(item.group, datum) as IColor; + + onMouseOver && onMouseOver(datum, element, event); + onFocus && onFocus({ element, event, datum }); + onTooltip && onTooltip({ datum, event, name: item.group, value: undefined, color: routeColor }); + }; + + const handleMouseOut = (item: IRoute | IWaypoint, element: Element, event: MouseEvent) => { + const datum = "datum" in item ? item.datum : item.rows[item.rows.length - 1]; + + onMouseOut && onMouseOut(datum, element, event); + onFocus && onFocus(null); + onTooltip && onTooltip(null); + }; + + const handleClick = (item: IRoute | IWaypoint, element: Element, event: MouseEvent) => { + const datum = "datum" in item ? item.datum : item.rows[item.rows.length - 1]; + onClick && onClick(datum, element, event); + }; + + const Routes = useCanvas ? CanvasRoutesPlot : SVGRoutesPlot; + const Waypoints = useCanvas ? CanvasWaypointsPlot : SVGWaypointsPlot; + + return ( + + route.key} + d={(route) => route.d} + stroke={(route) => colorFor(route.group, route.rows[route.rows.length - 1])} + strokeOpacity={strokeOpacity} + strokeWidth={strokeWidth} + cursor={() => (interactive ? "pointer" : "default")} + interactive={interactive} + onMouseOver={handleMouseOver} + onMouseOut={handleMouseOut} + onClick={handleClick} + /> + {showPoints && ( + waypoint.key} + cx={(waypoint) => waypoint.cx} + cy={(waypoint) => waypoint.cy} + radius={() => pointRadius} + color={(waypoint) => colorFor(waypoint.group, waypoint.datum)} + opacity={theme.series.opacity} + cursor={() => (interactive ? "pointer" : "default")} + interactive={interactive} + onMouseOver={handleMouseOver} + onMouseOut={handleMouseOut} + onClick={handleClick} + /> + )} + + ); +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPaths/index.ts b/packages/react/src/lib/components/Plots/Geo/GeoPaths/index.ts new file mode 100644 index 000000000..866dc5ac2 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPaths/index.ts @@ -0,0 +1 @@ +export * from "./GeoPaths"; diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPie.tsx b/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPie.tsx new file mode 100644 index 000000000..c0ae2df2b --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPie.tsx @@ -0,0 +1,29 @@ +import React from "react"; + +import { withCanvas, withSVG } from "../../../../hoc"; + +import { GeoPieBase, IGeoPieBaseProps } from "./GeoPieBase"; + +export interface IGeoPieProps extends Omit { + /** + * Should Canvas be used instead of SVG? + */ + useCanvas?: boolean; +} + +const CanvasGeoPie = withCanvas(GeoPieBase, "plot geo-pie"); +const SVGGeoPie = withSVG(GeoPieBase, "plot geo-pie"); + +/** + * Represents a GeoPie layer - see `GeoPieBase` + * @param useCanvas Should Canvas be used instead of SVG? + * @param props The set of React properties + * @return The GeoPie component + */ +export function GeoPie({ useCanvas = false, ...props }: IGeoPieProps) { + if (useCanvas) { + return ; + } + + return ; +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPieBase.tsx b/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPieBase.tsx new file mode 100644 index 000000000..15e6097bd --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPie/GeoPieBase.tsx @@ -0,0 +1,307 @@ +import { chartSelectors, d3, IState } from "@chart-io/core"; +import type { IColor, IData, IDatum, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { useDatumContextMenu, useLegendItems, useRender } from "../../../../hooks"; + +import { renderCanvas } from "../../renderCanvas"; +import type { IArcAngles } from "../../interpolateArc"; +import { interpolateArc } from "../../interpolateArc"; +import { useFocused } from "../../useFocused"; +import { useTooltip } from "../../useTooltip"; +import { useGeoContext } from "../GeoContext"; + +interface ISlice { + key: string; + group: string; + cx: number; + cy: number; + innerRadius: number; + outerRadius: number; + startAngle: number; + endAngle: number; + datum: IDatum; +} + +export interface IGeoPieBaseProps { + /** + * The layer to be rendered upon. Typically this is an `` or a fake HTMLElement when using canvas. + */ + layer?: React.MutableRefObject; + /** + * The key of the field holding each row's latitude + */ + lat: string; + /** + * The key of the field holding each row's longitude + */ + lon: string; + /** + * The key of the field identifying which pie each row belongs to. Rows sharing a `lat`/`lon` + * are grouped into a single pie if omitted + */ + group?: string; + /** + * The key of the field used for the category/label of each slice + */ + category: string; + /** + * The key of the field used for the value of each slice + */ + value: string; + /** + * The outer radius, in pixels, of every pie - or a function of that pie's total `value` for + * area-proportional sizing (e.g. `(total) => scale(total)`) + * @default 20 + */ + radius?: number | ((total: number) => number); + /** + * The inner radius, as a fraction (0-1) of `radius`. Set this above `0` for a Donut instead of a + * Pie + * @default 0 + */ + innerRadius?: number; + /** + * The angular gap, in radians, to leave between each slice + * @default 0.01 + */ + padAngle?: number; + /** + * The corner radius, in pixels, to apply to each slice + * @default 0 + */ + cornerRadius?: number; + /** + * Should the slices within each pie be sorted by value (descending) rather than using the order + * of the data? + * @default false + */ + sort?: boolean; + /** + * The set of colors to use for each category. Defaults to the theme's series colors + */ + colors?: IColor[]; + /** + * Should the plot be interactive and be able to trigger tooltips? + * @default true + */ + interactive?: boolean; + /** + * Should this series feature in the Legend? + * @default true + */ + showInLegend?: boolean; + /** + * An HTML Canvas if the plot should be rendering to canvas instead + */ + canvas?: HTMLCanvasElement; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +/** + * Represents a GeoPie layer, drawing a small pie (or Donut, via `innerRadius`) glyph for every + * `lat`/`lon`/`group` on the map, summarizing that location's rows by `category`/`value` - e.g. the + * energy mix for every country on a world map. Used inside a `` chart alongside any other + * layer - not a standalone chart itself. Built the same way as ``, repeated once per pie + * @param props The set of React properties + * @return The GeoPieBase component + */ +export function GeoPieBase({ + layer, + lat, + lon, + group, + category, + value, + radius = 20, + innerRadius = 0, + padAngle = 0.01, + cornerRadius = 0, + sort = false, + colors, + interactive = true, + showInLegend = true, + canvas, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IGeoPieBaseProps) { + const data = useSelector((s: IState) => chartSelectors.data(s)); + const width = useSelector((s: IState) => chartSelectors.dimensions.width(s)); + const height = useSelector((s: IState) => chartSelectors.dimensions.height(s)); + const theme = useSelector((s: IState) => chartSelectors.theme(s)); + const animationDuration = useSelector((s: IState) => chartSelectors.animationDuration(s)); + const { project } = useGeoContext("GeoPie"); + + const palette = colors ?? theme.series.colors; + const categories = useMemo(() => (data as IData).map((d) => `${d[category]}`), [data, category]); + const legendKeys = useMemo(() => Array.from(new Set(categories)), [categories]); + const legendColors = useMemo(() => legendKeys.map((_, index) => palette[index % palette.length]), [legendKeys, palette]); + + useLegendItems(legendKeys, "square", showInLegend, legendColors); + const onTooltip = useTooltip(); + const onFocus = useFocused(theme, { canvas, width, height, layer }); + const onDatumContextMenu = useDatumContextMenu(); + + const { slices, colorScale } = useMemo(() => { + // @ts-ignore: TODO: Not sure how to fix this + const colorScale = d3.scaleOrdinal().domain(categories).range(palette); + + const byGroup = new Map>(); + (data as IData).forEach((datum) => { + const key = group ? `${datum[group]}` : `${datum[lat]}:${datum[lon]}`; + const rows = byGroup.get(key) ?? []; + rows.push({ datum, key }); + byGroup.set(key, rows); + }); + + const pieLayout = d3 + .pie<{ datum: IDatum; key: string }>() + .value(({ datum }) => Number(datum[value]) || 0) + // @ts-ignore: TODO: Not sure how to fix this + .sort(sort ? (a, b) => d3.descending(Number(a.datum[value]), Number(b.datum[value])) : null); + + const slices: ISlice[] = Array.from(byGroup.entries()).flatMap(([key, rows]) => { + const longitude = Number(rows[0].datum[lon]); + const latitude = Number(rows[0].datum[lat]); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return []; + + const projected = project([longitude, latitude]); + if (!projected) return []; + + const total = rows.reduce((sum, { datum }) => sum + (Number(datum[value]) || 0), 0); + const outerRadiusPx = typeof radius === "function" ? radius(total) : radius; + const innerRadiusPx = innerRadius * outerRadiusPx; + + return pieLayout(rows).map((arc, index) => ({ + key: `${key}:${index}`, + group: key, + cx: projected[0], + cy: projected[1], + innerRadius: innerRadiusPx, + outerRadius: outerRadiusPx, + startAngle: arc.startAngle, + endAngle: arc.endAngle, + datum: arc.data.datum, + })); + }); + + return { slices, colorScale }; + }, [data, lat, lon, group, category, value, radius, innerRadius, sort, categories, palette, project]); + + useRender(() => { + // Unable to render without the layer avaliable + if (!layer.current) return; + + const arcGenerator = d3 + .arc<{ startAngle: number; endAngle: number; innerRadius: number; outerRadius: number }>() + .padAngle(padAngle) + .cornerRadius(cornerRadius); + + const join = d3.select(layer.current).selectAll(".geo-pie-slice").data(slices, (d) => d.key); + + join.exit().remove(); + + const enter = join + .enter() + .append("path") + .attr("class", "geo-pie-slice") + .attr("data-path-type", "arc") + .style("fill", (d) => colorScale(`${d.datum[category]}`).toString()); + + const update = enter + .merge(join) + .attr("transform", (d) => `translate(${d.cx}, ${d.cy})`) + .attr("data-cx", (d) => d.cx) + .attr("data-cy", (d) => d.cy) + .attr("data-pad-angle", padAngle) + .attr("data-corner-radius", cornerRadius) + .style("opacity", theme.series.opacity) + .style("fill", (d) => colorScale(`${d.datum[category]}`).toString()) + .on("mouseover", function (event, d) { + // istanbul ignore next + if (!interactive) return; + + const color = colorScale(`${d.datum[category]}`) as IColor; + onMouseOver && onMouseOver(d.datum, this, event); + onFocus && onFocus({ element: this, event, datum: d.datum }); + onTooltip && onTooltip({ datum: d.datum, event, name: `${d.datum[category]}`, value: d.datum[value], color }); + }) + .on("mouseout", function (event, d) { + // istanbul ignore next + if (!interactive) return; + + onMouseOut && onMouseOut(d.datum, this, event); + onFocus && onFocus(null); + onTooltip && onTooltip(null); + }) + .on("click", function (event, d) { + // istanbul ignore next + if (!interactive) return; + + onClick && onClick(d.datum, this, event); + onDatumContextMenu(d.datum, event); + }) + .transition("arc") + .duration(animationDuration) + .attrTween("d", function (d) { + const node = this as unknown as { _current?: IArcAngles }; + const previous = node._current || { + startAngle: d.startAngle, + endAngle: d.startAngle, + innerRadius: d.innerRadius, + outerRadius: d.outerRadius, + }; + const target = { + startAngle: d.startAngle, + endAngle: d.endAngle, + innerRadius: d.innerRadius, + outerRadius: d.outerRadius, + }; + node._current = target; + + return (t: number) => { + const interpolated = interpolateArc(previous, target, t); + d3.select(this) + .attr("data-start-angle", interpolated.startAngle) + .attr("data-end-angle", interpolated.endAngle) + .attr("data-inner-radius", interpolated.innerRadius) + .attr("data-outer-radius", interpolated.outerRadius); + + return arcGenerator(interpolated); + }; + }); + + renderCanvas(canvas, renderVirtualCanvas, width, height, update); + }, [ + slices, + colorScale, + category, + value, + padAngle, + cornerRadius, + canvas, + renderVirtualCanvas, + layer, + animationDuration, + theme, + interactive, + onMouseOver, + onMouseOut, + onClick, + onDatumContextMenu, + width, + height, + ]); + + return null; +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPie/index.ts b/packages/react/src/lib/components/Plots/Geo/GeoPie/index.ts new file mode 100644 index 000000000..b57202ab0 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPie/index.ts @@ -0,0 +1 @@ +export * from "./GeoPie"; diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPoints/GeoPoints.tsx b/packages/react/src/lib/components/Plots/Geo/GeoPoints/GeoPoints.tsx new file mode 100644 index 000000000..a219bd803 --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPoints/GeoPoints.tsx @@ -0,0 +1,188 @@ +import { chartSelectors, d3, IState } from "@chart-io/core"; +import type { IColor, IData, IDatum, IOnClick, IOnMouseOut, IOnMouseOver } from "@chart-io/core"; + +import React, { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import { useDatumContextMenu, useLegendItems } from "../../../../hooks"; +import { withCanvas, withSVG } from "../../../../hoc"; + +import { INodesPlotProps, NodesPlot } from "../../NodesPlot"; +import { useFocused } from "../../useFocused"; +import { useTooltip } from "../../useTooltip"; +import { useGeoContext } from "../GeoContext"; + +interface IPoint { + key: string; + datum: IDatum; + cx: number; + cy: number; +} + +const CanvasPointsPlot = withCanvas>(NodesPlot, "plot geo-points"); +const SVGPointsPlot = withSVG>(NodesPlot, "plot geo-points"); + +export interface IGeoPointsProps { + /** + * Should Canvas be used instead of SVG? + */ + useCanvas?: boolean; + /** + * The key of the field holding each row's latitude + */ + lat: string; + /** + * The key of the field holding each row's longitude + */ + lon: string; + /** + * The key of a numeric field to size each point by (area-proportional, via a square-root + * scale). Points are drawn at a fixed `radius` if omitted + */ + value?: string; + /** + * A fixed radius for every point, or the `[min, max]` pixel range to scale `value` into + * @default 4 + */ + radius?: number | [number, number]; + /** + * The key of a field used to color each point categorically. Every point uses `color`/the + * theme's first series color if omitted + */ + category?: string; + /** + * A fixed color for every point, used when `category` isn't given + */ + color?: IColor; + /** + * The set of colors to use for each `category`. Defaults to the theme's series colors + */ + colors?: IColor[]; + /** + * Should the plot be interactive and be able to trigger tooltips? + * @default true + */ + interactive?: boolean; + /** + * Should this series feature in the Legend? + * @default true + */ + showInLegend?: boolean; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + onMouseOver?: IOnMouseOver; + onMouseOut?: IOnMouseOut; + onClick?: IOnClick; +} + +/** + * Represents a GeoPoints layer, plotting a circle marker for every row of data at its `lat`/`lon` + * coordinate - optionally sized by a `value` field and/or colored by a `category` field. Used inside + * a `` chart alongside any other layer - not a standalone chart itself + * @param props The set of React properties + * @return The GeoPoints component + */ +export function GeoPoints({ + useCanvas = false, + lat, + lon, + value, + radius = 4, + category, + color, + colors, + interactive = true, + showInLegend = true, + renderVirtualCanvas, + onMouseOver, + onMouseOut, + onClick, +}: IGeoPointsProps) { + const data = useSelector((s: IState) => chartSelectors.data(s)); + const theme = useSelector((s: IState) => chartSelectors.theme(s)); + const { project } = useGeoContext("GeoPoints"); + + const palette = colors ?? theme.series.colors; + const categories = useMemo( + () => (category ? Array.from(new Set((data as IData).map((d) => `${d[category]}`))) : []), + [data, category], + ); + const legendColors = useMemo(() => categories.map((_, index) => palette[index % palette.length]), [categories, palette]); + + useLegendItems(categories, "circle", showInLegend && Boolean(category), legendColors); + + const onTooltip = useTooltip(); + const onFocus = useFocused(theme); + const onDatumContextMenu = useDatumContextMenu(); + + const { points, colorFor, radiusFor } = useMemo(() => { + const points: IPoint[] = (data as IData) + .map((datum, index) => { + const longitude = Number(datum[lon]); + const latitude = Number(datum[lat]); + if (!Number.isFinite(longitude) || !Number.isFinite(latitude)) return null; + + const projected = project([longitude, latitude]); + return projected ? { key: `${index}`, datum, cx: projected[0], cy: projected[1] } : null; + }) + .filter((point): point is IPoint => point !== null); + + // @ts-ignore: TODO: Not sure how to fix this + const colorScale = category ? d3.scaleOrdinal().domain(categories).range(palette) : null; + const colorFor = (point: IPoint) => (colorScale ? colorScale(`${point.datum[category]}`).toString() : (color ?? palette[0]).toString()); + + const radiusFor = Array.isArray(radius) + ? (() => { + const extent = d3.extent((data as IData).map((datum) => Number(datum[value]))) as [number, number]; + const scale = d3.scaleSqrt().domain(extent).range(radius); + return (point: IPoint) => scale(Number(point.datum[value])); + })() + : () => radius; + + return { points, colorFor, radiusFor }; + }, [data, lat, lon, value, radius, category, categories, palette, color, project]); + + const handleMouseOver = (point: IPoint, element: Element, event: MouseEvent) => { + const { datum } = point; + const color = colorFor(point) as IColor; + + onMouseOver && onMouseOver(datum, element, event); + onFocus && onFocus({ element, event, datum }); + onTooltip && onTooltip({ datum, event, name: category ? `${datum[category]}` : `${datum[lat]}, ${datum[lon]}`, value: value ? datum[value] : undefined, color }); + }; + + const handleMouseOut = (point: IPoint, element: Element, event: MouseEvent) => { + onMouseOut && onMouseOut(point.datum, element, event); + onFocus && onFocus(null); + onTooltip && onTooltip(null); + }; + + const handleClick = (point: IPoint, element: Element, event: MouseEvent) => { + onClick && onClick(point.datum, element, event); + onDatumContextMenu(point.datum, event); + }; + + const Points = useCanvas ? CanvasPointsPlot : SVGPointsPlot; + + return ( + point.key} + cx={(point) => point.cx} + cy={(point) => point.cy} + radius={radiusFor} + color={colorFor} + opacity={theme.series.opacity} + stroke={theme.background.toString()} + cursor={() => (interactive ? "pointer" : "default")} + interactive={interactive} + onMouseOver={handleMouseOver} + onMouseOut={handleMouseOut} + onClick={handleClick} + /> + ); +} diff --git a/packages/react/src/lib/components/Plots/Geo/GeoPoints/index.ts b/packages/react/src/lib/components/Plots/Geo/GeoPoints/index.ts new file mode 100644 index 000000000..725fc263c --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/GeoPoints/index.ts @@ -0,0 +1 @@ +export * from "./GeoPoints"; diff --git a/packages/react/src/lib/components/Plots/Geo/__image_snapshots__/geo-unit-tsx-geo-using-canvas-should-render-a-choropleth-to-canvas-without-throwing-1-snap.png b/packages/react/src/lib/components/Plots/Geo/__image_snapshots__/geo-unit-tsx-geo-using-canvas-should-render-a-choropleth-to-canvas-without-throwing-1-snap.png new file mode 100644 index 0000000000000000000000000000000000000000..0022bbe98bd81ff9eab23734725de4acaffcbca0 GIT binary patch literal 2337 zcmai0c|26@9@jK6_8H7ewvMq(LKR#e z(jsZ&N=cZJZ6^EnB4OM!djGxmp7S~9InVQ3zQ5)9J-?H5%E4M(L|#NdKtSBq#=;34 zJNXYx5PVm;R~LhWAn}B?g}^rdzSr_FTR;HTXKR6Xp%l)tOwtuSkT*xj^HrW-N)Gp$ z_@#fXgA}BT3GXV%0PxT4Xc#YL;k^lFl-B>L|ajTe0w-`@U2;yP6L%wH}g|%e7 zB^RpgP;CzNn~ds-*Cz)P0KfV1bd(vO1Z1R9GKFNx5urx8-N6IZ+T)aeXA6a8kDP3s z-o-wLww)MdraEPba>rXg+E5gzp%#{D)BnN)$+Wb<@|T0KvfcDEN$&vlJQg$1sZl1A zN+%fhj+}&kO5H#$MfIk-PS{(pN#SY?Gk|sEbmsC&EFp)CFuM5nN{_h2x*~FYNRRtW(u;;X870wn%V)(QlQ-##+HMJ{pKUp# z{%x#^CnD6)=SJVM1<{k5Z-Wz^E6}TULz!_O0KO}_6)N&Q350I|vGjPDFMA6_T zCi`n)v&_h`c6n-l!Ia?e?ufyVeGrTluv$LwU22|`{Dv*uE(g*Kjqq*EW^H?*1J=&| z>p&n+v9G?&Nt$K?slmD;1Dl-B%$V=cTK%E5L)?2gNl*Tu%AK&4aFJ*2MtB_S@f5|1 zenij=bXAd!mzqoLin3BX^MpwV8b()-@X;M#=w)OL^nuE4LLjzI#^};8tCvTiM0-ms z4=tKal^vg!|L)L4VVUlIdFEl6KG3hOx6cE>xnzyttbM{YSi6sJfyq?s_X#_TUsd+; z_27aj7fxE=LhwE6aCxAcw}ywcD^umj4)j9f$@huKb*q13Aeb`x18s+f0@GymWb{o} zgSn_f3`>A{tCoVeR=qd(dLV*Ug@3hnZmVPhi)j2zqTw!!zYd6Et!j}8a8D?a^eF%8 zo@p&8F$H&dg{CHsa>MXjunYR|4Rke25uC)>))fd!80Y9n}_!qbEDfzGp~rUFA;xi z5_!vxy!Dw^ZQIk6G33>4Ha1OTc$+g~f>430LW!M5rW@0y#mBa0=Z{C+soM^YUVjm{ zFKT)rWG(xg5}Wa7XVM2EqE?dRcjfp`9B>QPehM)Z;1NI5-K-tA>~Q{BkqA}=ZvJ8; zS#HU6S*}^q=U|vIGRO?L)tu{5^IC;~XkKR5#mb@Jp3AkJ+nw)UCec2G8^MFD0466u zyEcY*&#QSQAIDEyglof$Ipa2tA7YQ8(WjE5sDoYj9g23;s)&=-PI5>-oMFMt2^|>- z#sbuV;rMcNWH+Q}Dv%n3WY5B~H{Xqfz+K4X^l(e!+P}NwQ$Tg=1TC8vDt8S)9^GV= zDSZAI6-hCsc>}MkxFT-urUr$YOq_$ zeIpJrV$bKexvk{>KKOSZnER_MI(SUBll5)d68UD`S_oDqO7E6fi&S_FByPm*r*{u1 ziB!UEgbK-ylA!$FoPdQn)4!!#ejG1Jqo+#NXp9}Wn-DoL=i;7#I|@%8U2J)KcC4Gn z3;p4Ddh^-uhB3Cd5q?X-^a&+IUktU>z{=!zk+H>=trs2Lel6I&F3?Y4r|B4*Y>*FX zTJ(1j7cNr(yCJ$XT2Uxw!_iD=e&~r?isiU-LC=B)*pkRW1hk21ov(#6mAAn52@Z3OIe!?*wgQwYrvDnB4iAzXCxu&?xOSN~Wo;!_ z_B$XpOMSO~4(^NQj)aExh7I4iLV?F_XS4h%&SOtWXR$6T4^d0m;@Is@qc!eb>;L~V caV2|uS0qBZ?bsfDEB>KnYw2K7ZFV;HKM0~0aR2}S literal 0 HcmV?d00001 diff --git a/packages/react/src/lib/components/Plots/Geo/index.ts b/packages/react/src/lib/components/Plots/Geo/index.ts new file mode 100644 index 000000000..e3110a91a --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/index.ts @@ -0,0 +1,7 @@ +export * from "./Geo"; +export * from "./resolveProjection"; +export * from "./Choropleth"; +export * from "./GeoPoints"; +export * from "./GeoPie"; +export * from "./GeoArcs"; +export * from "./GeoPaths"; diff --git a/packages/react/src/lib/components/Plots/Geo/resolveProjection.ts b/packages/react/src/lib/components/Plots/Geo/resolveProjection.ts new file mode 100644 index 000000000..54dd3d5db --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/resolveProjection.ts @@ -0,0 +1,35 @@ +import { d3 } from "@chart-io/core"; + +export type IGeoProjectionType = + | "equalEarth" + | "mercator" + | "naturalEarth1" + | "orthographic" + | "albersUsa" + | "albers" + | "azimuthalEqualArea"; + +const PROJECTIONS: Record d3.GeoProjection> = { + equalEarth: d3.geoEqualEarth, + mercator: d3.geoMercator, + naturalEarth1: d3.geoNaturalEarth1, + orthographic: d3.geoOrthographic, + albersUsa: d3.geoAlbersUsa, + albers: d3.geoAlbers, + azimuthalEqualArea: d3.geoAzimuthalEqualArea, +}; + +/** + * Resolves ``'s `projection` prop into a fresh `d3.GeoProjection` instance - either one of the + * built-in named presets, or a caller-supplied factory for full control (e.g. a custom `.rotate()`, + * `.clipAngle()`, or an entirely different `d3-geo` projection not offered as a preset) + * @param projection A preset name, or a factory function returning a `d3.GeoProjection` + * @return A new projection instance + */ +export function resolveProjection(projection: IGeoProjectionType | (() => d3.GeoProjection) = "equalEarth"): d3.GeoProjection { + if (typeof projection === "function") { + return projection(); + } + + return PROJECTIONS[projection](); +} diff --git a/packages/react/src/lib/components/Plots/Geo/useGeoProjection.ts b/packages/react/src/lib/components/Plots/Geo/useGeoProjection.ts new file mode 100644 index 000000000..e4586f4be --- /dev/null +++ b/packages/react/src/lib/components/Plots/Geo/useGeoProjection.ts @@ -0,0 +1,77 @@ +import { chartSelectors, d3, IState, normalizeGeoFeatures } from "@chart-io/core"; +import type { IGeoFeatures } from "@chart-io/core"; + +import { useMemo } from "react"; +import { useSelector } from "react-redux"; + +import type { IGeoContextValue } from "./GeoContext"; +import { IGeoProjectionType, resolveProjection } from "./resolveProjection"; + +// Used to fit the projection to the whole globe/plane when no `features` are given yet (e.g. while +// a ``/``/`` map with no basemap geography is loading its own data) +const WORLD = { type: "Sphere" } as const; + +export interface IUseGeoProjectionProps { + /** + * The geography to render/fit the projection to - GeoJSON (a `Feature` or `FeatureCollection`) + * or a TopoJSON `Topology` (see `object`) + */ + features?: IGeoFeatures; + /** + * Which object to extract, if `features` is a TopoJSON `Topology` with more than one. Defaults + * to the first object on the topology + */ + object?: string; + /** + * The projection to use - either one of the built-in presets, or a factory for full control + * @default "equalEarth" + */ + projection?: IGeoProjectionType | (() => d3.GeoProjection); + /** + * An optional `[lambda, phi, gamma]` rotation, in degrees, applied to the projection - e.g. to + * recenter a world map on a different meridian + */ + rotate?: [number, number, number]; +} + +/** + * Builds the projection shared by a `` chart's layers - fitted to the plot area (and, if given, + * to `features`) once per render rather than being repeated by each layer + * @param props The set of properties needed to build the projection + * @return The projection, its `path` generator, a `project` shorthand, and the + * normalized `features` + */ +export function useGeoProjection({ features, object, projection, rotate }: IUseGeoProjectionProps): IGeoContextValue { + const plotLeft = useSelector((s: IState) => chartSelectors.dimensions.plot.left(s)); + const plotTop = useSelector((s: IState) => chartSelectors.dimensions.plot.top(s)); + const plotWidth = useSelector((s: IState) => chartSelectors.dimensions.plot.width(s)); + const plotHeight = useSelector((s: IState) => chartSelectors.dimensions.plot.height(s)); + + const featureCollection = useMemo(() => normalizeGeoFeatures(features, object), [features, object]); + + return useMemo(() => { + const projectionInstance = resolveProjection(projection); + + if (rotate) { + projectionInstance.rotate(rotate); + } + + // A projection can't be usefully fitted to a zero-sized plot area (e.g. the first render, + // before has dispatched its measured dimensions) + if (plotWidth > 0 && plotHeight > 0) { + const extent: [[number, number], [number, number]] = [ + [plotLeft, plotTop], + [plotLeft + plotWidth, plotTop + plotHeight], + ]; + const geometry = featureCollection?.features?.length ? featureCollection : WORLD; + + projectionInstance.fitExtent(extent, geometry as any); + } + + const path = d3.geoPath(projectionInstance); + const project = ([longitude, latitude]: [number, number]): [number, number] | null => + projectionInstance([longitude, latitude]); + + return { projection: projectionInstance, path, project, features: featureCollection }; + }, [projection, rotate, featureCollection, plotLeft, plotTop, plotWidth, plotHeight]); +} diff --git a/packages/react/src/lib/components/Plots/ShapesPlot.tsx b/packages/react/src/lib/components/Plots/ShapesPlot.tsx new file mode 100644 index 000000000..15cee552a --- /dev/null +++ b/packages/react/src/lib/components/Plots/ShapesPlot.tsx @@ -0,0 +1,174 @@ +import { chartSelectors, d3, IState } from "@chart-io/core"; + +import React from "react"; +import { useSelector } from "react-redux"; + +import { useRender } from "../../hooks"; + +import { renderCanvas } from "./renderCanvas"; + +export interface IShapesPlotProps { + /** + * The layer to be rendered upon. Typically this is an `` or a fake HTMLElement when using canvas. + */ + layer?: React.MutableRefObject; + /** + * An HTML Canvas if the plot should be rendering to canvas instead + */ + canvas?: HTMLCanvasElement; + /** + * This is an internally used function to allow the plot to render to a virtual canvas + */ + renderVirtualCanvas?: (update: d3.Transition) => void; + /** + * The CSS class applied to every shape - also doubles as the D3 join/transition selector, so it + * should be unique to this plot instance + */ + className: string; + items: T[]; + keyFor: (item: T) => string; + /** + * The already-projected SVG path `d` string for this item - e.g. `d3.geoPath(projection)(feature)` + * for a `` region, or a hand-built `M...L...` string for a flow arc or tracked route + */ + d: (item: T) => string; + /** + * The fill color, either fixed for every shape or derived per-item - e.g. a `` region + * colored by its value. Defaults to `"none"`, for a stroke-only shape like a route or flow line + */ + fill?: string | ((item: T) => string); + fillOpacity?: number; + /** + * The stroke color, either fixed for every shape or derived per-item. Defaults to `"none"`, for a + * fill-only shape like a `` region with no border + */ + stroke?: string | ((item: T) => string); + strokeOpacity?: number; + strokeWidth?: number | ((item: T) => number); + cursor?: (item: T) => string; + /** + * The baseline opacity applied to every shape - independent of `useFocused`'s per-item + * hover/selected opacity, which is applied (and reset back to this) on top + */ + opacity?: number; + /** + * Should the plot be interactive and dispatch mouseover/mouseout/click callbacks? + * @default true + */ + interactive?: boolean; + onMouseOver?: (item: T, element: Element, event: MouseEvent) => void; + onMouseOut?: (item: T, element: Element, event: MouseEvent) => void; + onClick?: (item: T, element: Element, event: MouseEvent) => void; +} + +/** + * Renders a set of arbitrary SVG shapes from a `d` path string, with Canvas support (via the `"geo"` + * Canvas path type - see `renderGeoPath`). Used by ``'s layers (``'s regions, + * ``'s flow lines, ``'s tracked routes) but not specific to any one of them - not + * geo-specific either, just generalizes `` to arbitrary (not just source/target) items and + * to filled (not just stroked) shapes + * @param props The set of React properties + * @return The ShapesPlot component + */ +export function ShapesPlot({ + layer, + canvas, + renderVirtualCanvas, + className, + items, + keyFor, + d, + fill = "none", + fillOpacity = 1, + stroke = "none", + strokeOpacity = 1, + strokeWidth, + cursor, + opacity, + interactive = true, + onMouseOver, + onMouseOut, + onClick, +}: IShapesPlotProps) { + const width = useSelector((s: IState) => chartSelectors.dimensions.width(s)); + const height = useSelector((s: IState) => chartSelectors.dimensions.height(s)); + const animationDuration = useSelector((s: IState) => chartSelectors.animationDuration(s)); + + useRender(() => { + // Unable to render without the layer avaliable + if (!layer.current) return; + + const fillFor = typeof fill === "function" ? fill : () => fill; + const strokeFor = typeof stroke === "function" ? stroke : () => stroke; + const strokeWidthFor = typeof strokeWidth === "function" ? strokeWidth : () => strokeWidth ?? 1; + + const join = d3.select(layer.current).selectAll(`.${className}`).data(items, (item) => keyFor(item)); + + join.exit().remove(); + + const enter = join + .enter() + .append("path") + .attr("class", className) + .attr("data-path-type", "geo") + .attr("d", (item) => d(item)) + .style("opacity", 0) + .on("mouseover", function (event, item) { + // istanbul ignore next + if (!interactive) return; + + onMouseOver && onMouseOver(item, this, event); + }) + .on("mouseout", function (event, item) { + // istanbul ignore next + if (!interactive) return; + + onMouseOut && onMouseOut(item, this, event); + }) + .on("click", function (event, item) { + // istanbul ignore next + if (!interactive) return; + + onClick && onClick(item, this, event); + }); + + const update = enter + .merge(join as any) + .style("fill", fillFor) + .style("fill-opacity", fillOpacity) + .style("stroke", strokeFor) + .style("stroke-opacity", strokeOpacity) + .style("stroke-width", strokeWidth === undefined ? null : strokeWidthFor) + .style("cursor", (item) => (cursor ? cursor(item) : "default")); + + const transition = update + .transition(className) + .duration(animationDuration) + .attr("d", (item) => d(item)) + .style("opacity", opacity ?? 1); + + renderCanvas(canvas, renderVirtualCanvas, width, height, transition); + }, [ + items, + keyFor, + d, + fill, + fillOpacity, + stroke, + strokeOpacity, + strokeWidth, + cursor, + opacity, + className, + interactive, + onMouseOver, + onMouseOut, + onClick, + canvas, + renderVirtualCanvas, + layer, + animationDuration, + ]); + + return null; +} diff --git a/packages/react/src/lib/components/Plots/index.ts b/packages/react/src/lib/components/Plots/index.ts index 32bd4ad4d..aa511b2f3 100644 --- a/packages/react/src/lib/components/Plots/index.ts +++ b/packages/react/src/lib/components/Plots/index.ts @@ -12,6 +12,7 @@ export * from "./Dendrogram"; export * from "./RadialDendrogram"; export * from "./CirclePacking"; export * from "./Chord"; +export * from "./Geo"; export * from "./WordCloud"; export * from "./ParallelCoordinates"; export * from "./Sankey"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 652e1185c..7bd3bb40d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,9 @@ importers: d3-format: specifier: ^3.1.0 version: 3.1.0 + d3-geo: + specifier: ^3.1.1 + version: 3.1.1 d3-hierarchy: specifier: ^3.1.2 version: 3.1.2 @@ -129,6 +132,9 @@ importers: proxy-memoize: specifier: ^2.0.4 version: 2.0.6 + topojson-client: + specifier: ^3.1.0 + version: 3.1.0 uuid: specifier: ^9.0.0 version: 9.0.1 @@ -166,6 +172,9 @@ importers: '@types/d3-format': specifier: ^3.0.1 version: 3.0.4 + '@types/d3-geo': + specifier: ^3.1.0 + version: 3.1.1 '@types/d3-hierarchy': specifier: ^3.1.7 version: 3.1.7 @@ -193,9 +202,15 @@ importers: '@types/d3-transition': specifier: ^3.0.3 version: 3.0.9 + '@types/geojson': + specifier: ^7946.0.14 + version: 7946.0.16 '@types/jest': specifier: ^29.4.0 version: 29.5.14 + '@types/topojson-client': + specifier: ^3.1.4 + version: 3.1.5 '@typescript-eslint/eslint-plugin': specifier: ^6.14.0 version: 6.21.0(@typescript-eslint/parser@5.62.0)(eslint@8.57.1)(typescript@5.9.2) @@ -320,6 +335,9 @@ importers: '@types/d3-selection': specifier: ^3.0.4 version: 3.0.11 + '@types/geojson': + specifier: ^7946.0.14 + version: 7946.0.16 '@types/jest': specifier: ^29.4.0 version: 29.5.14 @@ -6089,6 +6107,12 @@ packages: resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} dev: true + /@types/d3-geo@3.1.1: + resolution: {integrity: sha512-65Emv9fQiQQqphLlRkuQ5ypPsOmWPhtBGCMv61JDPEPMvsx+gzhGf74yw1a78xFKPj6zw4AgQICJoQv0vK9M2w==} + dependencies: + '@types/geojson': 7946.0.16 + dev: true + /@types/d3-hierarchy@3.1.7: resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} dev: true @@ -6228,6 +6252,10 @@ packages: resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} dev: true + /@types/geojson@7946.0.16: + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + dev: true + /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -6461,6 +6489,19 @@ packages: - react-dom dev: true + /@types/topojson-client@3.1.5: + resolution: {integrity: sha512-C79rySTyPxnQNNguTZNI1Ct4D7IXgvyAs3p9HPecnl6mNrJ5+UhvGNYcZfpROYV2lMHI48kJPxwR+F9C6c7nmw==} + dependencies: + '@types/geojson': 7946.0.16 + '@types/topojson-specification': 1.0.5 + dev: true + + /@types/topojson-specification@1.0.5: + resolution: {integrity: sha512-C7KvcQh+C2nr6Y2Ub4YfgvWvWCgP2nOQMtfhlnwsRL4pYmmwzBS7HclGiS87eQfDOU/DLQpX6GEscviaz4yLIQ==} + dependencies: + '@types/geojson': 7946.0.16 + dev: true + /@types/tough-cookie@4.0.5: resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==} dev: true @@ -9239,6 +9280,13 @@ packages: engines: {node: '>=12'} dev: false + /d3-geo@3.1.1: + resolution: {integrity: sha512-637ln3gXKXOwhalDzinUgY83KzNWZRKbYubaG+fGVuc/dxO64RRljtCTnf5ecMyE1RIdtqpkVcq0IbtU2S8j2Q==} + engines: {node: '>=12'} + dependencies: + d3-array: 3.2.4 + dev: false + /d3-hierarchy@3.1.2: resolution: {integrity: sha512-FX/9frcub54beBdugHjDCdikxThEqjnR93Qt7PvQTOHxyiNCAlvMrHhclk3cD5VeAaq9fxmfRp+CnWw9rEMBuA==} engines: {node: '>=12'} @@ -18480,6 +18528,13 @@ packages: engines: {node: '>=0.6'} dev: false + /topojson-client@3.1.0: + resolution: {integrity: sha512-605uxS6bcYxGXw9qi62XyrV6Q3xwbndjachmNxu8HWTtVPxZfEJN9fd/SZS1Q54Sn2y0TMyMxFj/cJINqGHrKw==} + hasBin: true + dependencies: + commander: 2.20.3 + dev: false + /totalist@3.0.1: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'}