// 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()])