Breadcrumbs

How to: Create a Dynamic Stacked Bar Chart

Screenshot 2025-09-30 at 10.57.40 AM.png
Screenshot 2025-09-30 at 11.02.59 AM.png


{Grain} {Metric} by {Dimension} Over Time.

A stacked column chart with the y-axis showing {Metric} and the x-axis showing {grain: day/week/month/year}. Each bar is divided by {Dimension}, so you can see how different segments contribute to the total over time. Use the dropdowns to change the metric or dimension.


Steps to create

  1. Choose the slot & open Code View
    Pick the visualization you want to replace and switch to Code. This mirrors the approach in our other “How to” docs (select a chart → go to code).

  2. Select the columns (minimum required)

  • One date column (e.g., date).

  • Two or more candidate metrics you may want users to pick from (examples: cost, impressions, clicks, conversions, revenue, etc.).

  • One or more candidate dimensions that make sense to stack by (examples: platform, channel, market, tactic, audience, business_category, etc.).

Tip: You’re not limited to revenue—include whatever KPIs matter for the client. Users just need at least one date field and two+ metrics to make the selector useful.

  1. Paste the chart code
    Paste the provided AreaChart component into Code View (replacing the existing code). This follows the same “paste code then customize” pattern used in the other guides.

  2. Map to your client’s schema (critical!)
    Update the Metric catalog and Dimension catalog so the accessors read from the right columns for your client:

  • Metrics: In METRICS, change each accessor: to the correct field names (e.g., if the client uses media_spend instead of cost, point spend at media_spend).

  • Types/labels: Set type to currency, number, or percent to control axis/tooltip formatting. Rename label to the client’s terminology.

  • Dimensions: In DIMENSIONS, keep the flexible accessors (they try multiple name variants), or replace them with a single, exact column (e.g., row => row.market_name).

  • Defaults: Change useState('spend') and useState('platform') to the defaults you want selected on load.

  1. Save, then verify behavior

  • Use the Metric and Dimension dropdowns to confirm stacks and totals look right.

  • Scan tooltips and axis ticks for correct formatting (currency vs number vs percent).

  • Spot-check a date or two against the table to confirm values align.


Editing later (what/where to tweak)

  • Add/remove a metric: add/remove an entry in METRICS.

  • Change a metric’s definition: update its accessor function (e.g., switch revenue to a different column or compute a derived KPI).

  • Add a new dimension: add to DIMENSIONS with an accessor that returns a string for that field.

  • Branding: update the colors array (stacks cycle through it).

  • Performance: if there are many unique dimension values, consider pre-filtering to a top-N list before building series.

Troubleshooting

  • Empty chart: check the date field is populated and parsable; verify your metric accessors return numbers (not strings/undefined).

  • Stacks look off: ensure the dimension accessor returns a single label per row (no arrays/objects) and isn’t blank/null.

  • Currency formatting wrong: confirm the metric’s type is currency; non-currency metrics should be number/percent.

  • Too many stacks: pre-filter or regroup the dimension (e.g., map granular placements to broader “Platform” values).


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

