/* googlechart_engine.js -- Google Charts (Visualization API) renderer for the `googlechart` Stata package. Loads the Google Charts CDN, applies a Texas 2036 brand theme, and dispatches per type() to a small per-chart renderer. Designed as a SIBLING (not a replacement) for sparkta2. Trade-off vs sparkta2: - sparkta2 ships d3 + topojson inline; works offline. - googlechart uses Google Charts loader.js + per-package fetches from gstatic.com; CDN-only by Google ToS, requires network at view. In return: native filter controls (CategoryFilter, ChartRangeFilter, NumberRangeFilter), 14 chart types, polished default tooltips. The engine reads `window.__GOOGLECHART__` (emitted by googlechart_writehtml.ado) and renders into #chart. Supported types (v0.1): column, bar, line, area, combo, pie, donut, scatter, bubble, geo, timeline, table, histogram, divbar (Pew-style via sign-flip). Cross-cutting features (apply to every type that supports them): - tx2036style: Montserrat + brand palette baked into chart options - download menu: PNG (getImageURI), SVG (serialize), CSV (dataTableToCsv), View data table toggle - datatable: collapsible HTML table beneath the chart - animate: IntersectionObserver gates chart.draw() until the container scrolls into view (deferred-draw pattern, not d3-transition pattern) - downloadpos: side (default) | below | none - filters(): builds a google.visualization.Dashboard with CategoryFilter / NumberRangeFilter per filter var */ (function () { "use strict"; // ---- Brand palettes ------------------------------------------------------ // tx2036 = Texas 2036 brand cycle. Sequential / diverging palettes // mirror sparkta2's scheme names so existing users find them familiar. var PALETTES = { tx2036: ["#1B2D55","#D44500","#2B6CB0","#6C7A8D","#7A9D54","#A67B36","#9C5BA5","#3F8A8C","#C0392B","#F1A208"], blues: ["#deebf7","#9ecae1","#3182bd","#08519c","#08306b","#2171b5","#4292c6","#6baed6","#9ecae1","#c6dbef"], reds: ["#fee5d9","#fcbba1","#fc9272","#fb6a4a","#ef3b2c","#cb181d","#a50f15","#67000d"], greens: ["#e5f5e0","#c7e9c0","#a1d99b","#74c476","#41ab5d","#238b45","#006d2c","#00441b"], oranges: ["#feedde","#fdd0a2","#fdae6b","#fd8d3c","#f16913","#d94801","#a63603","#7f2704"], purples: ["#efedf5","#dadaeb","#bcbddc","#9e9ac8","#807dba","#6a51a3","#54278f","#3f007d"], rdbu: ["#b2182b","#d6604d","#f4a582","#fddbc7","#f7f7f7","#d1e5f0","#92c5de","#4393c3","#2166ac"], rdylgn: ["#a50026","#d73027","#f46d43","#fdae61","#fee08b","#d9ef8b","#a6d96a","#66bd63","#1a9850","#006837"], viridis: ["#440154","#482878","#3e4989","#31688e","#26828e","#1f9e89","#35b779","#6ece58","#b5de2b","#fde725"] }; // ---- Helpers ------------------------------------------------------------- function esc(s) { return String(s == null ? "" : s) .replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); } function fmt(v) { if (v == null || !isFinite(+v)) return ""; var n = +v; if (Math.abs(n) >= 1000) return n.toLocaleString(undefined, { maximumFractionDigits: 0 }); if (Math.abs(n) >= 1) return n.toLocaleString(undefined, { maximumFractionDigits: 1 }); return n.toLocaleString(undefined, { maximumFractionDigits: 2 }); } function uniqueOrdered(items) { var seen = {}; var out = []; for (var i = 0; i < items.length; i++) { var k = String(items[i]); if (!seen[k]) { seen[k] = 1; out.push(items[i]); } } return out; } function paletteFor(meta) { var name = (meta.scheme || (meta.tx2036style ? "tx2036" : "blues")).toLowerCase(); return PALETTES[name] || PALETTES.tx2036; } function brandOptions(meta) { // Common chart options used as the base; per-type renderers spread their // own overrides on top. var fontName = meta.tx2036style ? "Montserrat" : "Roboto, Arial, sans-serif"; return { colors: paletteFor(meta), fontName: fontName, backgroundColor: "transparent", titleTextStyle: { color: "#1B2D55", bold: true, fontName: fontName, fontSize: 16 }, legend: { position: "bottom", alignment: "center", textStyle: { color: "#6C7A8D", fontName: fontName, fontSize: 11 } }, tooltip: { textStyle: { fontName: fontName, fontSize: 12 } }, hAxis: { textStyle: { color: "#6C7A8D", fontName: fontName, fontSize: 11 }, titleTextStyle: { color: "#1B2D55", fontName: fontName, fontSize: 12, italic: false }, gridlines: { color: "#E4E7EB", count: -1 } }, vAxis: { textStyle: { color: "#6C7A8D", fontName: fontName, fontSize: 11 }, titleTextStyle: { color: "#1B2D55", fontName: fontName, fontSize: 12, italic: false }, gridlines: { color: "#E4E7EB", count: -1 } }, chartArea: { left: 80, top: 50, width: "75%", height: "70%" } }; } // Deep merge for option objects (engine defaults <- user title/axes etc.) function deepMerge(a, b) { if (b == null) return a; if (typeof a !== "object" || Array.isArray(a)) return b; var out = {}; var k; for (k in a) out[k] = a[k]; for (k in b) { if (b[k] && typeof b[k] === "object" && !Array.isArray(b[k])) { out[k] = deepMerge(a[k] || {}, b[k]); } else { out[k] = b[k]; } } return out; } // Reshape long form [{name, value, series}, ...] to wide rows // [[, , , ...], ...] keyed by name across series order. function longToWide(rows, xKey, valueKey, seriesKey) { var xs = uniqueOrdered(rows.map(function (r) { return r[xKey]; })); var series = seriesKey ? uniqueOrdered(rows.map(function (r) { return r[seriesKey]; })) : [valueKey]; var byX = {}; xs.forEach(function (x) { byX[x] = {}; series.forEach(function (s) { byX[x][s] = null; }); }); rows.forEach(function (r) { var s = seriesKey ? r[seriesKey] : valueKey; var v = r[valueKey]; byX[r[xKey]][s] = (v === "" || v == null) ? null : +v; }); var out = xs.map(function (x) { var row = [x]; series.forEach(function (s) { row.push(byX[x][s]); }); return row; }); return { xs: xs, series: series, rows: out }; } // ---- Module load --------------------------------------------------------- function packagesNeeded(type, hasFilters, hasTime) { var base = ["corechart"]; if (type === "table") return hasFilters ? ["table","controls"] : ["table"]; if (type === "geo") return ["geochart"]; if (type === "timeline") base = ["timeline"]; if (type === "sankey") base = ["sankey"]; if (type === "treemap") base = ["treemap"]; if (type === "calendar") base = ["calendar"]; if (type === "gauge") base = ["gauge"]; // Bubble with a time dimension also drives a Dashboard + ControlWrapper // for the Play button, so it needs controls too. if (hasFilters || (type === "bubble" && hasTime)) base.push("controls"); return base; } function bootstrap(cfg) { var meta = cfg.meta || {}; var hasFilters = !!(cfg.filters && cfg.filters.length); var hasTime = !!(meta.time && cfg.data && cfg.data.some(function (r) { return r.t != null; })); var pkgs = packagesNeeded((meta.type || "column").toLowerCase(), hasFilters, hasTime); if (typeof google === "undefined" || !google.charts) { // Loader hasn't arrived yet; retry on next frame setTimeout(function () { bootstrap(cfg); }, 50); return; } google.charts.load("current", { packages: pkgs }); google.charts.setOnLoadCallback(function () { setup(cfg); }); } // ---- Top-level setup ----------------------------------------------------- function setup(cfg) { var meta = cfg.meta || {}; var data = cfg.data || []; var filterDefs = cfg.filters || []; var tipvars = cfg.tooltipvars || []; // Build controls panel (Export + Filters), regardless of chart type. buildControlsPanel(meta, data, tipvars, filterDefs); // The actual chart instance + DataTable are constructed inside the // type-specific renderer. Each renderer returns { chart, dataTable, // dashboard?, redraw } so the export menu can call back into them. var ctx = renderByType(meta, data, filterDefs); if (!ctx) return; // Hook the export-menu actions up to the just-built chart. wireExportMenu(meta, ctx, data, tipvars); // IntersectionObserver-gated initial draw. Sparkta2 fades in elements // that already exist; Google Charts has to defer the .draw() itself. setupAnimateOnView(meta, ctx); } // ---- Dispatch by chart type --------------------------------------------- function renderByType(meta, data, filterDefs) { var type = (meta.type || "column").toLowerCase(); switch (type) { case "column": return renderXYChart(meta, data, "ColumnChart", filterDefs); case "bar": return renderXYChart(meta, data, "BarChart", filterDefs); case "line": return renderXYChart(meta, data, "LineChart", filterDefs); case "area": return renderXYChart(meta, data, "AreaChart", filterDefs); case "combo": return renderCombo(meta, data, filterDefs); case "scatter": return renderScatter(meta, data); case "pie": return renderPie(meta, data, false); case "donut": return renderPie(meta, data, true); case "bubble": return renderBubble(meta, data); case "geo": return renderGeo(meta, data); case "timeline": return renderTimeline(meta, data); case "table": return renderTable(meta, data, filterDefs); case "histogram": return renderHistogram(meta, data); case "divbar": return renderDivbar(meta, data); default: renderError("type(" + type + ") not recognised by googlechart_engine.js"); return null; } } function renderError(msg) { var el = document.getElementById("chart"); if (el) el.innerHTML = "
" + esc(msg) + "
"; } // ============================================================ // XY-AXIS CHARTS // column / bar / line / area / scatter share enough plumbing // that one function handles all five with class-name swap. // ============================================================ function renderXYChart(meta, data, className, filterDefs) { var W = +meta.width || 980; var H = +meta.height || 560; var xKey = "name"; // category / time var yKey = "value"; // numeric value var sKey = meta.over ? "g" : null; // multi-series via Stata `over()` var wide = longToWide(data, xKey, yKey, sKey); // Build DataTable from the wide reshape. var header = [meta.namelabel || meta.name || "x"]; if (sKey) wide.series.forEach(function (s) { header.push(String(s)); }); else header.push(meta.xvar || meta.valuelabel || "value"); var dt = new google.visualization.DataTable(); // Column 0 type: detect if all xs are numbers (number axis) else string. var allNumeric = wide.xs.every(function (x) { return x !== "" && isFinite(+x); }); dt.addColumn(allNumeric ? "number" : "string", header[0]); for (var i = 1; i < header.length; i++) dt.addColumn("number", header[i]); wide.rows.forEach(function (row) { var clean = row.slice(); if (allNumeric) clean[0] = +clean[0]; dt.addRow(clean); }); // Direct-labels: for ColumnChart / BarChart, wrap in a DataView that // appends a stringify'd annotation column after each numeric series. // Classic corechart only -- material charts ignore the role. var drawSource = dt; if (meta.directlabels && (className === "ColumnChart" || className === "BarChart")) { var ncols = dt.getNumberOfColumns(); var viewCols = [0]; for (var ci = 1; ci < ncols; ci++) { viewCols.push(ci); viewCols.push({ calc: "stringify", sourceColumn: ci, type: "string", role: "annotation" }); } var dv = new google.visualization.DataView(dt); dv.setColumns(viewCols); drawSource = dv; } var opts = deepMerge(brandOptions(meta), { title: meta.title || "", width: W, height: H, hAxis: { title: meta.xlabel || "", slantedText: (className === "ColumnChart" || className === "BarChart") && !allNumeric, slantedTextAngle: 30 }, vAxis: { title: meta.ylabel || "", baseline: 0 }, legend: { position: sKey ? "bottom" : "none" }, isStacked: meta.stacked ? (meta.normalize ? "percent" : true) : false, animation: { startup: !!meta.animate, duration: 900, easing: "out" }, // Make line and area charts readable -- thicker stroke + visible point markers // (Google's defaults are 2px stroke and zero markers, which can vanish at small sizes). lineWidth: (className === "LineChart" || className === "AreaChart") ? 3 : 2, pointSize: (className === "LineChart" || className === "AreaChart") ? 5 : (className === "ScatterChart" ? 6 : 0), areaOpacity: className === "AreaChart" ? 0.35 : undefined, annotations: { // alwaysOutside places the label outside the bar end. The // `stem' is the short line Google draws from the bar to the // label; on a horizontal bar it renders as a leading dash that // makes positive values read as negative. Hide the stem by // colouring it transparent but keep its length so the label // stays offset from the bar end. alwaysOutside: true, textStyle: { fontName: meta.tx2036style ? "Montserrat" : undefined, fontSize: 14, bold: true, color: "#1B2D55", auraColor: "none" }, stem: { color: "transparent", length: 12 }, highContrast: false, style: "point" } }); // Bar charts read better with the title on hAxis (data dimension); // ColumnChart reads better with the title on vAxis. if (className === "BarChart") { opts.hAxis.title = meta.ylabel || opts.hAxis.title; opts.vAxis.title = meta.xlabel || opts.vAxis.title; } var container = document.getElementById("chart"); var chart = new google.visualization[className](container); // If filters are requested, wire a Dashboard so the chart redraws // whenever filter state changes. var dashboard = null, chartWrapper = null; if (filterDefs && filterDefs.length) { dashboard = buildDashboardWithFilters(meta, dt, filterDefs, className, opts); // dashboard.draw is the entry point in dashboard mode; the chart // instance is owned by the wrapper. return { type: "dashboard", className: className, dashboard: dashboard, dataTable: dt, opts: opts, draw: function () { dashboard.draw(dt); } }; } return { type: "chart", className: className, chart: chart, dataTable: dt, opts: opts, draw: function () { chart.draw(drawSource, opts); } }; } // ============================================================ // COMBO CHART // Same as XY but with per-series chartType overrides via // meta.combo_types ("line|bars|area"). // ============================================================ function renderCombo(meta, data, filterDefs) { var W = +meta.width || 980; var H = +meta.height || 560; var wide = longToWide(data, "name", "value", meta.over ? "g" : null); var dt = new google.visualization.DataTable(); dt.addColumn("string", meta.namelabel || meta.name || "x"); wide.series.forEach(function (s) { dt.addColumn("number", String(s)); }); wide.rows.forEach(function (r) { dt.addRow(r); }); // combo_types pipe-list maps to series:{0:{type},1:{type},...} var defaultType = (meta.combo_default || "bars").toLowerCase(); var perSeries = (meta.combo_types || "").split("|").filter(Boolean); var seriesOpts = {}; wide.series.forEach(function (s, i) { var t = perSeries[i] || defaultType; // For line-type combo series, default to thicker stroke + visible // point markers so the line stands apart from the bars and isn't // lost against the chart background. var sOpt = { type: t }; if (t === "line" || t === "area") { sOpt.lineWidth = 3; sOpt.pointSize = 6; } seriesOpts[i] = sOpt; }); var opts = deepMerge(brandOptions(meta), { title: meta.title || "", width: W, height: H, seriesType: defaultType, series: seriesOpts, hAxis: { title: meta.xlabel || "", slantedText: true, slantedTextAngle: 30 }, vAxis: { title: meta.ylabel || "" }, legend: { position: "bottom" }, animation: { startup: !!meta.animate, duration: 900, easing: "out" } }); var container = document.getElementById("chart"); var chart = new google.visualization.ComboChart(container); return { type: "chart", className: "ComboChart", chart: chart, dataTable: dt, opts: opts, draw: function () { chart.draw(dt, opts); } }; } // ============================================================ // PIE / DONUT // ============================================================ function renderPie(meta, data, isDonut) { var dt = new google.visualization.DataTable(); dt.addColumn("string", meta.namelabel || meta.name || "slice"); dt.addColumn("number", meta.valuelabel || "value"); data.forEach(function (r) { dt.addRow([String(r.name), r.value == null ? null : +r.value]); }); // PieChart does NOT honor animation.startup (Google has never // shipped it for pie -- confirmed by their docs + issue #330). // Instead, fade the container in via CSS once the chart fires // 'ready', which gives a startup-animation feel without the // unsupported option. // Pie / donut layout: legend sits BELOW by default so the pie itself // is centred in a square-ish frame. The previous "right" default // reserved a wide legend column that pushed the pie left and left a // visible empty band on the right of the card. `chartArea' is // expanded so the pie nearly fills the SVG. var legendPos = meta.labelwrap === "outside" ? "labeled" : (meta.legendpos || "bottom"); var opts = deepMerge(brandOptions(meta), { title: meta.title || "", width: +meta.width || 640, height: +meta.height || 520, pieHole: isDonut ? (+meta.innerradius || 0.45) : 0, legend: { position: legendPos, alignment: "center", textStyle: { fontSize: 11 } }, chartArea: { left: "6%", right: "6%", top: "12%", width: "88%", height: legendPos === "bottom" ? "70%" : "78%" }, pieSliceText: meta.directlabels ? "percentage" : "value", pieSliceTextStyle: { fontSize: 12, color: "#FFFFFF" }, sliceVisibilityThreshold: 0.005 }); var container = document.getElementById("chart"); var chart = new google.visualization.PieChart(container); if (meta.animate) { // Pie / donut don't honor Google's animation.startup (issue #330); // substitute a fade + scale-up pop on the chart's 'ready' event. // 1100 ms is long enough to be perceptible on first view but // short enough to feel snappy. container.style.opacity = "0"; container.style.transform = "scale(0.88)"; container.style.transformOrigin = "center center"; container.style.transition = "opacity 1100ms ease-out, transform 1100ms cubic-bezier(.22,1.1,.36,1)"; google.visualization.events.addListener(chart, "ready", function () { // setTimeout instead of requestAnimationFrame because rAF can // be throttled by the browser when the iframe document isn't // fully visible (sparkta2_dashboard embeds these in iframes). setTimeout(function () { container.style.opacity = "1"; container.style.transform = "scale(1)"; }, 16); }); } return { type: "chart", className: "PieChart", chart: chart, dataTable: dt, opts: opts, draw: function () { chart.draw(dt, opts); } }; } // ============================================================ // SCATTER CHART // Inputs in long form: { x, y, g (optional series) } // Each row is one point. When `g' (series) is present, reshape so // each series gets its own numeric Y column. // ============================================================ function renderScatter(meta, data) { var hasSeries = data.length > 0 && data.some(function (r) { return r.g != null; }); var dt = new google.visualization.DataTable(); dt.addColumn("number", meta.xlabel || "x"); if (hasSeries) { var seriesList = uniqueOrdered(data.map(function (r) { return String(r.g || ""); })); seriesList.forEach(function (s) { dt.addColumn("number", s); }); data.forEach(function (r) { var row = [r.x == null ? null : +r.x]; seriesList.forEach(function (s) { row.push(String(r.g || "") === s ? (r.y == null ? null : +r.y) : null); }); dt.addRow(row); }); } else { dt.addColumn("number", meta.ylabel || "y"); data.forEach(function (r) { dt.addRow([r.x == null ? null : +r.x, r.y == null ? null : +r.y]); }); } var opts = deepMerge(brandOptions(meta), { title: meta.title || "", width: +meta.width || 980, height: +meta.height || 644, hAxis: { title: meta.xlabel || "" }, vAxis: { title: meta.ylabel || "" }, legend: { position: hasSeries ? "bottom" : "none" }, pointSize: 8, animation: { startup: !!meta.animate, duration: 900, easing: "out" } }); var container = document.getElementById("chart"); var chart = new google.visualization.ScatterChart(container); return { type: "chart", className: "ScatterChart", chart: chart, dataTable: dt, opts: opts, draw: function () { chart.draw(dt, opts); } }; } // ============================================================ // BUBBLE CHART // Inputs in long form: { name, x, y, g (group/color), size } // ============================================================ function renderBubble(meta, data) { var dt = new google.visualization.DataTable(); dt.addColumn("string", "ID"); dt.addColumn("number", meta.xlabel || "x"); dt.addColumn("number", meta.ylabel || "y"); // Column 3 type controls how Google Charts colours the bubbles: // string -> categorical legend colours // number -> continuous colorAxis gradient var groupIsNumeric = data.length > 0 && data.every(function (r) { return r.g == null || r.g === "" || isFinite(+r.g); }); if (groupIsNumeric) dt.addColumn("number", meta.over || "color"); else dt.addColumn("string", meta.over || "group"); dt.addColumn("number", "size"); // Time column lives in the DataTable when meta.time is set, but is // HIDDEN from the chart via a DataView so BubbleChart doesn't try to // use it as series/color/size. var hasTime = !!meta.time && data.some(function (r) { return r.t != null; }); if (hasTime) dt.addColumn("number", meta.time || "time"); data.forEach(function (r) { var row = [ String(r.name || ""), r.x == null ? null : +r.x, r.y == null ? null : +r.y, groupIsNumeric ? (r.g == null ? null : +r.g) : String(r.g || ""), r.size == null ? null : +r.size ]; if (hasTime) row.push(r.t == null ? null : +r.t); dt.addRow(row); }); // Fixed axis windows so motion is interpretable across time slices. var xs = data.map(function (r) { return +r.x; }).filter(isFinite); var ys = data.map(function (r) { return +r.y; }).filter(isFinite); function padded(arr) { var lo = Math.min.apply(null, arr), hi = Math.max.apply(null, arr); var pad = (hi - lo) * 0.08 || 1; return { min: lo - pad, max: hi + pad }; } var xw = xs.length ? padded(xs) : null; var yw = ys.length ? padded(ys) : null; var opts = deepMerge(brandOptions(meta), { title: meta.title || "", width: +meta.width || 980, height: +meta.height || 560, hAxis: { title: meta.xlabel || "", viewWindow: xw }, vAxis: { title: meta.ylabel || "", viewWindow: yw }, bubble: { textStyle: { auraColor: "transparent", color: "#1B2D55", fontName: meta.tx2036style ? "Montserrat" : "sans-serif", fontSize: 10 }, opacity: 0.78, stroke: "#1B2D55" }, sizeAxis: { minSize: 3, maxSize: 36 }, legend: { position: "right" } }); // BubbleChart in plain (non-dashboard) mode does support // `animation.startup`, but inside a Dashboard / ChartWrapper the // same option throws "Cannot read properties of undefined (reading // 'Do')" on draw. Apply animation only outside the dashboard path. if (!hasTime) { opts.animation = { startup: !!meta.animate, duration: 900, easing: "out" }; } var container = document.getElementById("chart"); if (hasTime) { // Build a Dashboard with a one-thumb NumberRangeFilter as the year // slider + a Play button that advances the filter through the // distinct time values to animate motion across time. var times = uniqueOrdered(data.map(function (r) { return +r.t; })) .filter(isFinite).sort(function (a, b) { return a - b; }); var tMin = times[0], tMax = times[times.length - 1]; var dashRoot = document.getElementById("dashboard"); if (!dashRoot) { dashRoot = document.createElement("div"); dashRoot.id = "dashboard"; container.parentNode.insertBefore(dashRoot, container); } var dash = new google.visualization.Dashboard(dashRoot); var filterHost = document.getElementById("gc-filters"); if (!filterHost) { filterHost = document.createElement("div"); filterHost.id = "gc-filters"; filterHost.className = "gc-filters"; container.parentNode.insertBefore(filterHost, container); } filterHost.innerHTML = ""; var slot = document.createElement("div"); slot.id = "gc-filter-time"; // The NumberRangeFilter renders its own label from filterColumnLabel, so // the wrapper does not add a second