Source

152 chart lines · 125 data-selection lines · 3 files · 7.3 kBBenchmark harness excluded · shared/mount.ts

cases/110-projection-gallery/tanstack.ts50 lines · entry
cases/110-projection-gallery/tanstack.ts
import { defineChart } from '@tanstack/charts'
import { geoShape } from '@tanstack/charts/geo'
import { worldLand, worldSphere } from '../../shared/fixtures/country-atlas'
import {
  fitGalleryProjection,
  projectionGalleryData,
  projectionPane,
} from './projection'
import { tanstackMount } from '../../shared/mount'
import type { ConformanceInput } from '../../types'

const projectionColors = [
  ['#2563eb', '#7c3aed', '#0891b2', '#ea580c'],
  ['#1d4ed8', '#6d28d9', '#0e7490', '#c2410c'],
]

const definition = (input: ConformanceInput) => {
  const projections = projectionGalleryData()

  return defineChart({
    marks: projections.flatMap((entry, index) => [
      geoShape([worldSphere], {
        projection: ({ chart }) =>
          fitGalleryProjection(entry.create(), projectionPane(chart, index)),
        fill: 'none',
        stroke: 'currentColor',
        strokeOpacity: 0.5,
        strokeWidth: 0.8,
      }),
      geoShape([worldLand], {
        projection: ({ chart }) =>
          fitGalleryProjection(entry.create(), projectionPane(chart, index)),
        color: () => entry.id,
        fillOpacity: 0.78,
        stroke: 'currentColor',
        strokeOpacity: 0.28,
        strokeWidth: 0.45,
      }),
    ]),
    color: {
      range: projectionColors[input.revision % 2] ?? projectionColors[0],
    },
    margin: 0,
  })
}

export const mount = tanstackMount(
  definition,
  'Standard world projection gallery',
)
cases/110-projection-gallery/projection.ts102 lines · support
cases/110-projection-gallery/projection.ts
import {
  geoEqualEarth,
  geoEquirectangular,
  geoMercator,
  geoNaturalEarth1,
} from 'd3-geo'
import { worldSphere } from '../../shared/fixtures/country-atlas'
import type { GeoProjection } from 'd3-geo'

export type ProjectionGalleryId =
  'equal-earth' | 'natural-earth' | 'mercator' | 'equirectangular'

interface ProjectionGalleryDefinition {
  id: ProjectionGalleryId
  label: string
}

export interface ProjectionGalleryDatum {
  id: ProjectionGalleryId
  label: string
  create: () => GeoProjection
}

export interface ProjectionPane {
  x: number
  y: number
  width: number
  height: number
}

const projectionGalleryDefinitions: readonly ProjectionGalleryDefinition[] = [
  {
    id: 'equal-earth',
    label: 'Equal Earth',
  },
  {
    id: 'natural-earth',
    label: 'Natural Earth',
  },
  {
    id: 'mercator',
    label: 'Mercator',
  },
  {
    id: 'equirectangular',
    label: 'Equirectangular',
  },
]

export function projectionGalleryData(): readonly ProjectionGalleryDatum[] {
  return projectionGalleryDefinitions.map((definition) => ({
    id: definition.id,
    label: definition.label,
    create: projectionFactory(definition.id),
  }))
}

export function projectionPane(
  bounds: ProjectionPane,
  index: number,
): ProjectionPane {
  const leftWidth = Math.floor(bounds.width / 2)
  const topHeight = Math.floor(bounds.height / 2)
  const column = index % 2
  const row = Math.floor(index / 2)
  const width = column === 0 ? leftWidth : bounds.width - leftWidth
  const height = row === 0 ? topHeight : bounds.height - topHeight

  return {
    x: bounds.x + (column === 0 ? 0 : leftWidth),
    y: bounds.y + (row === 0 ? 0 : topHeight),
    width,
    height,
  }
}

export function fitGalleryProjection(
  projection: GeoProjection,
  { x, y, width, height }: ProjectionPane,
  inset = 8,
): GeoProjection {
  return projection.fitExtent(
    [
      [x + inset, y + inset],
      [x + width - inset, y + height - inset],
    ],
    worldSphere,
  )
}

function projectionFactory(id: ProjectionGalleryId): () => GeoProjection {
  switch (id) {
    case 'equal-earth':
      return geoEqualEarth
    case 'natural-earth':
      return geoNaturalEarth1
    case 'mercator':
      return geoMercator
    case 'equirectangular':
      return geoEquirectangular
  }
}
shared/fixtures/country-atlas.ts125 lines · fixture
shared/fixtures/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 { geoGraticule10 } from 'd3-geo'
import { feature } from 'topojson-client'
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()

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 detailedWorldLand = convertLand(
  detailedLandAtlasJson,
  'world-atlas land-50m',
)

function atlasTopology(value: unknown, label: string): AtlasTopology {
  if (
    !isRecord(value) ||
    value.type !== 'Topology' ||
    !Array.isArray(value.arcs) ||
    !isRecord(value.objects)
  ) {
    throw new TypeError(`${label} is not valid TopoJSON`)
  }
  return value as unknown as AtlasTopology
}

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 isRecord(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null
}