Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New: Add support for handling device pixel ratio #45

Merged
merged 5 commits into from
Feb 20, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 24 additions & 11 deletions docs/view-default.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ interface IOrbViewSettings {
};
// For canvas rendering and events
render: {
devicePixelRatio: number | null;
fps: number;
minZoom: number;
maxZoom: number;
Expand All @@ -74,6 +75,7 @@ interface IOrbViewSettings {
contextAlphaOnEvent: number;
contextAlphaOnEventIsEnabled: boolean;
backgroundColor: Color | string | null;
areCollapsedContainerDimensionsAllowed: boolean;
};
// For select and hover look-and-feel
strategy: {
Expand All @@ -90,7 +92,6 @@ interface IOrbViewSettings {
isOutOfBoundsDragEnabled: boolean;
areCoordinatesRounded: boolean;
isSimulationAnimated: boolean;
areCollapsedContainerDimensionsAllowed: boolean;
}
```

Expand Down Expand Up @@ -138,6 +139,7 @@ const defaultSettings = {
},
},
render: {
devicePixelRatio: window.devicePixelRatio,
fps: 60,
minZoom: 0.25,
maxZoom: 8,
Expand All @@ -149,6 +151,7 @@ const defaultSettings = {
contextAlphaOnEvent: 0.3,
contextAlphaOnEventIsEnabled: true,
backgroundColor: null,
areCollapsedContainerDimensionsAllowed: false,
},
strategy: {
isDefaultSelectEnabled: true,
Expand All @@ -162,7 +165,6 @@ const defaultSettings = {
isOutOfBoundsDragEnabled: false,
areCoordinatesRounded: true,
isSimulationAnimated: true,
areCollapsedContainerDimensionsAllowed: false;
}
```

Expand Down Expand Up @@ -269,6 +271,26 @@ Here you can use your original properties to indicate which ones represent your
Optional property `render` has several rendering options that you can tweak. Read more about them
on [Styling guide](./styles.md).

#### Property `render.devicePixelRatio`

`devicePixelRatio` is useful when dealing with the difference between rendering on a standard
display versus a HiDPI or Retina display, which uses more screen pixels to draw the same
objects, resulting in a sharper image. ([Reference: MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio)).
Orb will listen for `devicePixelRatio` changes and handles them by default. You can override the
value with a settings property `render.devicePixelRatio`. Once a custom value is provided, Orb will
stop listening for `devicePixelRatio` changes.
If you want to return automatic `devicePixelRatio` handling, just set `render.devicePixelRatio`
to `null`.

#### Property `render.areCollapsedContainerDimensionsAllowed`

Enables setting the dimensions of the Orb container element to zero.
If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`),
Orb will expand the container by setting the values to `100%`.
If that doesn't work (the parent of the container also has collapsed dimensions),
Orb will set an arbitrary fixed dimension to the container.
Disabled by default (`false`).

### Property `strategy`

The optional property `strategy` has two properties that you can enable/disable:
Expand Down Expand Up @@ -362,15 +384,6 @@ Shows the process of simulation where the nodes are moved by the physics engine
converge to a stable position. If disabled, the graph will suddenly appear in its final position.
Enabled by default (`true`).

### Property `areCollapsedContainerDimensionsAllowed`

Enables setting the dimensions of the Orb container element to zero.
If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`),
Orb will expand the container by setting the values to `100%`.
If that doesn't work (the parent of the container also has collapsed dimensions),
Orb will set an arbitrary fixed dimension to the container.
Disabled by default (`false`).

## Settings

The above settings of the `OrbView` can be defined on view initialization, but also anytime
Expand Down
31 changes: 22 additions & 9 deletions docs/view-map.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ interface IOrbMapViewSettings {
getGeoPosition(node: INode): { lat: number; lng: number } | undefined;
// For canvas rendering and events
render: {
devicePixelRatio: number | null;
fps: number;
minZoom: number;
maxZoom: number;
Expand Down Expand Up @@ -147,6 +148,7 @@ The default settings that `OrbMapView` uses is:
```typescript
const defaultSettings = {
render: {
devicePixelRatio: window.devicePixelRatio,
fps: 60,
minZoom: 0.25,
maxZoom: 8,
Expand Down Expand Up @@ -191,6 +193,26 @@ Optional property `map` has two properties that you can set which are:
Optional property `render` has several rendering options that you can tweak. Read more about them
on [Styling guide](./styles.md).

#### Property `render.devicePixelRatio`

`devicePixelRatio` is useful when dealing with the difference between rendering on a standard
display versus a HiDPI or Retina display, which uses more screen pixels to draw the same
objects, resulting in a sharper image. ([Reference: MDN Web Docs](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio)).
Orb will listen for `devicePixelRatio` changes and handles them by default. You can override the
value with a settings property `render.devicePixelRatio`. Once a custom value is provided, Orb will
stop listening for `devicePixelRatio` changes.
If you want to return automatic `devicePixelRatio` handling, just set `render.devicePixelRatio`
to `null`.

#### Property `render.areCollapsedContainerDimensionsAllowed`

Enables setting the dimensions of the Orb container element to zero.
If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`),
Orb will expand the container by setting the values to `100%`.
If that doesn't work (the parent of the container also has collapsed dimensions),
Orb will set an arbitrary fixed dimension to the container.
Disabled by default (`false`).

### Property `strategy`

The optional property `strategy` has two properties that you can enable/disable:
Expand Down Expand Up @@ -243,15 +265,6 @@ orb.events.on(OrbEventType.MOUSE_CLICK, (event) => {
});
```

### Property `areCollapsedContainerDimensionsAllowed`

Enables setting the dimensions of the Orb container element to zero.
If the container element of Orb has collapsed dimensions (`width: 0;` or `height: 0;`),
Orb will expand the container by setting the values to `100%`.
If that doesn't work (the parent of the container also has collapsed dimensions),
Orb will set an arbitrary fixed dimension to the container.
Disabled by default (`false`).

## Settings

The above settings of `OrbMapView` can be defined on view initialization, but also anytime after the
Expand Down
117 changes: 101 additions & 16 deletions src/renderer/canvas/canvas-renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ import {
import { throttle } from '../../utils/function.utils';
import { getThrottleMsFromFPS } from '../../utils/math.utils';
import { copyObject } from '../../utils/object.utils';
import {
appendCanvas,
IObserveDPRUnsubscribe,
observeDevicePixelRatioChanges,
setupContainer,
} from '../../utils/html.utils';
import { OrbError } from '../../exceptions';
import { isNumber } from '../../utils/type.utils';

const DEBUG = false;
const DEBUG_RED = '#FF5733';
Expand All @@ -26,12 +34,16 @@ const DEBUG_BLUE = '#3383FF';
const DEBUG_PINK = '#F333FF';

export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Emitter<RE> implements IRenderer<N, E> {
private readonly _container: HTMLElement;
private readonly _canvas: HTMLCanvasElement;
private _resizeObs: ResizeObserver;

// Contains the HTML5 Canvas element which is used for drawing nodes and edges.
private readonly _context: CanvasRenderingContext2D;

// Width and height of the canvas. Used for clearing
public width: number;
public height: number;
private _width: number;
private _height: number;
private _settings: IRendererSettings;

// Includes translation (pan) in the x and y direction
Expand All @@ -45,23 +57,58 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
private _isInitiallyRendered = false;

private _throttleRender: (graph: IGraph<N, E>) => void;
private _dprObserveUnsubscribe?: IObserveDPRUnsubscribe;

constructor(context: CanvasRenderingContext2D, settings?: Partial<IRendererSettings>) {
constructor(container: HTMLElement, settings?: Partial<IRendererSettings>) {
super();
setupContainer(container, settings?.areCollapsedContainerDimensionsAllowed);
this._container = container;
this._canvas = appendCanvas(container);

const context = this._canvas.getContext('2d');
if (!context) {
throw new OrbError('Failed to create Canvas context.');
}

this._context = context;
this.width = DEFAULT_RENDERER_WIDTH;
this.height = DEFAULT_RENDERER_HEIGHT;
this._width = DEFAULT_RENDERER_WIDTH;
this._height = DEFAULT_RENDERER_HEIGHT;
this.transform = zoomIdentity;
this._settings = {
...DEFAULT_RENDERER_SETTINGS,
...settings,
};

// Resize the canvas based on the dimensions of its parent container <div>.
this._resizeObs = new ResizeObserver(() => this._resize());
this._resizeObs.observe(this._container);
this._resize();

if (!isNumber(settings?.devicePixelRatio)) {
this._dprObserveUnsubscribe = observeDevicePixelRatioChanges(() => this._resize());
}

this._throttleRender = throttle((graph: IGraph<N, E>) => {
this._render(graph);
}, getThrottleMsFromFPS(this._settings.fps));
}

get width(): number {
return this._width;
}

get height(): number {
return this._height;
}

get container(): HTMLElement {
return this._container;
}

get canvas(): HTMLCanvasElement {
return this._canvas;
}

get isInitiallyRendered(): boolean {
return this._isInitiallyRendered;
}
Expand All @@ -72,6 +119,9 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em

setSettings(settings: Partial<IRendererSettings>) {
const isFpsChanged = settings.fps && settings.fps !== this._settings.fps;
const previousDprValue = this._settings.devicePixelRatio;
const newDprValue = settings.devicePixelRatio;

this._settings = {
...this._settings,
...settings,
Expand All @@ -82,6 +132,17 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
this._render(graph);
}, getThrottleMsFromFPS(this._settings.fps));
}

// Change DPR from automatic to manual handling or change DPR value manually
if (!isNumber(previousDprValue) && isNumber(newDprValue) && newDprValue !== previousDprValue) {

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking, but is newDprValue !== previousDprValue necessary here? It seems it's always true because newDprValue is a number while previousDprValue is not. On the other hand, !isNumber(previousDprValue) can be removed to allow users to change the DPR value more than once, but it does not seem like a real use case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep you are right, newDrpValue !== previousDprValue can be removed, but not the other check.

This is removing automatic observer for DPR. You can see on line 87 that it checks if settings doesn't have DPR, it will start automatic observer that checked for DPR from the browser.

So these lines 137 and 143 are for:

  • 137: Stop the automatic observer - this can happen only if previousDPR was not a number, and the new value is defined
  • 142: Start the observer again - this can happen only if previousDRP was a number, and the new value is null (this enables users to remove manual DPR handling).

Users can still change DPR manually which happens on line 127.

tonilastre marked this conversation as resolved.
Show resolved Hide resolved
this._dprObserveUnsubscribe?.();
this._resize();
}

// Change DPR from manual to automatic handling
if (isNumber(previousDprValue) && newDprValue === null) {
this._dprObserveUnsubscribe = observeDevicePixelRatioChanges(() => this._resize());
}
}

render(graph: IGraph<N, E>) {
Expand All @@ -97,30 +158,30 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
const renderStartedAt = Date.now();

// Clear drawing.
this._context.clearRect(0, 0, this.width, this.height);
this._context.clearRect(0, 0, this._width, this._height);
if (this._settings.backgroundColor) {
this._context.fillStyle = this._settings.backgroundColor.toString();
this._context.fillRect(0, 0, this.width, this.height);
this._context.fillRect(0, 0, this._width, this._height);
}
this._context.save();

if (DEBUG) {
this._context.lineWidth = 3;
this._context.fillStyle = DEBUG_RED;
this._context.fillRect(0, 0, this.width, this.height);
this._context.fillRect(0, 0, this._width, this._height);
}

// Apply any scaling (zoom) or translation (pan) transformations.
this._context.translate(this.transform.x, this.transform.y);
if (DEBUG) {
this._context.fillStyle = DEBUG_BLUE;
this._context.fillRect(0, 0, this.width, this.height);
this._context.fillRect(0, 0, this._width, this._height);
}

this._context.scale(this.transform.k, this.transform.k);
if (DEBUG) {
this._context.fillStyle = DEBUG_GREEN;
this._context.fillRect(0, 0, this.width, this.height);
this._context.fillRect(0, 0, this._width, this._height);
}

// Move coordinates (0, 0) to canvas center.
Expand All @@ -129,11 +190,11 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
// relative to (0, 0), so any source mouse event position needs to take this
// offset into account. (Handled in getMousePos())
if (this._isOriginCentered) {
this._context.translate(this.width / 2, this.height / 2);
this._context.translate(this._width / 2, this._height / 2);
}
if (DEBUG) {
this._context.fillStyle = DEBUG_PINK;
this._context.fillRect(0, 0, this.width, this.height);
this._context.fillRect(0, 0, this._width, this._height);
}

this.drawObjects(graph.getEdges());
Expand Down Expand Up @@ -195,6 +256,23 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
}
}

private _resize() {
const dpr = this._settings.devicePixelRatio || window.devicePixelRatio;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nitpicking, but it would be nice to add a check for window.devicePixelRatio being null or undefined, just in case of older browsers that do not support this. I think using || 1 will do the job.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not nitpicking, but it's a valid bug. Great catch.

tonilastre marked this conversation as resolved.
Show resolved Hide resolved

const containerSize = this._container.getBoundingClientRect();
this._canvas.style.width = `${containerSize.width}px`;
this._canvas.style.height = `${containerSize.height}px`;
this._canvas.width = containerSize.width * dpr;
this._canvas.height = containerSize.height * dpr;

// Normalize coordinate system to use CSS pixels
this._context.scale(dpr, dpr);

this._width = containerSize.width;
this._height = containerSize.height;
this.emit(RenderEventType.RESIZE, undefined);
}

private drawObject(obj: INode<N, E> | IEdge<N, E>, options?: Partial<INodeDrawOptions> | Partial<IEdgeDrawOptions>) {
if (isNode(obj)) {
drawNode(this._context, obj, options);
Expand All @@ -207,7 +285,7 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
this.transform = zoomIdentity;

// Clear drawing.
this._context.clearRect(0, 0, this.width, this.height);
this._context.clearRect(0, 0, this._width, this._height);
this._context.save();
}

Expand Down Expand Up @@ -252,8 +330,8 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
// simulation coordinates (O) when dragging and hovering nodes.
const [x, y] = this.transform.invert([canvasPoint.x, canvasPoint.y]);
return {
x: x - this.width / 2,
y: y - this.height / 2,
x: x - this._width / 2,
y: y - this._height / 2,
};
}

Expand All @@ -264,7 +342,7 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
*/
getSimulationViewRectangle(): IRectangle {
const topLeftPosition = this.getSimulationPosition({ x: 0, y: 0 });
const bottomRightPosition = this.getSimulationPosition({ x: this.width, y: this.height });
const bottomRightPosition = this.getSimulationPosition({ x: this._width, y: this._height });
return {
x: topLeftPosition.x,
y: topLeftPosition.y,
Expand All @@ -276,4 +354,11 @@ export class CanvasRenderer<N extends INodeBase, E extends IEdgeBase> extends Em
translateOriginToCenter() {
this._isOriginCentered = true;
}

destroy(): void {
this._resizeObs.unobserve(this._container);
this._dprObserveUnsubscribe?.();
this.removeAllListeners();
this._canvas.outerHTML = '';
}
}
Loading
Loading