From 7bad220255c746430cf1ec5db0f18212f3d4d37c Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 31 Aug 2026 19:38:43 +0200 Subject: [PATCH 1/4] Keep pivot card text inside the card A card's title and its value column are drawn to the canvas with word wrapping off and no width budget, so anything longer than the card kept painting straight past its edge and over the card beside it. A long title was unreadable and made its neighbour unreadable too. Both are now measured against the room they actually have and cut with an ellipsis when they do not fit. Measuring is the only way to know where to cut, since a glyph's width depends on the font, so the search is binary rather than a character-at-a-time walk and the result is cached against the text and the width it was fitted to - a card re-measures only when what it shows, or the room it has, changed. Co-Authored-By: Claude Opus 5 (1M context) --- .../PivotViewer/components/pivot/constants.ts | 8 +++ .../PivotViewer/components/pivot/ellipsize.ts | 60 +++++++++++++++++++ .../PivotViewer/components/pivot/sprites.ts | 33 +++++++++- .../when_every_line_fits.ts | 18 ++++++ .../when_one_line_is_too_wide.ts | 21 +++++++ .../for_ellipsizeLine/given/a_measurer.ts | 13 ++++ .../when_only_the_ellipsis_fits.ts | 17 ++++++ .../when_the_budget_is_zero.ts | 17 ++++++ .../for_ellipsizeLine/when_the_text_fits.ts | 17 ++++++ .../when_the_text_is_empty.ts | 17 ++++++ .../when_the_text_is_wider_than_the_budget.ts | 25 ++++++++ 11 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 Source/PivotViewer/components/pivot/ellipsize.ts create mode 100644 Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts create mode 100644 Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts create mode 100644 Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts diff --git a/Source/PivotViewer/components/pivot/constants.ts b/Source/PivotViewer/components/pivot/constants.ts index bfadecdc..66cf15ea 100644 --- a/Source/PivotViewer/components/pivot/constants.ts +++ b/Source/PivotViewer/components/pivot/constants.ts @@ -42,6 +42,14 @@ export interface CardSprite { lastTitle?: string; lastLabels?: string; lastValues?: string; + // The text and width the fitted title and values were last measured against, so a card + // only re-measures when what it shows, or the room it has, actually changed. + lastFittedSource?: string; + lastFittedWidth?: number; + /** The title as it fits the card, ellipsized when the full text does not. */ + fittedTitle?: string; + /** The value column as it fits the card, ellipsized line by line. */ + fittedValues?: string; } export default {}; diff --git a/Source/PivotViewer/components/pivot/ellipsize.ts b/Source/PivotViewer/components/pivot/ellipsize.ts new file mode 100644 index 00000000..5b6e7ff3 --- /dev/null +++ b/Source/PivotViewer/components/pivot/ellipsize.ts @@ -0,0 +1,60 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +/** Measures how wide `candidate` renders, in the same units as the budget it is compared against. */ +export type MeasureText = (candidate: string) => number; + +/** The character appended to text that had to be cut short. */ +export const ellipsis = '…'; + +/** + * Shortens one line to the longest prefix that still fits `maxWidth`, marking the cut with an + * ellipsis. Text that already fits is returned unchanged. + * + * Card text is drawn to a canvas with word wrapping off, so anything wider than the card simply + * kept painting past its edge and over the card beside it. Measuring is the only way to know + * where to cut — a glyph's width depends on the font, so counting characters cannot tell you. + * + * The search is binary rather than a character-at-a-time walk: measuring is the expensive part, + * and this runs for every visible card. + * + * @param text The line to fit. + * @param maxWidth The width budget. A budget of zero or less leaves the text untouched, since + * there is no room to render anything meaningful and a bare ellipsis says less than a clipped word. + * @param measure Measures a candidate string. + * @returns The text, shortened and suffixed with an ellipsis only if it did not fit. + */ +export function ellipsizeLine(text: string, maxWidth: number, measure: MeasureText): string { + if (maxWidth <= 0 || text.length === 0) return text; + if (measure(text) <= maxWidth) return text; + + let fits = 0; + let tooWide = text.length; + while (fits < tooWide) { + const middle = Math.ceil((fits + tooWide) / 2); + if (measure(text.slice(0, middle) + ellipsis) <= maxWidth) { + fits = middle; + } else { + tooWide = middle - 1; + } + } + + return text.slice(0, fits) + ellipsis; +} + +/** + * Applies {@link ellipsizeLine} to every line of a block of text, so a multi-line value column + * is fitted line by line rather than as one string. + * + * @param text The block to fit, newline separated. + * @param maxWidth The width budget for a single line. + * @param measure Measures a candidate string. + * @returns The block, with each line shortened only if it did not fit. + */ +export function ellipsizeBlock(text: string, maxWidth: number, measure: MeasureText): string { + if (!text.includes('\n')) return ellipsizeLine(text, maxWidth, measure); + return text + .split('\n') + .map(line => ellipsizeLine(line, maxWidth, measure)) + .join('\n'); +} diff --git a/Source/PivotViewer/components/pivot/sprites.ts b/Source/PivotViewer/components/pivot/sprites.ts index 7ae6a65a..97071302 100644 --- a/Source/PivotViewer/components/pivot/sprites.ts +++ b/Source/PivotViewer/components/pivot/sprites.ts @@ -4,6 +4,14 @@ import * as PIXI from 'pixi.js'; import { CARD_GAP, CARD_PADDING, CARD_RADIUS } from './constants'; import type { CardSprite, CardColors } from './constants'; +import { ellipsizeBlock, ellipsizeLine } from './ellipsize'; + +/** How far the value column is inset from the card's text origin, matching where it is drawn. */ +const VALUES_INSET = 65; + +/** Measures a candidate string with the style the sprite draws it in. */ +const measureWith = (candidate: string, style: PIXI.TextStyle) => + PIXI.CanvasTextMetrics.measureText(candidate, style).width; const spritePool: CardSprite[] = []; @@ -341,9 +349,9 @@ export function updateCardContent( const colors = cardColors; const cardData = cardRenderer(item); - const titleDisplay = cardData.title; + const rawTitle = cardData.title; const labelsText = (cardData.labels || []).join('\n'); - const valuesText = (cardData.values || []).join('\n'); + const rawValues = (cardData.values || []).join('\n'); const colorsChanged = sprite.lastCardColors !== colors; // Ensure text objects exist before using them @@ -351,6 +359,27 @@ export function updateCardContent( if (!sprite.labelsText || sprite.labelsText.destroyed) return; if (!sprite.valuesText || sprite.valuesText.destroyed) return; + // The title and the value column are drawn with word wrapping off, so without a budget they + // paint straight past the card and over its neighbour. Both start at a known inset, so the + // room each has is the card's inner width less where it begins. + const innerWidth = cardWidth - CARD_GAP - CARD_PADDING * 2; + const fitSource = `${rawTitle}\u0000${rawValues}`; + if (sprite.lastFittedSource !== fitSource || sprite.lastFittedWidth !== innerWidth) { + sprite.lastFittedSource = fitSource; + sprite.lastFittedWidth = innerWidth; + sprite.fittedTitle = ellipsizeLine( + rawTitle, + innerWidth, + candidate => measureWith(candidate, sprite.titleText.style)); + sprite.fittedValues = ellipsizeBlock( + rawValues, + innerWidth - VALUES_INSET, + candidate => measureWith(candidate, sprite.valuesText.style)); + } + + const titleDisplay = sprite.fittedTitle ?? rawTitle; + const valuesText = sprite.fittedValues ?? rawValues; + if (sprite.lastTitle !== titleDisplay) { sprite.titleText.text = titleDisplay; sprite.lastTitle = titleDisplay; diff --git a/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts b/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts new file mode 100644 index 00000000..1ef0a3d9 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts @@ -0,0 +1,18 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeBlock } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from '../for_ellipsizeLine/given/a_measurer'; + +describe('when every line fits', () => { + const block = '66%\n170'; + let result: string; + + beforeEach(() => { + result = ellipsizeBlock(block, 6 * characterWidth, fixedWidth); + }); + + it('should return the block unchanged', () => { + result.should.equal(block); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts b/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts new file mode 100644 index 00000000..58b32e9f --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts @@ -0,0 +1,21 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeBlock } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from '../for_ellipsizeLine/given/a_measurer'; + +describe('when one line is too wide', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeBlock('66%\nAnswerPayrollQuery', 6 * characterWidth, fixedWidth); + }); + + it('should leave the line that fits alone', () => { + result.split('\n')[0].should.equal('66%'); + }); + + it('should shorten only the line that does not fit', () => { + result.split('\n')[1].should.equal('Answe' + ellipsis); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts b/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts new file mode 100644 index 00000000..336c0d42 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts @@ -0,0 +1,13 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import type { MeasureText } from '../../components/pivot/ellipsize'; + +/** Width of one character in the fake font every spec measures with. */ +export const characterWidth = 10; + +/** + * Measures as a fixed-width font would, so a budget can be expressed as a character count and the + * expected cut is obvious from the spec. + */ +export const fixedWidth: MeasureText = candidate => candidate.length * characterWidth; diff --git a/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts b/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts new file mode 100644 index 00000000..f7f46844 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_only_the_ellipsis_fits.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when only the ellipsis fits', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('AnswerPayrollQuery', characterWidth, fixedWidth); + }); + + it('should return just the ellipsis', () => { + result.should.equal(ellipsis); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts new file mode 100644 index 00000000..0c18e5d6 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { fixedWidth } from './given/a_measurer'; + +describe('when the budget is zero', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('Ledger', 0, fixedWidth); + }); + + it('should return the text unchanged', () => { + result.should.equal('Ledger'); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts new file mode 100644 index 00000000..1f5080be --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text fits', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('Ledger', 6 * characterWidth, fixedWidth); + }); + + it('should return the text unchanged', () => { + result.should.equal('Ledger'); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts new file mode 100644 index 00000000..a980359d --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts @@ -0,0 +1,17 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text is empty', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('', 4 * characterWidth, fixedWidth); + }); + + it('should return an empty string', () => { + result.should.equal(''); + }); +}); diff --git a/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts new file mode 100644 index 00000000..596654b0 --- /dev/null +++ b/Source/PivotViewer/for_ellipsizeLine/when_the_text_is_wider_than_the_budget.ts @@ -0,0 +1,25 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { ellipsis, ellipsizeLine } from '../components/pivot/ellipsize'; +import { characterWidth, fixedWidth } from './given/a_measurer'; + +describe('when the text is wider than the budget', () => { + let result: string; + + beforeEach(() => { + result = ellipsizeLine('AnswerPayrollQuery', 8 * characterWidth, fixedWidth); + }); + + it('should end with an ellipsis', () => { + result.endsWith(ellipsis).should.be.true; + }); + + it('should fit within the budget', () => { + fixedWidth(result).should.be.at.most(8 * characterWidth); + }); + + it('should keep as much of the text as fits', () => { + result.should.equal('AnswerP' + ellipsis); + }); +}); From a7e0962e58531e97207843f57662b45993c20c23 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 31 Aug 2026 19:39:01 +0200 Subject: [PATCH 2/4] Make the pivot viewer's toolbar title optional The toolbar always rendered a hardcoded "Pivot Viewer" heading, which is wrong wherever the host already names the view - and it rendered it as a bare h1, so it inherited whatever heading size the consuming application had set rather than the size the component asked for. The title is a prop now, rendered as a span with its own size. Omit it and the heading collapses entirely, leaving the item count directly beside the filter button. The count is sized to that button so the two read as one group either way. Co-Authored-By: Claude Opus 5 (1M context) --- Source/PivotViewer/PivotViewer.css | 21 ++++++++++++++++----- Source/PivotViewer/PivotViewer.tsx | 2 ++ Source/PivotViewer/components/Toolbar.tsx | 5 ++++- Source/PivotViewer/types.ts | 7 +++++++ 4 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Source/PivotViewer/PivotViewer.css b/Source/PivotViewer/PivotViewer.css index c6290b2d..e7390b88 100644 --- a/Source/PivotViewer/PivotViewer.css +++ b/Source/PivotViewer/PivotViewer.css @@ -1,4 +1,7 @@ .pivot-viewer { + /* The height every toolbar control on the left of the bar shares, so the filter button and + the item count line up as one group. */ + --pv-toolbar-control-size: 2.25rem; position: relative; /* CRITICAL: Don't set explicit height - let parent control it via container constraints */ display: flex; @@ -311,22 +314,30 @@ gap: 1rem; } -.pv-toolbar h1 { +/* A span rather than a heading: the host owns its page's heading levels, and a bare `h1` + inherited whatever heading size the consuming application had set. */ +.pv-title { margin: 0; font-size: 1.1rem; + font-weight: 600; + line-height: 1.2; letter-spacing: -0.01em; white-space: nowrap; } +/* Sized to the filter button beside it, so the two read as one group whether or not a title + separates them. */ .pv-count { - margin-left: 1rem; display: inline-flex; align-items: center; + justify-content: center; gap: 0.35rem; - padding: 0.3rem 0.65rem; + height: var(--pv-toolbar-control-size); + padding: 0 0.65rem; border-radius: 999px; background: var(--cratis-highlight-bg); font-size: 0.8rem; + line-height: 1; color: var(--cratis-text-color); } @@ -358,8 +369,8 @@ appearance: none; border: none; border-radius: 0.5rem; - width: 2.25rem; - height: 2.25rem; + width: var(--pv-toolbar-control-size); + height: var(--pv-toolbar-control-size); display: flex; align-items: center; justify-content: center; diff --git a/Source/PivotViewer/PivotViewer.tsx b/Source/PivotViewer/PivotViewer.tsx index 3341a1d6..46f06764 100644 --- a/Source/PivotViewer/PivotViewer.tsx +++ b/Source/PivotViewer/PivotViewer.tsx @@ -59,6 +59,7 @@ export function PivotViewer({ detailRenderer, getItemId, searchFields, + title, className, emptyContent, isLoading = false, @@ -438,6 +439,7 @@ export function PivotViewer({ activeDimensionKey={activeDimensionKey} dimensions={dimensions} activeFilterCount={activeFilterCount} + title={title} onFiltersToggle={() => setFiltersOpen((prev) => !prev)} onViewModeChange={setViewMode} onZoomIn={handleZoomIn} diff --git a/Source/PivotViewer/components/Toolbar.tsx b/Source/PivotViewer/components/Toolbar.tsx index 3a5f7f6b..f2674c36 100644 --- a/Source/PivotViewer/components/Toolbar.tsx +++ b/Source/PivotViewer/components/Toolbar.tsx @@ -16,6 +16,8 @@ export interface ToolbarProps { activeDimensionKey: string; dimensions: PivotDimension[]; activeFilterCount: number; + /** Heading shown at the left of the toolbar. Omitted entirely when not given. */ + title?: string; onFiltersToggle: () => void; onViewModeChange: (mode: ViewMode) => void; onZoomIn: () => void; @@ -36,6 +38,7 @@ export function Toolbar({ activeDimensionKey, dimensions, activeFilterCount, + title, onFiltersToggle, onViewModeChange, onZoomIn, @@ -102,7 +105,7 @@ export function Toolbar({ )} )} -

Pivot Viewer

+ {title && {title}} {filteredCount} events
diff --git a/Source/PivotViewer/types.ts b/Source/PivotViewer/types.ts index 10035022..89e7d29b 100644 --- a/Source/PivotViewer/types.ts +++ b/Source/PivotViewer/types.ts @@ -162,6 +162,13 @@ export interface PivotViewerProps { getItemId?: (item: TItem, index: number) => string | number; /** Property accessors defining which fields are searchable. */ searchFields?: PropertyAccessor[]; + /** + * Optional heading shown at the left of the toolbar. + * + * Omit it when the host already names the view - the heading collapses entirely and the + * item count sits directly beside the filter button. + */ + title?: string; /** Optional CSS class name to apply to the root element. */ className?: string; /** Content to display when no items match the current filters/search. */ From 572211b6c3942406d2ad835066c88a68ee4f3a2b Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 31 Aug 2026 19:39:09 +0200 Subject: [PATCH 3/4] Restore pinch to zoom in the pivot viewer The zoom gesture listeners were bound in an effect that read the viewport out of a ref. That viewport mounts a render or more later - it sits behind the loading gate, inside a child component - and a ref object keeps the same identity forever, so the effect bound to nothing and had no reason to run again. Pinching did nothing at all until something else changed the zoom and happened to re-run the effect, which made zooming once with the toolbar the only way to get the gesture working. The hook now tracks the node itself and binds the moment it appears. Touch devices also need the viewport to give up the browser's own pinch gesture, or the two-finger moves never reach the handler; panning stays with the browser. Co-Authored-By: Claude Opus 5 (1M context) --- Source/PivotViewer/PivotViewer.css | 4 ++++ Source/PivotViewer/hooks/useWheelZoom.ts | 19 ++++++++++++++++--- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/Source/PivotViewer/PivotViewer.css b/Source/PivotViewer/PivotViewer.css index e7390b88..c265c0ad 100644 --- a/Source/PivotViewer/PivotViewer.css +++ b/Source/PivotViewer/PivotViewer.css @@ -619,6 +619,10 @@ var(--cratis-primary-500), var(--cratis-surface-ground) ); + /* Panning stays with the browser, pinching comes to us: without this a touch device + resolves a two-finger gesture as its own page zoom and never delivers the moves the + viewer needs to scale the cards. */ + touch-action: pan-x pan-y; } .pv-groups-grouped { diff --git a/Source/PivotViewer/hooks/useWheelZoom.ts b/Source/PivotViewer/hooks/useWheelZoom.ts index f1615e47..6c9073d7 100644 --- a/Source/PivotViewer/hooks/useWheelZoom.ts +++ b/Source/PivotViewer/hooks/useWheelZoom.ts @@ -1,7 +1,7 @@ // Copyright (c) Cratis. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. -import { useCallback, useEffect } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { ZOOM_MIN, ZOOM_MAX } from '../utils/utils'; export function useWheelZoom( @@ -53,8 +53,19 @@ export function useWheelZoom( } }, [zoomLevel, setZoomLevel, containerRef]); + // The viewport this binds to mounts a render or more after the hook first runs - it sits + // behind the loading gate, inside a child component - and a ref object keeps the same identity + // forever, so an effect keyed on the ref alone bound to nothing and never retried. Pinching did + // nothing until something else happened to change `zoomLevel` and re-run the effect, which + // meant zooming with the toolbar first was the only way to make the gesture start working. + // Comparing the node after every render picks it up the moment it appears, and lets go again + // if it is unmounted. + const [container, setContainer] = useState(null); + useEffect(() => { + if (containerRef.current !== container) setContainer(containerRef.current); + }); + useEffect(() => { - const container = containerRef.current; if (!container) return; container.addEventListener('wheel', handleWheel, { passive: false }); @@ -113,12 +124,14 @@ export function useWheelZoom( container.addEventListener('touchstart', handleTouchStart, { passive: true }); container.addEventListener('touchmove', handleTouchMove, { passive: false }); container.addEventListener('touchend', handleTouchEnd, { passive: true }); + container.addEventListener('touchcancel', handleTouchEnd, { passive: true }); return () => { container.removeEventListener('wheel', handleWheel); container.removeEventListener('touchstart', handleTouchStart); container.removeEventListener('touchmove', handleTouchMove); container.removeEventListener('touchend', handleTouchEnd); + container.removeEventListener('touchcancel', handleTouchEnd); }; - }, [handleWheel, zoomLevel, setZoomLevel, containerRef]); + }, [container, handleWheel, zoomLevel, setZoomLevel]); } From 1d8ef68d62d6d9df9c4c97bd999fa5e8ae5ebbc9 Mon Sep 17 00:00:00 2001 From: Einar Date: Mon, 31 Aug 2026 20:14:09 +0200 Subject: [PATCH 4/4] Keep the pivot viewer's content in view across a zoom change Zooming resized the content underneath a scroll offset that stayed where it was, and the toolbar's buttons, slider and reset had no anchoring of their own. Zooming all the way out is the worst case: the content then fits the viewport, the scroll range collapses to nothing, and zooming back in left the offset at zero. In grouped mode that reads as the viewer losing its cards. Groups are drawn from the bottom up, so the top of the scrollable area is empty for every group shorter than the tallest one - an offset of zero shows a blank canvas with no clue that the cards are below it, and the only way back is to scroll all the way down. The viewport now holds its place when the scrollable area is resized: grouped mode keeps its distance from the bottom, where its content is anchored, and every other mode keeps the centre. It watches the spacer that defines the scrollable area rather than the zoom level, since the spacer is what actually resizes. Co-Authored-By: Claude Opus 5 (1M context) --- Source/PivotViewer/PivotViewer.tsx | 3 +- Source/PivotViewer/hooks/index.ts | 1 + .../PivotViewer/hooks/useZoomScrollAnchor.ts | 93 +++++++++++++++++++ 3 files changed, 96 insertions(+), 1 deletion(-) create mode 100644 Source/PivotViewer/hooks/useZoomScrollAnchor.ts diff --git a/Source/PivotViewer/PivotViewer.tsx b/Source/PivotViewer/PivotViewer.tsx index 46f06764..c6cd8caf 100644 --- a/Source/PivotViewer/PivotViewer.tsx +++ b/Source/PivotViewer/PivotViewer.tsx @@ -18,7 +18,7 @@ import { import { PivotViewerMain } from './components/PivotViewerMain'; import { FilterPanelContainer } from './components/FilterPanelContainer'; import { ToolbarContainer } from './components/ToolbarContainer'; -import { usePanning, useWheelZoom, useFilterOptions } from './hooks'; +import { usePanning, useWheelZoom, useZoomScrollAnchor, useFilterOptions } from './hooks'; import { useContainerDimensions } from './hooks/useContainerDimensions'; import type { ViewMode } from './components/Toolbar'; import { useFieldExtractors } from './hooks/useFieldExtractors'; @@ -126,6 +126,7 @@ export function PivotViewer({ ); useWheelZoom(containerRef, zoomLevel, setZoomLevel); + useZoomScrollAnchor(containerRef, spacerRef, viewMode); // Track container dimensions for responsive layout const containerDimensions = useContainerDimensions(containerRef, isLoading); diff --git a/Source/PivotViewer/hooks/index.ts b/Source/PivotViewer/hooks/index.ts index 4041d088..d691a589 100644 --- a/Source/PivotViewer/hooks/index.ts +++ b/Source/PivotViewer/hooks/index.ts @@ -8,6 +8,7 @@ export { useFilterOptions } from './useFilterOptions'; export { useZoomState } from './useZoomState'; export { usePanning } from './usePanning'; export { useWheelZoom } from './useWheelZoom'; +export { useZoomScrollAnchor } from './useZoomScrollAnchor'; export { useFilterPanelDrag } from './useFilterPanelDrag'; export { useSelectedItem } from './useSelectedItem'; export * from './usePivotEngine'; diff --git a/Source/PivotViewer/hooks/useZoomScrollAnchor.ts b/Source/PivotViewer/hooks/useZoomScrollAnchor.ts new file mode 100644 index 00000000..43794d93 --- /dev/null +++ b/Source/PivotViewer/hooks/useZoomScrollAnchor.ts @@ -0,0 +1,93 @@ +// Copyright (c) Cratis. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +import { useEffect, useRef, useState } from 'react'; + +/** + * Keeps the content the viewport is looking at in view when the scrollable area is resized by a + * zoom change. + * + * Zooming resizes the content underneath a scroll offset that stays where it was, so without this + * the view lands somewhere unrelated to what the user was looking at. Zooming all the way out is + * the worst case: the content then fits the viewport, the scroll range collapses to nothing, and + * zooming back in leaves the offset at zero. + * + * In grouped mode that is not merely disorienting - it looks broken. Groups are drawn from the + * bottom up, so the top of the scrollable area is empty for every group shorter than the tallest + * one, and an offset of zero shows a blank canvas with no clue that the cards are below. Grouped + * mode therefore holds its distance from the *bottom*, which is where its content is anchored; + * every other mode holds the centre. + * + * This watches the spacer that defines the scrollable area rather than the zoom level, because the + * spacer is what actually resizes - reacting to the zoom directly would read the extent before it + * had been laid out. The wheel handler keeps its own cursor-anchored adjustment for the gesture in + * progress; this covers the zoom paths that have no anchor of their own, such as the toolbar's + * buttons, slider and reset. + * + * @param containerRef The scrolling viewport. + * @param spacerRef The element whose size defines the scrollable area. + * @param viewMode The active view mode. + */ +export function useZoomScrollAnchor( + containerRef: React.RefObject, + spacerRef: React.RefObject, + viewMode: string, +) { + const [spacer, setSpacer] = useState(null); + const previousExtent = useRef<{ width: number; height: number } | null>(null); + const viewModeRef = useRef(viewMode); + viewModeRef.current = viewMode; + + // The spacer mounts inside a child, a render or more after this hook first runs, and a ref + // object never changes identity - so the element is picked up by comparing it after a render + // rather than by depending on the ref. + useEffect(() => { + if (spacerRef.current !== spacer) setSpacer(spacerRef.current); + }); + + useEffect(() => { + const container = containerRef.current; + if (!container || !spacer) return; + + const anchor = (previous: { width: number; height: number }) => { + const height = spacer.offsetHeight; + const width = spacer.offsetWidth; + if (previous.height <= 0 || previous.width <= 0) return; + if (previous.height === height && previous.width === width) return; + + const maxScrollTop = Math.max(0, height - container.clientHeight); + const maxScrollLeft = Math.max(0, width - container.clientWidth); + + const horizontalRatio = width / previous.width; + const anchoredLeft = + (container.scrollLeft + container.clientWidth / 2) * horizontalRatio + - container.clientWidth / 2; + container.scrollLeft = Math.min(maxScrollLeft, Math.max(0, anchoredLeft)); + + const verticalRatio = height / previous.height; + if (viewModeRef.current === 'grouped') { + const previousMaxScrollTop = Math.max(0, previous.height - container.clientHeight); + const gapFromBottom = Math.max(0, previousMaxScrollTop - container.scrollTop); + container.scrollTop = Math.min( + maxScrollTop, + Math.max(0, maxScrollTop - gapFromBottom * verticalRatio)); + } else { + const anchoredTop = + (container.scrollTop + container.clientHeight / 2) * verticalRatio + - container.clientHeight / 2; + container.scrollTop = Math.min(maxScrollTop, Math.max(0, anchoredTop)); + } + }; + + previousExtent.current ??= { width: spacer.offsetWidth, height: spacer.offsetHeight }; + + const observer = new ResizeObserver(() => { + const previous = previousExtent.current; + previousExtent.current = { width: spacer.offsetWidth, height: spacer.offsetHeight }; + if (previous) anchor(previous); + }); + observer.observe(spacer); + + return () => observer.disconnect(); + }, [containerRef, spacer]); +}