<template>
<div>
<div class="wrap">
<h1>Earnings versus the cost of living</h1>
<p class="lead">
Each bubble is a city: average income across the bottom, cost of living
up the side, sized by population.
</p>
<div class="chart-wrap">
<div id="chart">
<apexchart
type="unit"
height="520"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
<div class="actions" id="size-nav">
<button data-size="population" class="active">
Size by population
</button>
<button data-size="gdp">Size by GDP</button>
</div>
<p class="caption" id="caption"></p>
<div class="note">
<code>plotOptions.unit.scatter.y: 'value'</code> switches the scatter
layout to a 2D value-value plot: each datum's own <code>x</code> and
<code>y</code> on two drawn numeric axes, coloured by category.
<code>scatter.sizeRange</code>
turns the dots into bubbles scaled by area from each datum's
<code>sizeField</code> (here <code>z</code>). Switching the size metric
is a plain <code>chart.updateSeries()</code>, so every bubble tweens its
radius; hiding a region from the legend fades its bubbles out in place.
The unit chart is a premium type; without a license it renders with a
trial watermark. Figures are approximate.
</div>
</div>
</div>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
// A 2D bubble scatter: each bubble is a city, placed by average income (x) and
// cost of living (y), sized by a third field and coloured by region. This is
// the unit chart's `layout: 'scatter'` in `y: 'value'` mode (each datum's own x
// + y on two numeric axes) with `sizeRange` turning the dots into area-scaled
// bubbles.
//
// The size toggle is the story: by POPULATION the megacities (Tokyo, Delhi,
// Shanghai, Sao Paulo) swell; by GDP the money centres (Tokyo, New York, London,
// Paris) lead instead. Every bubble tweens its size. Figures are approximate
// (public-data style). Shared by the vanilla-js / React / Vue builds.
var REGIONS = [
{ name: 'Americas', color: '#22C55E' },
{ name: 'Europe', color: '#0EA5E9' },
{ name: 'Asia', color: '#F59E0B' },
{ name: 'Oceania', color: '#F43F5E' },
]
// [city, avg income (US$/yr), cost of living (index, NYC = 100),
// metro population (millions), metro GDP (US$ billions)]
var DATA = {
Americas: [
['New York', 55000, 100, 18.9, 2000],
['San Francisco', 62000, 98, 4.7, 650],
['Toronto', 40000, 72, 6.4, 400],
['Sao Paulo', 12000, 40, 22.6, 480],
['Mexico City', 11000, 38, 22.0, 480],
],
Europe: [
['Zurich', 72000, 118, 1.4, 340],
['London', 45000, 85, 14.3, 1000],
['Paris', 38000, 80, 11.1, 850],
['Berlin', 36000, 70, 6.1, 180],
['Moscow', 18000, 45, 12.6, 550],
],
Asia: [
['Tokyo', 40000, 83, 37.4, 1600],
['Singapore', 55000, 95, 5.9, 470],
['Dubai', 45000, 70, 3.5, 130],
['Hong Kong', 42000, 90, 7.5, 380],
['Seoul', 34000, 78, 26.0, 780],
['Shanghai', 20000, 60, 27.0, 680],
['Mumbai', 8000, 35, 20.7, 310],
['Delhi', 7000, 32, 32.0, 370],
],
Oceania: [
['Sydney', 48000, 88, 5.3, 400],
['Melbourne', 44000, 82, 5.0, 250],
],
}
// z = the bubble size field: 'population' (people) or 'gdp' (metro output).
function seriesFor(sizeBy) {
return REGIONS.map(function (r) {
return {
name: r.name,
data: DATA[r.name].map(function (c) {
return {
name: c[0],
x: c[1],
y: c[2],
z: sizeBy === 'gdp' ? c[4] : c[3],
}
}),
}
})
}
var VIEWS = {
population: {
caption:
'Sized by population, the megacities swell: Tokyo, Delhi, Shanghai and Sao Paulo dominate, even where incomes are low and living is cheap.',
},
gdp: {
caption:
'Sized by economic output, the money centres take over: New York, Tokyo, London and Paris lead, while populous but poorer cities shrink.',
},
}
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: seriesFor('population'),
chartOptions: {
chart: {
id: 'cityBubbles',
type: 'unit',
height: 520,
animations: {
enabled: true,
speed: 700,
},
},
colors: REGIONS.map(function (r) {
return r.color
}),
// Translucent fills so overlapping bubbles read through each other.
fill: {
opacity: 0.7,
},
legend: {
position: 'bottom',
},
plotOptions: {
unit: {
layout: 'scatter',
scatter: {
y: 'value',
xMin: 0,
yMin: 0,
xTitle: 'Average income (US$/yr)',
yTitle: 'Cost of living (NYC = 100)',
xFormatter: function (v) {
return '$' + Math.round(v / 1000) + 'k'
},
sizeRange: [7, 44],
},
tooltip: {
formatter: function (o) {
var d = (o && o.datum) || {}
if (!d.name) return ''
return (
d.name +
': $' +
Math.round(d.x / 1000) +
'k income, cost of living ' +
d.y
)
},
},
},
},
},
bubbleTimer: null,
}
},
mounted: function () {
// Reach the live instance by its chart.id, then wire the buttons. (Data lives
// in the shared head script.) updateSeries drives the instance directly, so no
// reactive data changes under it.
var me = this
me.bubbleTimer = window.setInterval(function () {
var chart = ApexCharts.getChartByID('cityBubbles')
if (!chart) return
window.clearInterval(me.bubbleTimer)
var buttons = document.querySelectorAll('#size-nav button')
var caption = document.getElementById('caption')
function showSize(key) {
buttons.forEach(function (b) {
b.classList.toggle('active', b.getAttribute('data-size') === key)
})
caption.textContent = VIEWS[key].caption
chart.updateSeries(seriesFor(key))
}
buttons.forEach(function (btn) {
btn.addEventListener('click', function () {
showSize(btn.getAttribute('data-size'))
})
})
caption.textContent = VIEWS.population.caption
}, 50)
},
beforeDestroy: function () {
window.clearInterval(this.bubbleTimer)
},,
}
</script>
<style>
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #fafbfc;
color: #2c3e50;
margin: 0;
padding: 32px 16px;
}
.wrap {
max-width: 800px;
margin: 0 auto;
}
h1 {
font-size: 22px;
margin: 0 0 8px;
}
.lead {
color: #5b6b78;
line-height: 1.5;
margin: 0 0 20px;
}
.chart-wrap {
background: #fff;
border-radius: 8px;
padding: 16px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.04);
}
.actions {
margin: 20px auto 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.actions button {
color: #fff;
background: #94a3b8;
border: none;
padding: 8px 18px;
font-weight: 600;
font-size: 13px;
border-radius: 6px;
cursor: pointer;
}
.actions button.active {
background: #4338ca;
}
.caption {
text-align: center;
color: #5b6b78;
font-size: 13px;
line-height: 1.6;
max-width: 620px;
margin: 0 auto;
min-height: 34px;
}
.note {
background: #eef2ff;
border-left: 3px solid #4338ca;
padding: 12px 16px;
font-size: 13px;
color: #333;
border-radius: 2px;
line-height: 1.6;
margin: 26px auto 0;
}
.note code {
background: #e0e7ff;
padding: 1px 5px;
border-radius: 3px;
}
</style>