Breadcrumbs

How to: Create a Dynamic Bubble Scatter Chart


Screenshot 2025-09-30 at 11.15.52 AM.png

{Y Metric} vs. {X Metric}, bubble size = {Size Metric}.
A bubble scatterplot that compares {Y Metric} on the y-axis and {X Metric} on the x-axis, with bubble size representing {Size Metric}. Dashed lines mark the average X and Y values, creating four quadrants. Lower-is-better metrics (e.g., CPA/CPM/CPC) auto-flip axes for intuitive reading. Click any bubble to exclude/include it from the averages; toggle visibility of excluded points with the checkbox.


Steps to create

  1. Paste the chart code
    Replace the visualization’s code with the provided ScatterChart

  2. Set sensible defaults (optional)
    In the component’s state, choose initial metrics that make sense for the client:

    • yMetric (default CPM)

    • xMetric (default CPA)

    • sizeMetric (default Impressions)

  3. Map the grouping dimension to your client (critical)
    In the “Group & base sums” block, the example groups by creative_name:

    const key = item.creative_name || 'Unknown';
    
    
    • Change creative_name to the dimension you need (e.g., platform, channel, market, campaign_name).

    • This value becomes the point label and the exclusion key (what gets toggled on click).

  4. Map the base metrics used for calculations
    The code currently accumulates:
    Experian example

    impressions -> item.impressions
    media_spend -> item.media_spend
    clicks      -> item.clicks
    cda_new_reg -> item.cda_new_reg   // replace with your conversions field
    
    
    • Replace any field names to match your schema (e.g., media_spendcost; cda_new_regtotal_new_reg or conversions).

  5. Review/adjust metric formulas
    The chart derives KPIs from the base sums:

    • CTR = clicks ÷ impressions

    • CPA = spend ÷ registrations (or your conversion field)

    • CPM = (spend ÷ impressions) × 1,000

    • CPC = spend ÷ clicks

    • Media Spend, Impressions, Clicks, Total New Registration = totals
      Edit these in the calc object if your definitions differ (e.g., use a different conversion column).

  6. Control which metrics appear in the dropdowns

    • X & Y axis options are defined by axisMetricOptions.

    • Bubble size options are defined by sizeMetricOptions.
      Add/remove labels there to reflect the KPIs you want exposed. If your client has no conversions, simply remove CPA and Total New Registration from the lists.

  7. Formatting (currency/percent/number)
    The chart auto-formats by metric:

    • Currency: CPA, CPM, CPC, Media Spend

    • Percent: CTR

    • Count: Impressions, Clicks, Total New Registration
      Adjust the isCurrency / isPercent helpers if you add custom KPIs.

  8. Exclusions & outliers

    • Click a bubble to exclude/include it from averages (label shows “(excluded)” in tooltip).

    • Show excluded points toggle determines whether excluded bubbles appear greyed out.

    • Reset Exclusions clears the set.

    • Optional outlier filter hides extreme points (> 3σ on X or Y) when filterOutliers prop is true. Keep it false unless you need it.

  9. Quadrant labels & averages

    • The chart draws average X and Y lines and automatically places quadrant helper labels.

    • For cost-type metrics (CPA, CPM, CPC), axes are reversed so “lower is better” appears toward the top-left quadrant.

  10. Branding & UX (optional)

  • Update the colors array for brand colors.

  1. Save and validate

  • Try different combinations of X, Y, Bubble.

  • Click several bubbles to confirm exclude/restore works and averages update.

  • Sanity-check tooltips/axes for correct formatting and units.


Troubleshooting

  • Empty plot / missing points: check your group-by field isn’t empty and metric formulas return finite numbers (NaN/null are filtered out).

  • CPA or CTR shows “—”: make sure the required base fields are populated (e.g., conversions for CPA, impressions for CTR).

  • Everything looks tiny: if bubble sizes are all near zero, switch the Bubble metric to a larger-range metric (e.g., Impressions).

  • Averages look off: remember excluded points are not included in the averages; click Reset Exclusions to recompute.


Use ChatGPT to help update this code with your clients metrics and dimensions