JavaScript
function AreaChart({ data = [], comparisonData = [] }) {
  const { useState, useMemo } = React;

  // ------- helpers -------
  const pick = (row, fields) => {
    for (const f of fields) {
      const v = row && row[f];
      if (v !== undefined && v !== null && String(v).trim() !== '') return v;
    }
    return null;
  };

  // Bucket a JS Date into UTC start of the chosen grain
  const bucketUTC = (d, grain) => {
    const year = d.getUTCFullYear();
    const month = d.getUTCMonth();
    const day = d.getUTCDate();

    if (grain === 'day') {
      return Date.UTC(year, month, day);
    }
    if (grain === 'week') {
      // Monday-start week
      const dow = d.getUTCDay();            // 0..6 (Sun..Sat)
      const delta = (dow + 6) % 7;          // days since Monday
      const monday = new Date(Date.UTC(year, month, day));
      monday.setUTCDate(monday.getUTCDate() - delta);
      return Date.UTC(monday.getUTCFullYear(), monday.getUTCMonth(), monday.getUTCDate());
    }
    if (grain === 'month') {
      return Date.UTC(year, month, 1);
    }
    if (grain === 'year') {
      return Date.UTC(year, 0, 1);
    }
    return Date.UTC(year, month, day);
  };

  const labelFormatFor = (grain) => {
    switch (grain) {
      case 'day':   return '{value:%m-%d-%Y}';
      case 'week':  return '{value:%m-%d-%Y}'; // start-of-week
      case 'month': return '{value:%b %Y}';
      case 'year':  return '{value:%Y}';
      default:      return '{value:%m-%d-%Y}';
    }
  };
  const tooltipDateFor = (ts, grain) => {
    if (grain === 'month')  return Highcharts.dateFormat('%b %Y', ts);
    if (grain === 'year')   return Highcharts.dateFormat('%Y', ts);
    if (grain === 'week')   return 'Week of ' + Highcharts.dateFormat('%m-%d-%Y', ts);
    return Highcharts.dateFormat('%m-%d-%Y', ts);
  };

  // Metric options
  const METRICS = [
    { key: 'spend',       label: 'Spend',          type: 'currency', accessor: (r) => Number(r.cost) || 0 },
    { key: 'clicks',      label: 'Clicks',         type: 'number',   accessor: (r) => Number(r.clicks) || 0 },
    { key: 'impressions', label: 'Impressions',    type: 'number',   accessor: (r) => Number(r.impressions) || 0 },
    { key: 'omni_rev',    label: 'Omni Revenue',   type: 'currency', accessor: (r) => Number(r.platform_omni_revenue) || 0 },
    { key: 'ecomm_rev',   label: 'Ecomm Revenue',  type: 'currency', accessor: (r) => Number(r.platform_ecomm_revenue) || 0 },
  ];

  // Dimension options (robust to name variants)
  const DIMENSIONS = [
    { value: 'channel',              label: 'Channel',              accessor: (i) => pick(i, ['channel','Channel']) },
    { value: 'platform',             label: 'Platform',             accessor: (i) => pick(i, ['platform','Platform']) },
    { value: 'market',               label: 'Market',               accessor: (i) => pick(i, ['market','Market']) },
    { value: 'funnel',               label: 'Funnel',               accessor: (i) => pick(i, ['funnel','Funnel']) },
    { value: 'tactic',               label: 'Tactic',               accessor: (i) => pick(i, ['tactic','Tactic']) },
    { value: 'objective',            label: 'Objective',            accessor: (i) => pick(i, ['objective','Objective']) },
    { value: 'client_initiative',    label: 'Client Initiative',    accessor: (i) => pick(i, ['client_initiative','Client Initiative']) },
    { value: 'business_category',    label: 'Business Category',    accessor: (i) => pick(i, ['business_category','Business Category','businessCategory','business category']) },
    { value: 'product_category',     label: 'Product Category',     accessor: (i) => pick(i, ['product_category','Product Category','productCategory','product category']) },
    { value: 'audience',             label: 'Audience',             accessor: (i) => pick(i, ['audience','Audience']) },
    { value: 'creative_message',     label: 'Creative Message',     accessor: (i) => pick(i, ['creative_message','Creative Message','creativeMessage','creative message']) },
    { value: 'creative_description', label: 'Creative Description', accessor: (i) => pick(i, ['creative_description','Creative Description','creativeDescription','creative description']) },
  ];

  const [metricKey, setMetricKey] = useState('spend');
  const [dimKey, setDimKey]       = useState('platform');
  const [dateGrain, setDateGrain] = useState('day'); // day | week | month | year

  const metricMeta = useMemo(() => METRICS.find(m => m.key === metricKey) || METRICS[0], [metricKey]);
  const dimMeta    = useMemo(() => {
    const found = DIMENSIONS.find(d => d.value === dimKey);
    return found || DIMENSIONS.find(d => d.value === 'platform') || DIMENSIONS[0];
  }, [dimKey]);

  // ------- aggregate by selected dimension × bucketed date -------
  const { seriesByDim, dimList } = useMemo(() => {
    const grouped = {}; // { dimValue: { bucketTs: value } }
    const allDatesSet = new Set();

    data.forEach(item => {
      const dimValRaw = dimMeta.accessor(item);
      const dimVal = (dimValRaw == null ? '' : String(dimValRaw)).trim();
      if (!dimVal || dimVal.toLowerCase() === 'null') return;

      const dateVal = item.calendardate;
      if (!dateVal) return;

      const d = new Date(dateVal);
      const bucketTs = bucketUTC(d, dateGrain);
      allDatesSet.add(bucketTs);

      if (!grouped[dimVal]) grouped[dimVal] = {};
      if (!grouped[dimVal][bucketTs]) grouped[dimVal][bucketTs] = 0;

      grouped[dimVal][bucketTs] += metricMeta.accessor(item);
    });

    const allDatesSorted = [...allDatesSet].sort((a, b) => a - b);

    const seriesByDim = {};
    Object.keys(grouped).forEach(val => {
      seriesByDim[val] = allDatesSorted.map(ts => [ts, grouped[val][ts] || 0]);
    });

    return { seriesByDim, dimList: Object.keys(seriesByDim) };
  }, [data, metricMeta, dimMeta, dateGrain]);

  const colors = ['#E11937','#FFC72C','#4CAF50','#00B8D9','#F36F21','#8E24AA','#1E88E5','#EC407A','#FF7043','#7E57C2'];

  // ------- formatting -------
  const abbrev = (v) => {
    const a = Math.abs(v);
    if (a >= 1_000_000_000) return (v / 1_000_000_000).toFixed(1) + 'B';
    if (a >= 1_000_000)     return (v / 1_000_000).toFixed(1) + 'M';
    if (a >= 1_000)         return (v / 1_000).toFixed(1) + 'K';
    return v.toFixed(1);
  };
  const fmtAxisTick = (type, v) => type === 'currency' ? ('$' + abbrev(v)) : abbrev(v);
  const fmtPoint = (type, v) => {
    if (type === 'currency') {
      const a = Math.abs(v);
      if (a >= 1_000_000_000) return '$' + (v / 1_000_000_000).toFixed(1) + 'B';
      if (a >= 1_000_000)     return '$' + (v / 1_000_000).toFixed(1) + 'M';
      if (a >= 1_000)         return '$' + (v / 1_000).toFixed(1) + 'K';
      return '$' + v.toFixed(1);
    }
    return abbrev(v);
  };

  const yAxisTitle   = metricMeta.type === 'currency' ? `${metricMeta.label} ($)` : metricMeta.label;
  const dynamicTitle = `${metricMeta.label} By ${dimMeta.label} Over Time`;

  // ------- chart -------
  const options = {
    chart: {
      type: 'column',
      marginTop: 10,
      marginBottom: 140,
      spacing: [8, 16, 16, 16],
      style: { fontFamily: 'Work Sans' }
    },
    title: { text: null },
    subtitle: { text: null },
    legend: {
      align: 'left',
      verticalAlign: 'bottom',
      layout: 'horizontal',
      itemStyle: { fontSize: '12px' },
      symbolHeight: 12,
      symbolWidth: 12,
      maxHeight: 60,
      x: 0, y: 20
    },
    xAxis: {
      title: { text: 'Date' },
      type: 'datetime',
      tickPixelInterval: 20,
      crosshair: { color: 'black', width: 1, dashStyle: 'Solid' },
      labels: { format: labelFormatFor(dateGrain) }
    },
    yAxis: {
      title: { text: yAxisTitle },
      labels: { formatter: function () { return fmtAxisTick(metricMeta.type, this.value); } }
    },
    tooltip: {
      shared: true,
      useHTML: true,
      backgroundColor: '#292A2EE5',
      borderRadius: 8,
      padding: 12,
      style: { color: '#FFFFFF', lineHeight: '20px' },
      formatter: function () {
        const dateLabel = tooltipDateFor(this.x, dateGrain);
        const lines = this.points.map(p =>
          `<span style="display:inline-block;vertical-align:middle;width:0.7rem;height:0.7rem;border-radius:50%;background-color:${p.color};margin-right:3px;"></span>
           <span style="vertical-align:middle;font-size:12px">${p.series.name}: ${fmtPoint(metricMeta.type, p.y)}</span>`
        ).join('<br/>');
        return `<span style="font-size:12px;display:block;margin-bottom:4px;">${dateLabel}</span>${lines}`;
      }
    },
    plotOptions: {
      column: { stacking: 'normal', groupPadding: 0.08, pointPadding: 0.02, borderWidth: 0 },
      series: { states: { hover: { enabled: true } } }
    },
    series: dimList.map((name, i) => ({
      type: 'column',
      name,
      data: seriesByDim[name],
      color: colors[i % colors.length]
    })),
    credits: { enabled: false },
    responsive: {
      rules: [{
        condition: { maxWidth: 600 },
        chartOptions: {
          xAxis: { labels: { style: { fontSize: '10px' } }, title: { style: { fontSize: '11px' } } },
          yAxis: { labels: { style: { fontSize: '10px' } }, title: { style: { fontSize: '11px' } } },
          legend: { itemStyle: { fontSize: '10px' }, symbolHeight: 10, symbolWidth: 10 },
          tooltip: { style: { fontSize: '10px' } }
        }
      }]
    }
  };

  // ------- header (title + Metric + Dimension + Date dropdowns) -------
  return (
    React.createElement('div', { style: { width: '100%' } },
      React.createElement('div', {
        style: { display: 'flex', flexDirection: 'column', gap: 8, marginBottom: 8, padding: '12px 16px 0 16px' }
      },
        React.createElement('div', {
          style: { fontFamily: 'Work Sans', fontWeight: 600, fontSize: 16, color: '#292A2E' }
        }, dynamicTitle),
        React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'wrap' } },
          // Metric
          React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
            React.createElement('span', { style: { fontSize: 12, color: '#6B7280' } }, 'Metric:'),
            React.createElement('select', {
              value: metricKey, 'aria-label': 'Metric',
              onChange: (e) => setMetricKey(e.target.value),
              style: { fontFamily:'Work Sans', fontSize:12, padding:'6px 8px', borderRadius:8, border:'1px solid #E5E7EB', background:'#fff', width:180 }
            }, METRICS.map(m => React.createElement('option', { key: m.key, value: m.key }, m.label)))
          ),
          // Dimension
          React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
            React.createElement('span', { style: { fontSize: 12, color: '#6B7280' } }, 'Dimension:'),
            React.createElement('select', {
              value: dimKey, 'aria-label': 'Dimension',
              onChange: (e) => setDimKey(e.target.value),
              style: { fontFamily:'Work Sans', fontSize:12, padding:'6px 8px', borderRadius:8, border:'1px solid #E5E7EB', background:'#fff', width:220 }
            }, DIMENSIONS.map(d => React.createElement('option', { key: d.value, value: d.value }, d.label)))
          ),
          // Date grain
          React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 6 } },
            React.createElement('span', { style: { fontSize: 12, color: '#6B7280' } }, 'Date:'),
            React.createElement('select', {
              value: dateGrain, 'aria-label': 'Date grain',
              onChange: (e) => setDateGrain(e.target.value),
              style: { fontFamily:'Work Sans', fontSize:12, padding:'6px 8px', borderRadius:8, border:'1px solid #E5E7EB', background:'#fff', width:140 }
            },
              ['day','week','month','year'].map(g => React.createElement('option', { key: g, value: g }, g.charAt(0).toUpperCase()+g.slice(1)))
            )
          )
        )
      ),
      React.createElement(HighchartsReact, { highcharts: Highcharts, options })
    )
  );
}