diff --git a/projects/core/.visual/viewport-crosses.dark.png b/projects/core/.visual/viewport-crosses.dark.png new file mode 100644 index 000000000..1684609ed --- /dev/null +++ b/projects/core/.visual/viewport-crosses.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48bcd15fd04da7b051abe45e13091fbde555288671024f3b24db04a729f3ef9e +size 3941 diff --git a/projects/core/.visual/viewport-crosses.png b/projects/core/.visual/viewport-crosses.png new file mode 100644 index 000000000..fdb15b0d7 --- /dev/null +++ b/projects/core/.visual/viewport-crosses.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:552759fa2e17bb6c3640fcf7469be4ad1bbaae8eb29ff001984a0c26c2758804 +size 3905 diff --git a/projects/core/.visual/viewport-dots.dark.png b/projects/core/.visual/viewport-dots.dark.png new file mode 100644 index 000000000..94ff41d22 --- /dev/null +++ b/projects/core/.visual/viewport-dots.dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3281787e6ab1995abe5f7dcc8aec6b73db967515f666263153fb914db40043c9 +size 3997 diff --git a/projects/core/.visual/viewport-dots.png b/projects/core/.visual/viewport-dots.png new file mode 100644 index 000000000..874487955 --- /dev/null +++ b/projects/core/.visual/viewport-dots.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:22fc3b1ad55c7d499011c97dfdde247ae37587b08896f8e39f9ce37c37a1ba4c +size 3978 diff --git a/projects/core/.visual/viewport.dark.png b/projects/core/.visual/viewport.dark.png index 3c28ca744..053545141 100644 --- a/projects/core/.visual/viewport.dark.png +++ b/projects/core/.visual/viewport.dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0d8e6d438f74ced6947574fcf86fd72ee12bab91990a7bedc16c08989685f3cb -size 4382 +oid sha256:e5d596b575ceaf1d57da0dc1a6eec0307775cbdfb9c7b2591de0f5c143004083 +size 3722 diff --git a/projects/core/.visual/viewport.png b/projects/core/.visual/viewport.png index a77936bf8..57f4848f6 100644 --- a/projects/core/.visual/viewport.png +++ b/projects/core/.visual/viewport.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:44a47eb87763ca7ca2f6e1da9b39b3deb88dbe032652608e7c7502a18bf0bbaf -size 4310 +oid sha256:75916012a4a87e021c1e43e3529862ccae321c58772205cf0550314714fa05b1 +size 3681 diff --git a/projects/core/src/index.test.lighthouse.ts b/projects/core/src/index.test.lighthouse.ts index fcb539105..eca9dd28f 100644 --- a/projects/core/src/index.test.lighthouse.ts +++ b/projects/core/src/index.test.lighthouse.ts @@ -18,7 +18,7 @@ describe('lighthouse report', () => { expect(report.scores.performance).toBe(100); expect(report.scores.accessibility).toBe(100); expect(report.scores.bestPractices).toBe(100); - expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(123.5); + expect(report.payload.javascript.requests['index.js'].kb).toBeLessThan(124.0); // if sudden drop in size, check vite bundle config and bundle demo to ensure side effects are properly preserved expect(report.payload.javascript.requests['index.js'].kb).toBeGreaterThan(100); diff --git a/projects/core/src/viewport/viewport-gridlines.css b/projects/core/src/viewport/viewport-gridlines.css index 7a3e7caed..0b107cd43 100644 --- a/projects/core/src/viewport/viewport-gridlines.css +++ b/projects/core/src/viewport/viewport-gridlines.css @@ -2,9 +2,12 @@ /* SPDX-License-Identifier: Apache-2.0 */ :host { - --_scale: 1; --color: var(--nve-ref-border-color-muted); - --line-width: 1px; + --line-width: var(--nve-ref-border-width-sm); + --dot-radius: var(--nve-ref-size-50); + --cross-size: var(--nve-ref-size-200); + + --_scale: 1; position: absolute; inset: 0; @@ -24,3 +27,26 @@ path { stroke: var(--color); stroke-width: calc(var(--line-width) / var(--_scale)); } + +circle { + fill: var(--color); + r: calc(var(--dot-radius) / var(--_scale)); +} + +.cross { + fill: var(--color); +} + +.cross-horizontal { + x: calc(var(--cross-size) / var(--_scale) / -2); + y: calc(var(--line-width) / var(--_scale) / -2); + width: calc(var(--cross-size) / var(--_scale)); + height: calc(var(--line-width) / var(--_scale)); +} + +.cross-vertical { + x: calc(var(--line-width) / var(--_scale) / -2); + y: calc(var(--cross-size) / var(--_scale) / -2); + width: calc(var(--line-width) / var(--_scale)); + height: calc(var(--cross-size) / var(--_scale)); +} diff --git a/projects/core/src/viewport/viewport-gridlines.test.ts b/projects/core/src/viewport/viewport-gridlines.test.ts index 1ad58a26d..2d40b2d24 100644 --- a/projects/core/src/viewport/viewport-gridlines.test.ts +++ b/projects/core/src/viewport/viewport-gridlines.test.ts @@ -53,15 +53,18 @@ describe(ViewportGridlines.metadata.tag, () => { expect({ originX: gridlines.originX, originY: gridlines.originY, + pattern: gridlines.pattern, step: gridlines.step, targetSpacing: gridlines.targetSpacing - }).toEqual({ originX: 0, originY: 0, step: 10, targetSpacing: 64 }); + }).toEqual({ originX: 0, originY: 0, pattern: 'lines', step: 10, targetSpacing: 64 }); + gridlines.pattern = 'dots'; gridlines.step = 20; gridlines.originX = -15; gridlines.originY = 25; gridlines.targetSpacing = 80; await elementIsStable(gridlines); + expect(gridlines.getAttribute('pattern')).toBe('dots'); expect(gridlines.getAttribute('step')).toBe('20'); expect(gridlines.getAttribute('origin-x')).toBe('-15'); expect(gridlines.getAttribute('origin-y')).toBe('25'); @@ -119,13 +122,150 @@ describe(ViewportGridlines.metadata.tag, () => { gridlines.originX = 20; gridlines.originY = -30; await elementIsStable(gridlines); - expect(patternOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(lineOrigin(gridlines)).toEqual({ x: 20, y: -30 }); viewport.x = 600; viewport.y = -450; viewport.scale = 2; await elementIsStable(gridlines); - expect(patternOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(lineOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + }); + + it('places dots at line intersections through panning and adaptive zoom', async () => { + gridlines.originX = 20; + gridlines.originY = -30; + await elementIsStable(gridlines); + const lineInterval = gridlines.shadowRoot?.querySelector('pattern')?.getAttribute('width'); + + gridlines.pattern = 'dots'; + await elementIsStable(gridlines); + expect(gridlines.getAttribute('pattern')).toBe('dots'); + expect(gridlines.shadowRoot?.querySelector('pattern')?.getAttribute('width')).toBe(lineInterval); + expect(dotOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(gridlines.shadowRoot?.querySelector('circle')?.namespaceURI).toBe('http://www.w3.org/2000/svg'); + + viewport.x = 600; + viewport.y = -450; + viewport.scale = 2; + await elementIsStable(gridlines); + expect(dotOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(gridlines.shadowRoot?.querySelector('pattern')?.getAttribute('width')).toBe('20'); + }); + + it('keeps configured dot radius stable on screen while zooming', async () => { + gridlines.pattern = 'dots'; + await elementIsStable(gridlines); + const circle = gridlines.shadowRoot?.querySelector('circle'); + if (!circle) throw new Error('Expected a dot'); + expect(getComputedStyle(circle).r).toBe('2px'); + + gridlines.style.setProperty('--dot-radius', '4px'); + gridlines.style.setProperty('--color', 'rgb(10, 20, 30)'); + expect(getComputedStyle(circle).r).toBe('4px'); + expect(getComputedStyle(circle).fill).toBe('rgb(10, 20, 30)'); + + viewport.scale = 2; + await elementIsStable(gridlines); + expect(getComputedStyle(circle).r).toBe('2px'); + }); + + it('places crosses at line intersections through panning and adaptive zoom', async () => { + const root = gridlines.shadowRoot; + if (!root) throw new Error('Expected a gridline shadow root'); + gridlines.originX = 20; + gridlines.originY = -30; + await elementIsStable(gridlines); + const lineInterval = root.querySelector('pattern')?.getAttribute('width'); + + gridlines.pattern = 'crosses'; + await elementIsStable(gridlines); + expect(gridlines.getAttribute('pattern')).toBe('crosses'); + expect(root.querySelector('pattern')?.getAttribute('width')).toBe(lineInterval); + expect(crossOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(root.querySelector('g')?.namespaceURI).toBe('http://www.w3.org/2000/svg'); + + viewport.x = 600; + viewport.y = -450; + viewport.scale = 2; + await elementIsStable(gridlines); + expect(crossOrigin(gridlines)).toEqual({ x: 20, y: -30 }); + expect(root.querySelector('pattern')?.getAttribute('width')).toBe('20'); + }); + + it('keeps configured cross size and arm thickness stable on screen while zooming', async () => { + gridlines.pattern = 'crosses'; + await elementIsStable(gridlines); + const horizontal = gridlines.shadowRoot?.querySelector('.cross-horizontal'); + const vertical = gridlines.shadowRoot?.querySelector('.cross-vertical'); + if (!(horizontal instanceof SVGRectElement) || !(vertical instanceof SVGRectElement)) { + throw new Error('Expected both cross arms'); + } + expect(getComputedStyle(horizontal).width).toBe('8px'); + expect(getComputedStyle(horizontal).height).toBe('1px'); + + gridlines.style.setProperty('--cross-size', '12px'); + gridlines.style.setProperty('--line-width', '2px'); + gridlines.style.setProperty('--color', 'rgb(10, 20, 30)'); + expect(getComputedStyle(horizontal).width).toBe('12px'); + expect(getComputedStyle(horizontal).height).toBe('2px'); + expect(getComputedStyle(vertical).width).toBe('2px'); + expect(getComputedStyle(vertical).height).toBe('12px'); + const cross = gridlines.shadowRoot?.querySelector('.cross'); + if (!cross) throw new Error('Expected a cross'); + expect(getComputedStyle(cross).fill).toBe('rgb(10, 20, 30)'); + + viewport.scale = 2; + await elementIsStable(gridlines); + expect(getComputedStyle(horizontal).width).toBe('6px'); + expect(getComputedStyle(horizontal).height).toBe('1px'); + expect(getComputedStyle(horizontal).x).toBe('-3px'); + expect(getComputedStyle(horizontal).y).toBe('-0.5px'); + expect(getComputedStyle(vertical).width).toBe('1px'); + expect(getComputedStyle(vertical).height).toBe('6px'); + expect(getComputedStyle(vertical).x).toBe('-0.5px'); + expect(getComputedStyle(vertical).y).toBe('-3px'); + }); + + it('paints translucent crosses with the same opacity at the center and along each arm', async () => { + gridlines.pattern = 'crosses'; + gridlines.originX = 25; + gridlines.originY = 25; + gridlines.style.setProperty('--cross-size', '20px'); + gridlines.style.setProperty('--line-width', '4px'); + gridlines.style.setProperty('--color', 'rgb(10 20 30 / 50%)'); + await elementIsStable(gridlines); + const source = gridlines.shadowRoot?.querySelector('svg'); + if (!source) throw new Error('Expected a gridline SVG'); + + // Rasterize the rendered SVG with resolved CSS geometry and paint. + const snapshot = source.cloneNode(true); + if (!(snapshot instanceof SVGSVGElement)) throw new Error('Expected an SVG snapshot'); + snapshot.removeAttribute('style'); + snapshot.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + snapshot.setAttribute('viewBox', '0 0 50 50'); + snapshot.setAttribute('width', '50'); + snapshot.setAttribute('height', '50'); + const sourceRects = source.querySelectorAll('rect'); + snapshot.querySelectorAll('rect').forEach((rect, index) => { + const original = sourceRects.item(index); + const style = getComputedStyle(original); + for (const property of ['x', 'y', 'width', 'height', 'fill']) { + rect.style.setProperty(property, style.getPropertyValue(property)); + } + }); + const image = new Image(); + image.src = `data:image/svg+xml,${encodeURIComponent(new XMLSerializer().serializeToString(snapshot))}`; + await image.decode(); + const canvas = document.createElement('canvas'); + canvas.width = canvas.height = 50; + const context = canvas.getContext('2d'); + if (!context) throw new Error('Expected a canvas context'); + context.drawImage(image, 0, 0); + const alphaAt = (x: number, y: number) => context.getImageData(x, y, 1, 1).data[3]; + expect(alphaAt(25, 25)).toBe(128); + expect(alphaAt(18, 25)).toBe(128); + expect(alphaAt(25, 18)).toBe(128); + expect(alphaAt(18, 18)).toBe(0); }); it('uses one shared interval and visually compensates line width for scale', async () => { @@ -150,18 +290,22 @@ describe(ViewportGridlines.metadata.tag, () => { expect(requestUpdate).not.toHaveBeenCalled(); }); - it('retains property values when they are assigned again', () => { + it('does not schedule an update or render when configuration values are assigned again', async () => { + gridlines.step = 20; + gridlines.originX = -15; + gridlines.originY = 25; + gridlines.targetSpacing = 80; + await elementIsStable(gridlines); + const render = vi.spyOn(gridlines, 'render'); + gridlines.step = gridlines.step; gridlines.originX = gridlines.originX; gridlines.originY = gridlines.originY; gridlines.targetSpacing = gridlines.targetSpacing; - expect({ - originX: gridlines.originX, - originY: gridlines.originY, - step: gridlines.step, - targetSpacing: gridlines.targetSpacing - }).toEqual({ originX: 0, originY: 0, step: 10, targetSpacing: 64 }); + expect(gridlines.isUpdatePending).toBe(false); + await elementIsStable(gridlines); + expect(render).not.toHaveBeenCalled(); }); it('refreshes its projection when the viewport is resized', async () => { @@ -225,7 +369,26 @@ describe('viewport gridline interval selection', () => { }); }); -function patternOrigin(element: ViewportGridlines): { x: number; y: number } { +function lineOrigin(element: ViewportGridlines): { x: number; y: number } { const pattern = element.shadowRoot?.querySelector('[data-gridline-pattern]'); return { x: Number(pattern?.getAttribute('x')), y: Number(pattern?.getAttribute('y')) }; } + +function dotOrigin(element: ViewportGridlines): { x: number; y: number } { + const pattern = element.shadowRoot?.querySelector('[data-gridline-pattern]'); + const circle = pattern?.querySelector('circle'); + return { + x: Number(pattern?.getAttribute('x')) + Number(circle?.getAttribute('cx')), + y: Number(pattern?.getAttribute('y')) + Number(circle?.getAttribute('cy')) + }; +} + +function crossOrigin(element: ViewportGridlines): { x: number; y: number } { + const pattern = element.shadowRoot?.querySelector('[data-gridline-pattern]'); + const cross = pattern?.querySelector('g'); + const translation = cross?.transform.baseVal.getItem(0).matrix; + return { + x: Number(pattern?.getAttribute('x')) + Number(translation?.e), + y: Number(pattern?.getAttribute('y')) + Number(translation?.f) + }; +} diff --git a/projects/core/src/viewport/viewport-gridlines.ts b/projects/core/src/viewport/viewport-gridlines.ts index 3fb56d8e6..8a2f264c4 100644 --- a/projects/core/src/viewport/viewport-gridlines.ts +++ b/projects/core/src/viewport/viewport-gridlines.ts @@ -1,7 +1,7 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { html, LitElement, nothing, type PropertyValues } from 'lit'; +import { html, LitElement, nothing, svg, type PropertyValues } from 'lit'; import { property } from 'lit/decorators/property.js'; import { styleMap } from 'lit/directives/style-map.js'; import { finiteOr, hostAttr, positiveFiniteOr, useStyles } from '@nvidia-elements/core/internal'; @@ -29,12 +29,14 @@ interface ProjectionSyncOptions { /** * @element nve-viewport-gridlines - * @description Renders origin-stable, zoom-adaptive gridlines behind viewport content. + * @description Renders origin-stable, zoom-adaptive gridline patterns behind viewport content. * @documentation https://nvidia.github.io/elements/docs/elements/viewport/ * @since 0.0.0 * @entrypoint \@nvidia-elements/core/viewport - * @cssprop --color - Sets the gridline stroke color. + * @cssprop --color - Sets the gridline pattern color. * @cssprop --line-width - Sets the approximate stroke width in CSS pixels. + * @cssprop --dot-radius - Sets the approximate dot radius in CSS pixels. + * @cssprop --cross-size - Sets the approximate full width and height of each cross in CSS pixels. */ export class ViewportGridlines extends LitElement { static styles = useStyles([styles]); @@ -58,6 +60,9 @@ export class ViewportGridlines extends LitElement { #resizeObserver?: ResizeObserver; #projection?: GridlineProjection; + /** Grid pattern to render. */ + @property({ type: String, reflect: true }) pattern: 'lines' | 'dots' | 'crosses' = 'lines'; + /** Smallest gridline interval in content-space units. */ @property({ type: Number, reflect: true }) get step(): number { @@ -150,6 +155,7 @@ export class ViewportGridlines extends LitElement { const projection = this.#projection; if (!projection) return nothing; const { coverage, interval } = projection; + const centered = this.pattern === 'dots' || this.pattern === 'crosses'; return html` - + ${this.#renderPattern(interval)} `; + case 'crosses': + return svg` + + + + + + + + `; + case 'lines': + default: + return svg``; + } + } + #handleViewportChange = (): void => { const viewport = this.#viewport; if (!viewport) return; diff --git a/projects/core/src/viewport/viewport.examples.ts b/projects/core/src/viewport/viewport.examples.ts index 7ed9a37ca..fef54ded1 100644 --- a/projects/core/src/viewport/viewport.examples.ts +++ b/projects/core/src/viewport/viewport.examples.ts @@ -43,7 +43,7 @@ export const Default = { }; /** - * @summary Provide custom minimap content to replace the automatic bounding boxes with a simplified representation of the viewport content. + * @summary Provide custom minimap content to replace the default bounding boxes with a simplified representation of the viewport content. */ export const Minimap = { render: () => html` @@ -75,45 +75,6 @@ export const Minimap = { ` }; -/** - * @summary Place custom content in the background slot to provide a spatial reference without affecting content fitting. - */ -export const Background = { - render: () => html` - - - - - - - - - - - ` -}; - /** * @summary Place native and Elements controls inside a viewport without losing their standard interactions. */ @@ -366,3 +327,92 @@ export const ProgrammaticNavigation = { ` }; + +/** + * @summary Optionally configure viewport grid lines to use dots instead of lines. + */ +export const Dots = { + render: () => html` + + + + + + + + + + + ` +}; + +/** + * @summary Use crosses as spatial reference points to help users judge positions while keeping the content between them unobstructed. + */ +export const Crosses = { + render: () => html` + + + + + + + + + + + ` +}; + +/** + * @summary Place custom content in the background slot to provide a spatial reference without affecting content fitting. + */ +export const Background = { + render: () => html` + + + + + + + + + + + ` +}; diff --git a/projects/core/src/viewport/viewport.test.visual.ts b/projects/core/src/viewport/viewport.test.visual.ts index fe47d605a..4bb007714 100644 --- a/projects/core/src/viewport/viewport.test.visual.ts +++ b/projects/core/src/viewport/viewport.test.visual.ts @@ -15,6 +15,26 @@ describe('viewport visual', () => { expect(report.maxDiffPercentage).toBeLessThan(1); }); + test('viewport dots should match visual baseline', async () => { + const report = await visualRunner.render('viewport-dots', template('', 'dots')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('viewport dots should match visual baseline dark theme', async () => { + const report = await visualRunner.render('viewport-dots.dark', template('dark', 'dots')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('viewport crosses should match visual baseline', async () => { + const report = await visualRunner.render('viewport-crosses', template('', 'crosses')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + + test('viewport crosses should match visual baseline dark theme', async () => { + const report = await visualRunner.render('viewport-crosses.dark', template('dark', 'crosses')); + expect(report.maxDiffPercentage).toBeLessThan(1); + }); + test('viewport minimap should match visual baseline', async () => { const report = await visualRunner.render('viewport-minimap', minimapTemplate(), { waitFor: waitForMinimap }); expect(report.maxDiffPercentage).toBeLessThan(1); @@ -46,16 +66,16 @@ async function waitForMinimap(waitForFunction: (...args: unknown[]) => Promise document.querySelector('nve-viewport-minimap')?.hasAttribute('data-visual-ready')); } -function template(theme: '' | 'dark' = '') { +function template(theme: '' | 'dark' = '', pattern: 'lines' | 'dots' | 'crosses' = 'lines') { return /* html */ ` - +
•︎•︎•︎•︎•︎
•︎•︎•︎
diff --git a/projects/site/src/docs/elements/viewport.md b/projects/site/src/docs/elements/viewport.md index cb2b6f03f..597251d6a 100644 --- a/projects/site/src/docs/elements/viewport.md +++ b/projects/site/src/docs/elements/viewport.md @@ -24,10 +24,6 @@ {% example '@nvidia-elements/core/viewport/viewport.examples.json' 'Minimap' '{ "inline": false, "height": "420px" }' %} -## Customizable Background - -{% example '@nvidia-elements/core/viewport/viewport.examples.json' 'Background' '{ "inline": false, "height": "420px" }' %} - ## Interactive Content {% example '@nvidia-elements/core/viewport/viewport.examples.json' 'InteractiveContent' '{ "inline": false, "height": "420px" }' %} @@ -43,3 +39,15 @@ ## Programmatic Navigation {% example '@nvidia-elements/core/viewport/viewport.examples.json' 'ProgrammaticNavigation' '{ "inline": false, "height": "420px" }' %} + +## Dot Grid + +{% example '@nvidia-elements/core/viewport/viewport.examples.json' 'Dots' '{ "inline": false, "height": "420px" }' %} + +## Cross Grid + +{% example '@nvidia-elements/core/viewport/viewport.examples.json' 'Crosses' '{ "inline": false, "height": "420px" }' %} + +## Customizable Background + +{% example '@nvidia-elements/core/viewport/viewport.examples.json' 'Background' '{ "inline": false, "height": "420px" }' %}