From 6691ac47e3f1c8b2d2e97edaf6028baf55b1ac64 Mon Sep 17 00:00:00 2001 From: Michael Carenzo <79934822+mikecarenzo@users.noreply.github.com> Date: Tue, 29 Aug 2023 15:37:33 -0400 Subject: [PATCH] account for device pixel ratio when rendering graphics --- .../DiagramElement/BlockDiagram.ts | 119 ++++++++---------- .../DiagramElement/RasterCache.ts | 6 +- .../scripts/BlockDiagram/Utilities/Canvas.ts | 77 ++++++++++++ .../scripts/BlockDiagram/Utilities/Events.ts | 105 +++++++++++++--- .../src/assets/scripts/Browser.ts | 59 +++++++-- 5 files changed, 265 insertions(+), 101 deletions(-) create mode 100644 src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Canvas.ts diff --git a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/BlockDiagram.ts b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/BlockDiagram.ts index 4670f249..8b505278 100644 --- a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/BlockDiagram.ts +++ b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/BlockDiagram.ts @@ -1,9 +1,14 @@ import * as d3 from "d3"; +import { Browser } from "../../Browser"; import { RasterCache } from "./RasterCache"; import { ViewportRegion } from "./ViewportRegion"; +import { CameraLocation } from "./Camera"; import { DiagramObjectMover } from "./DiagramObjectMover"; import { DiagramDisplaySettings } from "./DiagramDisplaySettings"; -import { CameraLocation } from "./Camera"; +import { + resizeAndTransformContext, + resizeContext, + transformContext } from "../Utilities/Canvas"; import { EventEmitter, MouseClick @@ -28,7 +33,7 @@ import { PositionSetByUser } from "../Attributes"; -export class BlockDiagram extends EventEmitter { +export class BlockDiagram extends EventEmitter { /** * The viewport's padding. @@ -173,20 +178,24 @@ export class BlockDiagram extends EventEmitter { this._canvas = d3.select(container) .append("canvas") .attr("style", "display:block;") - .attr("width", this._elWidth) - .attr("height", this._elHeight) .on("mousemove", (event) => { this.onHoverSubject(...d3.pointer(event)); }) .on("contextmenu", (e: any) => e.preventDefault()); this._context = this._canvas.node()!.getContext("2d", { alpha: false }); + // Size context + resizeContext(this._context!, this._elWidth, this._elHeight); + // Configure resize observer this._resizeObserver = new ResizeObserver( entries => this.onCanvasResize(entries[0].target) ); this._resizeObserver.observe(container); + // Configure dppx change behavior + Browser.on("dppx-change", this.onDevicePixelRatioChange, this); + // Configure canvas interactions this._canvas .call(d3.drag() @@ -208,61 +217,12 @@ export class BlockDiagram extends EventEmitter { this._context = null; this.removeAllListeners(); this._resizeObserver?.disconnect(); + Browser.removeEventListenersWithContext(this); } /////////////////////////////////////////////////////////////////////////// - // 2. Event Subscription //////////////////////////////////////////////// - /////////////////////////////////////////////////////////////////////////// - - - /** - * Adds an event listener to the diagram. - * @param event - * The event to subscribe to. - * @param callback - * The function to call once the event has fired. - */ - public override on(event: K, callback: DiagramEvents[K]): void { - super.on(event, callback); - } - - /** - * Adds an event listener to the diagram that will be fired once and then - * removed. - * @param event - * The event to subscribe to. - * @param callback - * The function to call once the event has fired. - */ - public override once(event: K, callback: DiagramEvents[K]): void { - super.once(event, callback); - } - - /** - * Removes all event listeners associated with a given event. If no event - * name is specified, all event listeners are removed. - * @param event - * The name of the event. - */ - public override removeAllListeners(event?: K): void { - super.removeAllListeners(event); - } - - /** - * Dispatches the event listeners associated with a given event. - * @param event - * The name of the event to raise. - * @param args - * The arguments to pass to the event listeners. - */ - protected override emit(event: K, ...args: Parameters): void { - super.emit(event, ...args); - } - - - /////////////////////////////////////////////////////////////////////////// - // 3. Rendering ///////////////////////////////////////////////////////// + // 2. Rendering ///////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// @@ -361,7 +321,7 @@ export class BlockDiagram extends EventEmitter { /////////////////////////////////////////////////////////////////////////// - // 4. Canvas Interactions /////////////////////////////////////////////// + // 3. Canvas Interactions /////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// @@ -622,10 +582,12 @@ export class BlockDiagram extends EventEmitter { this._transform = event.transform; // Update viewport this.updateViewportBounds(); - this._context?.setTransform( - this._transform.k, 0, 0, - this._transform.k, this._transform.x, this._transform.y - ); + if(this._context) { + transformContext( + this._context, this._transform.k, + this._transform.x, this._transform.y + ); + } // If no source event, then we are already // running inside a requestAnimationFrame() if(event.sourceEvent === null) { @@ -665,21 +627,42 @@ export class BlockDiagram extends EventEmitter { // Update dimensions this._elWidth = newWidth; this._elHeight = newHeight; - this._canvas - ?.attr("width", this._elWidth) - ?.attr("height", this._elHeight); // Update viewport this.updateViewportBounds(); // Adjust viewport - this._context?.setTransform( - this._transform.k, 0, 0, - this._transform.k, this._transform.x, this._transform.y - ); + if(this._context) { + resizeAndTransformContext( + this._context, this._elWidth, this._elHeight, + this._transform.k, this._transform.x, this._transform.y + ) + } // Immediately redraw diagram to context, if possible if(this._context) this.executeRenderPipeline(); } + /** + * Device pixel ratio change behavior. + * @remarks + * The device's pixel ratio can change when dragging the window to and + * from a monitor with high pixel density (like Apple Retina displays). + */ + private onDevicePixelRatioChange() { + // Update cache + let k = this._transform.k * this._display.ssaaScale; + this._rasterCache.setScale(k); + if(!this._context) { + return; + } + // Resize and transform context + resizeAndTransformContext( + this._context, this._elWidth, this._elHeight, + this._transform.k, this._transform.x, this._transform.y + ) + // Render + this.render(); + } + /** * Recalculates the viewport's bounds based on the container's current * dimensions. @@ -696,7 +679,7 @@ export class BlockDiagram extends EventEmitter { /////////////////////////////////////////////////////////////////////////// - // 5. Data ////////////////////////////////////////////////////////////// + // 4. Data ////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////// diff --git a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/RasterCache.ts b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/RasterCache.ts index d434e076..82bfad48 100644 --- a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/RasterCache.ts +++ b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/DiagramElement/RasterCache.ts @@ -20,7 +20,7 @@ export class RasterCache { * Creates a new {@link RasterCache}. */ constructor() { - this._scale = 1; + this._scale = window.devicePixelRatio; this._cache = new Map(); } @@ -74,7 +74,7 @@ export class RasterCache { * The new scale value. */ public setScale(scale: number) { - this._scale = scale; + this._scale = scale * window.devicePixelRatio; this._cache.clear(); } @@ -84,7 +84,7 @@ export class RasterCache { * The cache's current scale. */ public getScale(): number { - return this._scale; + return this._scale / window.devicePixelRatio; } } diff --git a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Canvas.ts b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Canvas.ts new file mode 100644 index 00000000..a3f948c3 --- /dev/null +++ b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Canvas.ts @@ -0,0 +1,77 @@ +/** + * Resizes a canvas context according to the current screen's pixel ratio. + * @param context + * The context to resize. + * @param width + * The new width of the context. + * @param height + * The new height of the context. + */ +export function resizeContext( + context: CanvasRenderingContext2D, + width: number, + height: number +) { + context.canvas.width = width * window.devicePixelRatio; + context.canvas.height = height * window.devicePixelRatio; + context.canvas.style.width = `${ width }px`; + context.canvas.style.height = `${ height }px`; + context.scale(window.devicePixelRatio, window.devicePixelRatio); +} + +/** + * Transforms a canvas context according to the current screen's pixel ratio. + * @param context + * The context to resize. + * @param k + * The context's scale. + * @param x + * The context's x translation. + * @param y + * The context's y translation. + */ +export function transformContext( + context: CanvasRenderingContext2D, + k: number, + x: number, + y: number +) { + k *= window.devicePixelRatio, + x *= window.devicePixelRatio, + y *= window.devicePixelRatio; + context.setTransform( + k, 0, 0, + k, x, y + ); +} + +/** + * Resizes and transforms a canvas context according to the current screen's + * pixel ratio. + * @param context + * The context to resize. + * @param width + * The new width of the context. + * @param height + * The new height of the context. + * @param k + * The context's scale. + * @param x + * The context's x translation. + * @param y + * The context's y translation. + */ +export function resizeAndTransformContext( + context: CanvasRenderingContext2D, + width: number, + height: number, + k: number, + x: number, + y: number +) { + context.canvas.width = width * window.devicePixelRatio; + context.canvas.height = height * window.devicePixelRatio; + context.canvas.style.width = `${ width }px`; + context.canvas.style.height = `${ height }px`; + transformContext(context, k, x, y); +} diff --git a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Events.ts b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Events.ts index ee8187fc..05d6f6f2 100644 --- a/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Events.ts +++ b/src/attack_flow_builder/src/assets/scripts/BlockDiagram/Utilities/Events.ts @@ -1,11 +1,11 @@ -export class EventEmitter { +export class EventEmitter { /** - * The event emitter's index of event listeners. + * The event listeners. */ - private _listeners: Map; + private _listeners: Map; + - /** * Creates a new {@link EventEmitter}. */ @@ -13,62 +13,133 @@ export class EventEmitter { this._listeners = new Map(); } + + /** + * Adds a listener function for the named event. + * @param event + * The name of the event. + * @param callback + * The function to call when the event is raised. + * @param ctx + * The function's execution context. + */ + public on(event: K, callback: T[K]): void; /** - * Adds an event listener to an event. + * Adds a listener function for the named event. * @param event * The name of the event. * @param callback * The function to call when the event is raised. + * @param ctx + * The function's execution context. */ - public on(event: string, callback: Function) { + public on(event: K, callback: T[K], ctx?: Object): void; + public on(event: string, callback: Function, ctx?: Object) { if(!this._listeners.has(event)) this._listeners.set(event, []); - this._listeners.get(event)!.unshift(callback); + this._listeners.get(event)!.unshift({ callback, ctx }); } /** - * Adds a one-time event listener to an event. + * Adds a one-time listener function for the named event. * @param event * The name of the event. * @param callback * The function to call when the event is raised. */ + public once(event: K, callback: Function): void; public once(event: string, callback: Function) { let once = (...args: any[]) => { + // Remove this function let actions = this._listeners.get(event)!; - actions.splice(actions.indexOf(once), 1); + let index = actions.findIndex( + o => o.callback === once + ); + actions.splice(index, 1); + // Invoke callback callback(...args); } if(!this._listeners.has(event)) this._listeners.set(event, []); - this._listeners.get(event)!.unshift(once); + this._listeners.get(event)!.unshift({ callback: once }); } /** - * Dispatches the event listeners associated with a given event. + * Dispatches the listener functions associated with a given event. * @param event * The name of the event to raise. * @param args - * The arguments to pass to the event listeners. + * The arguments to pass to the listener functions. */ + protected emit(event: K, ...args: any[]): void; protected emit(event: string, ...args: any[]) { if(this._listeners.has(event)) { let listeners = this._listeners.get(event)!; for(let i = listeners.length - 1; 0 <= i; i--) { - listeners[i](...args); + if(listeners[i].ctx) { + listeners[i].callback.apply(listeners[i].ctx, args); + } else { + listeners[i].callback(...args); + } } } } /** - * Removes all event listeners associated with a given event. If no event - * name is specified, all event listeners are removed. + * Removes an event listener function associated with a given event. + * @param event + * The name of the event. + * @param callback + * The function to remove. + */ + public removeEventListener(event: K, callback: T[K]): void; + + /** + * Removes an event listener function associated with a given event. + * @param event + * The name of the event. + * @param callback + * The function to remove. + * @param ctx + * The function's execution context. + */ + public removeEventListener(event: K, callback: T[K], ctx?: Object): void; + public removeEventListener(event: string, callback: Function, ctx?: Object) { + if(this._listeners.has(event)) { + let actions = this._listeners.get(event)!; + let index = actions.findIndex( + o => o.callback === callback && o.ctx === ctx + ); + actions.splice(index, 1); + } + } + + /** + * Removes all event listeners associated with a given execution context. + * @param ctx + * The execution context. + */ + public removeEventListenersWithContext(ctx: Object) { + for(let event of this._listeners.keys()) { + let listeners = this._listeners.get(event)!.filter(o => o.ctx !== ctx); + this._listeners.set(event, listeners); + } + } + + /** + * Removes the listener functions associated with all events. + */ + protected removeAllListeners(): void; + + /** + * Removes the listener functions associated with a given event. * @param event * The name of the event. */ - public removeAllListeners(event?: string) { - if(event !== undefined) { + protected removeAllListeners(event?: K): void; + protected removeAllListeners(event?: string) { + if(event) { this._listeners.delete(event); } else { this._listeners.clear(); diff --git a/src/attack_flow_builder/src/assets/scripts/Browser.ts b/src/attack_flow_builder/src/assets/scripts/Browser.ts index 36f5080c..9e3e2710 100644 --- a/src/attack_flow_builder/src/assets/scripts/Browser.ts +++ b/src/attack_flow_builder/src/assets/scripts/Browser.ts @@ -1,5 +1,17 @@ -export class Browser { +import { EventEmitter } from "./BlockDiagram"; + +class DeviceManager extends EventEmitter<{ + "dppx-change" : (dpr: number) => void; +}> { + /** + * Creates a new {@link DeviceManager}. + */ + constructor() { + super(); + this.listenForPixelRatioChange(); + } + /////////////////////////////////////////////////////////////////////////// // 1. Download Files //////////////////////////////////////////////////// @@ -21,12 +33,12 @@ export class Browser { * The text file's extension. * (Default: 'txt') */ - public static downloadTextFile(filename: string, text: string, ext = "txt") { + public downloadTextFile(filename: string, text: string, ext = "txt") { let blob = new Blob([text], { type: "octet/stream" }); let url = window.URL.createObjectURL(blob); - this._aLink.href = url; - this._aLink.download = `${ filename }.${ ext }`; - this._aLink.click(); + DeviceManager._aLink.href = url; + DeviceManager._aLink.download = `${ filename }.${ ext }`; + DeviceManager._aLink.click(); window.URL.revokeObjectURL(url); } @@ -37,14 +49,14 @@ export class Browser { * @param canvas * The image file's contents. */ - public static downloadImageFile(filename: string, canvas: HTMLCanvasElement) { + public downloadImageFile(filename: string, canvas: HTMLCanvasElement) { canvas.toBlob(blob => { if(!blob) return; let url = window.URL.createObjectURL(blob); - this._aLink.href = url; - this._aLink.download = `${ filename }.png` - this._aLink.click(); + DeviceManager._aLink.href = url; + DeviceManager._aLink.download = `${ filename }.png` + DeviceManager._aLink.click(); window.URL.revokeObjectURL(url); }, "image/octet-stream") } @@ -62,7 +74,7 @@ export class Browser { * @returns * A Promise that resolves with the chosen text file. */ - public static openTextFileDialog(...fileTypes: string[]): Promise { + public openTextFileDialog(...fileTypes: string[]): Promise { // Create file input let fileInput = document.createElement("input"); @@ -106,7 +118,7 @@ export class Browser { * The element to fullscreen. * (Default: `document.body`) */ - public static fullscreen(el: HTMLElement = document.body) { + public fullscreen(el: HTMLElement = document.body) { let cast = el as any; if (cast.requestFullscreen) { cast.requestFullscreen(); @@ -130,7 +142,7 @@ export class Browser { * @returns * The device's current operating system class. */ - public static getOperatingSystemClass(): OperatingSystem { + public getOperatingSystemClass(): OperatingSystem { if(navigator.userAgent.search("Win") !== -1) { return OperatingSystem.Windows } else if(navigator.userAgent.search("Mac") !== -1) { @@ -143,7 +155,24 @@ export class Browser { return OperatingSystem.Other; } } - + + + /////////////////////////////////////////////////////////////////////////// + // 5. Device Events ///////////////////////////////////////////////////// + /////////////////////////////////////////////////////////////////////////// + + + /** + * Listens for changes in the device's current pixel ratio. + */ + private listenForPixelRatioChange() { + window.matchMedia(`(resolution: ${ window.devicePixelRatio }dppx)`) + .addEventListener("change", () => { + this.emit("dppx-change", window.devicePixelRatio); + this.listenForPixelRatioChange(); + }, { once: true }); + } + } /** @@ -167,3 +196,7 @@ type TextFile = { filename: string, contents: string | ArrayBuffer | null | undefined } + + +// Export Browser +export const Browser = new DeviceManager();