diff --git a/.gitignore b/.gitignore index f8770f0..c1c7842 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /candlestick.svg /layer-line.svg +*-orig.svg *.png *.jpg *.txt diff --git a/README.md b/README.md index 01264aa..712cf82 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # vegagoja -`vegagoja` renders [Vega visualizations][vega-examples] and [Vega-lite -visualizations][vega-lite-examples] as SVGs using the [`goja`][goja] JavaScript -runtime. Developed for use by [`usql`][usql] for rendering charts. +`vegagoja` renders [Vega][vega-examples] and [Vega-Lite visualizations][vega-lite-examples] +as SVGs using the [`goja`][goja] JavaScript runtime. Developed for use by +[`usql`][usql] for rendering charts. [Overview][] | [TODO][] | [About][] @@ -116,7 +116,7 @@ const candlestickSpec = `{ - [usql][usql] - a universal command-line interface for SQL databases Users of this package may find the [`github.com/xo/resvg`][resvg] package -helpful in rendering the +helpful in rendering generated SVGs. [usql]: https://github.com/xo/usql [resvg]: https://github.com/xo/resvg diff --git a/example_test.go b/example_test.go index 34b74aa..6f1c5f0 100644 --- a/example_test.go +++ b/example_test.go @@ -13,11 +13,11 @@ func Example() { vega := vegagoja.New( vegagoja.WithDemoData(), ) - data, err := vega.Render(context.Background(), candlestickSpec) + svg, err := vega.Render(context.Background(), candlestickSpec) if err != nil { log.Fatal(err) } - if err := os.WriteFile("candestick.svg", []byte(data), 0o644); err != nil { + if err := os.WriteFile("candlestick.svg", []byte(svg), 0o644); err != nil { log.Fatal(err) } // Output: @@ -25,271 +25,30 @@ func Example() { func Example_compile() { vega := vegagoja.New() - s, err := vega.Compile(candlestickSpec) + res, err := vega.Compile(candlestickSpec) if err != nil { log.Fatal(err) } - fmt.Println(s) + fmt.Println(res) // Output: - // { - // "$schema": "https://vega.github.io/schema/vega/v5.json", - // "description": "A candlestick chart inspired by an example in Protovis (http://mbostock.github.io/protovis/ex/candlestick.html)", - // "background": "white", - // "padding": 5, - // "width": 400, - // "height": 200, - // "style": "cell", - // "data": [ - // { - // "name": "source_0", - // "url": "data/ohlc.json", - // "format": { - // "type": "json", - // "parse": { - // "date": "date" - // } - // } - // }, - // { - // "name": "data_0", - // "source": "source_0", - // "transform": [ - // { - // "type": "filter", - // "expr": "(isDate(datum[\"date\"]) || (isValid(datum[\"date\"]) && isFinite(+datum[\"date\"]))) && isValid(datum[\"low\"]) && isFinite(+datum[\"low\"])" - // } - // ] - // }, - // { - // "name": "data_1", - // "source": "source_0", - // "transform": [ - // { - // "type": "filter", - // "expr": "(isDate(datum[\"date\"]) || (isValid(datum[\"date\"]) && isFinite(+datum[\"date\"]))) && isValid(datum[\"open\"]) && isFinite(+datum[\"open\"])" - // } - // ] - // } - // ], - // "marks": [ - // { - // "name": "layer_0_marks", - // "type": "rule", - // "style": [ - // "rule" - // ], - // "from": { - // "data": "data_0" - // }, - // "encode": { - // "update": { - // "stroke": [ - // { - // "test": "datum.open < datum.close", - // "value": "#06982d" - // }, - // { - // "value": "#ae1325" - // } - // ], - // "description": { - // "signal": "\"Date in 2009: \" + (timeFormat(datum[\"date\"], '%m/%d')) + \"; low: \" + (format(datum[\"low\"], \"\")) + \"; high: \" + (format(datum[\"high\"], \"\"))" - // }, - // "x": { - // "scale": "x", - // "field": "date" - // }, - // "y": { - // "scale": "y", - // "field": "low" - // }, - // "y2": { - // "scale": "y", - // "field": "high" - // } - // } - // } - // }, - // { - // "name": "layer_1_marks", - // "type": "rect", - // "style": [ - // "bar" - // ], - // "from": { - // "data": "data_1" - // }, - // "encode": { - // "update": { - // "fill": [ - // { - // "test": "datum.open < datum.close", - // "value": "#06982d" - // }, - // { - // "value": "#ae1325" - // } - // ], - // "ariaRoleDescription": { - // "value": "bar" - // }, - // "description": { - // "signal": "\"Date in 2009: \" + (timeFormat(datum[\"date\"], '%m/%d')) + \"; open: \" + (format(datum[\"open\"], \"\")) + \"; close: \" + (format(datum[\"close\"], \"\"))" - // }, - // "xc": { - // "scale": "x", - // "field": "date" - // }, - // "width": { - // "value": 5 - // }, - // "y": { - // "scale": "y", - // "field": "open" - // }, - // "y2": { - // "scale": "y", - // "field": "close" - // } - // } - // } - // } - // ], - // "scales": [ - // { - // "name": "x", - // "type": "time", - // "domain": { - // "fields": [ - // { - // "data": "data_0", - // "field": "date" - // }, - // { - // "data": "data_1", - // "field": "date" - // } - // ] - // }, - // "range": [ - // 0, - // { - // "signal": "width" - // } - // ], - // "padding": 5 - // }, - // { - // "name": "y", - // "type": "linear", - // "domain": { - // "fields": [ - // { - // "data": "data_0", - // "field": "low" - // }, - // { - // "data": "data_0", - // "field": "high" - // }, - // { - // "data": "data_1", - // "field": "open" - // }, - // { - // "data": "data_1", - // "field": "close" - // } - // ] - // }, - // "range": [ - // { - // "signal": "height" - // }, - // 0 - // ], - // "zero": false, - // "nice": true - // } - // ], - // "axes": [ - // { - // "scale": "x", - // "orient": "bottom", - // "gridScale": "y", - // "grid": true, - // "tickCount": { - // "signal": "ceil(width/40)" - // }, - // "domain": false, - // "labels": false, - // "aria": false, - // "maxExtent": 0, - // "minExtent": 0, - // "ticks": false, - // "zindex": 0 - // }, - // { - // "scale": "y", - // "orient": "left", - // "gridScale": "x", - // "grid": true, - // "tickCount": { - // "signal": "ceil(height/40)" - // }, - // "domain": false, - // "labels": false, - // "aria": false, - // "maxExtent": 0, - // "minExtent": 0, - // "ticks": false, - // "zindex": 0 - // }, - // { - // "scale": "x", - // "orient": "bottom", - // "grid": false, - // "title": "Date in 2009", - // "format": "%m/%d", - // "labelAngle": 315, - // "labelAlign": "right", - // "labelBaseline": "top", - // "labelFlush": true, - // "labelOverlap": true, - // "tickCount": { - // "signal": "ceil(width/40)" - // }, - // "zindex": 0 - // }, - // { - // "scale": "y", - // "orient": "left", - // "grid": false, - // "title": "Price", - // "labelOverlap": true, - // "tickCount": { - // "signal": "ceil(height/40)" - // }, - // "zindex": 0 - // } - // ] - // } + // {"$schema":"https://vega.github.io/schema/vega/v5.json","axes":[{"aria":false,"domain":false,"grid":true,"gridScale":"y","labels":false,"maxExtent":0,"minExtent":0,"orient":"bottom","scale":"x","tickCount":{"signal":"ceil(width/40)"},"ticks":false,"zindex":0},{"aria":false,"domain":false,"grid":true,"gridScale":"x","labels":false,"maxExtent":0,"minExtent":0,"orient":"left","scale":"y","tickCount":{"signal":"ceil(height/40)"},"ticks":false,"zindex":0},{"format":"%m/%d","grid":false,"labelAlign":"right","labelAngle":315,"labelBaseline":"top","labelFlush":true,"labelOverlap":true,"orient":"bottom","scale":"x","tickCount":{"signal":"ceil(width/40)"},"title":"Date in 2009","zindex":0},{"grid":false,"labelOverlap":true,"orient":"left","scale":"y","tickCount":{"signal":"ceil(height/40)"},"title":"Price","zindex":0}],"background":"white","data":[{"format":{"parse":{"date":"date"},"type":"json"},"name":"source_0","url":"data/ohlc.json"},{"name":"data_0","source":"source_0","transform":[{"expr":"(isDate(datum[\"date\"]) || (isValid(datum[\"date\"]) && isFinite(+datum[\"date\"]))) && isValid(datum[\"low\"]) && isFinite(+datum[\"low\"])","type":"filter"}]},{"name":"data_1","source":"source_0","transform":[{"expr":"(isDate(datum[\"date\"]) || (isValid(datum[\"date\"]) && isFinite(+datum[\"date\"]))) && isValid(datum[\"open\"]) && isFinite(+datum[\"open\"])","type":"filter"}]}],"description":"A candlestick chart inspired by an example in Protovis (http://mbostock.github.io/protovis/ex/candlestick.html)","height":200,"marks":[{"encode":{"update":{"description":{"signal":"\"Date in 2009: \" + (timeFormat(datum[\"date\"], '%m/%d')) + \"; low: \" + (format(datum[\"low\"], \"\")) + \"; high: \" + (format(datum[\"high\"], \"\"))"},"stroke":[{"test":"datum.open < datum.close","value":"#06982d"},{"value":"#ae1325"}],"x":{"field":"date","scale":"x"},"y":{"field":"low","scale":"y"},"y2":{"field":"high","scale":"y"}}},"from":{"data":"data_0"},"name":"layer_0_marks","style":["rule"],"type":"rule"},{"encode":{"update":{"ariaRoleDescription":{"value":"bar"},"description":{"signal":"\"Date in 2009: \" + (timeFormat(datum[\"date\"], '%m/%d')) + \"; open: \" + (format(datum[\"open\"], \"\")) + \"; close: \" + (format(datum[\"close\"], \"\"))"},"fill":[{"test":"datum.open < datum.close","value":"#06982d"},{"value":"#ae1325"}],"width":{"value":5},"xc":{"field":"date","scale":"x"},"y":{"field":"open","scale":"y"},"y2":{"field":"close","scale":"y"}}},"from":{"data":"data_1"},"name":"layer_1_marks","style":["bar"],"type":"rect"}],"padding":5,"scales":[{"domain":{"fields":[{"data":"data_0","field":"date"},{"data":"data_1","field":"date"}]},"name":"x","padding":5,"range":[0,{"signal":"width"}],"type":"time"},{"domain":{"fields":[{"data":"data_0","field":"low"},{"data":"data_0","field":"high"},{"data":"data_1","field":"open"},{"data":"data_1","field":"close"}]},"name":"y","nice":true,"range":[{"signal":"height"},0],"type":"linear","zero":false}],"style":"cell","width":400} } func Example_withCSV() { vega := vegagoja.New( vegagoja.WithCSVString(co2Data), ) - data, err := vega.Render(context.Background(), layerLineSpec) + svg, err := vega.Render(context.Background(), layerLineSpec) if err != nil { log.Fatal(err) } - if err := os.WriteFile("layer-line.svg", []byte(data), 0o644); err != nil { + if err := os.WriteFile("layer-line.svg", []byte(svg), 0o644); err != nil { log.Fatal(err) } // Output: } +// candlestickSpec is the same as testdata/lite/layer_candlestick.vl.json const candlestickSpec = `{ "$schema": "https://vega.github.io/schema/vega-lite/v5.json", "width": 400, @@ -337,248 +96,72 @@ const candlestickSpec = `{ ] }` +// layerLineSpec is same as testdata/lite/layer_line_co2_concentration.vl.json const layerLineSpec = `{ - "$schema": "https://vega.github.io/schema/vega/v5.json", - "background": "white", - "padding": 5, + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": "data/co2-concentration.csv", + "format": {"parse": {"Date": "utc:'%Y-%m-%d'"}} + }, "width": 800, "height": 500, - "style": "cell", - "data": [ - { - "name": "source_0", - "url": "data/co2-concentration.csv", - "format": {"type": "csv", "parse": {"Date": "utc:'%Y-%m-%d'"}}, - "transform": [ - {"type": "formula", "expr": "year(datum.Date)", "as": "year"}, - {"type": "formula", "expr": "floor(datum.year / 10)", "as": "decade"}, - { - "type": "formula", - "expr": "(datum.year % 10) + (month(datum.Date)/12)", - "as": "scaled_date" - }, - { - "type": "formula", - "expr": "datum.first_date === datum.scaled_date ? 'first' : datum.last_date === datum.scaled_date ? 'last' : null", - "as": "end" - } - ] - }, - { - "name": "data_0", - "source": "source_0", - "transform": [ - { - "type": "aggregate", - "groupby": ["decade"], - "ops": ["max", "argmax", "min", "argmin"], - "fields": [ - "scaled_date", - "scaled_date", - "scaled_date", - "scaled_date" - ], - "as": [ - "max_scaled_date", - "argmax_scaled_date", - "min_scaled_date", - "argmin_scaled_date" - ] - } - ] - }, + "transform": [ + {"calculate": "year(datum.Date)", "as": "year"}, + {"calculate": "floor(datum.year / 10)", "as": "decade"}, { - "name": "data_1", - "source": "data_0", - "transform": [ - { - "type": "filter", - "expr": "isValid(datum[\"max_scaled_date\"]) && isFinite(+datum[\"max_scaled_date\"]) && isValid(datum[\"argmax_scaled_date\"][\"CO2\"]) && isFinite(+datum[\"argmax_scaled_date\"][\"CO2\"])" - } - ] + "calculate": "(datum.year % 10) + (month(datum.Date)/12)", + "as": "scaled_date" }, { - "name": "data_2", - "source": "data_0", - "transform": [ - { - "type": "filter", - "expr": "isValid(datum[\"min_scaled_date\"]) && isFinite(+datum[\"min_scaled_date\"]) && isValid(datum[\"argmin_scaled_date\"][\"CO2\"]) && isFinite(+datum[\"argmin_scaled_date\"][\"CO2\"])" - } - ] + "calculate": "datum.first_date === datum.scaled_date ? 'first' : datum.last_date === datum.scaled_date ? 'last' : null", + "as": "end" } ], - "marks": [ - { - "name": "layer_0_pathgroup", - "type": "group", - "from": { - "facet": { - "name": "faceted_path_layer_0_main", - "data": "source_0", - "groupby": ["decade"] - } - }, - "encode": { - "update": { - "width": {"field": {"group": "width"}}, - "height": {"field": {"group": "height"}} - } - }, - "marks": [ - { - "name": "layer_0_marks", - "type": "line", - "style": ["line"], - "sort": {"field": "datum[\"scaled_date\"]"}, - "from": {"data": "faceted_path_layer_0_main"}, - "encode": { - "update": { - "stroke": {"scale": "color", "field": "decade"}, - "description": { - "signal": "\"Year into Decade: \" + (format(datum[\"scaled_date\"], \"\")) + \"; CO2 concentration in ppm: \" + (format(datum[\"CO2\"], \"\")) + \"; decade: \" + (isValid(datum[\"decade\"]) ? datum[\"decade\"] : \"\"+datum[\"decade\"])" - }, - "x": {"scale": "x", "field": "scaled_date"}, - "y": {"scale": "y", "field": "CO2"}, - "defined": { - "signal": "isValid(datum[\"scaled_date\"]) && isFinite(+datum[\"scaled_date\"]) && isValid(datum[\"CO2\"]) && isFinite(+datum[\"CO2\"])" - } - } - } - } - ] - }, - { - "name": "layer_1_marks", - "type": "text", - "style": ["text"], - "aria": false, - "from": {"data": "data_2"}, - "encode": { - "update": { - "baseline": {"value": "top"}, - "fill": {"scale": "color", "field": "decade"}, - "x": {"scale": "x", "field": "min_scaled_date"}, - "y": {"scale": "y", "field": "argmin_scaled_date[\"CO2\"]"}, - "text": { - "signal": "isValid(datum[\"argmin_scaled_date\"][\"year\"]) ? datum[\"argmin_scaled_date\"][\"year\"] : \"\"+datum[\"argmin_scaled_date\"][\"year\"]" - } - } - } - }, - { - "name": "layer_2_marks", - "type": "text", - "style": ["text"], - "aria": false, - "from": {"data": "data_1"}, - "encode": { - "update": { - "fill": {"scale": "color", "field": "decade"}, - "x": {"scale": "x", "field": "max_scaled_date"}, - "y": {"scale": "y", "field": "argmax_scaled_date[\"CO2\"]"}, - "text": { - "signal": "isValid(datum[\"argmax_scaled_date\"][\"year\"]) ? datum[\"argmax_scaled_date\"][\"year\"] : \"\"+datum[\"argmax_scaled_date\"][\"year\"]" - }, - "baseline": {"value": "middle"} - } - } - } - ], - "scales": [ - { - "name": "x", - "type": "linear", - "domain": { - "fields": [ - {"data": "source_0", "field": "scaled_date"}, - {"data": "data_2", "field": "min_scaled_date"}, - {"data": "data_1", "field": "max_scaled_date"} - ] - }, - "range": [0, {"signal": "width"}], - "nice": true, - "zero": false + "encoding": { + "x": { + "type": "quantitative", + "title": "Year into Decade", + "axis": {"tickCount": 11} }, - { - "name": "y", - "type": "linear", - "domain": { - "fields": [ - {"data": "source_0", "field": "CO2"}, - {"data": "data_2", "field": "argmin_scaled_date[\"CO2\"]"}, - {"data": "data_1", "field": "argmax_scaled_date[\"CO2\"]"} - ] - }, - "range": [{"signal": "height"}, 0], - "zero": false, - "nice": true + "y": { + "title": "CO2 concentration in ppm", + "type": "quantitative", + "scale": {"zero": false} }, - { - "name": "color", + "color": { + "field": "decade", "type": "ordinal", - "domain": { - "fields": [ - {"data": "source_0", "field": "decade"}, - {"data": "data_2", "field": "decade"}, - {"data": "data_1", "field": "decade"} - ], - "sort": true - }, - "range": {"scheme": "magma"}, - "interpolate": "hcl" + "scale": {"scheme": "magma"}, + "legend": null } - ], - "axes": [ - { - "scale": "x", - "orient": "bottom", - "tickCount": 11, - "gridScale": "y", - "grid": true, - "domain": false, - "labels": false, - "aria": false, - "maxExtent": 0, - "minExtent": 0, - "ticks": false, - "zindex": 0 - }, + }, + + "layer": [ { - "scale": "y", - "orient": "left", - "gridScale": "x", - "grid": true, - "tickCount": {"signal": "ceil(height/40)"}, - "domain": false, - "labels": false, - "aria": false, - "maxExtent": 0, - "minExtent": 0, - "ticks": false, - "zindex": 0 + "mark": "line", + "encoding": { + "x": {"field": "scaled_date"}, + "y": {"field": "CO2"} + } }, { - "scale": "x", - "orient": "bottom", - "grid": false, - "title": "Year into Decade", - "tickCount": 11, - "labelFlush": true, - "labelOverlap": true, - "zindex": 0 + "mark": {"type": "text", "baseline": "top", "aria": false}, + "encoding": { + "x": {"aggregate": "min", "field": "scaled_date"}, + "y": {"aggregate": {"argmin": "scaled_date"}, "field": "CO2"}, + "text": {"aggregate": {"argmin": "scaled_date"}, "field": "year"} + } }, { - "scale": "y", - "orient": "left", - "grid": false, - "title": "CO2 concentration in ppm", - "labelOverlap": true, - "tickCount": {"signal": "ceil(height/40)"}, - "zindex": 0 + "mark": {"type": "text", "aria": false}, + "encoding": { + "x": {"aggregate": "max", "field": "scaled_date"}, + "y": {"aggregate": {"argmax": "scaled_date"}, "field": "CO2"}, + "text": {"aggregate": {"argmax": "scaled_date"}, "field": "year"} + } } ], - "config": {"style": {"text": {"align": "left", "dx": 3, "dy": 1}}} + "config": {"text": {"align": "left", "dx": 3, "dy": 1}} }` const co2Data = `Date,CO2,adjusted CO2 diff --git a/vegagoja.go b/vegagoja.go index d70db43..1224057 100644 --- a/vegagoja.go +++ b/vegagoja.go @@ -1,4 +1,11 @@ -// Package vegagoja renders Vega and Vega Lite visualizations as SVGs. +// Package vegagoja renders [Vega] and [Vega-Lite] visualizations as SVGs using +// the [goja] JavaScript runtime. Developed for use by [usql] for rendering +// charts. +// +// [Vega]: https://vega.github.io/vega/examples/ +// [Vega-Lite]: https://vega.github.io/vega-lite/examples/ +// [goja]: https://github.com/dop251/goja +// [usql]: https://github.com/xo/usql package vegagoja import ( @@ -6,6 +13,7 @@ import ( "context" "embed" "encoding/json" + "errors" "fmt" "io" "io/fs" @@ -19,20 +27,22 @@ import ( "github.com/dop251/goja_nodejs/require" ) -// Vega handles rendering Vega and Vega Lite visualizations as SVGs. Wraps a -// goja runtime vm, and uses embedded javascript to render Vega and Vega Lite -// visualizations. +// Vega handles rendering [Vega] and [Vega-Lite] visualizations as SVGs, +// wrapping a [goja] runtime vm. +// +// [Vega]: https://vega.github.io/vega/examples/ +// [Vega-Lite]: https://vega.github.io/vega-lite/examples/ +// [goja]: https://github.com/dop251/goja type Vega struct { - r *goja.Runtime - vegaVer func() string - liteVer func() string - vegaRender renderFunc - liteRender renderFunc - liteCompile func(loggerFunc, string) (string, error) - logger func(...interface{}) - source fs.FS - once sync.Once - err error + r *goja.Runtime + vegaVer func() string + liteVer func() string + render renderFunc + compile compileFunc + logger func(...interface{}) + source fs.FS + once sync.Once + err error } // New creates a new vega instance. @@ -68,7 +78,7 @@ func (vm *Vega) init() error { return } if _, err := r.RunProgram(p); err != nil { - vm.err = fmt.Errorf("unable to load %s: %w", name, err) + vm.err = fmt.Errorf("unable to run %s: %w", name, err) return } } @@ -76,20 +86,16 @@ func (vm *Vega) init() error { vm.err = fmt.Errorf("unable to bind vega_version func: %w", err) return } - if err := r.ExportTo(r.Get("vega_lite_version"), &vm.liteVer); err != nil { - vm.err = fmt.Errorf("unable to bind vega_lite_version func: %w", err) + if err := r.ExportTo(r.Get("lite_version"), &vm.liteVer); err != nil { + vm.err = fmt.Errorf("unable to bind lite_version func: %w", err) return } - if err := r.ExportTo(r.Get("vega_render"), &vm.vegaRender); err != nil { - vm.err = fmt.Errorf("unable to bind vega_render func: %w", err) + if err := r.ExportTo(r.Get("render"), &vm.render); err != nil { + vm.err = fmt.Errorf("unable to bind render func: %w", err) return } - if err := r.ExportTo(r.Get("vega_lite_compile"), &vm.liteCompile); err != nil { - vm.err = fmt.Errorf("unable to bind vega_lite_compile func: %w", err) - return - } - if err := r.ExportTo(r.Get("vega_lite_render"), &vm.liteRender); err != nil { - vm.err = fmt.Errorf("unable to bind vega_lite_render func: %w", err) + if err := r.ExportTo(r.Get("compile"), &vm.compile); err != nil { + vm.err = fmt.Errorf("unable to bind compile func: %w", err) return } vm.r = r @@ -107,16 +113,75 @@ func (vm *Vega) Version() (string, string, error) { return vegaVer, liteVer, nil } -// Compile compiles a vega lite specification to a vega specification. -func (vm *Vega) Compile(spec string) (string, error) { +// CompileSpec compiles a vega-lite specification to a vega specification, +// returning the entire raw compiled json containing the "spec" and +// "normalized" fields. +func (vm *Vega) CompileSpec(spec string) (string, error) { if err := vm.init(); err != nil { return "", err } - return vm.liteCompile(vm.log, spec) + return vm.compile(vm.log, spec) } -// Render renders the spec with the specified data. +// Compile compiles a vega-lite specification to a vega specification. +// +// Wraps [CompileSpec], returning only the compiled "spec". +func (vm *Vega) Compile(spec string) (string, error) { + spec, err := vm.CompileSpec(spec) + if err != nil { + return "", err + } + var res map[string]interface{} + if err := json.Unmarshal([]byte(spec), &res); err != nil { + return "", ErrInvalidCompiledSpec + } + s, ok := res["spec"] + if !ok { + return "", ErrInvalidCompiledSpec + } + buf := new(bytes.Buffer) + enc := json.NewEncoder(buf) + enc.SetEscapeHTML(false) + if err := enc.Encode(s); err != nil { + return "", err + } + return buf.String(), nil +} + +// Render renders a vega visualization spec as a SVG. func (vm *Vega) Render(ctx context.Context, spec string) (res string, err error) { + if spec == "" { + return + } + // unmarshal + var m map[string]interface{} + if err = json.Unmarshal([]byte(spec), &m); err != nil { + err = ErrInvalidJSON + return + } + // check schema + s, ok := m["$schema"] + if !ok { + err = ErrMissingSchema + return + } + schema, ok := s.(string) + switch { + case !ok: + err = ErrSchemaInvalid + return + case !strings.HasPrefix(schema, "https://vega.github.io/schema/vega"): + err = ErrNotVegaOrVegaLiteSchema + return + } + // convert vega-lite -> vega + if strings.HasPrefix(schema, "https://vega.github.io/schema/vega-lite/") { + if spec, err = vm.Compile(spec); err != nil { + err = fmt.Errorf("unable to compile vega-lite spec: %w", err) + return + } + } + // init if err = vm.init(); err != nil { return } @@ -129,22 +194,23 @@ func (vm *Vega) Render(ctx context.Context, spec string) (res string, err error) } } }() - f := vm.vegaRender - switch s, ok := decodeSchema(spec); { - case !ok: - err = fmt.Errorf("spec does not contain or has invalid $schema definition") - return - case strings.Contains(s, "vega-lite"): - f = vm.liteRender - } - ch := make(chan struct{}) - f(vm.log, spec, vm.data(), func(s string) { + ch, errch := make(chan struct{}, 1), make(chan error, 1) + err = vm.render(vm.log, spec, vm.data(), func(s string) { defer close(ch) + defer close(errch) res = s + }, func(e string) { + defer close(ch) + defer close(errch) + errch <- errors.New(e) }) + if err != nil { + return + } select { case <-ctx.Done(): err = ctx.Err() + case err = <-errch: case <-ch: } return @@ -161,7 +227,7 @@ func (vm *Vega) log(s []string) { } } -// data returns the +// data returns the data. func (vm *Vega) data() interface{} { return vm.load } @@ -259,35 +325,36 @@ func WithPrefixedSourceDir(prefix, dir string) Option { } } -// decodeSchema decodes the schema from the spec. -func decodeSchema(spec string) (string, bool) { - // check $schema definition - var m map[string]interface{} - if err := json.NewDecoder(strings.NewReader(spec)).Decode(&m); err != nil { - return "", false - } - s, ok := m["$schema"] - if !ok { - return "", false - } - schema, ok := s.(string) - if !ok { - return "", false - } - return schema, strings.HasPrefix(schema, vegaSchemaPrefix) || strings.HasPrefix(schema, liteSchemaPrefix) +// Error is a error. +type Error string + +// Errors. +const ( + // ErrInvalidCompiledSpec is the invalid compiled spec error. + ErrInvalidCompiledSpec Error = "invalid compiled spec" + // ErrInvalidJSON is the invalid json error. + ErrInvalidJSON Error = "invalid json" + // ErrMissingSchema is the missing $schema error. + ErrMissingSchema Error = "missing $schema" + // ErrSchemaInvalid is the $schema invalid error. + ErrSchemaInvalid Error = "$schema invalid" + // ErrNotVegaOrVegaLiteSchema is the not vega or vega-lite schema error. + ErrNotVegaOrVegaLiteSchema Error = "not vega or vega-lite schema" +) + +// Error satisfies the [error] interface. +func (err Error) Error() string { + return string(err) } // loggerFunc is the signature for the log func. type loggerFunc func([]string) // renderFunc is the signature for the render func. -type renderFunc func(logger loggerFunc, spec string, data interface{}, cb func(string)) string +type renderFunc func(logf loggerFunc, spec string, data interface{}, cb, errcb func(string)) error -// vega schema prefixes. -const ( - vegaSchemaPrefix = "https://vega.github.io/schema/vega/" - liteSchemaPrefix = "https://vega.github.io/schema/vega-lite/" -) +// compileFunc is the signature for the compile func. +type compileFunc func(logf loggerFunc, spec string) (string, error) // vegaVersionTxt is the embedded vega-version.txt. // diff --git a/vegagoja.js b/vegagoja.js index a50e6b5..dea4944 100644 --- a/vegagoja.js +++ b/vegagoja.js @@ -18,16 +18,8 @@ function logger(logf) { }; } -function vega_version() { - return vega.version; -} - -function vega_lite_version() { - return vegaLite.version; -} - -function vega_render(logf, spec, loadf, cb) { - const loader = { +function loader(logf, loadf) { + return { load(name, res) { var s = ""; try { @@ -38,28 +30,35 @@ function vega_render(logf, spec, loadf, cb) { return s; }, }; +} + +function parse(spec) { + if (typeof spec == "object") { + return spec; + } + return JSON.parse(spec); +} + +function vega_version() { + return vega.version; +} + +function lite_version() { + return vegaLite.version; +} + +function render(logf, spec, loadf, cb, errcb) { try { - var s = {}; - switch (typeof spec) { - case "object": - s = spec; - break; - case "string": - s = JSON.parse(spec); - break; - default: - throw Error("invalid type " + typeof spec); - } - var runtime = vega.parse(s); + var runtime = vega.parse(parse(spec)); var view = new vega.View(runtime, { - loader: loader, - logger: logger(logf), logLevel: vega.Debug, + logger: logger(logf), + loader: loader(logf, loadf), }); view.toSVG().then(cb); } catch (e) { logf(["RENDER ERROR", e]); - throw e; + errcb(e); } finally { if (view) { view.finalize(); @@ -67,22 +66,15 @@ function vega_render(logf, spec, loadf, cb) { } } -function vega_lite_compile(logf, spec) { - const s = vegaLite.compile(JSON.parse(spec), { - logger: logger(logf), - }).spec; - return JSON.stringify(s, null, 2); -} - -function vega_lite_render(logf, spec, loadf, cb) { - var s = ""; +function compile(logf, spec) { + var res = {}; try { - s = vegaLite.compile(JSON.parse(spec), { + res = vegaLite.compile(parse(spec), { logger: logger(logf), - }).spec; + }); } catch (e) { logf(["COMPILE ERROR", e]); throw e; } - return vega_render(logf, s, loadf, cb); + return JSON.stringify(res); } diff --git a/vegagoja_test.go b/vegagoja_test.go index b7b376a..a8c5268 100644 --- a/vegagoja_test.go +++ b/vegagoja_test.go @@ -26,6 +26,56 @@ func TestVersion(t *testing.T) { t.Logf("vega: %s vega-lite: %s", vegaVer, liteVer) } +func TestCompile(t *testing.T) { + var files []string + err := filepath.Walk("testdata", func(name string, info fs.FileInfo, err error) error { + switch { + case err != nil: + return err + case info.IsDir() || !strings.HasSuffix(name, ".vl.json"): + return nil + } + files = append(files, name) + return nil + }) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + for _, nn := range files { + name := nn + n := strings.Split(name, string(os.PathSeparator)) + n[len(n)-1] = suffixRE.ReplaceAllString(n[len(n)-1], "") + testName := path.Join(n[1:]...) + t.Run(testName, func(t *testing.T) { + testCompile(t, name) + }) + } +} + +func testCompile(t *testing.T, name string) { + t.Helper() + t.Parallel() + spec, err := os.ReadFile(name) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + opts := []Option{ + WithLogger(t.Log), + WithDemoData(), + } + vm := New(opts...) + start := time.Now() + res, err := vm.Compile(string(spec)) + total := time.Now().Sub(start) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if os.Getenv("VERBOSE") != "" { + t.Logf("---\n%s\n---", res) + } + t.Logf("duration: %s", total) +} + func TestRender(t *testing.T) { ctx := context.Background() timeout := 1 * time.Minute @@ -80,7 +130,7 @@ func testRender(t *testing.T, ctx context.Context, testName, name string, timeou } vm := New(opts...) start := time.Now() - s, err := vm.Render(ctx, string(spec)) + res, err := vm.Render(ctx, string(spec)) total := time.Now().Sub(start) switch { case err != nil && contains(broken, testName): @@ -90,11 +140,11 @@ func testRender(t *testing.T, ctx context.Context, testName, name string, timeou t.Fatalf("expected no error, got: %v", err) } if os.Getenv("VERBOSE") != "" { - t.Logf("---\n%s\n---", s) + t.Logf("---\n%s\n---", res) } t.Logf("duration: %s", total) - if s = strings.TrimSpace(s); len(s) != 0 { - if err := os.WriteFile(name+".svg", []byte(s), 0o644); err != nil { + if res = strings.TrimSpace(res); len(res) != 0 { + if err := os.WriteFile(name+".svg", []byte(res), 0o644); err != nil { t.Fatalf("expected no error, got: %v", err) } } @@ -114,6 +164,7 @@ func contains(v []string, s string) bool { } var broken = []string{ + "compiled/geo_circle", "compiled/point_href", "compiled/scatter_image", "lite/geo_circle",