View source
// CDN build: `ApexMaps` is a global (see index.html). With a bundler:
//   import ApexMaps from 'apexmaps'

// Premium feature: runs with a watermark until licensed. ApexMaps.setLicense(key) clears it.

// A hexbin is what to reach for when there are more points than pixels. Twenty
// thousand markers give you the shape of the data without its magnitude,
// because overlapping marks stop counting once they overlap. Binning aggregates
// instead: a cell of fixed area can carry a number, so the clusters arrive with
// a legend that says which is which.
//
// There is no `joinBy` here. A hexbin bins positions, so a datum with no
// position is dropped with a counted warning rather than joined to something.

// Twelve real metro coordinates as the anchors; the scatter around them is
// derived from the point index, so the page looks identical on every load.
const METROS = [
  ['Los Angeles', -118.24, 34.05, 1.0],
  ['New York', -74.01, 40.71, 1.1],
  ['Chicago', -87.63, 41.88, 0.8],
  ['Houston', -95.37, 29.76, 0.7],
  ['Phoenix', -112.07, 33.45, 0.5],
  ['San Francisco', -122.42, 37.77, 0.6],
  ['Atlanta', -84.39, 33.75, 0.6],
  ['Seattle', -122.33, 47.61, 0.5],
  ['Denver', -104.99, 39.74, 0.4],
  ['Miami', -80.19, 25.76, 0.5],
  ['Minneapolis', -93.27, 44.98, 0.35],
  ['Boston', -71.06, 42.36, 0.45],
]

// A stable hash in [0, 1), so the cloud is reproducible without a seeded RNG.
const unit = (key) => {
  let h = 2166136261
  for (let i = 0; i < String(key).length; i++) {
    h ^= String(key).charCodeAt(i)
    h = Math.imul(h, 16777619)
  }
  return ((h >>> 0) % 10007) / 10007
}

const points = []
for (let i = 0; i < 20000; i++) {
  const metro = METROS[Math.floor(unit(`m${i}`) * METROS.length)]
  // Two independent draws, so the cloud is round rather than diagonal.
  const angle = unit(`a${i}`) * Math.PI * 2
  // Square-rooted, so density falls off from the centre instead of the cloud
  // being a uniform disc with a hard edge.
  const spread = Math.sqrt(unit(`r${i}`)) * 4.2 * metro[3]
  points.push({
    lon: metro[1] + Math.cos(angle) * spread * 1.3,
    lat: metro[2] + Math.sin(angle) * spread,
    // Varies smoothly with position and owes nothing to density, which is what
    // makes the count-versus-mean pair below say two different things.
    value: 50 + 40 * Math.sin(metro[1] / 20) * Math.cos(metro[2] / 15),
  })
}

// No feature series is declared anywhere on this page. A hexbin is not bound to
// geometry, so the library draws the coastline underneath it by itself: points
// floating in the void are not a map. Declaring an empty choropleth to get the
// same picture would also put its own empty scale in the legend.
const make = (id, series) =>
  new ApexMaps(document.getElementById(id), {
    chart: { height: 400 },
    geo: { map: 'us', fill: '#eef1f5' },
    series: [series],
    legend: { position: 'bottom' },
  })

/* ---- how many: count needs no value field -------------------------------- */
const count = make('count', {
  type: 'hexbin',
  name: 'Points per cell',
  data: points,
  // Centre to vertex, in SCREEN pixels. That is what makes the lattice refine
  // as the reader zooms rather than magnifying: the resolution follows whoever
  // is looking. Cells are rebuilt at quantized zoom levels, so a pan never
  // re-bins and a smooth zoom crosses a level a handful of times.
  radius: 13,
  gap: 0.05, // shrink each cell so the lattice reads as cells, not one sheet
  // The domain follows the bins, so class breaks move with the zoom and the
  // legend moves with them. Pass `domain` to pin them, which is what you want
  // when two maps have to be read against each other.
  scale: { palette: 'blues', classes: 5 },
})

/* ---- how much: the same lattice, a different question -------------------- */
const mean = make('mean', {
  type: 'hexbin',
  name: 'Mean value',
  data: points,
  radius: 13,
  gap: 0.05,
  aggregate: 'mean', // or 'count' (default), 'sum', 'min', 'max'
  // A mean over one point IS that point, and it shouts as loudly as a mean over
  // a hundred. Raising the floor keeps the average off its thinnest cells.
  minCount: 5,
  scale: { palette: 'oranges', classes: 5 },
})

Promise.all([count.render(), mean.render()])