392 lines · 4 files · 10.1 kB
cases/110-projection-gallery/example.tsx89 lines · entry
cases/110-projection-gallery/example.tsx
import { Chart } from '@tanstack/charts/react/tooltip'
import { tooltip as exampleTooltip } from '@tanstack/charts/tooltip'
import { defineChart, facetChart } from '@tanstack/charts'
import { geoShape } from '@tanstack/charts/geo'
import {
previewWorldLand,
worldLand,
worldSphere,
} from '@tanstack/charts-data/country-atlas'
import { projectionGalleryData } from './projection'
const projectionColors = [
['#2563eb', '#7c3aed', '#0891b2', '#ea580c'],
['#1d4ed8', '#6d28d9', '#0e7490', '#c2410c'],
]
export const createExampleChart = (input: ChartOptions) => {
const preview = false
const projections = projectionGalleryData()
const color = {
domain: projections.map(({ id }) => id),
range: projectionColors[input.revision % 2] ?? projectionColors[0],
}
return defineChart(
facetChart(projections, {
id: 'projection-gallery',
by: 'id',
columns: 2,
gap: 0,
label: false,
chart: ([entry]) => {
const projection = {
type: preview ? () => entry.create().precision(2) : entry.create,
fit: 'sphere' as const,
inset: 8,
}
return {
marks: [
geoShape([worldSphere], {
id: 'sphere',
projection,
fill: 'none',
stroke: 'currentColor',
strokeOpacity: 0.5,
strokeWidth: 0.8,
}),
geoShape([preview ? previewWorldLand : worldLand], {
id: 'land',
projection,
color: () => entry.id,
fillOpacity: 0.78,
stroke: 'currentColor',
strokeOpacity: 0.28,
strokeWidth: 0.45,
}),
],
scales: {
x: null,
y: null,
},
color,
guides: false,
margin: 0,
}
},
}),
{
keyboard: true,
tooltip: exampleTooltip,
},
)
}
export interface ChartOptions {
revision: number
}
export const exampleAriaLabel = 'Standard world projection gallery'
export const chart = createExampleChart({
revision: 0,
})
export default function Example() {
return <Chart ariaLabel={exampleAriaLabel} definition={chart} height={480} />
}cases/110-projection-gallery/projection.ts38 lines · dependency
cases/110-projection-gallery/projection.ts
import {
geoEqualEarth,
geoEquirectangular,
geoMercator,
geoNaturalEarth1,
} from 'd3-geo'
import type { GeoProjection } from 'd3-geo'
export type ProjectionGalleryId =
'equal-earth' | 'natural-earth' | 'mercator' | 'equirectangular'
export interface ProjectionGalleryDatum {
id: ProjectionGalleryId
create: () => GeoProjection
}
const projections: readonly ProjectionGalleryDatum[] = [
{
id: 'equal-earth',
create: geoEqualEarth,
},
{
id: 'natural-earth',
create: geoNaturalEarth1,
},
{
id: 'mercator',
create: geoMercator,
},
{
id: 'equirectangular',
create: geoEquirectangular,
},
]
export function projectionGalleryData(): readonly ProjectionGalleryDatum[] {
return projections
}packages/charts-demo-data/src/country-atlas.ts135 lines · dependency
packages/charts-demo-data/src/country-atlas.ts
import countriesAtlasJson from 'world-atlas/countries-110m.json'
import landAtlasJson from 'world-atlas/land-110m.json'
import detailedLandAtlasJson from 'world-atlas/land-50m.json'
import { geoGraticule, geoGraticule10 } from 'd3-geo'
import { feature } from 'topojson-client'
import { simplifyPolygonGeometry } from './simplify-geo'
import type {
ExtendedFeature,
ExtendedFeatureCollection,
GeoGeometryObjects,
GeoSphere,
} from 'd3-geo'
type AtlasTopology = Parameters<typeof feature>[0]
export type CountryGeometry = Extract<
GeoGeometryObjects,
{ type: 'Polygon' | 'MultiPolygon' }
>
export interface CountryProperties {
name: string
}
export type CountryFeature = ExtendedFeature<CountryGeometry, CountryProperties>
export type LandFeature = ExtendedFeature<CountryGeometry, Record<never, never>>
export const worldSphere: GeoSphere = { type: 'Sphere' }
export const worldGraticule = geoGraticule10()
export const previewWorldGraticule = geoGraticule().step([30, 30])()
const countriesTopology = atlasTopology(
countriesAtlasJson,
'world-atlas countries-110m',
)
const countriesObject = countriesTopology.objects.countries
if (!countriesObject) {
throw new TypeError('world-atlas countries-110m is missing countries')
}
const convertedCountries = feature(countriesTopology, countriesObject)
if (convertedCountries.type !== 'FeatureCollection') {
throw new TypeError('world-atlas countries did not produce a collection')
}
export const worldCountries: readonly CountryFeature[] =
convertedCountries.features.flatMap<CountryFeature>((entry) => {
if (
!isCountryGeometry(entry.geometry) ||
!isRecord(entry.properties) ||
typeof entry.properties.name !== 'string'
) {
return []
}
return [
{
type: 'Feature',
id: entry.id === undefined ? entry.properties.name : String(entry.id),
geometry: entry.geometry,
properties: {
name: entry.properties.name,
},
},
]
})
if (worldCountries.length !== 177) {
throw new TypeError(
`Expected 177 world-atlas countries, got ${worldCountries.length}`,
)
}
export const worldCountryCollection: ExtendedFeatureCollection<CountryFeature> =
{
type: 'FeatureCollection',
features: [...worldCountries],
}
export const worldLand = convertLand(landAtlasJson, 'world-atlas land-110m')
export const previewWorldLand: LandFeature = {
...worldLand,
geometry: simplifyPolygonGeometry(worldLand.geometry, 2),
}
export const detailedWorldLand = convertLand(
detailedLandAtlasJson,
'world-atlas land-50m',
)
function atlasTopology(value: unknown, label: string): AtlasTopology {
if (!isAtlasTopology(value)) {
throw new TypeError(`${label} is not valid TopoJSON`)
}
return value
}
function convertLand(value: unknown, label: string): LandFeature {
const topology = atlasTopology(value, label)
const landObject = topology.objects.land
if (!landObject) {
throw new TypeError(`${label} is missing land`)
}
const converted = feature(topology, landObject)
const land =
converted.type === 'FeatureCollection' ? converted.features[0] : converted
if (!land || land.type !== 'Feature' || !isCountryGeometry(land.geometry)) {
throw new TypeError(`${label} did not produce polygon geometry`)
}
return {
type: 'Feature',
geometry: land.geometry,
properties: {},
}
}
function isCountryGeometry(
geometry: GeoGeometryObjects,
): geometry is CountryGeometry {
return geometry.type === 'Polygon' || geometry.type === 'MultiPolygon'
}
function isAtlasTopology(value: unknown): value is AtlasTopology {
return (
isRecord(value) &&
value.type === 'Topology' &&
Array.isArray(value.arcs) &&
isRecord(value.objects)
)
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null
}packages/charts-demo-data/src/simplify-geo.ts130 lines · dependency
packages/charts-demo-data/src/simplify-geo.ts
import type { GeoGeometryObjects } from 'd3-geo'
type PolygonGeometry = Extract<
GeoGeometryObjects,
{ type: 'Polygon' | 'MultiPolygon' }
>
type Position = number[]
export function simplifyPolygonGeometry(
geometry: PolygonGeometry,
tolerance: number,
): PolygonGeometry {
if (geometry.type === 'Polygon') {
return {
type: 'Polygon',
coordinates: geometry.coordinates.map((ring) =>
simplifyRing(ring, tolerance),
),
}
}
return {
type: 'MultiPolygon',
coordinates: geometry.coordinates.map((polygon) =>
polygon.map((ring) => simplifyRing(ring, tolerance)),
),
}
}
function simplifyRing(
ring: readonly Position[],
tolerance: number,
): Position[] {
if (ring.length <= 4) return [...ring]
const openRing = ring.slice(0, -1)
const anchor = openRing[0]
if (!anchor) return [...ring]
let splitIndex = 1
let farthestDistance = 0
for (let index = 1; index < openRing.length; index += 1) {
const point = openRing[index]
if (!point) continue
const distance = squaredDistance(anchor, point)
if (distance > farthestDistance) {
farthestDistance = distance
splitIndex = index
}
}
const firstHalf = simplifyLine(
openRing.slice(0, splitIndex + 1),
tolerance * tolerance,
)
const secondHalf = simplifyLine(
[...openRing.slice(splitIndex), anchor],
tolerance * tolerance,
)
const simplified = [...firstHalf.slice(0, -1), ...secondHalf]
return simplified.length >= 4 ? simplified : [...ring]
}
function simplifyLine(
points: readonly Position[],
squaredTolerance: number,
): Position[] {
const first = points[0]
const last = points.at(-1)
if (!first || !last || points.length <= 2) return [...points]
let farthestIndex = 0
let farthestDistance = squaredTolerance
for (let index = 1; index < points.length - 1; index += 1) {
const point = points[index]
if (!point) continue
const distance = squaredSegmentDistance(point, first, last)
if (distance > farthestDistance) {
farthestDistance = distance
farthestIndex = index
}
}
if (farthestIndex === 0) return [first, last]
const left = simplifyLine(
points.slice(0, farthestIndex + 1),
squaredTolerance,
)
const right = simplifyLine(points.slice(farthestIndex), squaredTolerance)
return [...left.slice(0, -1), ...right]
}
function squaredSegmentDistance(
point: Position,
start: Position,
end: Position,
): number {
const [pointX = 0, pointY = 0] = point
let [x = 0, y = 0] = start
const [endX = 0, endY = 0] = end
let dx = endX - x
let dy = endY - y
if (dx !== 0 || dy !== 0) {
const progress =
((pointX - x) * dx + (pointY - y) * dy) / (dx * dx + dy * dy)
if (progress > 1) {
x = endX
y = endY
} else if (progress > 0) {
x += dx * progress
y += dy * progress
}
dx = pointX - x
dy = pointY - y
} else {
dx = pointX - x
dy = pointY - y
}
return dx * dx + dy * dy
}
function squaredDistance(left: Position, right: Position): number {
const dx = (left[0] ?? 0) - (right[0] ?? 0)
const dy = (left[1] ?? 0) - (right[1] ?? 0)
return dx * dx + dy * dy
}