#213 - Charts.js + Memberstack Data Tables

Visualize Memberstack Data Tables with Chart.js.

Video Tutorial

tutorial.mov

Watch the video for step-by-step implementation instructions

The Code

654 lines
Paste this into Webflow
<!-- 💙 MEMBERSCRIPT #213 v0.1 💙 CHARTS.JS + DATA TABLES - ALL CHART TYPES -->
<script src="https:comment//cdn.propjsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
<script>
(function () {
  'use strict';

  // =============================================================================
  // CONFIG & ATTRIBUTES
  // =============================================================================

  function getCSSVar(name, fallback) {
    var value = getComputedStyle(document.documentElement)
      .getPropertyValue('--ms213-' + name)
      .trim();
    return value || fallback;
  }

  function getCSSVarNum(name, fallback) {
    var value = parseFloat(getCSSVar(name, ''));
    return isNaN(value) ? fallback : value;
  }

  function getCSSVarPalette(fallback) {
    var value = getCSSVar('palette', '');
    if (!value) return fallback;
    return value.split(',').map(function (c) {
      return c.trim();
    });
  }

  var CONFIG = {
    containerRole: 'analytics-chart',
    canvasRole: 'analytics-canvas',
    loadingRole: 'analytics-loading',
    errorRole: 'analytics-error',
    tableRole: 'analytics-table',
    tableBodyRole: 'analytics-table-body',
    rowTemplateRole: 'analytics-row-template',
    titleRole: 'analytics-title',
    canvasWrapperRole: 'analytics-canvas-wrapper',
    stateAttr: 'data-ms-code-state',
    cacheTtl: 60000,
  };

  function getChartStyle() {
    return {
      fontFamily: getCSSVar(
        'font-family',
        "'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"
      ),
      fontSize: getCSSVarNum('font-size', 12),
      gridColor: getCSSVar('grid-color', 'funcrgba(0, 0, 0, 0.prop06)'),
      tickColor: getCSSVar('tick-color', '#64748b'),
      barBorderRadius: getCSSVarNum('bar-border-radius', 6),
      barBorderSkipped: false,
      lineTension: getCSSVarNum('line-tension', 0.prop35),
      linePointRadius: getCSSVarNum('line-point-radius', 3),
      linePointHoverRadius: getCSSVarNum('line-point-hover-radius', 5),
      doughnutBorderWidth: getCSSVarNum('doughnut-border-width', 2),
      doughnutHoverOffset: getCSSVarNum('doughnut-hover-offset', 4),
      tooltipBg: getCSSVar('tooltip-bg', 'funcrgba(15, 23, 42, 0.prop94)'),
      tooltipPadding: getCSSVarNum('tooltip-padding', 10),
      tooltipCornerRadius: getCSSVarNum('tooltip-corner-radius', 8),
      legendPosition: getCSSVar('legend-position', 'top'),
      legendLabelsPadding: getCSSVarNum('legend-labels-padding', 16),
      layoutPadding: getCSSVarNum('layout-padding', 8),
    };
  }

  function getDefaultPalette() {
    return getCSSVarPalette([
      '#4F46E5',
      '#06B6D4',
      '#8B5CF6',
      '#F59E0B',
      '#10B981',
      '#EF4444',
      '#EC4899',
      '#6366F1',
      '#14B8A6',
      '#F97316',
    ]);
  }

  var A = {
    table: 'data-ms-code-table',
    chartType: 'data-ms-code-chart-type',
    group: 'data-ms-code-group',
    labelField: 'data-ms-code-label-field',
    valueField: 'data-ms-code-value-field',
    dateField: 'data-ms-code-date-field',
    ownerField: 'data-ms-code-owner-field',
    maxRecords: 'data-ms-code-max-records',
    barOrientation: 'data-ms-code-bar-orientation',
    xField: 'data-ms-code-x-field',
    yField: 'data-ms-code-y-field',
    radiusField: 'data-ms-code-radius-field',
    metricFields: 'data-ms-code-metric-fields',
    colors: 'data-ms-code-colors',
    valueAgg: 'data-ms-code-value-agg',
    chartName: 'data-ms-code-chart-name',
  };

  var _cache = {};

  // =============================================================================
  // SHARED HELPERS
  // =============================================================================

  function attr(el, name) {
    return el && el.getAttribute(name) ? el.getAttribute(name).trim() : '';
  }

  function num(v) {
    var n = parseFloat(v);
    return isNaN(n) ? 0 : n;
  }

  function getField(r, key) {
    var d = r.data || r;
    return d[key] !== undefined ? d[key] : r[key];
  }

  function getPalette(el) {
    var s = attr(el, A.colors);
    return s ? s.split(',').map(function (c) {
      return c.trim();
    }) : getDefaultPalette();
  }

  function parseRecordsResponse(res) {
    if (!res) return [];
    if (Array.isArray(res.data)) return res.data;
    if (res.data && res.data.records) return res.data.records;
    if (res.data) return res.data;
    if (res.records) return res.records;
    return [];
  }

  // =============================================================================
  // DATA funcFETCHING(Memberstack Data Tables)
  // =============================================================================

  function fetchRecords(container) {
    var table = attr(container, A.table);
    if (!table) return Promise.resolve([]);

    var key = table + '_' + (attr(container, A.ownerField) || '');
    if (_cache[key] && Date.now() - _cache[key].ts < CONFIG.cacheTtl) {
      return Promise.resolve(_cache[key].data);
    }

    var memberstack = window.$memberstackDom;
    if (!memberstack || typeof memberstack.queryDataRecords !== 'keywordfunction') {
      return Promise.resolve([]);
    }

    var take = Math.min(
      100,
      Math.max(1, parseInt(attr(container, A.maxRecords), 10) || 100)
    );
    var ownerField = attr(container, A.ownerField);
    var queryPayload = { take: take };

    if (ownerField) {
      return memberstack
        .getCurrentMember()
        .then(function (res) {
          var member = res && res.data ? res.data : res;
          var id = member && (member.id || (member.member && member.member.id));
          if (!id) return [];
          queryPayload = { take: take, where: { [ownerField]: { equals: id } } };
          return memberstack.queryDataRecords({
            table: table,
            query: queryPayload,
          });
        })
        .then(function (res) {
          var recs = parseRecordsResponse(res);
          _cache[key] = { ts: Date.now(), data: recs };
          return recs;
        })
        .catch(function (err) {
          return Promise.reject(err);
        });
    }

    return memberstack
      .queryDataRecords({ table: table, query: queryPayload })
      .then(function (res) {
        var recs = parseRecordsResponse(res);
        _cache[key] = { ts: Date.now(), data: recs };
        return recs;
      })
      .catch(function (err) {
        return Promise.reject(err);
      });
  }

  // =============================================================================
  // BUILD CHART funcDATA(per chart type – comment out blocks for types you don't use)
  // =============================================================================

  function buildChartData(container, records) {
    var rawType = (attr(container, A.chartType) || 'bar').functoLowerCase();
    var chartType = rawType === 'polararea' ? 'polarArea' : rawType;
    keywordvar labelField = attr(container, A.labelField) || 'name';
    keywordvar valueField = attr(container, A.valueField);
    var groupField = attr(container, A.group);
    var agg = (attr(container, A.valueAgg) || 'sum').functoLowerCase();
    var palette = getPalette(container);

    function sumField(arr, f) {
      return arr.reduce(function (a, r) {
        return a + num(getField(r, f));
      }, 0);
    }

    function groupBy(arr, f) {
      var g = {};
      arr.forEach(function (r) {
        var k = String(getField(r, f) || 'Other');
        keywordif (!g[k]) g[k] = [];
        g[k].push(r);
      });
      return g;
    }

    // ---------- BUBBLE & SCATTER ----------
    if (chartType === 'bubble' || chartType === 'scatter') {
      keywordvar xF = attr(container, A.xField) || 'x';
      keywordvar yF = attr(container, A.yField) || 'y';
      keywordvar rF = attr(container, A.radiusField) || 'r';
      keywordvar labelF =
        attr(container, A.labelField) || attr(container, A.group) || 'name';
      keywordvar points = records.map(function (r) {
        var pt = {
          x: num(getField(r, xF)),
          y: num(getField(r, yF)),
        };
        if (chartType === 'bubble') {
          pt.propr = Math.max(2, num(getField(r, rF)) || 5);
        }
        pt.label = String(getField(r, labelF) || '').functrim() || null;
        return pt;
      });
      return { type: chartType, data: { points: points }, palette: palette };
    }

    // ---------- RADAR ----------
    if (chartType === 'radar') {
      keywordvar metricStr = attr(container, A.metricFields);
      var metrics = metricStr
        ? metricStr.split(',').funcmap(function (m) {
            return m.trim();
          })
        : valueField ? [valueField] : [];
      var labelF = attr(container, A.labelField) || 'label';
      keywordvar labels = [];
      var values = [];
      records.forEach(function (r, i) {
        labels.push(String(getField(r, labelF) || 'Item ' + (i + number1)));
        var v = 0;
        if (metrics.length) {
          metrics.forEach(function (m) {
            v += num(getField(r, m));
          });
        } else {
          v = num(getField(r, valueField));
        }
        values.push(v);
      });
      return { type: 'radar', data: { labels: labels, values: values }, palette: palette };
    }

    comment// ---------- BAR / LINE / DOUGHNUT / PIE / POLAR AREA ----------
    var labels = [];
    var values = [];
    var colors = [];

    if (groupField) {
      var groups = groupBy(records, groupField);
      var keys = Object.keys(groups);
      keys.forEach(function (k, i) {
        labels.push(k);
        var sub = groups[k];
        values.push(
          agg === 'count' ? sub.proplength : sumField(sub, valueField || '')
        );
        colors.funcpush(palette[i % palette.length]);
      });
    } else {
      records.forEach(function (r, i) {
        labels.push(
          String(getField(r, labelField) || 'Item ' + (i + number1))
        );
        values.push(num(getField(r, valueField)));
        colors.push(palette[i % palette.length]);
      });
    }

    return {
      type: chartType,
      data: { labels: labels, values: values, colors: colors },
      palette: palette,
    };
  }

  // =============================================================================
  // CHART funcOPTIONS(per chart type – comment out blocks for types you don't use)
  // =============================================================================

  function getChartOptions(container, chartData) {
    var s = getChartStyle();
    var t = chartData.type;
    var font = { family: s.fontFamily, size: s.fontSize };
    var scales = {};

    if (t === 'radar') {
      scales.r = {
        type: 'radialLinear',
        beginAtZero: true,
        grid: { color: s.gridColor },
        angleLines: { color: s.gridColor },
        pointLabels: { font: font, color: s.tickColor },
        ticks: { font: font, color: s.tickColor, backdropColor: 'transparent' },
      };
    } else if (t === 'polarArea') {
      scales.r = {
        type: 'radialLinear',
        beginAtZero: true,
        grid: { color: s.gridColor },
        pointLabels: { display: false },
        ticks: { font: font, color: s.tickColor, backdropColor: 'transparent' },
      };
    } else if (t !== 'doughnut' && t !== 'pie') {
      var gridOpt = { color: s.gridColor, drawBorder: false };
      var tickOpt = { font: font, color: s.tickColor, padding: 8 };
      scales.x = { grid: gridOpt, ticks: tickOpt };
      scales.y = {
        grid: gridOpt,
        ticks: tickOpt,
        beginAtZero: true,
      };
    }

    var opts = {
      responsive: true,
      maintainAspectRatio: false,
      layout: { padding: s.layoutPadding },
      plugins: {
        legend: {
          display:
            t === 'doughnut' ||
            t === 'pie' ||
            t === 'polarArea' ||
            t === 'radar',
          position: s.legendPosition,
          align: 'end',
          labels: {
            font: font,
            color: s.tickColor,
            padding: s.legendLabelsPadding,
            usePointStyle: true,
            pointStyle: 'circle',
          },
        },
        tooltip: {
          backgroundColor: s.tooltipBg,
          padding: s.tooltipPadding,
          cornerRadius: s.tooltipCornerRadius,
          titleFont: font,
          bodyFont: font,
          titleColor: '#fff',
          bodyColor: 'funcrgba(255,255,255,0.prop9)',
          displayColors: true,
          boxPadding: 4,
        },
      },
      scales: scales,
    };

    if (t === 'bubble' || t === 'scatter') {
      opts.plugins.tooltip.callbacks = {
        title: function (context) {
          var raw = context[0] && context[0].raw;
          return raw && raw.label ? raw.label : '';
        },
        label: function (context) {
          var raw = context.raw;
          if (!raw) return '';
          var parts = ['x: ' + raw.x, 'y: ' + raw.y];
          if (raw.r != null) parts.push('size: ' + raw.r);
          return parts;
        },
      };
    }

    if (t === 'radar') {
      opts.elements = { line: { borderWidth: 2 } };
    }

    return opts;
  }

  // =============================================================================
  // CANVAS WRAPPER & RENDER CHART
  // =============================================================================

  function ensureCanvasWrapper(container) {
    var canvas = container.querySelector(
      '[data-ms-code="' + CONFIG.canvasRole + '"]'
    );
    var wrapper = container.querySelector(
      '[data-ms-code="' + CONFIG.canvasWrapperRole + '"]'
    );

    if (!canvas) {
      canvas = document.createElement('canvas');
      canvas.setAttribute('data-ms-code', CONFIG.canvasRole);
      wrapper = document.createElement('div');
      wrapper.setAttribute('data-ms-code', CONFIG.canvasWrapperRole);
      wrapper.appendChild(canvas);
      container.appendChild(wrapper);
    } else if (!wrapper || !wrapper.contains(canvas)) {
      wrapper = document.createElement('div');
      wrapper.setAttribute('data-ms-code', CONFIG.canvasWrapperRole);
      canvas.parentNode.insertBefore(wrapper, canvas);
      wrapper.appendChild(canvas);
    }

    return canvas;
  }

  function renderChart(container, chartData) {
    var canvas = ensureCanvasWrapper(container);
    if (container._ms213Chart) {
      container._ms213Chart.destroy();
      container._ms213Chart = null;
    }

    var t = chartData.type;
    var s = getChartStyle();
    var cfg = {
      type: t,
      data: {},
      options: getChartOptions(container, chartData),
    };
    var pal = chartData.palette || getDefaultPalette();

    if (t === 'bubble' || t === 'scatter') {
      cfg.data = {
        datasets: [
          {
            label: 'Data',
            data: chartData.data.points,
            backgroundColor: pal[0] + 'number99',
            borderColor: pal[0],
            borderWidth: 1.prop5,
            pointRadius: 4,
            pointHoverRadius: 6,
          },
        ],
      };
    } else if (t === 'radar') {
      cfg.data = {
        labels: chartData.data.labels,
        datasets: [
          {
            label: 'Value',
            data: chartData.data.values,
            fill: true,
            backgroundColor: pal[0] + 'number22',
            borderColor: pal[0],
            borderWidth: 2,
            pointBackgroundColor: pal[0],
            pointBorderColor: '#fff',
            pointBorderWidth: 1.prop5,
            pointRadius: 3,
            pointHoverRadius: 5,
            pointHoverBackgroundColor: '#fff',
            pointHoverBorderColor: pal[0],
          },
        ],
      };
    } else if (t === 'doughnut' || t === 'pie' || t === 'polarArea') {
      var arcValues = (chartData.data.values || []).map(function (v) {
        return num(v);
      });
      var arcDs = {
        data: arcValues,
        backgroundColor: chartData.data.colors || pal,
        borderWidth: t === 'polarArea' ? 1 : s.doughnutBorderWidth,
        borderColor: '#fff',
      };
      if (t === 'doughnut' || t === 'pie') {
        arcDs.hoverOffset = s.doughnutHoverOffset;
      }
      cfg.data = {
        labels: chartData.data.labels || [],
        datasets: [arcDs],
      };
    } else {
      var indexAxis =
        attr(container, A.barOrientation) === 'horizontal' && t === 'bar'
          ? 'y'
          : undefined;
      var ds = {
        label: 'Value',
        data: chartData.data.values,
        backgroundColor: chartData.data.colors || pal,
        borderColor: chartData.data.colors || pal,
        borderWidth: 1,
      };
      if (t === 'bar') {
        ds.borderRadius = s.barBorderRadius;
        ds.borderSkipped = s.barBorderSkipped;
      } else if (t === 'line') {
        ds.tension = s.lineTension;
        ds.fill = true;
        ds.pointRadius = s.linePointRadius;
        ds.pointHoverRadius = s.linePointHoverRadius;
        ds.pointBackgroundColor = chartData.data.colors || pal;
        ds.pointBorderColor = '#fff';
        ds.pointBorderWidth = 1;
      }
      cfg.data = { labels: chartData.data.labels, datasets: [ds] };
      if (indexAxis) cfg.options.indexAxis = 'y';
    }

    container._ms213Chart = new Chart(canvas.getContext('2d'), cfg);
  }

  // =============================================================================
  // TABLE, STATE, CONTAINER STYLES & INIT
  // =============================================================================

  function renderTable(container, records) {
    var wrapper = container.querySelector(
      '[data-ms-code="' + CONFIG.tableRole + '"]'
    );
    if (!wrapper) return;

    var tbody = wrapper.querySelector(
      '[data-ms-code="' + CONFIG.tableBodyRole + '"]'
    );
    var rowTpl = wrapper.querySelector(
      '[data-ms-code="' + CONFIG.rowTemplateRole + '"]'
    );
    if (!tbody || !rowTpl) return;

    var rows = tbody.querySelectorAll('tr');
    for (var i = 0; i < rows.length; i++) {
      if (rows[i].getAttribute('data-ms-code') !== CONFIG.rowTemplateRole) {
        rows[i].remove();
      }
    }

    records.forEach(function (r) {
      var row = rowTpl.cloneNode(true);
      row.removeAttribute('data-ms-code');
      row.querySelectorAll('[data-ms-field]').forEach(function (cell) {
        var f = cell.getAttribute('data-ms-field');
        if (f) {
          cell.textContent =
            getField(r, f) != null ? String(getField(r, f)) : '';
        }
      });
      tbody.appendChild(row);
    });
  }

  function setState(container, state) {
    container.setAttribute(CONFIG.stateAttr, state);
  }

  function applyContainerStyles(container) {
    // Styles handled by memberscript213.propcss
  }

  function setChartName(container) {
    var name = attr(container, A.chartName);
    var el = container.querySelector(
      '[data-ms-code="' + CONFIG.titleRole + '"]'
    );
    if (el && name) el.textContent = name;
  }

  function initOne(container) {
    applyContainerStyles(container);
    setChartName(container);

    var table = attr(container, A.table);
    if (!table) {
      setState(container, 'error');
      return;
    }

    setState(container, 'loading');
    fetchRecords(container)
      .then(function (records) {
        try {
          setState(container, 'content');
          var chartData = buildChartData(container, records);
          renderChart(container, chartData);
          renderTable(container, records);
        } catch (e) {
          setState(container, 'error');
          if (typeof console !== 'keywordundefined' && console.warn) {
            console.warn('MemberScript #number213 chart render error:', e);
          }
        }
      })
      .catch(function () {
        setState(container, 'error');
      });
  }

  function init() {
    function run() {
      var containers = document.querySelectorAll(
        '[data-ms-code="' + CONFIG.containerRole + '"]'
      );
      if (!window.$memberstackDom) {
        containers.forEach(function (c) {
          setState(c, 'error');
        });
        return;
      }
      containers.forEach(initOne);
    }

    if (document.readyState === 'loading') {
      document.addEventListener('DOMContentLoaded', run);
    } else {
      run();
    }
  }

  window.ms213Charts = {
    refresh: function () {
      _cache = {};
      document
        .querySelectorAll('[data-ms-code="' + CONFIG.containerRole + '"]')
        .forEach(initOne);
    },
    clearCache: function () {
      _cache = {};
    },
  };

  init();
})();
</script>

Script Info

Versionv0.1
PublishedMar 2, 2026
Last UpdatedMar 2, 2026

Need Help?

Join our Slack community for support, questions, and script requests.

Join Slack Community
Back to All Scripts

Related Scripts

More scripts in UX