@antv/g2
Version:
the Grammar of Graphics in Javascript
380 lines (341 loc) • 10.5 kB
text/typescript
import { IRenderer, RendererPlugin, Canvas as GCanvas } from '@antv/g';
import { Renderer as CanvasRenderer } from '@antv/g-canvas';
import { Plugin as DragAndDropPlugin } from '@antv/g-plugin-dragndrop';
import { debounce } from '@antv/util';
import EventEmitter from '@antv/event-emitter';
import { G2Context, render, destroy } from '../runtime';
import { G2Spec, ViewComposition } from '../spec';
import { ChartEvent } from '../utils/event';
import type { G2Library } from '../runtime/types/options';
import {
normalizeContainer,
removeContainer,
sizeOf,
optionsOf,
updateRoot,
createEmptyPromise,
REMOVE_FLAG,
} from './utils';
import { CompositionNode } from './composition';
import { Node } from './node';
import { defineProps, nodeProps } from './define';
import { MarkNode } from './mark';
import { library } from './library';
export const G2_CHART_KEY = 'G2_CHART_KEY';
export type RuntimeOptions = ViewComposition & {
container?: string | HTMLElement;
canvas?: GCanvas;
autoFit?: boolean;
renderer?: IRenderer;
plugins?: RendererPlugin[];
theme?: string;
lib?: G2Library;
createCanvas?: () => HTMLCanvasElement;
};
export class Runtime<Spec extends G2Spec = G2Spec> extends CompositionNode {
private _container: HTMLElement;
private _context: G2Context;
private _emitter: EventEmitter;
private _width: number;
private _height: number;
private _renderer: IRenderer;
private _plugins: RendererPlugin[];
// Identifies whether bindAutoFit.
private _hasBindAutoFit = false;
private _rendering = false;
private _trailing = false;
private _trailingResolve = null;
private _trailingReject = null;
private _previousDefinedType = null;
private _marks: Record<string, new () => Node>;
private _compositions: Record<string, new () => Node>;
constructor(options: RuntimeOptions) {
const { container, canvas, renderer, plugins, lib, createCanvas, ...rest } =
options;
super(rest, 'view');
this._renderer = renderer || new CanvasRenderer();
this._plugins = plugins || [];
this._container = normalizeContainer(container);
this._emitter = new EventEmitter();
this._context = {
library: { ...lib, ...library },
emitter: this._emitter,
canvas,
createCanvas,
};
this._create();
}
render(): Promise<Runtime<Spec>> {
if (this._rendering) return this._addToTrailing();
if (!this._context.canvas) this._createCanvas();
this._context.canvas.getConfig().supportsCSSTransform = true;
this._bindAutoFit();
this._rendering = true;
const finished = new Promise<Runtime<Spec>>((resolve, reject) =>
render(
this._computedOptions(),
this._context,
this._createResolve(resolve),
this._createReject(reject),
),
);
const [finished1, resolve, reject] = createEmptyPromise<Runtime<Spec>>();
finished
.then(resolve)
.catch(reject)
.then(() => this._renderTrailing());
return finished1;
}
/**
* @overload
* @returns {Spec}
*/
options(): Spec;
/**
* @overload
* @param {G2ViewTree} options
* @returns {Runtime}
*/
options(options: Spec): Runtime<Spec>;
/**
* @overload
* @param {G2ViewTree} [options]
* @returns {Runtime|Spec}
*/
options(options?: Spec): Runtime<Spec> | Spec {
if (arguments.length === 0) return optionsOf(this) as Spec;
const { type } = options;
if (type) this._previousDefinedType = type;
updateRoot(
this,
options,
this._previousDefinedType,
this._marks,
this._compositions,
);
return this;
}
getContainer(): HTMLElement {
return this._container;
}
getContext(): G2Context {
return this._context;
}
on(event: string, callback: (...args: any[]) => any, once?: boolean): this {
this._emitter.on(event, callback, once);
return this;
}
once(event: string, callback: (...args: any[]) => any): this {
this._emitter.once(event, callback);
return this;
}
emit(event: string, ...args: any[]): this {
this._emitter.emit(event, ...args);
return this;
}
off(event?: string, callback?: (...args: any[]) => any) {
this._emitter.off(event, callback);
return this;
}
clear() {
const options = this.options();
this.emit(ChartEvent.BEFORE_CLEAR);
this._reset();
destroy(options, this._context, false);
this.emit(ChartEvent.AFTER_CLEAR);
}
destroy() {
const options = this.options();
this.emit(ChartEvent.BEFORE_DESTROY);
this._unbindAutoFit();
this._reset();
destroy(options, this._context, true);
if (this._container[REMOVE_FLAG]) removeContainer(this._container);
this.emit(ChartEvent.AFTER_DESTROY);
}
forceFit() {
// Don't fit if size do not change.
this.options['autoFit'] = true;
const { width, height } = sizeOf(this.options(), this._container);
if (width === this._width && height === this._height) {
return Promise.resolve(this);
}
// Don't call changeSize to prevent update width and height of options.
this.emit(ChartEvent.BEFORE_CHANGE_SIZE);
const finished = this.render();
finished.then(() => {
this.emit(ChartEvent.AFTER_CHANGE_SIZE);
});
return finished;
}
changeSize(width: number, height: number): Promise<Runtime<Spec>> {
if (width === this._width && height === this._height) {
return Promise.resolve(this);
}
this.emit(ChartEvent.BEFORE_CHANGE_SIZE);
this.attr('width', width);
this.attr('height', height);
const finished = this.render();
finished.then(() => {
this.emit(ChartEvent.AFTER_CHANGE_SIZE);
});
return finished;
}
private _create() {
const { library } = this._context;
// @todo After refactor component as mark, remove this.
const isMark = (key) =>
key.startsWith('mark.') ||
key === 'component.axisX' ||
key === 'component.axisY' ||
key === 'component.legends';
const marks = [
'mark.mark', // chart.mark(composite)
...Object.keys(library).filter(isMark),
];
// Create mark generators.
this._marks = {};
for (const key of marks) {
const name = key.split('.').pop();
class Mark extends MarkNode {
constructor() {
super({}, name);
}
}
this._marks[name] = Mark;
this[name] = function (composite) {
const node = this.append(Mark);
if (name === 'mark') node.type = composite;
return node;
};
}
// Create composition generators.
const compositions = [
'composition.view', // chat.view()
...Object.keys(library).filter(
(key) => key.startsWith('composition.') && key !== 'composition.mark',
),
];
this._compositions = Object.fromEntries(
compositions.map((key) => {
const name = key.split('.').pop();
class Composition extends CompositionNode {
constructor() {
super({}, name);
}
}
return [name, Composition];
}),
);
for (const Ctor of Object.values(this._compositions)) {
defineProps(nodeProps(this._compositions))(Ctor);
}
for (const key of compositions) {
const name = key.split('.').pop();
this[name] = function () {
const Composition = this._compositions[name];
this.type = null;
return this.append(Composition);
};
}
}
private _reset() {
const KEYS = ['theme', 'type', 'width', 'height', 'autoFit'];
this.type = 'view';
this.value = Object.fromEntries(
Object.entries(this.value).filter(
([key]) =>
key.startsWith('margin') ||
key.startsWith('padding') ||
key.startsWith('inset') ||
KEYS.includes(key),
),
);
this.children = [];
}
private _renderTrailing() {
if (!this._trailing) return;
this._trailing = false;
this.render()
.then(() => {
const trailingResolve = this._trailingResolve.bind(this);
this._trailingResolve = null;
trailingResolve(this);
})
.catch((error) => {
const trailingReject = this._trailingReject.bind(this);
this._trailingReject = null;
trailingReject(error);
});
}
private _createResolve(resolve: (chart: Runtime<Spec>) => void) {
return () => {
this._rendering = false;
resolve(this);
};
}
private _createReject(reject: (error: Error) => void) {
return (error: Error) => {
this._rendering = false;
reject(error);
};
}
// Update actual size and key.
private _computedOptions() {
const options = this.options();
const { key = G2_CHART_KEY } = options;
const { width, height, depth } = sizeOf(options, this._container);
this._width = width;
this._height = height;
this._key = key;
return { key: this._key, ...options, width, height, depth };
}
// Create canvas if it does not exist.
// DragAndDropPlugin is for interaction.
// It is OK to register more than one time, G will handle this.
private _createCanvas() {
const { width, height } = sizeOf(this.options(), this._container);
this._plugins.push(new DragAndDropPlugin());
this._plugins.forEach((d) => this._renderer.registerPlugin(d));
this._context.canvas = new GCanvas({
container: this._container,
width,
height,
renderer: this._renderer,
});
}
private _addToTrailing(): Promise<Runtime<Spec>> {
// Resolve previous promise, and give up this task.
this._trailingResolve?.(this);
// Create new task.
this._trailing = true;
const promise = new Promise<Runtime<Spec>>((resolve, reject) => {
this._trailingResolve = resolve;
this._trailingReject = reject;
});
return promise;
}
private _onResize = debounce(() => {
this.forceFit();
}, 300);
private _bindAutoFit() {
const options = this.options();
const { autoFit } = options;
if (this._hasBindAutoFit) {
// If it was bind before, unbind it now.
if (!autoFit) this._unbindAutoFit();
return;
}
if (autoFit) {
this._hasBindAutoFit = true;
window.addEventListener('resize', this._onResize);
}
}
private _unbindAutoFit() {
if (this._hasBindAutoFit) {
this._hasBindAutoFit = false;
window.removeEventListener('resize', this._onResize);
}
}
}