JavaScript
// V6.5 — match Daily Trend layout: Title, controls row (left), small subtitle, chart, controls bottom-right
function ScatterChart({
  data = [],
  comparisonData = [],
  filterOutliers = false,
  initialExcluded = [],
  onExcludedChange
}) {
  const [excluded, setExcluded] = React.useState(new Set(initialExcluded));
  const [showExcluded, setShowExcluded] = React.useState(true);

  // Defaults
  const [yMetric, setYMetric] = React.useState('CPM');
  const [xMetric, setXMetric] = React.useState('CPA');
  const [sizeMetric, setSizeMetric] = React.useState('Impressions');

  const toggleExclude = React.useCallback((name) => {
    setExcluded(prev => {
      const next = new Set(prev);
      if (next.has(name)) next.delete(name); else next.add(name);
      onExcludedChange && onExcludedChange(Array.from(next));
      return next;
    });
  }, [onExcludedChange]);

  const resetExclusions = React.useCallback(() => {
    setExcluded(new Set());
    onExcludedChange && onExcludedChange([]);
  }, [onExcludedChange]);

  // ---------- Group & base sums ----------
  const grouped = {};
  data.forEach(item => {
    const key = item.creative_name || 'Unknown';
    if (!grouped[key]) grouped[key] = { impressions: 0, media_spend: 0, clicks: 0, cda_new_reg: 0 };
    grouped[key].impressions += Number(item.impressions) || 0;
    grouped[key].media_spend += Number(item.media_spend) || 0;
    grouped[key].clicks      += Number(item.clicks) || 0;
    grouped[key].cda_new_reg += Number(item.cda_new_reg) || 0;
  });

  // ---------- Metric calculators ----------
  const calc = {
    'CTR': g => (g.impressions > 0 ? g.clicks / g.impressions : null),
    'CPA': g => (g.cda_new_reg > 0 ? g.media_spend / g.cda_new_reg : null),
    'CPM': g => (g.impressions > 0 ? (g.media_spend / g.impressions) * 1000 : null),
    'CPC': g => (g.clicks > 0 ? g.media_spend / g.clicks : null),
    'Media Spend': g => (g.media_spend || null),
    'Impressions': g => (g.impressions || null),
    'Clicks': g => (g.clicks || null),
    'Total New Registration': g => (g.cda_new_reg || null) // switch to total_new_reg if you prefer
  };

  const isCurrency = (m) => ['CPA','CPM','CPC','Media Spend'].includes(m);
  const isPercent  = (m) => m === 'CTR';
  const formatCurrency = (val) => {
    if (!Number.isFinite(val)) return '—';
    if (val >= 1_000_000) return '$' + (val / 1_000_000).toFixed(1) + 'M';
    if (val >= 1_000)     return '$' + (val / 1_000).toFixed(1) + 'K';
    return '$' + val.toFixed(2);
  };
  const formatPercent = (v) => Number.isFinite(v) ? (v * 100).toFixed(2) + '%' : '—';
  const formatCount = (v) => Number.isFinite(v) ? (v >= 1_000 ? (v/1_000).toFixed(1)+'K' : Math.round(v).toString()) : '—';
  const formatByMetric = (m, v) => isCurrency(m) ? formatCurrency(v) : (isPercent(m) ? formatPercent(v) : formatCount(v));
  const metricLabel = (m) => m;

  // ---------- Build points ----------
  let points = Object.keys(grouped).map(name => {
    const g = grouped[name];
    return {
      name,
      x: calc[xMetric](g),
      y: calc[yMetric](g),
      sizeVal: calc[sizeMetric](g),
      impressions: g.impressions, clicks: g.clicks, media_spend: g.media_spend, regs: g.cda_new_reg
    };
  }).filter(d => Number.isFinite(d.x) && Number.isFinite(d.y));

  // ---------- Outlier filter ----------
  const mean = arr => arr.reduce((s, v) => s + v, 0) / (arr.length || 1);
  const stddev = arr => { if (arr.length < 2) return 0; const m = mean(arr); return Math.sqrt(mean(arr.map(v => (v - m) ** 2))); };
  let filtered = points;
  if (filterOutliers && points.length) {
    const xs = points.map(d => d.x), ys = points.map(d => d.y);
    const cutX = mean(xs) + 3 * stddev(xs), cutY = mean(ys) + 3 * stddev(ys);
    const tmp = points.filter(d => d.x <= cutX && d.y <= cutY);
    filtered = tmp.length ? tmp : points;
  }

  // ---------- Exclusions & stats ----------
  const includedPoints = filtered.filter(d => !excluded.has(d.name));
  const excludedPoints = filtered.filter(d => excluded.has(d.name));
  const statsBase = includedPoints.length ? includedPoints : filtered;

  const avgX = mean(statsBase.map(d => d.x));
  const avgY = mean(statsBase.map(d => d.y));

  const maxSize = Math.max(0, ...statsBase.map(d => d.sizeVal || 0));
  const radiusFor = (v) => {
    if (!Number.isFinite(v) || v <= 0 || !Number.isFinite(maxSize) || maxSize <= 0) return 5;
    const r = 3 + 14 * (Math.sqrt(v) / Math.sqrt(maxSize));
    return Math.max(3, Math.min(20, r));
  };

  const lowerIsBetter = (m) => ['CPA','CPM','CPC'].includes(m);
  const xReversed = lowerIsBetter(xMetric);
  const yReversed = lowerIsBetter(yMetric);

  const colors = ['#34A853','#FF9900','#FF0000','#4285F4','#DB4437','#F4B400','#AB47BC','#00ACC1','#8D6E63','#546E7A'];

  // ---------- Highcharts ----------
  const options = {
    chart: {
      type: 'scatter',
      marginTop: 10,            // align with other charts that use 16px title padding
      marginBottom: 110,
      spacing: [8,16,16,16],
      style: { fontFamily: 'Work Sans' },
      zoomType: 'xy',
      resetZoomButton: { theme: { fill:'white', stroke:'silver', r:0, style:{ color:'#333' }, states:{ hover:{ fill:'#e6e6e6' } } },
                         position: { align:'right', verticalAlign:'top', x:-10, y:6 } },
      events: {
        render: function () {
          const chart = this, xAxis = chart.xAxis[0], yAxis = chart.yAxis[0];
          if (!xAxis || !yAxis || !statsBase.length) return;
          const xs = statsBase.map(d=>d.x), ys = statsBase.map(d=>d.y);
          const minX = Math.min(...xs), maxX = Math.max(...xs);
          const minY = Math.min(...ys), maxY = Math.max(...ys);
          const xLowMid=(minX+avgX)/2, xHighMid=(avgX+maxX)/2, yLowMid=(minY+avgY)/2, yHighMid=(avgY+maxY)/2;
          const place = (id, text, xv, yv) => {
            const px = xAxis.toPixels(xv), py = yAxis.toPixels(yv);
            const w=180,h=16;
            chart._quadLabels=chart._quadLabels||{};
            const node = chart._quadLabels[id] || (chart._quadLabels[id]=
              chart.renderer.label(text, px-w/2, py-h/2, undefined, undefined, undefined, true)
                .css({ color:'#555', fontSize:'10.5px', fontWeight:600, pointerEvents:'none', textAlign:'center' })
                .attr({ padding:2, zIndex:5 }));
            node.attr({ text, x:px-w/2, y:py-h/2, width:w, height:h });
          };
          place('bl', 'High ' + yMetric + ', High ' + xMetric,  xHighMid, yHighMid);
          place('br', 'Low '  + yMetric + ', High ' + xMetric,  xLowMid,  yHighMid);
          place('tr', 'Low '  + yMetric + ', Low '  + xMetric,  xLowMid,  yLowMid);
          place('tl', 'High ' + yMetric + ', Low '  + xMetric,  xHighMid, yLowMid);
        }
      }
    },
    title: { text: null }, subtitle: { text: null },
    xAxis: {
      title: { text: metricLabel(xMetric) },
      labels: { formatter: function () { return formatByMetric(xMetric, this.value); } },
      reversed: xReversed,
      crosshair: { color: 'black', width: 1, dashStyle: 'Solid' },
      plotLines: [{ value: avgX, color:'#000', dashStyle:'Dash', width:1, zIndex:3,
                    label:{ text:'Avg ' + metricLabel(xMetric), align:'left', style:{ color:'#000', fontSize:'10px' } } }]
    },
    yAxis: {
      title: { text: metricLabel(yMetric) },
      labels: { formatter: function () { return formatByMetric(yMetric, this.value); } },
      reversed: yReversed,
      plotLines: [{ value: avgY, color:'#000', dashStyle:'Dash', width:1, zIndex:3,
                    label:{ text:'Avg ' + metricLabel(yMetric), align:'right', style:{ color:'#000', fontSize:'10px' } } }]
    },
    tooltip: {
      useHTML:true, backgroundColor:'#292A2EE5', borderRadius:8, padding:10,
      style:{ color:'#FFFFFF', lineHeight:'18px', fontSize:'11.5px' },
      formatter: function () {
        const isEx = excluded && excluded.has(this.point.name);
        return '<b style="font-size:12px">' + this.point.name + (isEx ? ' (excluded)' : '') + '</b><br/>' +
               metricLabel(xMetric) + ': ' + formatByMetric(xMetric, this.point.x) + '<br/>' +
               metricLabel(yMetric) + ': ' + formatByMetric(yMetric, this.point.y) + '<br/>' +
               'Bubble (' + metricLabel(sizeMetric) + '): ' + formatByMetric(sizeMetric, this.point.sizeVal) + '<br/>' +
               '<span style="opacity:.8">Click to ' + (isEx ? 'include' : 'exclude') + '</span>';
      }
    },
    legend: { enabled:true, align:'left', verticalAlign:'bottom', layout:'horizontal',
              itemStyle:{ fontSize:'12px' }, symbolHeight:12, symbolWidth:12, symbolRadius:0, x:0, y:0, maxHeight:56 },
    plotOptions: {
      scatter: {
        cursor:'pointer',
        marker:{ symbol:'circle', states:{ hover:{ enabled:true, lineColor:'black' } } },
        point:{ events:{ click:function(){ toggleExclude(this.name); } } }
      },
      series:{ animation:true, turboThreshold:0 }
    },
    series: [
      { name:'Creative',
        data: includedPoints.map((p,i)=>({ name:p.name, x:p.x, y:p.y, sizeVal:p.sizeVal,
                                           marker:{ radius: radiusFor(p.sizeVal), fillColor: colors[i%colors.length] } })) }
    ].concat(showExcluded ? [{
      name:'Excluded (hidden from avgs)',
      data: excludedPoints.map(p=>({ name:p.name, x:p.x, y:p.y, sizeVal:p.sizeVal,
                                     marker:{ radius: radiusFor(p.sizeVal), fillColor:'#9e9e9e' } })),
      opacity:0.35
    }] : []),
    credits: { enabled:false }
  };

  // ---------- UI (header matches the other chart’s title formatting) ----------
  const axisMetricOptions = ['CTR','CPA','CPM','CPC','Media Spend','Impressions','Clicks'];
  const sizeMetricOptions = ['Impressions','Clicks','Media Spend','Total New Registration'];

  const Picker = function ({label, value, onChange, options}) {
    return React.createElement('label', { style:{ display:'flex', alignItems:'center', gap:6, fontSize:12, color:'#374151' } }, [
      React.createElement('span', { key:'l', style:{ opacity:.85 } }, label),
      React.createElement('select', {
        key:'s', value:value, onChange:function(e){ onChange(e.target.value); },
        style:{ padding:'6px 8px', border:'1px solid #E5E7EB', borderRadius:8, fontSize:12, minWidth:180, height:34, background:'#fff', fontFamily:'Work Sans' }
      }, options.map(function (m) { return React.createElement('option', { key:m, value:m }, m); }))
    ]);
  };

  return React.createElement('div', { style:{ width:'100%' } }, [
    // Header container: title + controls (matches other component)
    React.createElement('div', {
      key:'hdr',
      style:{ display:'flex', flexDirection:'column', gap:8, marginBottom:8, padding:'12px 16px 0 16px' }
    }, [
      // Title (same formatting as the other code)
      React.createElement('div', {
        key:'title',
        style:{ fontFamily:'Work Sans', fontWeight:600, fontSize:16, color:'#292A2E' }
      }, 'Creative Performance Quadrant'),

      // Controls row (left-aligned)
      React.createElement('div', {
        key:'controls',
        style:{ display:'flex', alignItems:'center', gap:12, flexWrap:'wrap' }
      }, [
        React.createElement(Picker, { key:'y', label:'Y axis:', value:yMetric, onChange:setYMetric, options: axisMetricOptions }),
        React.createElement(Picker, { key:'x', label:'X axis:', value:xMetric, onChange:setXMetric, options: axisMetricOptions }),
        React.createElement(Picker, { key:'size', label:'Bubble:', value:sizeMetric, onChange:setSizeMetric, options: sizeMetricOptions })
      ])
    ]),

    // Small subtitle (kept, but spaced to align with header rhythm)
    React.createElement('div', { key:'subtitle',
      style:{ padding:'0 16px', fontSize:12, color:'#4B5563', fontWeight:500 }
    }, 'Click bubble to exclude/include · ' + excluded.size + ' excluded'),

    // Chart
    React.createElement('div', { key:'chartwrap' }, [
      React.createElement(HighchartsReact, { key:'chart', highcharts: Highcharts, options })
    ]),

    // Bottom-right controls
    React.createElement('div', { key:'bottom-controls',
      style:{ display:'flex', alignItems:'center', gap:12, alignSelf:'flex-end', padding:'6px 16px 10px 16px' } }, [
      React.createElement('label', { key:'toggle', style:{ display:'flex', alignItems:'center', gap:6, fontSize:12 } }, [
        React.createElement('input', { type:'checkbox', checked:showExcluded, onChange:function(e){ setShowExcluded(e.target.checked); } }),
        'Show excluded points'
      ]),
      React.createElement('button', { key:'reset', onClick:resetExclusions,
        style:{ padding:'6px 10px', border:'1px solid #d1d5db', background:'#fff', borderRadius:8, cursor:'pointer', fontSize:12 } },
        'Reset Exclusions (' + excluded.size + ')')
    ])
  ]);
}