A lightweight 2D graphics library providing canvas and SVG rendering for Apache ECharts
ZRender provides a comprehensive event system for handling mouse, touch, and custom events. The event system supports hit testing, event delegation, interaction handling, and custom event types with full event lifecycle management.
ZRender's event system is built on several key components:
All graphics elements inherit event handling capabilities:
interface Element {
// Event binding
on<Ctx>(eventName: ElementEventName, eventHandler: ElementEventCallback<Ctx>, context?: Ctx): this;
on<Ctx>(eventName: string, eventHandler: Function, context?: Ctx): this;
// Event unbinding
off(eventName?: string, eventHandler?: Function): void;
// Event triggering
trigger(eventName: string, event?: any): void;
// Event state
silent: boolean; // Disable all events on this element
ignore: boolean; // Ignore in hit testing
}The main ZRender instance provides global event handling:
interface ZRender {
// Global event binding
on<Ctx>(eventName: ElementEventName, eventHandler: ElementEventCallback<Ctx>, context?: Ctx): this;
on<Ctx>(eventName: string, eventHandler: Function, context?: Ctx): this;
// Global event unbinding
off(eventName?: string, eventHandler?: Function): void;
// Manual event triggering
trigger(eventName: string, event?: unknown): void;
// Hit testing
findHover(x: number, y: number): { target: Displayable; topTarget: Displayable } | undefined;
// Cursor management
setCursorStyle(cursorStyle: string): void;
}type ElementEventName =
| 'click'
| 'dblclick'
| 'mousedown'
| 'mouseup'
| 'mousemove'
| 'mouseover'
| 'mouseout'
| 'mouseenter'
| 'mouseleave'
| 'contextmenu';type TouchEventName =
| 'touchstart'
| 'touchmove'
| 'touchend'
| 'touchcancel';type DragEventName =
| 'drag'
| 'dragstart'
| 'dragend'
| 'dragenter'
| 'dragleave'
| 'dragover'
| 'drop';type GlobalEventName =
| 'rendered' // Fired after rendering frame
| 'finished' // Fired when animation finishes
| 'frame'; // Fired on each animation frameinterface ElementEvent {
type: string;
event: MouseEvent | TouchEvent | PointerEvent;
target: Element;
topTarget: Element;
cancelBubble: boolean;
offsetX: number;
offsetY: number;
gestureEvent?: GestureEvent;
pinchX?: number;
pinchY?: number;
pinchScale?: number;
wheelDelta?: number;
zrByTouch?: boolean;
which?: number;
stop(): void;
}
interface ElementEventCallback<Ctx = any, Impl = any> {
(this: CbThis$1<Ctx, Impl>, e: ElementEvent): boolean | void;
}interface RenderedEvent {
elapsedTime: number; // Time taken to render frame in milliseconds
}import { Circle, Rect } from "zrender";
const circle = new Circle({
shape: { cx: 150, cy: 150, r: 50 },
style: { fill: '#74b9ff', cursor: 'pointer' }
});
// Basic click handler
circle.on('click', (e) => {
console.log('Circle clicked!', e);
console.log('Click position:', e.offsetX, e.offsetY);
});
// Mouse enter/leave for hover effects
circle.on('mouseenter', (e) => {
console.log('Mouse entered circle');
circle.animate('style')
.when(200, { fill: '#a29bfe' })
.start();
});
circle.on('mouseleave', (e) => {
console.log('Mouse left circle');
circle.animate('style')
.when(200, { fill: '#74b9ff' })
.start();
});
// Double click handler
circle.on('dblclick', (e) => {
console.log('Double clicked!');
circle.animate('scale')
.when(300, [1.5, 1.5])
.when(600, [1, 1])
.start('easeOutBounce');
});
zr.add(circle);import { Rect, Group } from "zrender";
// Event delegation using groups
const buttonGroup = new Group();
const createButton = (text: string, x: number, y: number, color: string) => {
const button = new Group({
position: [x, y]
});
const bg = new Rect({
shape: { x: 0, y: 0, width: 100, height: 40, r: 5 },
style: { fill: color }
});
const label = new Text({
style: {
text: text,
fill: '#ffffff',
fontSize: 14,
textAlign: 'center',
textVerticalAlign: 'middle'
},
position: [50, 20]
});
button.add(bg);
button.add(label);
// Store button data
button.buttonData = { text, color };
return button;
};
// Create buttons
const button1 = createButton('Button 1', 50, 50, '#e17055');
const button2 = createButton('Button 2', 170, 50, '#00b894');
const button3 = createButton('Button 3', 290, 50, '#fdcb6e');
buttonGroup.add(button1);
buttonGroup.add(button2);
buttonGroup.add(button3);
// Handle events on the group level
buttonGroup.on('click', (e) => {
// Find which button was clicked
let clickedButton = e.target;
while (clickedButton && !clickedButton.buttonData) {
clickedButton = clickedButton.parent;
}
if (clickedButton && clickedButton.buttonData) {
console.log('Clicked button:', clickedButton.buttonData.text);
// Visual feedback
const bg = clickedButton.children()[0];
bg.animate('style')
.when(100, { fill: '#2d3436' })
.when(200, { fill: clickedButton.buttonData.color })
.start();
}
});
zr.add(buttonGroup);import { Circle, Rect } from "zrender";
// Draggable circle
const draggableCircle = new Circle({
shape: { cx: 200, cy: 200, r: 30 },
style: { fill: '#ff7675', cursor: 'move' },
draggable: true
});
let isDragging = false;
let dragOffset = { x: 0, y: 0 };
draggableCircle.on('mousedown', (e) => {
isDragging = true;
const pos = draggableCircle.position || [0, 0];
dragOffset.x = e.offsetX - pos[0];
dragOffset.y = e.offsetY - pos[1];
// Visual feedback
draggableCircle.animate('style')
.when(100, { shadowBlur: 10, shadowColor: '#ff7675' })
.start();
});
// Use global mouse events for smooth dragging
zr.on('mousemove', (e) => {
if (isDragging) {
const newX = e.offsetX - dragOffset.x;
const newY = e.offsetY - dragOffset.y;
draggableCircle.position = [newX, newY];
zr.refresh();
}
});
zr.on('mouseup', (e) => {
if (isDragging) {
isDragging = false;
// Remove visual feedback
draggableCircle.animate('style')
.when(100, { shadowBlur: 0 })
.start();
}
});
// Drop zone
const dropZone = new Rect({
shape: { x: 400, y: 150, width: 100, height: 100 },
style: {
fill: 'none',
stroke: '#ddd',
lineWidth: 2,
lineDash: [5, 5]
}
});
// Drop zone events
dropZone.on('dragover', (e) => {
dropZone.style.stroke = '#00b894';
zr.refresh();
});
dropZone.on('dragleave', (e) => {
dropZone.style.stroke = '#ddd';
zr.refresh();
});
dropZone.on('drop', (e) => {
console.log('Dropped on zone!');
dropZone.style.fill = 'rgba(0, 184, 148, 0.1)';
dropZone.style.stroke = '#00b894';
zr.refresh();
});
zr.add(draggableCircle);
zr.add(dropZone);import { Circle } from "zrender";
const touchCircle = new Circle({
shape: { cx: 150, cy: 350, r: 40 },
style: { fill: '#fd79a8' }
});
// Touch event handling
touchCircle.on('touchstart', (e) => {
console.log('Touch started');
touchCircle.animate('scale')
.when(100, [0.9, 0.9])
.start();
});
touchCircle.on('touchend', (e) => {
console.log('Touch ended');
touchCircle.animate('scale')
.when(100, [1, 1])
.start();
});
touchCircle.on('touchmove', (e) => {
// Handle touch move
const touch = e.event.touches[0];
if (touch) {
const rect = zr.dom.getBoundingClientRect();
const x = touch.clientX - rect.left;
const y = touch.clientY - rect.top;
touchCircle.position = [x, y];
zr.refresh();
}
});
zr.add(touchCircle);import { Group, Rect, Text } from "zrender";
// Custom component with custom events
class CustomButton extends Group {
constructor(options: { text: string, x: number, y: number }) {
super({ position: [options.x, options.y] });
this.background = new Rect({
shape: { x: 0, y: 0, width: 120, height: 40, r: 5 },
style: { fill: '#74b9ff' }
});
this.label = new Text({
style: {
text: options.text,
fill: '#ffffff',
fontSize: 14,
textAlign: 'center'
},
position: [60, 20]
});
this.add(this.background);
this.add(this.label);
this.setupEvents();
}
private setupEvents() {
this.on('click', () => {
// Trigger custom event
this.trigger('buttonClick', {
button: this,
text: this.label.style.text
});
});
this.on('mouseenter', () => {
this.trigger('buttonHover', { button: this, hovered: true });
});
this.on('mouseleave', () => {
this.trigger('buttonHover', { button: this, hovered: false });
});
}
}
// Create custom button
const customButton = new CustomButton({ text: 'Custom', x: 50, y: 400 });
// Listen to custom events
customButton.on('buttonClick', (e) => {
console.log('Custom button clicked:', e.text);
alert(`Button "${e.text}" was clicked!`);
});
customButton.on('buttonHover', (e) => {
const color = e.hovered ? '#a29bfe' : '#74b9ff';
e.button.background.animate('style')
.when(150, { fill: color })
.start();
});
zr.add(customButton);import { Group, Circle, Rect } from "zrender";
// Nested elements for testing event bubbling
const container = new Group({ position: [300, 300] });
const outerRect = new Rect({
shape: { x: 0, y: 0, width: 200, height: 150 },
style: { fill: 'rgba(116, 185, 255, 0.3)', stroke: '#74b9ff' }
});
const innerCircle = new Circle({
shape: { cx: 100, cy: 75, r: 30 },
style: { fill: '#e17055' }
});
container.add(outerRect);
container.add(innerCircle);
// Event handlers with bubbling
outerRect.on('click', (e) => {
console.log('Outer rect clicked');
// Event will bubble up to container
});
innerCircle.on('click', (e) => {
console.log('Inner circle clicked');
// Stop event bubbling
if (e.event.ctrlKey) {
e.cancelBubble = true;
console.log('Event bubbling stopped');
}
});
container.on('click', (e) => {
console.log('Container clicked (bubbled up)');
});
zr.add(container);// Global event handlers
zr.on('click', (e) => {
console.log('Global click at:', e.offsetX, e.offsetY);
});
zr.on('rendered', (e: RenderedEvent) => {
console.log('Frame rendered in:', e.elapsedTime, 'ms');
});
// Performance monitoring
zr.on('rendered', (e: RenderedEvent) => {
if (e.elapsedTime > 16.67) { // > 60fps threshold
console.warn('Slow frame detected:', e.elapsedTime, 'ms');
}
});
// Global keyboard events (requires DOM focus)
document.addEventListener('keydown', (e) => {
switch(e.key) {
case 'Escape':
// Clear selections, cancel operations, etc.
console.log('Escape pressed - clearing state');
break;
case 'Delete':
// Delete selected elements
console.log('Delete pressed');
break;
}
});import { Circle } from "zrender";
// Proper event cleanup
class ManagedElement {
private element: Circle;
private handlers: { [key: string]: Function } = {};
constructor() {
this.element = new Circle({
shape: { cx: 100, cy: 100, r: 30 },
style: { fill: '#00cec9' }
});
this.setupEvents();
}
private setupEvents() {
// Store handler references for cleanup
this.handlers.click = (e: any) => {
console.log('Managed element clicked');
};
this.handlers.hover = (e: any) => {
this.element.animate('style')
.when(200, { fill: '#55efc4' })
.start();
};
this.handlers.leave = (e: any) => {
this.element.animate('style')
.when(200, { fill: '#00cec9' })
.start();
};
// Bind events
this.element.on('click', this.handlers.click);
this.element.on('mouseenter', this.handlers.hover);
this.element.on('mouseleave', this.handlers.leave);
}
public dispose() {
// Clean up event handlers
this.element.off('click', this.handlers.click);
this.element.off('mouseenter', this.handlers.hover);
this.element.off('mouseleave', this.handlers.leave);
// Remove from scene
if (this.element.parent) {
this.element.parent.remove(this.element);
}
// Clear references
this.handlers = {};
}
public getElement() {
return this.element;
}
}
// Usage
const managedEl = new ManagedElement();
zr.add(managedEl.getElement());
// Later, clean up properly
// managedEl.dispose();// Efficient event handling for many elements
const createOptimizedEventHandling = () => {
const container = new Group();
const elements: Circle[] = [];
// Create many elements
for (let i = 0; i < 100; i++) {
const circle = new Circle({
shape: {
cx: (i % 10) * 50 + 25,
cy: Math.floor(i / 10) * 50 + 25,
r: 20
},
style: { fill: `hsl(${i * 3.6}, 70%, 60%)` }
});
// Store identifier
circle.elementId = i;
elements.push(circle);
container.add(circle);
}
// Use single event handler on container instead of individual handlers
container.on('click', (e) => {
// Find clicked element
let target = e.target;
while (target && target.elementId === undefined) {
target = target.parent;
}
if (target && target.elementId !== undefined) {
console.log('Clicked element:', target.elementId);
// Apply effect
target.animate('scale')
.when(200, [1.2, 1.2])
.when(400, [1, 1])
.start();
}
});
return container;
};
const optimizedGroup = createOptimizedEventHandling();
zr.add(optimizedGroup);Install with Tessl CLI
npx tessl i tessl/npm-zrender