<template>
<div>
<div class="cf-wrap">
<div class="cf-bar">
<button id="cf-reset">Reset filters</button>
<span class="readout" id="cf-readout"
>Click a slice or bar to filter every chart</span
>
</div>
<div class="cf-grid">
<div class="cf-card">
<div id="chart">
<apexchart
type="donut"
height="300"
:options="chartOptions"
:series="series"
></apexchart>
</div>
</div>
<div class="cf-card">
<div id="chart2">
<apexchart
type="donut"
height="300"
:options="chartOptions1"
:series="series1"
></apexchart>
</div>
</div>
<div class="cf-card full">
<div id="chart3">
<apexchart
type="bar"
height="280"
:options="chartOptions2"
:series="series2"
></apexchart>
</div>
</div>
</div>
<div class="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>
</template>
<script>
import VueApexCharts from 'vue-apexcharts'
import ApexCharts from 'apexcharts'
// 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'
}
export default {
components: {
apexchart: VueApexCharts,
},
data: function () {
return {
series: [],
chartOptions: {
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: [],
chartOptions1: {
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: [],
chartOptions2: {
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'],
},
cfTimer: null,
}
},
mounted: function () {
// The vue-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.)
this.cfTimer = window.setInterval(function () {
var cf = ApexCharts.getCrossfilter('trades')
if (!cf) return
window.clearInterval(this.cfTimer)
var readout = document.getElementById('cf-readout')
cf.on('change', function (state) {
readout.textContent = fmtFilters(state)
})
document.getElementById('cf-reset').addEventListener('click', function () {
cf.reset()
})
}.bind(this), 50)
},
beforeDestroy: function () {
window.clearInterval(this.cfTimer)
},,
}
</script>
<style>
.cf-wrap {
max-width: 700px;
margin: 12px auto;
font-family: Helvetica, Arial, sans-serif;
}
.cf-bar {
display: flex;
gap: 12px;
align-items: center;
flex-wrap: wrap;
margin: 4px 6px 14px;
}
.cf-bar button {
padding: 6px 13px;
cursor: pointer;
border: 1px solid #2563eb;
color: #4338ca;
background: #fff;
border-radius: 6px;
font-size: 13px;
font-weight: 600;
}
.cf-bar .readout {
font-family: monospace;
font-size: 13px;
color: #445;
}
.cf-grid {
display: grid;
/* Two donuts side by side at ~700px, collapsing to one column when narrow. */
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 8px;
}
.cf-grid .full {
grid-column: 1 / -1;
}
.cf-card {
border: 1px solid #e4e7f2;
border-radius: 8px;
padding: 6px;
background: #fff;
}
/* The shared demo stylesheet gives the FIRST chart container (#chart) a
white panel + border; the cards style all three uniformly instead. */
#chart {
padding: 0;
background: transparent;
border: 0;
box-shadow: none;
}
.cf-note {
background: #eef2ff;
border-left: 3px solid #2563eb;
padding: 11px 15px;
font-size: 13px;
color: #234;
border-radius: 2px;
line-height: 1.6;
margin: 14px 6px;
}
.cf-note code {
background: #dfe3ff;
padding: 1px 5px;
border-radius: 3px;
}
</style>