Repository: ag-grid/ag-charts
Pull request #4230: Large Dataset Perf
Author: login: jacobp100
Created: 2025-05-20T10:28:00Z | Merged: 2025-05-22T09:55:08Z

* Removes all `dirty` flags from `Node`, moves them to `Group`
* Skips all child iteration on non-dirty groups

Scene will need the root to be a `Group` (or maybe better - a `Root` subclass) to check the dirty flags to restore the no-op rendering


Diff:
diff --git a/packages/ag-charts-community/src/scene/group.ts b/packages/ag-charts-community/src/scene/group.ts
index c603270476c..49f6ed11553 100644
--- a/packages/ag-charts-community/src/scene/group.ts
+++ b/packages/ag-charts-community/src/scene/group.ts
@@ -34,6 +34,8 @@ export class Group<D = any> extends Node<D> {
         return compareZIndex(a.zIndex, b.zIndex) || a.serialNumber - b.serialNumber;
     }
 
+    public dirty = false;
+    private dirtyZIndex: boolean = false;
     private clipRect?: BBox;
 
     @SceneChangeDetection({ convertor: (v: number) => clamp(0, v, 1) })
@@ -103,32 +105,41 @@ export class Group<D = any> extends Node<D> {
         return sharedOffscreenCanvas;
     }
 
