Events
SurfView.js uses events for viewer state, picking, surface/layer changes, annotations, rendering, and plugin panels. The public viewer event names and payloads are defined by ViewerEventMap.
Viewer Events
Surface Events
viewer.on('surface:added', ({ surfaceId, surface }) => {
console.log(`Surface ${surfaceId} added`, surface);
});
viewer.on('surface:removed', ({ surfaceId }) => {
console.log(`Surface ${surfaceId} removed`);
});
viewer.on('surface:variant', ({ surfaceId, variant }) => {
console.log(`Surface ${surfaceId} switched to variant ${variant}`);
});
viewer.on('surface:colormap', ({ surfaceId, colormap }) => {
console.log(`Surface ${surfaceId} colormap changed to ${colormap}`);
});Layer Events
Viewer-level layer events are forwarded from MultiLayerNeuroSurface instances after they are added to the viewer.
viewer.on('layer:added', ({ surfaceId, layerId, layer }) => {
console.log(`Layer ${layerId} added to ${surfaceId}`, layer);
});
viewer.on('layer:removed', ({ surfaceId, layerId }) => {
console.log(`Layer ${layerId} removed from ${surfaceId}`);
});
viewer.on('layer:updated', ({ surfaceId, layerId, layer, changes }) => {
console.log(`Layer ${layerId} updated on ${surfaceId}`, layer, changes);
});
viewer.on('layer:colormap', ({ surfaceId, layerId, colormap }) => {
console.log(`Layer ${layerId} on ${surfaceId} changed to ${colormap}`);
});
viewer.on('layer:intensity', ({ surfaceId, layerId, range }) => {
console.log(`Layer ${layerId} intensity range`, range);
});
viewer.on('layer:threshold', ({ surfaceId, layerId, threshold }) => {
console.log(`Layer ${layerId} threshold`, threshold);
});
viewer.on('layer:opacity', ({ surfaceId, layerId, opacity }) => {
console.log(`Layer ${layerId} opacity changed to ${opacity}`);
});Picking Events
viewer.on('surface:click', (hit) => {
if (hit.surfaceId && hit.vertexIndex !== null) {
console.log(`Clicked ${hit.surfaceId} at vertex ${hit.vertexIndex}`);
console.log('Position:', hit.point);
}
});
viewer.on('mouse:click', ({ position, surface, point }) => {
console.log('Mouse click in normalized device coordinates:', position, surface, point);
});Hover Events
viewer.on('vertex:hover', ({ surfaceId, vertexIndex, screenX, screenY }) => {
if (surfaceId && vertexIndex !== null) {
console.log(`Hovering ${surfaceId} vertex ${vertexIndex} at (${screenX}, ${screenY})`);
} else {
console.log('Hover left surface');
}
});This event is useful for wiring up sparkline tooltips with SparklineOverlay. See the Temporal Playback guide.
Parcel Interaction Events
When the hovered or clicked surface provides parcel metadata, the viewer emits parcel-native interaction events. These are intended for synchronizing external views such as parcel heatmaps, tables, or connectivity matrices.
viewer.on('parcel:hover', ({ parcelId, parcelLabel, atlasId, surfaceId }) => {
if (parcelId === null) {
heatmap.clearHover();
return;
}
heatmap.setHoverParcel(parcelId);
console.log(`Hover parcel ${parcelLabel} on ${surfaceId} (${atlasId})`);
});
viewer.on('parcel:click', ({ parcelId }) => {
if (parcelId === null) {
return;
}
heatmap.setSelectedParcel(parcelId);
});
viewer.on('parcel:selected', ({ parcelId, selected }) => {
console.log('Parcel selection:', parcelId, selected);
});parcel:hover is emitted with parcelId: null when the pointer leaves parcelized geometry, so external views can clear their hover state without listening to lower-level vertex events.
External views can also drive the viewer back through the same parcel state:
heatmap.onHoverParcel((parcelId) => {
if (parcelId === null) {
viewer.clearParcelHover();
return;
}
viewer.setParcelHover('parcel-connectivity', parcelId);
});
heatmap.onSelectParcel((parcelId) => {
if (parcelId === null) {
viewer.clearParcelSelection();
return;
}
viewer.setParcelSelection('parcel-connectivity', parcelId);
});These methods use a parcelized surface's representative vertex internally, so external tools can synchronize hover and selection without synthesizing mouse events.
Scientific Selection Events
selection:changed is the canonical scientific-selection event. Its payload contains immutable current and previous selections and never contains a live surface, layer, annotation, or Three.js object.
viewer.on('selection:changed', ({ selection, previous }) => {
console.log('Inspection selection:', previous, '→', selection);
});
viewer.setInspectionSelection({
kind: 'parcel',
surfaceId: 'parcel-connectivity',
parcelId: 17
});Successful changes invalidate the selection state domain. Repeating the same selection is a successful no-op and emits no event. Invalid commands are atomic. parcel:selected remains as a compatibility interaction event; panel-local layer focus and annotation activation are not inspection selection.
Annotation Events
viewer.on('annotation:added', ({ id, surfaceId, vertexIndex, annotation }) => {
console.log(`Annotation ${id} added on ${surfaceId} at ${vertexIndex}`, annotation);
});
viewer.on('annotation:moved', ({ id, vertexIndex }) => {
console.log(`Annotation ${id} moved to vertex ${vertexIndex}`);
});
viewer.on('annotation:removed', ({ id }) => {
console.log(`Annotation ${id} removed`);
});
viewer.on('annotation:activated', ({ id, active }) => {
console.log(`Annotation ${id} active: ${active}`);
});
viewer.on('annotation:reset', () => {
console.log('All annotations cleared');
});Render Events
viewer.on('render:needed', () => {
console.log('A render has been requested');
});
viewer.on('render:before', () => {
console.log('About to render');
});
viewer.on('render:after', () => {
console.log('Rendered frame');
});render:needed is emitted when the viewer transitions from idle to needing a render. render:before and render:after are emitted around actual frame rendering.
View, Resize, And Controls Events
viewer.on('viewpoint:changed', ({ viewpoint, position, target }) => {
console.log(`Viewpoint changed to ${viewpoint}`, position, target);
});
viewer.on('camera:changed', ({ camera, position, target }) => {
console.log('Camera changed', camera, position, target);
});
viewer.on('resize', ({ width, height }) => {
console.log(`Viewer resized to ${width}x${height}`);
});
viewer.on('controls:changed', ({ enabled }) => {
console.log(`Camera interaction enabled: ${enabled}`);
});
viewer.on('controls:error', ({ error }) => {
console.error('Controls error:', error);
});controls:changed retains its 2.x event name for compatibility, but its payload describes camera and surface interaction. Prefer setInteractionEnabled() when changing that state; it is unrelated to optional panel visibility.
State Events
viewer.on('state:changed', ({ revision, domains }) => {
console.log(`Viewer state revision ${revision}`, domains);
});
viewer.on('state:restored', (report) => {
console.log('State restored:', report.success, report.errors, report.warnings);
});state:restored is also emitted for rejected input. report.errors contains validation issues with code, path, and message; validation errors are reported before mutation, so a failed restore does not advance the canonical state revision. report.warnings is reserved for runtime adapter failures that occur only after validation succeeds.
state:changed is the dependable coarse invalidation contract for external controllers. revision increases monotonically, and domains contains one or more of camera, surfaces, layers, selection, appearance, and timeline. Compound viewer operations may combine several domains into one revision. viewer.getStateRevision() returns the most recently issued revision. Direct public layer setters are included: for example, layer.setOpacity() and layer.setRange() produce a layers invalidation and a layer:updated event whose changes payload identifies the changed properties.
The event is synchronous state invalidation, not a render-completed signal. Do not infer state mutations from render:needed, animation frames, or requestRender(); render requests can occur without any canonical state change. Use render:after only when work truly depends on a completed frame.
Plugin Panels
Plugins mount into an element and subscribe through the typed viewer event API. Subscriptions made through api.on() are removed automatically when the plugin is unregistered.
const registration = viewer.registerPlugin({
id: 'retinotopy-panel',
mount(container, api) {
const label = document.createElement('div');
container.appendChild(label);
api.on('vertex:hover', ({ surfaceId, vertexIndex }) => {
label.textContent = surfaceId && vertexIndex !== null
? `${surfaceId}:${vertexIndex}`
: '';
});
return () => {
container.textContent = '';
};
}
});
// Either path deregisters and tears down exactly once.
registration.dispose();
viewer.unregisterPlugin('retinotopy-panel'); // false after direct disposalRegistration disposal is idempotent. Direct handle disposal removes the plugin from getPlugin() and listPlugins() immediately; later unregister, viewer disposal, or repeated handle disposal does not invoke teardown again. Viewer disposal also removes viewer event listeners, so no further viewer notifications are delivered.
Temporal Events
TimelineController has its own typed event map for temporal playback.
timeline.on('timechange', ({ time, frameA, frameB, alpha }) => {
console.log(`t=${time}`, frameA, frameB, alpha);
});
timeline.on('play', () => {
console.log('Timeline started');
});
timeline.on('pause', () => {
console.log('Timeline paused');
});
timeline.on('stop', () => {
console.log('Timeline stopped');
});
timeline.on('speedchange', ({ speed }) => {
console.log('Timeline speed:', speed);
});
timeline.on('loopchange', ({ loopMode }) => {
console.log('Timeline loop mode:', loopMode);
});Removing Listeners
const handler = (event) => console.log(event);
viewer.on('surface:click', handler);
viewer.off('surface:click', handler);One-Time Listeners
viewer.once('surface:added', ({ surfaceId }) => {
console.log(`First surface added: ${surfaceId}`);
});