diff --git a/docs/api/vgplot/plot.md b/docs/api/vgplot/plot.md index dab7a989..766496aa 100644 --- a/docs/api/vgplot/plot.md +++ b/docs/api/vgplot/plot.md @@ -48,6 +48,25 @@ Return the "inner" width of the plot, which is the `width` attribute value minus Return the "inner" height of the plot, which is the `height` attribute value minus the `topMargin` and `bottomMargin` values. +### status + +`plot.status` + +Return the `status` of a `Plot` instance (one of `idle`, `pendingQuery` and `pendingRender`). + +- Initial `status` is `idle`. +- When marks are initially connected to coordinator (via `plot.connect()`) or when a mark of a plot has a pending query, status will change to `pendingQuery`. +- When all pending queries of plot marks are done, status will change to `pendingRender`. +- After rendering, status will change to `idle` again. + +There is a `status` event listener available, see `plot.addEventListener` and `plot.removeEventListener`. + +### connect + +`plot.connect()` + +Connect all [`Mark`](./marks) instances to coordinator. + ### pending `plot.pending(mark)` @@ -94,6 +113,12 @@ Adds an event listener _callback_ that is invoked when the attribute with the gi Removes an event listener _callback_ associated with the given attribute _name_. +### addDirectives + +`plot.addDirectives(directives)` + +Adds directives to plot. + ### addParams `plot.addParams(mark, paramSet)` @@ -128,3 +153,16 @@ Called by [interactor directives](./interactors). Add a _legend_ associated with this plot. The _include_ flag (default `true`) indicates if the legend should be included within the same container element as the plot. Called by [legend directives](./legends). + +### addEventListener + +`plot.addEventListener(type, callback)` + +Add an event listener _callback_ function for the specified event _type_. +`Plot` supports `"status"` type events only. + +### removeEventListener + +`plot.removeEventListener(type, callback)` + +Remove an event listener _callback_ function for the specified event _type_. diff --git a/docs/api/vgplot/specs.md b/docs/api/vgplot/specs.md index a800092a..4a177278 100644 --- a/docs/api/vgplot/specs.md +++ b/docs/api/vgplot/specs.md @@ -180,6 +180,14 @@ The supported external _options_ are: - _params_: An array (default `[]`) of predefined [`Param`](../core/param) instances. Each entry should have the form `[name, param]`. - _datasets_: An array (default `[]`) of preloaded browser-managed datasets (such as GeoJSON data). Each entry should have the form `[name, dataset]`. +## parsePlotSpec + +`parsePlotSpec(specification, options, element)` + +Parse a JSON _specification_ of a single plot and return corresponding `Plot` instance. +See `parseSpec` for supported _options_. +If provided, the input _element_ will be used as the container for the plot, otherwise a new `div` element will be generated. + ## specToModule `specToModule(spec, options)` diff --git a/packages/core/src/index.js b/packages/core/src/index.js index 43b818ba..b6735ad9 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -11,3 +11,4 @@ export { wasmConnector } from './connectors/wasm.js'; export { distinct } from './util/distinct.js'; export { synchronizer } from './util/synchronizer.js'; export { throttle } from './util/throttle.js'; +export { AsyncDispatch } from './util/AsyncDispatch.js'; diff --git a/packages/vgplot/src/directives/plot.js b/packages/vgplot/src/directives/plot.js index 16c1b601..95e33fde 100644 --- a/packages/vgplot/src/directives/plot.js +++ b/packages/vgplot/src/directives/plot.js @@ -3,8 +3,10 @@ import { Plot } from '../plot.js'; export function plot(...directives) { const p = new Plot(); - directives.flat().forEach(dir => dir(p)); - p.marks.forEach(mark => coordinator().connect(mark)); + + p.addDirectives(directives.flat()); + p.connect(); + return p.element; } diff --git a/packages/vgplot/src/index.js b/packages/vgplot/src/index.js index 7460ec1d..fcc7c58f 100644 --- a/packages/vgplot/src/index.js +++ b/packages/vgplot/src/index.js @@ -300,6 +300,7 @@ export { export { parseSpec, + parsePlotSpec, ParseContext } from './spec/parse-spec.js'; diff --git a/packages/vgplot/src/plot.js b/packages/vgplot/src/plot.js index 5bb06593..72eed316 100644 --- a/packages/vgplot/src/plot.js +++ b/packages/vgplot/src/plot.js @@ -1,4 +1,4 @@ -import { distinct, synchronizer } from '@uwdata/mosaic-core'; +import { coordinator, distinct, synchronizer, AsyncDispatch } from '@uwdata/mosaic-core'; import { plotRenderer } from './plot-renderer.js'; const DEFAULT_ATTRIBUTES = { @@ -9,8 +9,10 @@ const DEFAULT_ATTRIBUTES = { marginBottom: 30 }; -export class Plot { +export class Plot extends AsyncDispatch { constructor(element) { + super(); + this.attributes = { ...DEFAULT_ATTRIBUTES }; this.listeners = null; this.interactors = []; @@ -23,6 +25,7 @@ export class Plot { this.element.value = this; this.params = new Map; this.synch = synchronizer(); + this.status = 'idle'; } margins() { @@ -44,13 +47,22 @@ export class Plot { return this.getAttribute('height') - top - bottom; } + async connect() { + this.updateStatus('pendingQuery'); + + await Promise.all(this.marks.map(mark => coordinator().connect(mark))); + } + pending(mark) { + if (this.status !== 'pendingQuery') this.updateStatus('pendingQuery'); + this.synch.pending(mark); } update(mark) { if (this.synch.ready(mark) && !this.pendingRender) { this.pendingRender = true; + this.updateStatus('pendingRender'); requestAnimationFrame(() => this.render()); } return this.synch.promise; @@ -65,6 +77,7 @@ export class Plot { }); this.element.replaceChildren(svg, ...legends); this.synch.resolve(); + this.updateStatus('idle'); } getAttribute(name) { @@ -97,6 +110,10 @@ export class Plot { return this.listeners?.get(name)?.delete(callback); } + addDirectives(directives) { + directives.forEach(dir => dir(this)); + } + addParams(mark, paramSet) { const { params } = this; for (const param of paramSet) { @@ -133,4 +150,9 @@ export class Plot { legend.setPlot(this); this.legends.push({ legend, include }); } + + updateStatus(status) { + this.status = status; + this.emit('status', this.status); + } } diff --git a/packages/vgplot/src/spec/parse-spec.js b/packages/vgplot/src/spec/parse-spec.js index 71af0c9e..4e1251d8 100644 --- a/packages/vgplot/src/spec/parse-spec.js +++ b/packages/vgplot/src/spec/parse-spec.js @@ -13,12 +13,12 @@ import { hconcat, vconcat, hspace, vspace } from '../layout/index.js'; import { parse as isoparse } from 'isoformat'; import { from } from '../directives/data.js'; -import { plot as _plot } from '../directives/plot.js'; import * as marks from '../directives/marks.js'; import * as legends from '../directives/legends.js'; import * as attributes from '../directives/attributes.js'; import * as interactors from '../directives/interactors.js'; import { Fixed } from '../symbols.js'; +import { Plot } from '../plot.js'; import { parseData, parseCSVData, parseJSONData, @@ -99,6 +99,22 @@ export function parseSpec(spec, options) { return new ParseContext(options).parse(spec); } +export function parsePlotSpec(spec, options, element) { + spec = isString(spec) ? JSON.parse(spec) : spec; + + if (!('plot' in spec)) + throw new Error('Plot spec requires a "plot" property.'); + + const parsePlot = (spec, ctx) => parsePlotInstance(spec, ctx, element); + + return new ParseContext({ + ...options, + specParsers: new Map([ + ['plot', { type: isArray, parse: parsePlot }] + ]) + }).parse(spec); +} + export class ParseContext { constructor({ specParsers = DefaultSpecParsers, @@ -273,13 +289,25 @@ function parseHConcat(spec, ctx) { return hconcat(spec.hconcat.map(s => parseComponent(s, ctx))); } -function parsePlot(spec, ctx) { +function parsePlotInstance(spec, ctx, element) { const { plot, ...attributes } = spec; const attrs = ctx.plotDefaults.concat( Object.keys(attributes).map(key => parseAttribute(spec, key, ctx)) ); const entries = plot.map(e => parseEntry(e, ctx)); - return _plot(attrs, entries); + + const p = new Plot(element); + p.addDirectives([...attrs, ...entries]); + + return p; +} + +function parsePlot(spec, ctx) { + const p = parsePlotInstance(spec, ctx); + + p.connect(); + + return p.element; } function parseNakedMark(spec, ctx) {