import React from 'react'
import ReactApexChart from 'react-apexcharts'
import ApexCharts from 'apexcharts'
import './styles.css'
// Deterministic trade records (one row per trade). No randomness, but the
// distributions are intentionally uneven so the crossfilter is visible:
// - quarters have different totals (the donut is 18 / 12 / 16 / 14, not 4x15)
// - each quarter peaks on a different weekday
// - outcome leans Gain early in the week and Loss later
// So clicking a quarter OR an outcome noticeably reshapes the day bar below.
function tradesData() {
var days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
// quarter -> trades per weekday (Mon..Fri); column totals differ per quarter.
var plan = {
Q1: [8, 4, 3, 2, 1], // early-week heavy
Q2: [1, 2, 5, 3, 1], // mid-week heavy
Q3: [2, 2, 3, 4, 5], // late-week heavy
Q4: [3, 6, 2, 2, 1], // Tuesday spike
}
// Share of Gains by weekday index (Mon..Fri): high early, low late.
var gainByDay = [0.85, 0.7, 0.5, 0.3, 0.15]
var out = []
Object.keys(plan).forEach(function (q) {
plan[q].forEach(function (count, di) {
var gains = Math.round(count * gainByDay[di])
for (var k = 0; k < count; k++) {
out.push({
q: q,
day: days[di],
gl: k < gains ? 'Gain' : 'Loss',
})
}
})
})
return out
}
// Register the shared record set BEFORE the charts are constructed, so each
// chart's initial paint is already the aggregation (no empty flash).
ApexCharts.crossfilter({ id: 'trades', records: tradesData() })
// Readout formatter, shared by the vanilla-js, React and Vue builds.
function fmtFilters(state) {
var keys = Object.keys(state.filters)
if (!keys.length) return 'No filter (all ' + state.total + ' trades)'
var parts = keys.map(function (id) {
return id + ': ' + state.filters[id].join(', ')
})
return (
parts.join(' | ') +
' -> ' +
state.filteredCount +
' / ' +
state.total +
' trades'
)
}
const ApexChart = () => {
const [state, setState] = React.useState({
series: [],
options: {
chart: {
id: 'byQuarter',
type: 'donut',
height: 300,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: { speed: 500 },
link: {
id: 'trades',
dimension: function (r) {
return r.q
},
reduce: 'count',
dimOpacity: 0.18,
},
},
title: { text: 'By quarter', align: 'left' },
legend: { position: 'bottom' },
plotOptions: { pie: { expandOnClick: false } },
dataLabels: {
enabled: true,
formatter: function (val, opts) {
return opts.w.config.series[opts.seriesIndex]
},
style: { colors: ['#334155'], fontWeight: 600 },
dropShadow: { enabled: false },
},
colors: ['#2563EB', '#38bdf8', '#4ade80', '#fbbf24'],
stroke: { width: 2, colors: ['#fff'] },
},
series1: [],
options1: {
chart: {
id: 'byOutcome',
type: 'donut',
height: 300,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: { speed: 500 },
link: {
id: 'trades',
dimension: function (r) {
return r.gl
},
reduce: 'count',
order: 'asc', // Gain before Loss, so the colors below map semantically
dimOpacity: 0.18,
},
},
title: { text: 'By outcome', align: 'left' },
legend: { position: 'bottom' },
plotOptions: { pie: { expandOnClick: false } },
dataLabels: {
enabled: true,
formatter: function (val, opts) {
return opts.w.config.series[opts.seriesIndex]
},
style: { colors: ['#334155'], fontWeight: 600 },
dropShadow: { enabled: false },
},
colors: ['#4ade80', '#f87171'],
stroke: { width: 2, colors: ['#fff'] },
},
series2: [],
options2: {
chart: {
id: 'byDay',
type: 'bar',
height: 280,
fontFamily: 'Helvetica, Arial, sans-serif',
animations: { speed: 500 },
link: {
id: 'trades',
dimension: function (r) {
return r.day
},
reduce: 'count',
seriesName: 'Trades',
// `order` also takes a comparator: keep the weekdays in calendar order
// instead of the order they first appear in the records.
order: function (a, b) {
var days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri']
return days.indexOf(a) - days.indexOf(b)
},
dimOpacity: 0.18,
},
},
title: { text: 'By day of week (click a bar too)', align: 'left' },
plotOptions: {
bar: { columnWidth: '55%', borderRadius: 3, distributed: true },
},
legend: { show: false },
dataLabels: { enabled: false },
colors: ['#2563EB', '#38bdf8', '#4ade80', '#fbbf24', '#f472b6'],
},
})
React.useEffect(() => {
// The react-apexcharts wrapper owns the render, and the crossfilter engine is
// registered by the shared head script (ApexCharts.crossfilter). Poll until the
// engine exists, then wire the readout + Reset button the same way the vanilla
// build does. (fmtFilters lives in the shared head script.)
const timer = window.setInterval(() => {
const cf = ApexCharts.getCrossfilter('trades')
if (!cf) return
window.clearInterval(timer)
const readout = document.getElementById('cf-readout')
cf.on('change', (state) => {
readout.textContent = fmtFilters(state)
})
document
.getElementById('cf-reset')
.addEventListener('click', () => cf.reset())
}, 50)
return () => window.clearInterval(timer)
}, [])
return (
<div>
<div className="cf-wrap">
<div className="cf-bar">
<button id="cf-reset">Reset filters</button>
<span className="readout" id="cf-readout">
Click a slice or bar to filter every chart
</span>
</div>
<div className="cf-grid">
<div className="cf-card">
<div id="chart">
<ReactApexChart
options={state.options}
series={state.series}
type="donut"
height={300}
/>
</div>
</div>
<div className="cf-card">
<div id="chart2">
<ReactApexChart
options={state.options1}
series={state.series1}
type="donut"
height={300}
/>
</div>
</div>
<div className="cf-card full">
<div id="chart3">
<ReactApexChart
options={state.options2}
series={state.series2}
type="bar"
height={280}
/>
</div>
</div>
</div>
<div className="cf-note">
All three charts declare a <code>chart.link.dimension</code> over one
record set registered with{' '}
<code>ApexCharts.crossfilter({ id, records })</code>.
Clicking a slice or bar toggles that bucket: the clicked chart dims
its other buckets, and every other chart{' '}
<b>re-aggregates over the filtered trades</b> and animates to its new
values. Selections combine (a chart never filters itself), so you
always see what is still available.
<b>Reset filters</b> clears everything. Needs the <code>link</code>{' '}
feature.
</div>
</div>
</div>
)
}
export default ApexChart