import React from 'react'
import ReactApexChart from 'react-apexcharts'
import ApexCharts from 'apexcharts'
import './styles.css'
// One synthetic cohort of 220 startups, each a persistent object with a name,
// sector, funding stage and the capital it has raised. The demo shows the SAME
// objects in four layouts; with plotOptions.unit.transition:'identity' every dot
// is keyed by its company name, so a given startup keeps its colour and GLIDES
// to its new place when you switch view instead of being rebuilt. Data is
// generated deterministically (seeded), so the demo is stable across reloads.
// Company names are fictional. Shared by the vanilla-js/React/Vue builds.
function makeRng(seed) {
var s = seed >>> 0
return function () {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0
return s / 4294967296
}
}
function pick(rand, items, weights) {
var total = 0
for (var i = 0; i < weights.length; i++) total += weights[i]
var r = rand() * total
for (var k = 0; k < items.length; k++) {
r -= weights[k]
if (r <= 0) return items[k]
}
return items[items.length - 1]
}
// A pool of fictional names. Combining a base with a suffix gives enough unique
// company names for a dense cohort (base x suffix), and the name is the dot's
// identity key across every view.
var BASE = [
'Nimbus',
'Cobalt',
'Lumen',
'Pathwise',
'Flowdesk',
'Draftly',
'Vault',
'Ledgr',
'Payline',
'Northbank',
'Kitepay',
'Vitalis',
'Pulse',
'CareLoop',
'Mendly',
'Orbit',
'Quill',
'Sable',
'Tandem',
'Verve',
'Wisp',
'Zephyr',
'Arbor',
'Bloomly',
'Cinder',
'Dune',
'Ember',
'Forge',
'Glint',
'Harbor',
'Iris',
'Juno',
'Kelp',
'Lark',
'Mica',
'Nova',
'Onyx',
'Plume',
'Quartz',
'Rune',
'Slate',
'Talon',
'Umbra',
'Vesper',
'Willow',
'Yarn',
'Zenith',
'Ashen',
]
var SUFFIX = [
'',
' Labs',
' AI',
' Systems',
' Cloud',
' Works',
' Base',
' Go',
]
var SECTORS = ['AI', 'SaaS', 'Fintech', 'Health', 'Commerce']
var SECTOR_COLORS = ['#2563EB', '#06B6D4', '#22C55E', '#EC4899', '#F97316']
var SECTOR_W = [13, 11, 10, 8, 7]
var STAGES = ['Seed', 'Series A', 'Series B', 'Series C+']
var STAGE_COLORS = ['#BAE6FD', '#7DD3FC', '#0EA5E9', '#0369A1']
var STAGE_W = [0.42, 0.3, 0.18, 0.1]
// funding range ($M) per stage: late-stage rounds dwarf early ones
var STAGE_FUND = {
Seed: [1, 6],
'Series A': [8, 28],
'Series B': [40, 95],
'Series C+': [150, 620],
}
var COUNT = 220
var rand = makeRng(20260724)
var STARTUPS = []
for (var i = 0; i < COUNT; i++) {
// base x suffix keeps every generated name unique (COUNT <= BASE * SUFFIX).
var nm =
BASE[i % BASE.length] + SUFFIX[Math.floor(i / BASE.length) % SUFFIX.length]
var sector = pick(rand, SECTORS, SECTOR_W)
var stage = pick(rand, STAGES, STAGE_W)
var f = STAGE_FUND[stage]
STARTUPS.push({
name: nm,
sector: sector,
stage: stage,
value: Math.round(f[0] + rand() * (f[1] - f[0])),
})
}
// Group the SAME startup objects by a field, in a fixed category order (so the
// colour arrays line up). Each datum is a startup: name = identity + tooltip,
// value = funding = bubble size.
function groupBy(field, order) {
return order.map(function (g) {
return {
name: g,
data: STARTUPS.filter(function (s) {
return s[field] === g
}),
}
})
}
var bySector = groupBy('sector', SECTORS)
var byStage = groupBy('stage', STAGES)
// Tooltip body for one hovered startup.
function startupTooltip(o) {
if (!o.datum) return o.seriesName
return (
'<div style="font-weight:700">' +
o.datum.name +
'</div><div style="opacity:0.75">' +
o.datum.sector +
' · ' +
o.datum.stage +
'</div><div style="opacity:0.75">$' +
o.datum.value +
'M raised</div>'
)
}
// Category label: name + headcount, e.g. "AI (58)".
function countLabel(name, opts) {
return name + ' (' + opts.value + ')'
}
// Each view is the SAME 220 startups, re-grouped and re-laid-out. transition
// 'identity' keeps every company's dot across the change. Switching view is a
// plain chart.updateOptions(view.options) call driven by the buttons below.
var VIEWS = [
{
label: 'The market',
caption:
'220 startups, one dot each, packed into a single cloud and coloured by sector. The whole cohort at a glance.',
options: {
series: bySector,
colors: SECTOR_COLORS,
plotOptions: {
unit: {
layout: 'packed',
transition: 'identity',
sortByGroup: true,
sizeByValue: { enabled: false },
clusterLabels: { show: false },
},
},
},
},
{
label: 'By sector',
caption:
'The same dots fan out into their sectors. The clusters show which corners of the market are crowded and which are thin.',
options: {
series: bySector,
colors: SECTOR_COLORS,
plotOptions: {
unit: {
layout: 'grouped',
transition: 'identity',
sizeByValue: { enabled: false },
// curved labels ride the top of each sector cluster
clusterLabels: { show: true, position: 'top', formatter: countLabel },
},
},
},
},
{
label: 'By stage',
caption:
'Regrouped by funding stage. Every company travels to its stage bar and the classic funnel appears: many seed, few late rounds.',
options: {
series: byStage,
colors: STAGE_COLORS,
plotOptions: {
unit: {
layout: 'columns',
transition: 'identity',
sizeByValue: { enabled: false },
// labels sit BELOW each stage bar
clusterLabels: {
show: true,
position: 'bottom',
formatter: countLabel,
},
},
},
},
},
{
label: 'By capital',
caption:
"Back into one cloud, but now each dot's area is the money it raised: a handful of late-stage bubbles dwarf the crowd of small rounds.",
options: {
series: bySector,
colors: SECTOR_COLORS,
plotOptions: {
unit: {
layout: 'packed',
transition: 'identity',
sortByGroup: true,
clusterLabels: { show: false },
sizeByValue: {
enabled: true,
maxRadius: 4.5,
minRadius: 1.4,
scale: 'area',
},
},
},
},
},
]
const ApexChart = () => {
const [state, setState] = React.useState({
series: bySector,
options: {
chart: {
id: 'startupCohort',
type: 'unit',
height: 440,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: {
enabled: true,
speed: 800,
},
toolbar: { show: false },
},
colors: SECTOR_COLORS,
legend: {
position: 'bottom',
},
plotOptions: {
unit: {
layout: 'packed',
transition: 'identity',
size: 4,
spacing: 1.15,
sortByGroup: true,
clusterLabels: {
show: false,
},
tooltip: {
// startupTooltip lives in the shared head script.
formatter: startupTooltip,
},
},
},
},
})
React.useEffect(() => {
// Reach the live instance by its chart.id, then wire the buttons. (Data +
// VIEWS live in the shared head script.) updateOptions drives the instance
// directly, so no React state changes under it.
let chart
const timer = window.setInterval(() => {
chart = ApexCharts.getChartByID('startupCohort')
if (!chart) return
window.clearInterval(timer)
const buttons = document.querySelectorAll('#view-nav button')
const caption = document.getElementById('caption')
const showView = (i) => {
buttons.forEach((b, k) => b.classList.toggle('active', k === i))
caption.textContent = VIEWS[i].caption
chart.updateOptions(VIEWS[i].options)
}
buttons.forEach((btn) => {
btn.addEventListener('click', () => {
showView(parseInt(btn.getAttribute('data-view'), 10))
})
})
caption.textContent = VIEWS[0].caption
}, 50)
return () => window.clearInterval(timer)
}, [])
return (
<div>
<div className="wrap">
<h1>220 startups, four ways to look at them</h1>
<p className="lead">
One cohort of startups as a unit chart, one dot per company.
</p>
<div className="chart-wrap">
<div id="chart">
<ReactApexChart
options={state.options}
series={state.series}
type="unit"
height={440}
/>
</div>
</div>
<div className="actions" id="view-nav">
<button data-view="0" className="active">
The market
</button>
<button data-view="1">By sector</button>
<button data-view="2">By stage</button>
<button data-view="3">By capital</button>
</div>
<p className="caption" id="caption"></p>
<div className="note">
Every view carries the SAME 220 startup objects with
<code>plotOptions.unit.transition: 'identity'</code>, which keys each
dot by its company name: a specific startup keeps its colour and
glides across every regroup, re-bin and resize rather than fading out
and back in. Switching view is a plain{' '}
<code>chart.updateOptions(view)</code> call. Cluster labels can sit
above or below via <code>clusterLabels.position</code>: the sector
labels curve over the top, the stage-bar labels sit below. The unit
chart is a premium type; without a license it renders with a trial
watermark.
</div>
</div>
</div>
)
}
export default ApexChart