Skip to content

Commit

Permalink
[FEAT] improved zoom performance (#865)
Browse files Browse the repository at this point in the history
* [FEAT] improve zoom for better user experience
- throttle for cumulativeZoom calculation
- debounce for scaleAndTranslate to limit excessive browser painting

Co-authored-by: Céline Souchet <[email protected]>
Co-authored-by: Thomas Bouffard <[email protected]>
  • Loading branch information
3 people authored Nov 13, 2020
1 parent 2840f31 commit 61271b4
Show file tree
Hide file tree
Showing 14 changed files with 293 additions and 45 deletions.
69 changes: 69 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@
"dependencies": {
"entities": "^2.1.0",
"fast-xml-parser": "^3.17.4",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"mxgraph": "4.1.0"
},
"devDependencies": {
Expand All @@ -79,6 +81,8 @@
"@types/jest": "^26.0.15",
"@types/jest-environment-puppeteer": "^4.3.2",
"@types/jest-image-snapshot": "^4.1.2",
"@types/lodash.debounce": "^4.0.6",
"@types/lodash.throttle": "^4.1.6",
"@types/minimist": "^1.2.0",
"@types/puppeteer": "^5.4.0",
"@typescript-eslint/eslint-plugin": "^4.4.1",
Expand Down Expand Up @@ -106,6 +110,7 @@
"puppeteer": "^5.4.0",
"rimraf": "^3.0.2",
"rollup": "^2.33.1",
"rollup-plugin-auto-external": "^2.0.0",
"rollup-plugin-commonjs": "^10.1.0",
"rollup-plugin-copy": "^3.3.0",
"rollup-plugin-copy-watch": "0.0.1",
Expand Down
4 changes: 3 additions & 1 deletion rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import copy from 'rollup-plugin-copy';
import copyWatch from 'rollup-plugin-copy-watch';
import { terser } from 'rollup-plugin-terser';
import sizes from 'rollup-plugin-sizes';
import autoExternal from 'rollup-plugin-auto-external';

import typescript from 'rollup-plugin-typescript2';
import commonjs from 'rollup-plugin-commonjs';
Expand Down Expand Up @@ -63,6 +64,7 @@ plugins.push(commonjs());
plugins.push(json());

pluginsNoDeps.push(json());
pluginsNoDeps.push(autoExternal());

// Copy static resources to dist
if (devMode || demoMode) {
Expand Down Expand Up @@ -151,7 +153,7 @@ if (!buildBundles) {
file: pkg.module.replace('.js', '.min.js'),
format: 'es',
},
// TODO check the possibility to use pkg.dependencies
// except these 'custom specified' dependencies, rest of them is treated by the plugin: autoExternal
external: ['entities/lib/decode', 'fast-xml-parser/src/parser'],
plugins: pluginsNoDeps,
};
Expand Down
33 changes: 33 additions & 0 deletions src/component/helpers/validators.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright 2020 Bonitasoft S.A.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { ZoomConfiguration } from '../options';

export function ensureInRange(value: number, min: number, max: number, defaultValue: number): number {
let inRangeValue = value == undefined ? defaultValue : value;
inRangeValue = Math.min(Math.max(inRangeValue, min), max);
return inRangeValue;
}

/**
* Make sure the configuration parameters are defined and in range
* @param config the {@link ZoomConfiguration} to make valid
*/
export function ensureValidZoomConfiguration(config: ZoomConfiguration): ZoomConfiguration {
const validatedConfig = config ?? {};
validatedConfig.debounceDelay = ensureInRange(validatedConfig.debounceDelay, 0, 100, 50);
validatedConfig.throttleDelay = ensureInRange(validatedConfig.throttleDelay, 0, 100, 50);
return validatedConfig;
}
63 changes: 51 additions & 12 deletions src/component/mxgraph/BpmnMxGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,14 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FitOptions, FitType } from '../options';
import { FitOptions, FitType, ZoomConfiguration } from '../options';
import { mxgraph } from 'ts-mxgraph';
import { ensureValidZoomConfiguration } from '../helpers/validators';
import debounce from 'lodash.debounce';
import throttle from 'lodash.throttle';

// TODO unable to load mxClient from [email protected]
declare const mxClient: typeof mxgraph.mxClient;

export class BpmnMxGraph extends mxGraph {
private cumulativeZoomFactor = 1;
Expand Down Expand Up @@ -74,23 +81,54 @@ export class BpmnMxGraph extends mxGraph {
return Math.max(input || 0, 0);
}

// solution inspired by https://github.com/algenty/grafana-flowcharting/blob/0.9.0/src/graph_class.ts#L1254
public performZoom(up: boolean, evt: MouseEvent): void {
const rect = this.container.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
this.zoomTo(null, null, up, x, y);
}

zoomTo(scale: number, center?: boolean, up?: boolean, offsetX?: number, offsetY?: number): void {
zoomTo(scale: number, center?: boolean, up?: boolean, offsetX?: number, offsetY?: number, performScaling?: boolean): void {
if (scale === null) {
const [newScale, dx, dy] = this.getScaleAndTranslationDeltas(up, offsetX, offsetY);
this.view.scaleAndTranslate(newScale, this.view.translate.x + dx, this.view.translate.y + dy);
if (performScaling) {
this.view.scaleAndTranslate(newScale, this.view.translate.x + dx, this.view.translate.y + dy);
}
} else {
super.zoomTo(scale, center);
}
}

createMouseWheelZoomExperience(config: ZoomConfiguration): void {
config = ensureValidZoomConfiguration(config);
mxEvent.addMouseWheelListener(debounce(this.getZoomHandler(true), config.debounceDelay), this.container);
mxEvent.addMouseWheelListener(throttle(this.getZoomHandler(false), config.throttleDelay), this.container);
}

// solution inspired by https://github.com/algenty/grafana-flowcharting/blob/0.9.0/src/graph_class.ts#L1254
private performZoom(up: boolean, evt: MouseEvent, performScaling: boolean): void {
const [x, y] = this.getRelativeEventCoordinates(evt);
this.zoomTo(null, null, up, x, y, performScaling);
if (performScaling) {
mxEvent.consume(evt);
}
}

private getZoomHandler(calculateFactorOnly: boolean) {
return (event: Event, up: boolean) => {
// TODO review type: this hack is due to the introduction of mxgraph-type-definitions
const evt = (event as unknown) as MouseEvent;
if (mxEvent.isConsumed((evt as unknown) as mxMouseEvent)) {
return;
}
// only the ctrl key or the meta key on mac
const isZoomWheelEvent = (evt.ctrlKey || (mxClient.IS_MAC && evt.metaKey)) && !evt.altKey && !evt.shiftKey;
if (isZoomWheelEvent) {
this.performZoom(up, evt, calculateFactorOnly);
}
};
}

private getRelativeEventCoordinates(evt: MouseEvent): [number, number] {
const rect = this.container.getBoundingClientRect();
const x = evt.clientX - rect.left;
const y = evt.clientY - rect.top;
return [x, y];
}

private getScaleAndTranslationDeltas(up: boolean, offsetX: number, offsetY: number): [number, number, number] {
let dx = offsetX * 2;
let dy = offsetY * 2;
Expand All @@ -113,7 +151,8 @@ export class BpmnMxGraph extends mxGraph {
}

private calculateFactorAndScale(up: boolean): [number, number] {
this.cumulativeZoomFactor *= up ? 1.25 : 0.8;
// as with new zoom scaling is invoked 2x the factor's square root is taken
this.cumulativeZoomFactor *= up ? Math.sqrt(1.25) : Math.sqrt(0.8);
let factor = this.cumulativeZoomFactor / this.view.scale;
const scale = Math.round(this.view.scale * factor * 100) / 100;
factor = scale / this.view.scale;
Expand Down
28 changes: 3 additions & 25 deletions src/component/mxgraph/MxGraphConfigurator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,7 @@ import ShapeConfigurator from './config/ShapeConfigurator';
import MarkerConfigurator from './config/MarkerConfigurator';
import MxClientConfigurator from './config/MxClientConfigurator';
import { GlobalOptions } from '../options';
import { mxgraph } from 'ts-mxgraph';
import { BpmnMxGraph } from './BpmnMxGraph';
// TODO unable to load mxClient from [email protected]
declare const mxClient: typeof mxgraph.mxClient;

/**
* Configure the BpmnMxGraph graph that can be used by the lib
Expand Down Expand Up @@ -72,12 +69,13 @@ export default class MxGraphConfigurator {
this.graph.panningHandler.addListener(mxEvent.PAN_START, this.getPanningHandler('grab'));
this.graph.panningHandler.addListener(mxEvent.PAN_END, this.getPanningHandler('default'));
this.graph.setPanning(true);

// Zoom configuration
this.graph.createMouseWheelZoomExperience(options.zoomConfiguration);
} else {
this.graph.setPanning(false);
this.graph.panningHandler.setPinchEnabled(false); // ensure gesture support is disabled (zoom only for now!)
}

this.configureMouseEvent(mouseNavigationSupport);
}

private getPanningHandler(cursor: 'grab' | 'default'): OmitThisParameter<(this: BpmnMxGraph) => void> {
Expand All @@ -89,24 +87,4 @@ export default class MxGraphConfigurator {
this.isEnabled() && (this.container.style.cursor = cursor);
};
}

private configureMouseEvent(activated = false): void {
if (!activated) {
return;
}

mxEvent.addMouseWheelListener((event: Event, up: boolean) => {
// TODO review type: this hack is due to the introduction of mxgraph-type-definitions
const evt = (event as unknown) as MouseEvent;
if (mxEvent.isConsumed((evt as unknown) as mxMouseEvent)) {
return;
}
// only the ctrl key or the meta key on mac
const isZoomWheelEvent = (evt.ctrlKey || (mxClient.IS_MAC && evt.metaKey)) && !evt.altKey && !evt.shiftKey;
if (isZoomWheelEvent) {
this.graph.performZoom(up, evt);
mxEvent.consume(evt);
}
}, this.container);
}
}
20 changes: 20 additions & 0 deletions src/component/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@ export interface GlobalOptions {
* If set to `true`, activate panning i.e. the BPMN diagram is draggable and can be moved using the mouse.
*/
mouseNavigationSupport: boolean;
zoomConfiguration: ZoomConfiguration;
}

/**
* Zoom specific options.
*/
export interface ZoomConfiguration {
/**
* throttleDelay [ms] responsible for throttling the mouse scroll event (not every event is firing the function handler, only limited number can lunch handler). A smaller value
* results in more events fired, bigger gain in zoom factor.
* Values must be in the [0, 100] interval, values outside of this interval are set to the interval bounds.
* @default 50
*/
throttleDelay?: number;
/**
* debounceDelay [ms] responsible for debouncing the zoom function - the actual scaling. A bigger value results in bigger gain in zoom factor before actual scaling takes place.
* Values must be in the [0, 100] interval, values outside of this interval are set to the interval bounds.
* @default 50
*/
debounceDelay?: number;
}

/**
Expand Down
Loading

0 comments on commit 61271b4

Please sign in to comment.