*! googlechart v0.1.3 2026-07-04 *! Stata wrapper for the Google Charts (Visualization API) library. *! *! SIBLING to sparkta2 -- different use case, not a replacement. *! Trade-off: *! - sparkta2 ships d3 + topojson inline; HTML works offline. *! - googlechart uses Google Charts CDN (https://www.gstatic.com); *! HTML requires network at view time per Google ToS. In return: *! 14 native chart types, free interactive filters via *! google.visualization.Dashboard + ControlWrapper, native animations, *! polished default tooltips. *! *! Authorship: Eric Booth, Texas 2036. Built atop Google Charts *! (https://developers.google.com/chart) -- not affiliated with Google. *! *! Supported types (v0.1): *! column, bar, line, area, combo, pie, donut, scatter, bubble, geo, *! timeline, table, histogram, divbar. *! *! Common features (apply to most types): *! tx2036style Texas 2036 brand + Montserrat font *! download Export menu (PNG/SVG) *! datatable View data table + CSV download *! animate IntersectionObserver-gated initial draw *! filters() Filter dropdowns via Google Dashboard + Controls *! downloadpos side (default) | below | none program define googlechart, rclass version 17.0 syntax [varlist(numeric default=none)] [if] [in], TYPE(string) [ /// NAME(varname) /// OVER(varname) /// LEVel(varname) /// LEVELORDer(string) /// CENTERlevel(string) /// TIME(varname) /// STARTvar(varname) /// ENDvar(varname) /// SIZEvar(varname numeric) /// HORIzontal /// STACKed /// NORMAlize /// DIRECTlabels /// SCHEME(string) /// TX2036STyle /// DOWNload /// DATATable /// ANIMate /// DOWNLOADPos(string) /// FILTERS(varlist) /// TOOLTIPvars(varlist) /// TITLE(string) SUBtitle(string) NOTE(string) /// XLABel(string) YLABel(string) /// NAMELabel(string) VALUELabel(string) /// LABELWRap(string) /// LEGENDPos(string) /// TABLESEArch /// TABLEHEAdersticky /// TABLEFRozencols(integer 0) /// GEOREGion(string) GEORESolution(string) /// COMBOTypes(string) COMBODflt(string) /// INNERradius(real 0.45) /// BUCKETSize(real 0) /// WIDTH(integer 980) HEIGHT(integer 644) /// EXPORT(string) NOOPEN /// ] marksample touse, novarlist local type = lower("`type'") local _valid_types "column bar line area combo pie donut scatter bubble geo timeline table histogram divbar" if !`:list type in _valid_types' { display as error "googlechart: type(`type') not recognised." display as error " Valid: `_valid_types'" exit 198 } * Validation of feature flags local is_tx2036st = cond("`tx2036style'" != "", 1, 0) local is_download = cond("`download'" != "", 1, 0) local is_datatable = cond("`datatable'" != "", 1, 0) local is_animate = cond("`animate'" != "", 1, 0) local is_stacked = cond("`stacked'" != "", 1, 0) local is_normalize = cond("`normalize'" != "", 1, 0) local is_directlbl = cond("`directlabels'" != "", 1, 0) local is_tblsearch = cond("`tablesearch'" != "", 1, 0) local is_tblsticky = cond("`tableheadersticky'" != "", 1, 0) * Guard: on a bar/column chart, directlabels adds an annotation-role * column, and Google Charts throws on animation.startup when that column * is present -- the chart then renders blank. The two options are * mutually exclusive here, so drop animate and tell the user rather than * emit a page that silently fails to draw. if `is_animate' & `is_directlbl' & inlist("`type'","bar","column") { display as text "googlechart: animate is incompatible with directlabels on " /// "type(`type') (Google Charts errors on the annotation column); ignoring animate." local is_animate = 0 } if "`legendpos'" == "" local legendpos "" local legendpos = lower("`legendpos'") if "`downloadpos'" == "" local downloadpos "side" local downloadpos = lower("`downloadpos'") local _vd "side below none" if !`:list downloadpos in _vd' { display as error "googlechart: downloadpos(`downloadpos') -- valid: side | below | none" exit 198 } if "`scheme'" == "" { if `is_tx2036st' local scheme "tx2036" else if "`type'" == "divbar" local scheme "rdbu" else local scheme "tx2036" } local scheme = lower("`scheme'") * For BAR type, the user often expects HORIZONTAL. Google Charts' * BarChart class IS horizontal; ColumnChart is vertical. Resolve here. local _engine_type "`type'" if "`type'" == "column" & "`horizontal'" != "" local _engine_type "bar" if "`type'" == "bar" & "`horizontal'" == "" { * Bar in googlechart means horizontal by convention. * No change needed. } * Per-type required-input checks (kept minimal so the engine can * surface its own errors when something's off) local nvar : word count `varlist' local _v1 : word 1 of `varlist' local _v2 : word 2 of `varlist' * When user calls `googlechart y x, over(series) type(line|area|combo)' * with 2 numeric vars and no explicit name(), treat _v2 as the category * dimension (x-axis) and _v1 as the value. Without this, the engine * gets every row with name="" and reshapes them onto a single x slot. if (inlist("`type'","line","area","combo")) & (`nvar' == 2) & ("`name'" == "") { local name "`_v2'" local _v2 "" local nvar = 1 display as txt "googlechart: type(`type') with two vars + no name() -- using `name' as the x-axis category." } * String inlist() caps at ~10 args -- split. if inlist("`type'","column","bar","line","area","combo") | /// inlist("`type'","pie","donut","geo","histogram","divbar") { if `nvar' < 1 { display as error "googlechart: type(`type') requires at least one numeric var" exit 198 } } if inlist("`type'","scatter","bubble") { if `nvar' < 2 { display as error "googlechart: type(`type') requires two numeric vars (x y)" exit 198 } } if "`type'" == "divbar" { if "`level'" == "" { display as error "googlechart: type(divbar) requires level(varname)" exit 198 } if "`name'" == "" { display as error "googlechart: type(divbar) requires name(varname) -- the item label" exit 198 } } if "`type'" == "timeline" { if "`startvar'" == "" | "`endvar'" == "" { display as error "googlechart: type(timeline) requires startvar() and endvar()" exit 198 } if "`name'" == "" { display as error "googlechart: type(timeline) requires name(varname) -- the row label" exit 198 } } if "`type'" == "geo" { if "`name'" == "" { display as error "googlechart: type(geo) requires name(varname) -- region/country code" exit 198 } display as txt "googlechart: type(geo) supports country and US-state level only." display as txt " For county or school-district choropleths, use a mapping command that reads shapefiles." } if "`title'" == "" { if "`type'" == "donut" local title "Donut chart" else if "`type'" == "divbar" local title "Diverging stacked bar" else if "`type'" == "timeline" local title "Timeline" else { local _tprop = proper("`type'") local title "`_tprop' chart" } } if "`export'" == "" local export "`c(pwd)'/googlechart_`type'.html" * --- Discover the engine and helpers -------------------------- * The engine is shipped as googlechart_engine.sthlp because Stata's * net install only copies files with a small set of recognised * extensions (.ado, .sthlp, .mata, .mlib, .dta, ...). .js is NOT in * that list -- net install silently skips it -- so we use .sthlp as * a safe container for the JavaScript text. The contents are not * Stata syntax; the file is treated as opaque text and embedded * into the output HTML via googlechart_embedjs. capture findfile googlechart_engine.sthlp if _rc { display as error "googlechart: googlechart_engine.sthlp not on adopath." display as error " Make sure the googlechart package is in your _codeshare or local clone." exit 601 } local engpath "`r(fn)'" * --- Build the long-form row JSON ------------------------------------ * Schema: each row is one observation with optional fields -- * name, value, g (series), lev (level), t (time), x, y, start, end, * size, t__VAR. Empty values omitted to keep the JSON tight. tempfile rowjson tempname rfh quietly file open `rfh' using "`rowjson'", write text replace local _first 1 local _rows_written = 0 quietly { forvalues _i = 1/`=_N' { if !`touse'[`_i'] continue * --- Build the per-row record ----------------------------- * For bubble: _v1=x, _v2=y. For scatter: _v1=x, _v2=y. * For timeline: no varlist; uses start/end. * For everything else: _v1=value, _v2 unused. * The engine knows which fields to consume per type. local _xv . local _yv . if `nvar' >= 1 local _xv = `_v1'[`_i'] if `nvar' >= 2 local _yv = `_v2'[`_i'] * Skip rows where the primary numeric is missing. Two * exceptions: divbar treats missing share as 0; timeline * relies on start/end (no primary numeric). if "`type'" != "timeline" & "`type'" != "table" & missing(`_xv') { if "`type'" != "divbar" continue } if inlist("`type'","scatter","bubble") & missing(`_yv') continue * Resolve string-or-label fields local _nm "" if "`name'" != "" { capture confirm string variable `name' if !_rc { local _nm = `name'[`_i'] } else { local _lab : value label `name' local _num = `name'[`_i'] if "`_lab'" != "" & !missing(`_num') local _nm : label `_lab' `_num' else if !missing(`_num') local _nm = strofreal(`_num') } } local _nm : subinstr local _nm `"\"' `"\\"', all local _nm : subinstr local _nm `"""' `"\""', all local _ov "" if "`over'" != "" { capture confirm string variable `over' if !_rc { local _ov = `over'[`_i'] } else { local _olab : value label `over' local _onum = `over'[`_i'] if "`_olab'" != "" & !missing(`_onum') local _ov : label `_olab' `_onum' else if !missing(`_onum') local _ov = strofreal(`_onum') } } local _ov : subinstr local _ov `"\"' `"\\"', all local _ov : subinstr local _ov `"""' `"\""', all local _lv "" if "`level'" != "" { capture confirm string variable `level' if !_rc { local _lv = `level'[`_i'] } else { local _llab : value label `level' local _lnum = `level'[`_i'] if "`_llab'" != "" & !missing(`_lnum') local _lv : label `_llab' `_lnum' else if !missing(`_lnum') local _lv = strofreal(`_lnum') } } local _lv : subinstr local _lv `"\"' `"\\"', all local _lv : subinstr local _lv `"""' `"\""', all * Numeric helper fields local _start "" local _end "" local _size . if "`startvar'" != "" { * Try to format as ISO date if the var has format suggesting Stata date local _fmt : format `startvar' local _sv = `startvar'[`_i'] if missing(`_sv') continue if regexm("`_fmt'", "^%[dt]") { local _start = string(`_sv', "%tdCCYY-NN-DD") } else { local _start = strofreal(`_sv') } } if "`endvar'" != "" { local _fmt : format `endvar' local _ev = `endvar'[`_i'] if missing(`_ev') continue if regexm("`_fmt'", "^%[dt]") { local _end = string(`_ev', "%tdCCYY-NN-DD") } else { local _end = strofreal(`_ev') } } if "`sizevar'" != "" { local _size = `sizevar'[`_i'] } * Emit the row if `_first' local _first 0 else file write `rfh' "," _n file write `rfh' " {" * Map varlist to fields per type. Important: Stata writes a * literal "." for missing numerics via file write, which is NOT * valid JSON. Emit `null' explicitly for any missing primary * numeric -- otherwise the whole payload fails to parse and the * chart never renders (the type=table case has no varlist at all, * so _xv is always missing there). if inlist("`type'","scatter","bubble") { if !missing(`_xv') file write `rfh' `""x":"' (`_xv') else file write `rfh' `""x":null"' if !missing(`_yv') file write `rfh' `","y":"' (`_yv') else file write `rfh' `","y":null"' if "`type'" == "bubble" { if !missing(`_size') file write `rfh' `","size":"' (`_size') else file write `rfh' `","size":null"' } } else if "`type'" == "table" { * type(table) typically passes no varlist -- columns come * from tooltipvars() instead. Emit an opening placeholder * so the row JSON is valid even when no fields follow. file write `rfh' `""_":null"' } else { * For column/bar/line/area/pie/donut/geo/divbar/histogram/combo: * primary numeric is the value, optionally with a series. if !missing(`_xv') file write `rfh' `""value":"' (`_xv') else file write `rfh' `""value":null"' } if "`_nm'" != "" file write `rfh' `","name":"`_nm'""' if "`_ov'" != "" file write `rfh' `","g":"`_ov'""' if "`_lv'" != "" file write `rfh' `","lev":"`_lv'""' if "`_start'" != "" file write `rfh' `","start":"`_start'""' if "`_end'" != "" file write `rfh' `","end":"`_end'""' * Time dimension (numeric) -- used by bubble/play and reserved * for future per-type uses. if "`time'" != "" { local _tv = `time'[`_i'] if !missing(`_tv') file write `rfh' `","t":"' (`_tv') } * Tooltipvars (extra columns shown in the data table) if "`tooltipvars'" != "" { foreach _tv of varlist `tooltipvars' { local _val "" capture confirm string variable `_tv' if !_rc { local _val = `_tv'[`_i'] local _val : subinstr local _val `"\"' `"\\"', all local _val : subinstr local _val `"""' `"\""', all file write `rfh' `","t__`_tv'":"`_val'""' } else { local _tlab : value label `_tv' local _tnum = `_tv'[`_i'] if "`_tlab'" != "" & !missing(`_tnum') { local _tdisp : label `_tlab' `_tnum' local _tdisp : subinstr local _tdisp `"\"' `"\\"', all local _tdisp : subinstr local _tdisp `"""' `"\""', all file write `rfh' `","t__`_tv'":"`_tdisp'""' } else if missing(`_tnum') { file write `rfh' `","t__`_tv'":null"' } else { file write `rfh' `","t__`_tv'":"' (`_tnum') } } } } file write `rfh' "}" _n local ++_rows_written } } file close `rfh' if `_rows_written' == 0 { display as error "googlechart: no rows to plot (check [if] / [in] / missing values)" exit 459 } * --- Build filter spec JSON ------------------------------------------ tempfile filterjson quietly file open `rfh' using "`filterjson'", write text replace file write `rfh' "[" local _fcount = 0 if "`filters'" != "" { foreach _fv of varlist `filters' { if `_fcount' > 0 file write `rfh' "," local ++_fcount local _lbl : variable label `_fv' if "`_lbl'" == "" local _lbl "`_fv'" local _lbl : subinstr local _lbl `"""' `"\""', all capture confirm numeric variable `_fv' local _isnum = (_rc == 0) local _vlab : value label `_fv' * Treat numeric with a value label as categorical, not numeric range local _numeric_filter = cond(`_isnum' & "`_vlab'" == "", 1, 0) file write `rfh' `"{"var":"`_fv'","label":"`_lbl'","numeric":`_numeric_filter'}"' } } file write `rfh' "]" file close `rfh' * --- Build placeholder meta JSON (engine still needs the meta block) - tempfile metajson quietly file open `rfh' using "`metajson'", write text replace file write `rfh' "{}" file close `rfh' * --- Emit final HTML ------------------------------------------------- local _v1_name "`_v1'" if "`_v1_name'" == "" local _v1_name "value" local _v2_name "`_v2'" googlechart_writehtml, /// engpath("`engpath'") rowjson("`rowjson'") metajson("`metajson'") /// filterjson("`filterjson'") export(`"`export'"') /// type("`_engine_type'") scheme("`scheme'") /// title(`"`title'"') subtitle(`"`subtitle'"') note(`"`note'"') /// xlabel(`"`xlabel'"') ylabel(`"`ylabel'"') /// xvar("`_v1_name'") yvar("`_v2_name'") name("`name'") over("`over'") /// level("`level'") time("`time'") /// namelabel(`"`namelabel'"') valuelabel(`"`valuelabel'"') /// levelorder(`"`levelorder'"') centerlevel(`"`centerlevel'"') /// labelwrap("`labelwrap'") /// isdownload(`is_download') isdatatable(`is_datatable') /// isanimate(`is_animate') istx2036style(`is_tx2036st') /// downloadpos("`downloadpos'") /// isstacked(`is_stacked') isnormalize(`is_normalize') /// isdirectlabels(`is_directlbl') /// innerradius(`innerradius') bucketsize(`bucketsize') /// georegion("`georegion'") georesolution("`georesolution'") /// combotypes("`combotypes'") combodefault("`combodflt'") /// legendpos("`legendpos'") /// istblsearch(`is_tblsearch') istblsticky(`is_tblsticky') /// tblfrozencols(`tablefrozencols') /// width(`width') height(`height') display as text _n "[googlechart] `type' written:" display as text `" {browse "`export'":`export'}"' display as text " Rows: `_rows_written' Scheme: `scheme'" return local export "`export'" return local type "`type'" return scalar n_rows = `_rows_written' end