+    override markDirty(property?: string): void {
+        this.dirty = true;
+        super.markDirty(property);
+    }
+
+    override markDirtyChildrenOrder(): void {
+        super.markDirtyChildrenOrder();
+        this.dirtyZIndex = true;
+        this.markDirty();
+    }
+
     private _lastWidth = NaN;
     private _lastHeight = NaN;
     private _lastDevicePixelRatio = NaN;
     private isDirty(renderCtx: RenderContext) {
         const { width, height, devicePixelRatio } = renderCtx;
-        const { dirty, dirtyZIndex, layer } = this;
+        const { dirty, layer } = this;
         const layerResized = layer != null && (this._lastWidth !== width || this._lastHeight !== height);
         const pixelRatioChanged = this._lastDevicePixelRatio !== devicePixelRatio;
         this._lastWidth = width;
         this._lastHeight = height;
         this._lastDevicePixelRatio = devicePixelRatio;
-        if (dirty || dirtyZIndex || layerResized || pixelRatioChanged) return true;
-
-        for (const child of this.children()) {
-            if (child.dirty) return true;
-        }
-
-        return false;
+        return dirty || layerResized || pixelRatioChanged;
     }
 
     override preRender(renderCtx: RenderContext): ChildNodeCounts {
-        const counts = super.preRender(renderCtx, 0);
-
-        // Correct counts for this group.
-        counts.groups += 1;
-        counts.nonGroups -= 1;
+        let counts: ChildNodeCounts;
+        if (this.dirty) {
+            counts = super.preRender(renderCtx, 0);
+            // Correct counts for this group.
+            counts.groups += 1;
+            counts.nonGroups -= 1;
+        } else {
+            counts = this.childNodeCounts;
+        }
 
         if (
             this.renderToOffscreenCanvas &&
@@ -149,6 +160,9 @@ export class Group<D = any> extends Node<D> {
         const { layer, renderToOffscreenCanvas } = this;
         const childRenderCtx: RenderContext = { ...renderCtx };
 
+        const dirty = this.isDirty(renderCtx);
+        this.dirty = false;
+
         if (!renderToOffscreenCanvas) {
             this.renderInContext(childRenderCtx);
             super.render(childRenderCtx); // Calls markClean().
@@ -158,7 +172,7 @@ export class Group<D = any> extends Node<D> {
         const { ctx, stats, devicePixelRatio: pixelRatio } = renderCtx;
 
         let { image } = this;
-        if (this.isDirty(renderCtx)) {
+        if (dirty) {
             image?.bitmap.close();
             image = undefined;
 
@@ -189,18 +203,13 @@ export class Group<D = any> extends Node<D> {
                 renderOffscreen(canvas, pixelRatio, 0, 0, pixelRatio, -x * pixelRatio, -y * pixelRatio);
 
                 image = { bitmap: canvas.transferToImageBitmap(), x, y, width, height };
-            } else if (this.dirtyZIndex) {
-                // Required to make this.isDirty return false on the next render
-                this.sortChildren(Group.compareChildren);
             }
 
             this.image = image;
 
             if (stats) stats.layersRendered++;
-        } else {
-            this.skipRender(childRenderCtx);
-
-            if (stats) stats.layersSkipped++;
+        } else if (stats) {
+            stats.layersSkipped++;
         }
 
         const { globalAlpha } = ctx;
@@ -219,19 +228,6 @@ export class Group<D = any> extends Node<D> {
         super.render(childRenderCtx); // Calls markClean().
     }
 
-    private skipRender(childRenderCtx: RenderContext) {
-        const { stats } = childRenderCtx;
-
-        for (const child of this.children()) {
-            // Skip invisible children, but make sure their dirty flag is reset.
-            child.markClean();
-            if (stats) {
-                stats.nodesSkipped += this.childNodeCounts.groups + this.childNodeCounts.nonGroups;
-                stats.opsSkipped += this.childNodeCounts.complexity;
-            }
-        }
-    }
-
     protected applyClip(ctx: CanvasContext, clipRect: BBox) {
         const { x, y, width, height } = clipRect;
 
@@ -245,6 +241,7 @@ export class Group<D = any> extends Node<D> {
 
         if (this.dirtyZIndex) {
             this.sortChildren(Group.compareChildren);
+            this.dirtyZIndex = false;
         }
 
         ctx.save();
@@ -264,7 +261,6 @@ export class Group<D = any> extends Node<D> {
         for (const child of this.children()) {
             // Skip invisible children, but make sure their dirty flag is reset.
             if (!child.visible) {
-                child.markClean();
                 if (stats) {
                     stats.nodesSkipped += child.childNodeCounts.nonGroups + child.childNodeCounts.groups;
                     stats.opsSkipped += child.childNodeCounts.complexity;
diff --git a/packages/ag-charts-community/src/scene/node.ts b/packages/ag-charts-community/src/scene/node.ts
index 1a08cdf1c31..e4477b03734 100644
--- a/packages/ag-charts-community/src/scene/node.ts
+++ b/packages/ag-charts-community/src/scene/node.ts
@@ -5,7 +5,7 @@ import { BBox } from './bbox';
 import { SceneChangeDetection, SceneObjectChangeDetection } from './changeDetectable';
 import type { ImageLoader } from './image/imageLoader';
 import type { LayersManager } from './layersManager';
-import type { ZIndex } from './zIndex';
+import { type ZIndex } from './zIndex';
 
 export { SceneChangeDetection };
 
@@ -115,9 +115,6 @@ export abstract class Node<D = any> {
     protected scene: IScene | undefined = undefined;
     private readonly _debugDirtyProperties?: Map<string, string[]>;
 
-    protected _dirty: boolean = true;
-    protected dirtyZIndex: boolean = false;
-
     private parentNode?: Node;
     private childNodes?: Set<Node>;
 
@@ -175,10 +172,6 @@ export abstract class Node<D = any> {
         return this.scene?.imageLoader;
     }
 
-    get dirty() {
-        return this._dirty;
-    }
-
     closestDatum() {
         for (const { datum } of this.traverseUp(true)) {
             if (datum != null) {
@@ -207,7 +200,6 @@ export abstract class Node<D = any> {
     render(renderCtx: RenderContext): void {
         const { stats } = renderCtx;
 
-        this._dirty = false;
         this.debugDirtyProperties();
 
         if (renderCtx.debugNodeSearch) {
@@ -233,7 +225,6 @@ export abstract class Node<D = any> {
     }
 
     protected sortChildren(compareFn?: (a: Node, b: Node) => number) {
-        this.dirtyZIndex = false;
         if (!this.childNodes) return;
 
         // Sort children, and re-add in new order (Set preserves insertion order).
@@ -300,8 +291,7 @@ export abstract class Node<D = any> {
             node.setScene(this.scene);
         }
 
-        this.invalidateCachedBBox();
-        this.dirtyZIndex = true;
+        this.markDirtyChildrenOrder();
         this.markDirty();
     }
 
@@ -320,8 +310,7 @@ export abstract class Node<D = any> {
         delete node.parentNode;
         node.setScene();
 
-        this.invalidateCachedBBox();
-        this.dirtyZIndex = true;
+        this.markDirtyChildrenOrder();
         this.markDirty();
     }
 
@@ -335,7 +324,7 @@ export abstract class Node<D = any> {
             child.setScene();
         }
         this.childNodes?.clear();
-        this.invalidateCachedBBox();
+        this.markDirty();
     }
 
     destroy(): void {
@@ -400,13 +389,6 @@ export abstract class Node<D = any> {
         return into;
     }
 
-    private invalidateCachedBBox() {
-        if (this.cachedBBox != null) {
-            this.cachedBBox = undefined;
-            this.parentNode?.invalidateCachedBBox();
-        }
-    }
-
     getBBox(): BBox {
         if (this.cachedBBox == null) {
             this.cachedBBox = Object.freeze(this.computeBBox()) as BBox;
@@ -423,32 +405,17 @@ export abstract class Node<D = any> {
         this.markDirty(property);
     }
 
-    markDirty(property?: string) {
-        const { _dirty } = this;
+    markDirtyChildrenOrder() {
+        this.cachedBBox = undefined;
+    }
 
+    markDirty(property?: string) {
         if (property != null && this._debugDirtyProperties) {
             this.markDebugProperties(property);
         }
 
-        const noParentCachedBBox = this.cachedBBox == null;
-        if (noParentCachedBBox && _dirty) return;
-
-        this.invalidateCachedBBox();
-        this._dirty = true;
-        if (this.parentNode) {
-            this.parentNode.markDirty();
-        }
-    }
-
-    markClean() {
-        if (!this._dirty) return;
-
-        this._dirty = false;
-        this.debugDirtyProperties();
-
-        for (const child of this.children()) {
-            child.markClean();
-        }
+        this.cachedBBox = undefined;
+        this.parentNode?.markDirty();
     }
 
     private markDebugProperties(property: string) {
@@ -489,11 +456,7 @@ export abstract class Node<D = any> {
     }
 
     protected onZIndexChange() {
-        const { parentNode } = this;
-
-        if (parentNode) {
-            parentNode.dirtyZIndex = true;
-        }
+        this.parentNode?.markDirtyChildrenOrder();
     }
 
     toSVG(): { elements: SVGElement[]; defs?: SVGElement[] } | undefined {
diff --git a/packages/ag-charts-community/src/scene/scene.ts b/packages/ag-charts-community/src/scene/scene.ts
index ab3118454c5..7d9c75e35fd 100644
--- a/packages/ag-charts-community/src/scene/scene.ts
+++ b/packages/ag-charts-community/src/scene/scene.ts
@@ -3,6 +3,7 @@ import { EventEmitter, Logger, createId, downloadUrl } from 'ag-charts-core';
 import { Debug } from '../util/debug';
 import type { BBox } from './bbox';
 import { type CanvasOptions, HdpiCanvas } from './canvas/hdpiCanvas';
+import { Group } from './group';
 import { ImageLoader } from './image/imageLoader';
 import { LayersManager } from './layersManager';
 import { Node, type RenderContext } from './node';
@@ -175,7 +176,12 @@ export class Scene extends EventEmitter<EventMap> {
             return;
         }
 
-        if (root?.dirty === false && !this.isDirty) {
+        let rootDirty: boolean | undefined;
+        if (root instanceof Group) {
+            rootDirty = root.dirty;
+        }
+
+        if (root != null && rootDirty === false && !this.isDirty) {
             if (this.debug.check()) {
                 this.debug('Scene.render() - no-op', {
                     tree: buildTree(root, 'console'),
@@ -211,7 +217,7 @@ export class Scene extends EventEmitter<EventMap> {
         // Groups are considered dirty when the layer is resized,
         // but this doesn't actually get propagated to the scene graph,
         // so the root isn't marked as dirty on the resize case
-        if (root?.dirty !== false || resized) {
+        if (rootDirty !== false || resized) {
             // start with a blank canvas, clear previous drawing
             canvasCleared = true;
             canvas.clear();
diff --git a/packages/ag-charts-community/src/scene/sceneDebug.ts b/packages/ag-charts-community/src/scene/sceneDebug.ts
index b70951ddab9..6d11809cd75 100644
--- a/packages/ag-charts-community/src/scene/sceneDebug.ts
+++ b/packages/ag-charts-community/src/scene/sceneDebug.ts
@@ -190,7 +190,7 @@ export function buildTree(node: Node, mode: 'json' | 'console'): BuildTree {
     return {
         node: mode === 'json' ? nodeProps(node) : node,
         name: node.name ?? node.id,
-        dirty: node.dirty,
+        dirty: node instanceof Group ? node.dirty : undefined,
         ...Array.from(node.children(), (c) => buildTree(c, mode)).reduce<Record<string, object>>(
             (result, childTree) => {
                 let { name: treeNodeName } = childTree;
@@ -235,7 +235,8 @@ export function buildDirtyTree(node: Node): {
     dirtyTree: { name?: string; node?: any; dirty?: boolean };
     paths: string[];
 } {
-    if (!node.dirty) {
+    const nodeDirty = node instanceof Group ? node.dirty : undefined;
+    if (!nodeDirty) {
         return { dirtyTree: {}, paths: [] };
     }
 
@@ -249,7 +250,7 @@ export function buildDirtyTree(node: Node): {
         dirtyTree: {
             name,
             node,
-            dirty: node.dirty,
+            dirty: nodeDirty,
             ...childrenDirtyTree
                 .map((c) => c.dirtyTree)
                 .filter((t) => t.dirty != null)
diff --git a/packages/ag-charts-community/src/scene/shape/shape.ts b/packages/ag-charts-community/src/scene/shape/shape.ts
index 14dd60913ef..7f0fc39fbc8 100644
--- a/packages/ag-charts-community/src/scene/shape/shape.ts
+++ b/packages/ag-charts-community/src/scene/shape/shape.ts
@@ -12,7 +12,7 @@ import { LinearGradient } from '../gradient/linearGradient';
 import { RadialGradient } from '../gradient/radialGradient';
 import { getColorStops } from '../gradient/stops';
 import { Image } from '../image/image';
-import { Node, type RenderContext, SceneChangeDetection } from '../node';
+import { Node, SceneChangeDetection } from '../node';
 import { Pattern } from '../pattern/pattern';
 import { isGradientFill, isImageFill, isPatternFill } from '../util/fill';
 import { align } from '../util/pixel';
@@ -201,11 +201,9 @@ export abstract class Shape<D = any> extends Node<D> {
 
     private cachedDefaultGradientFillBBox?: BBox;
 
-    override preRender(renderCtx: RenderContext, thisComplexity?: number) {
-        if (this.dirty) {
-            this.cachedDefaultGradientFillBBox = undefined;
-        }
-        return super.preRender(renderCtx, thisComplexity);
+    override markDirty(property?: string): void {
+        super.markDirty(property);
+        this.cachedDefaultGradientFillBBox = undefined;
     }
 
     protected fillStroke(ctx: CanvasContext, path?: Path2D) {
diff --git a/packages/ag-charts-community/src/scene/transformable.ts b/packages/ag-charts-community/src/scene/transformable.ts
index 0cc8d9df9d1..4f525569edf 100644
--- a/packages/ag-charts-community/src/scene/transformable.ts
+++ b/packages/ag-charts-community/src/scene/transformable.ts
@@ -57,7 +57,7 @@ function MatrixTransform<N extends Node>(Parent: Constructor<N>) {
         private _dirtyTransform = true;
         markDirtyTransform() {
             this._dirtyTransform = true;
-            super.markDirty();
+            this.markDirty();
         }
 
         override onChangeDetection(property: string): void {


Reviews:
user: login: alantreadway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T12:45:24Z
user: login: alantreadway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T12:49:24Z
user: login: alantreadway
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-20T12:52:41Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T08:32:53Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T08:34:36Z
user: login: jacobp100
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-21T08:34:40Z
user: login: alantreadway
body: LGTM
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-22T09:55:03Z

Review comments:
diff_hunk: @@ -201,10 +201,12 @@ export abstract class Shape<D = any> extends Node<D> {
 
     private cachedDefaultGradientFillBBox?: BBox;
 
+    override markDirty(property?: string): void {
+        super.markDirty(property);
+        this.cachedDefaultGradientFillBBox = undefined;
+    }
+
     override preRender(renderCtx: RenderContext, thisComplexity?: number) {
path: packages/ag-charts-community/src/scene/shape/shape.ts
user: login: alantreadway
body: Nit: Just remove this method as the override is now a no-op?
created_at: 2025-05-20T12:45:24Z
author_association: MEMBER
diff_hunk: @@ -423,32 +403,17 @@ export abstract class Node<D = any> {
         this.markDirty(property);
     }
 
-    markDirty(property?: string) {
-        const { _dirty } = this;
+    markDirtyZIndex() {
+        this.parentNode?.markDirtyZIndex();
+    }
 
+    markDirty(property?: string) {
         if (property != null && this._debugDirtyProperties) {
             this.markDebugProperties(property);
         }
 
-        const noParentCachedBBox = this.cachedBBox == null;
-        if (noParentCachedBBox && _dirty) return;
-
-        this.invalidateCachedBBox();
-        this._dirty = true;
-        if (this.parentNode) {
-            this.parentNode.markDirty();
-        }
-    }
-
-    markClean() {
-        if (!this._dirty) return;
-
-        this._dirty = false;
-        this.debugDirtyProperties();
-
-        for (const child of this.children()) {
-            child.markClean();
-        }
+        this.cachedBBox = undefined;
+        this.parentNode?.markDirty();
path: packages/ag-charts-community/src/scene/node.ts
user: login: alantreadway
body: The original intent was to avoid percolating `markDirty()` calls upwards if this has happened previously, so we need to be careful that we haven't reduced performance in other cases by causing a lot more calls upwards.
created_at: 2025-05-20T12:49:24Z
author_association: MEMBER
diff_hunk: @@ -219,19 +228,6 @@ export class Group<D = any> extends Node<D> {
         super.render(childRenderCtx); // Calls markClean().
     }
 
-    private skipRender(childRenderCtx: RenderContext) {
-        const { stats } = childRenderCtx;
-
-        for (const child of this.children()) {
-            // Skip invisible children, but make sure their dirty flag is reset.
-            child.markClean();
path: packages/ag-charts-community/src/scene/group.ts
user: login: alantreadway
body: The intent is that all child `dirty` flags are reset at this point, as we have had a recurring class of bug where unrendered nodes never had their dirty flags reset to `false` - we need to be sure we're not allowing this class of issue to be re-introduced.
created_at: 2025-05-20T12:52:41Z
author_association: MEMBER
diff_hunk: @@ -219,19 +228,6 @@ export class Group<D = any> extends Node<D> {
         super.render(childRenderCtx); // Calls markClean().
     }
 
-    private skipRender(childRenderCtx: RenderContext) {
-        const { stats } = childRenderCtx;
-
-        for (const child of this.children()) {
-            // Skip invisible children, but make sure their dirty flag is reset.
-            child.markClean();
path: packages/ag-charts-community/src/scene/group.ts
user: login: jacobp100
body: If something gets marked, all its parents get marked dirty. Only groups track dirty state. Groups may skip rendering if they're not dirty - which means none of their children are dirty. A group's dirty flag is reset on render
created_at: 2025-05-21T08:32:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -423,32 +403,17 @@ export abstract class Node<D = any> {
         this.markDirty(property);
     }
 
-    markDirty(property?: string) {
-        const { _dirty } = this;
+    markDirtyZIndex() {
+        this.parentNode?.markDirtyZIndex();
+    }
 
+    markDirty(property?: string) {
         if (property != null && this._debugDirtyProperties) {
             this.markDebugProperties(property);
         }
 
-        const noParentCachedBBox = this.cachedBBox == null;
-        if (noParentCachedBBox && _dirty) return;
-
-        this.invalidateCachedBBox();
-        this._dirty = true;
-        if (this.parentNode) {
-            this.parentNode.markDirty();
-        }
-    }
-
-    markClean() {
-        if (!this._dirty) return;
-
-        this._dirty = false;
-        this.debugDirtyProperties();
-
-        for (const child of this.children()) {
-            child.markClean();
-        }
+        this.cachedBBox = undefined;
+        this.parentNode?.markDirty();
path: packages/ag-charts-community/src/scene/node.ts
user: login: jacobp100
body: I can't figure out how to do this. I tried not doing it, but it breaks tests. There's probably more performance to be won here - but it's at least not a regression
created_at: 2025-05-21T08:34:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -201,10 +201,12 @@ export abstract class Shape<D = any> extends Node<D> {
 
     private cachedDefaultGradientFillBBox?: BBox;
 
+    override markDirty(property?: string): void {
+        super.markDirty(property);
+        this.cachedDefaultGradientFillBBox = undefined;
+    }
+
     override preRender(renderCtx: RenderContext, thisComplexity?: number) {
path: packages/ag-charts-community/src/scene/shape/shape.ts
user: login: jacobp100
body: Updated
created_at: 2025-05-21T08:34:40Z
author_association: CONTRIBUTOR

PR comments:
user: login: jacobp100
created_at: 2025-05-20T17:50:46Z
body: /benchmarks
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-05-20T17:50:56Z
body: ✅ https://github.com/ag-grid/ag-charts/actions/runs/15144411847
```
Comparing latest-60aaa564fa4238213de9929b44e042aa756ba227 (baseline) vs. large-dataset-perf-dc4411e82505cf3d6b4f660bebcfed247c8db498
Time
┌─────────┬───────────────────────────────────────────┬───────────────┬──────────┬─────────┐
│ (index) │ test                                      │ pctTimeChange │ beforeMs │ afterMs │
├─────────┼───────────────────────────────────────────┼───────────────┼──────────┼─────────┤
│ 0       │ 'large-dataset (load 4x datum highlight)' │ -35.1         │ 1985     │ 1289    │
│ 1       │ 'large-dataset (load 1x datum highlight)' │ -33.3         │ 1096     │ 731     │
│ 2       │ 'sparkline (load update)'                 │ -2.8          │ 1.2      │ 1.2     │
│ 3       │ 'simple-chart (load 10x legend toggle)'   │ -2            │ 144      │ 141     │
│ 4       │ 'simple-chart (load 1x legend toggle)'    │ -1.5          │ 32       │ 32      │
│ 5       │                                           │               │          │         │
│ 6       │ 'resize initial load'                     │ 0.4           │ 216      │ 217     │
│ 7       │ 'large-scale multi-series initial load'   │ 1.9           │ 1420     │ 1447    │
│ 8       │ 'multi-series (load 1x legend toggle)'    │ 1.9           │ 259      │ 264     │
│ 9       │ 'multi-series initial load'               │ 2.8           │ 455      │ 468     │
│ 10      │ 'large-dataset initial load'              │ 3.6           │ 9002     │ 9324    │
└─────────┴───────────────────────────────────────────┴───────────────┴──────────┴─────────┘
Memory
┌─────────┬───────────────────────────────────────────┬─────────────────┬──────────┬─────────┐
│ (index) │ test                                      │ pctMemoryChange │ beforeMB │ afterMB │
├─────────┼───────────────────────────────────────────┼─────────────────┼──────────┼─────────┤
│ 0       │ 'large-dataset (load 1x datum highlight)' │ -0.4            │ 982      │ 978     │
│ 1       │ 'large-dataset initial load'              │ -0.1            │ 922      │ 921     │
│ 2       │ 'large-dataset (load 1x legend toggle)'   │ -0.1            │ 1091     │ 1091    │
│ 3       │ 'large-dataset (load 4x datum highlight)' │ -0.1            │ 987      │ 986     │
│ 4       │ 'large-scale multi-series initial load'   │ -0.1            │ 274      │ 273     │
│ 5       │                                           │                 │          │         │
│ 6       │ 'sparkline initial load (pooled)'         │ 0               │ 168      │ 168     │
│ 7       │ 'sparkline (load update)'                 │ -0              │ 167      │ 167     │
│ 8       │ 'sparkline (load updateDelta)'            │ 0               │ 167      │ 167     │
│ 9       │ 'zoom-large-dataset initial load'         │ -0              │ 373      │ 373     │
│ 10      │ 'zoom-large-dataset (load 100x zoom)'     │ 0               │ 361      │ 361     │
└─────────┴───────────────────────────────────────────┴─────────────────┴──────────┴─────────┘
```
author_association: CONTRIBUTOR