diff --git a/change/@ni-nimble-components-5067d35f-244c-42ff-b498-4329212f03ce.json b/change/@ni-nimble-components-5067d35f-244c-42ff-b498-4329212f03ce.json new file mode 100644 index 0000000000..35a5413070 --- /dev/null +++ b/change/@ni-nimble-components-5067d35f-244c-42ff-b498-4329212f03ce.json @@ -0,0 +1,7 @@ +{ + "type": "patch", + "comment": "Add zoom functionality to wafer map", + "packageName": "@ni/nimble-components", + "email": "49208904+arinluca333@users.noreply.github.com", + "dependentChangeType": "patch" +} diff --git a/package-lock.json b/package-lock.json index 8ffc17042d..7df78d8450 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19198,6 +19198,34 @@ "node": ">=12" } }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -19240,6 +19268,14 @@ "node": ">=12" } }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "engines": { + "node": ">=12" + } + }, "node_modules/d3-time": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", @@ -19262,6 +19298,47 @@ "node": ">=12" } }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -42734,8 +42811,12 @@ "@tanstack/table-core": "^8.7.0", "@types/d3": "^7.4.0", "@types/d3-scale": "^4.0.2", + "@types/d3-selection": "^3.0.0", + "@types/d3-zoom": "^3.0.0", "d3-random": "^3.0.1", "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "hex-rgb": "^5.0.0", "tslib": "^2.2.0" }, @@ -46864,6 +46945,8 @@ "@tanstack/table-core": "^8.7.0", "@types/d3": "^7.4.0", "@types/d3-scale": "^4.0.2", + "@types/d3-selection": "^3.0.0", + "@types/d3-zoom": "^3.0.0", "@types/jasmine": "^3.6.0", "@types/webpack-env": "^1.15.2", "babel-loader": "^8.2.2", @@ -46872,6 +46955,8 @@ "css-loader": "^6.2.0", "d3-random": "^3.0.1", "d3-scale": "^4.0.2", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "dotenv-webpack": "^7.0.2", "eslint-plugin-jsdoc": "^37.9.7", "eslint-plugin-storybook": "^0.5.1", @@ -58081,6 +58166,25 @@ "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==" }, + "d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==" + }, + "d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + } + }, + "d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==" + }, "d3-format": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", @@ -58111,6 +58215,11 @@ "d3-time-format": "2 - 4" } }, + "d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==" + }, "d3-time": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.0.0.tgz", @@ -58127,6 +58236,35 @@ "d3-time": "1 - 3" } }, + "d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==" + }, + "d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "requires": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + } + }, + "d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "requires": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + } + }, "dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", diff --git a/packages/nimble-components/package.json b/packages/nimble-components/package.json index fa5893fa23..0d9d906ad1 100644 --- a/packages/nimble-components/package.json +++ b/packages/nimble-components/package.json @@ -54,6 +54,10 @@ "@tanstack/table-core": "^8.7.0", "@types/d3": "^7.4.0", "@types/d3-scale": "^4.0.2", + "@types/d3-zoom": "^3.0.0", + "@types/d3-selection": "^3.0.0", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0", "d3-random": "^3.0.1", "d3-scale": "^4.0.2", "hex-rgb": "^5.0.0", diff --git a/packages/nimble-components/src/wafer-map/index.ts b/packages/nimble-components/src/wafer-map/index.ts index 2d8fc64b78..4fe170810e 100644 --- a/packages/nimble-components/src/wafer-map/index.ts +++ b/packages/nimble-components/src/wafer-map/index.ts @@ -16,6 +16,7 @@ import { } from './types'; import { DataManager } from './modules/data-manager'; import { RenderingModule } from './modules/rendering'; +import { ZoomHandler } from './modules/zoom-handler'; declare global { interface HTMLElementTagNameMap { @@ -63,7 +64,12 @@ export class WaferMap extends FoundationElement { /** * @internal */ - @observable public canvasSideLength: number | undefined; + public readonly zoomContainer!: HTMLElement; + + /** + * @internal + */ + @observable public canvasSideLength?: number; @observable public highlightedValues: string[] = []; @observable public dies: WaferMapDie[] = []; @observable public colorScale: WaferMapColorScale = { @@ -72,9 +78,10 @@ export class WaferMap extends FoundationElement { }; private renderQueued = false; - private dataManager: DataManager | undefined; - private renderer: RenderingModule | undefined; - private resizeObserver: ResizeObserver | undefined; + private dataManager?: DataManager; + private renderer?: RenderingModule; + private resizeObserver?: ResizeObserver; + private zoomHandler?: ZoomHandler; public override connectedCallback(): void { super.connectedCallback(); this.resizeObserver = new ResizeObserver(entries => { @@ -86,11 +93,15 @@ export class WaferMap extends FoundationElement { this.canvasSideLength = Math.min(height, width); }); this.resizeObserver.observe(this); + this.canvas.addEventListener('wheel', event => event.preventDefault(), { + passive: false + }); this.queueRender(); } public override disconnectedCallback(): void { super.disconnectedCallback(); + this.canvas.removeEventListener('wheel', event => event.preventDefault()); this.resizeObserver!.unobserve(this); } @@ -121,6 +132,14 @@ export class WaferMap extends FoundationElement { this.maxCharacters ); this.renderer = new RenderingModule(this.dataManager, this.canvas); + this.zoomHandler = new ZoomHandler( + this.canvas, + this.zoomContainer, + this.dataManager, + this.renderer, + this.canvasSideLength + ); + this.zoomHandler.attachZoomBehavior(); this.renderer.drawWafer(); } @@ -168,6 +187,7 @@ export class WaferMap extends FoundationElement { this.canvas.width = this.canvasSideLength; this.canvas.height = this.canvasSideLength; } + this.zoomHandler?.resetTransform(); this.queueRender(); } diff --git a/packages/nimble-components/src/wafer-map/modules/zoom-handler.ts b/packages/nimble-components/src/wafer-map/modules/zoom-handler.ts new file mode 100644 index 0000000000..da98a2f1ff --- /dev/null +++ b/packages/nimble-components/src/wafer-map/modules/zoom-handler.ts @@ -0,0 +1,147 @@ +import { select } from 'd3-selection'; +import { + zoom, + ZoomBehavior, + zoomIdentity, + ZoomTransform, + zoomTransform +} from 'd3-zoom'; +import type { DataManager } from './data-manager'; +import type { RenderingModule } from './rendering'; + +/** + * ZoomHandler deals with user interactions and events like zooming + */ +export class ZoomHandler { + private zoomTransform: ZoomTransform = zoomIdentity; + private readonly minScale = 1.1; + private readonly minExtentPoint: [number, number] = [-100, -100]; + private readonly extentPadding = 100; + private zoomBehavior: ZoomBehavior | undefined; + + public constructor( + private readonly canvas: HTMLCanvasElement, + private readonly zoomContainer: HTMLElement, + private readonly dataManager: DataManager, + private readonly renderingModule: RenderingModule, + private readonly canvasLength: number + ) {} + + public attachZoomBehavior(): void { + this.zoomBehavior = this.createZoomBehavior(); + this.zoomBehavior(select(this.canvas as Element)); + } + + public resetTransform(): void { + const canvasContext = this.canvas.getContext('2d'); + if (canvasContext === null) { + return; + } + this.zoomTransform = zoomIdentity; + this.clearCanvas(canvasContext, this.canvasLength, this.canvasLength); + this.scaleCanvas( + canvasContext, + zoomIdentity.x, + zoomIdentity.y, + zoomIdentity.k + ); + this.renderingModule.drawWafer(); + this.zoomBehavior?.transform( + select(this.canvas as Element), + zoomIdentity + ); + } + + private createZoomBehavior(): ZoomBehavior { + const zoomBehavior = zoom() + .scaleExtent([ + 1.1, + this.getZoomMax( + this.canvasLength * this.canvasLength, + this.dataManager.containerDimensions.width + * this.dataManager.containerDimensions.height + ) + ]) + .translateExtent([ + this.minExtentPoint, + [ + this.canvasLength + this.extentPadding, + this.canvasLength + this.extentPadding + ] + ]) + .filter((event: Event) => { + const transform = zoomTransform(this.canvas); + return transform.k >= this.minScale || event.type === 'wheel'; + }) + .on('zoom', (event: { transform: ZoomTransform }) => { + const transform = event.transform; + const canvasContext = this.canvas.getContext('2d'); + if (canvasContext === null) { + return; + } + canvasContext.save(); + if (transform.k === this.minScale) { + this.zoomTransform = zoomIdentity; + this.clearCanvas( + canvasContext, + this.canvasLength, + this.canvasLength + ); + this.scaleCanvas( + canvasContext, + zoomIdentity.x, + zoomIdentity.y, + zoomIdentity.k + ); + this.renderingModule.drawWafer(); + zoomBehavior.transform( + select(this.canvas as Element), + zoomIdentity + ); + } else { + this.zoomTransform = transform; + this.clearCanvas( + canvasContext, + this.canvasLength * this.zoomTransform.k, + this.canvasLength * this.zoomTransform.k + ); + this.scaleCanvas( + canvasContext, + transform.x, + transform.y, + transform.k + ); + this.renderingModule.drawWafer(); + } + canvasContext.restore(); + this.zoomContainer.setAttribute( + 'transform', + this.zoomTransform.toString() + ); + }); + + return zoomBehavior; + } + + private getZoomMax(canvasArea: number, dataArea: number): number { + return Math.ceil((dataArea / canvasArea) * 100); + } + + private clearCanvas( + context: CanvasRenderingContext2D, + width: number, + height: number + ): void { + context.clearRect(0, 0, width, height); + } + + private scaleCanvas( + context: CanvasRenderingContext2D, + x = 0, + y = 0, + scale = 1 + ): void { + context.translate(x, y); + context.scale(scale, scale); + } +} diff --git a/packages/nimble-components/src/wafer-map/styles.ts b/packages/nimble-components/src/wafer-map/styles.ts index 452809eb45..9dbb58bb55 100644 --- a/packages/nimble-components/src/wafer-map/styles.ts +++ b/packages/nimble-components/src/wafer-map/styles.ts @@ -23,19 +23,30 @@ export const styles = css` position: absolute; } - .svg-root.top { + .circle-base { + width: 100%; + height: 100%; + position: absolute; + fill: white; + } + + .notch { + transform-origin: center center; + } + + .notch.top { transform: rotate(-90deg); } - .svg-root.right { + .notch.right { transform: rotate(0deg); } - .svg-root.left { + .notch.left { transform: rotate(180deg); } - .svg-root.bottom { + .notch.bottom { transform: rotate(90deg); } diff --git a/packages/nimble-components/src/wafer-map/template.ts b/packages/nimble-components/src/wafer-map/template.ts index ce63dbd728..cc106c5ebf 100644 --- a/packages/nimble-components/src/wafer-map/template.ts +++ b/packages/nimble-components/src/wafer-map/template.ts @@ -3,20 +3,22 @@ import type { WaferMap } from '.'; export const template = html`
- - - - - + + + + + + +
diff --git a/packages/nimble-components/src/wafer-map/tests/wafer-map.spec.ts b/packages/nimble-components/src/wafer-map/tests/wafer-map.spec.ts index b8461a04f4..1fb0701d67 100644 --- a/packages/nimble-components/src/wafer-map/tests/wafer-map.spec.ts +++ b/packages/nimble-components/src/wafer-map/tests/wafer-map.spec.ts @@ -19,6 +19,7 @@ describe('WaferMap', () => { beforeEach(async () => { ({ element, connect, disconnect } = await setup()); await connect(); + element.canvasSideLength = 500; DOM.processUpdates(); spy = spyOn(element, 'render'); }); @@ -100,4 +101,51 @@ describe('WaferMap', () => { DOM.processUpdates(); expect(spy).toHaveBeenCalledTimes(1); }); + + describe('ZoomHandler', () => { + let initialValue: string | undefined; + + beforeEach(() => { + initialValue = getTransform(); + expect(initialValue).not.toBeDefined(); + }); + + it('will zoom in the wafer-map', () => { + element.canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: -2, deltaMode: -1 }) + ); + + const zoomedValue = getTransform(); + expect(zoomedValue).not.toBe(initialValue); + }); + + it('will zoom out to identity', () => { + element.canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: -2, deltaMode: -1 }) + ); + + const zoomedValue = getTransform(); + expect(zoomedValue).not.toEqual('translate(0,0) scale(1)'); + + element.canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: 2, deltaMode: -1 }) + ); + + const zoomedOut = getTransform(); + expect(zoomedOut).toBe('translate(0,0) scale(1)'); + }); + + it('will not zoom out when at identity', () => { + element.canvas.dispatchEvent( + new WheelEvent('wheel', { deltaY: 2, deltaMode: -1 }) + ); + + const zoomedOut = getTransform(); + expect(zoomedOut).toBe('translate(0,0) scale(1)'); + }); + }); + + function getTransform(): string | undefined { + return element.zoomContainer.getAttribute('transform')?.toString(); + } });