TanStack
Catalog

Standard world projection gallery

geography

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
}