Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Source/PivotViewer/PivotViewer.css
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -608,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 {
Expand Down
5 changes: 4 additions & 1 deletion Source/PivotViewer/PivotViewer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -59,6 +59,7 @@ export function PivotViewer<TItem extends object>({
detailRenderer,
getItemId,
searchFields,
title,
className,
emptyContent,
isLoading = false,
Expand Down Expand Up @@ -125,6 +126,7 @@ export function PivotViewer<TItem extends object>({
);

useWheelZoom(containerRef, zoomLevel, setZoomLevel);
useZoomScrollAnchor(containerRef, spacerRef, viewMode);

// Track container dimensions for responsive layout
const containerDimensions = useContainerDimensions(containerRef, isLoading);
Expand Down Expand Up @@ -438,6 +440,7 @@ export function PivotViewer<TItem extends object>({
activeDimensionKey={activeDimensionKey}
dimensions={dimensions}
activeFilterCount={activeFilterCount}
title={title}
onFiltersToggle={() => setFiltersOpen((prev) => !prev)}
onViewModeChange={setViewMode}
onZoomIn={handleZoomIn}
Expand Down
5 changes: 4 additions & 1 deletion Source/PivotViewer/components/Toolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export interface ToolbarProps<TItem extends object> {
activeDimensionKey: string;
dimensions: PivotDimension<TItem>[];
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;
Expand All @@ -36,6 +38,7 @@ export function Toolbar<TItem extends object>({
activeDimensionKey,
dimensions,
activeFilterCount,
title,
onFiltersToggle,
onViewModeChange,
onZoomIn,
Expand Down Expand Up @@ -102,7 +105,7 @@ export function Toolbar<TItem extends object>({
)}
</button>
)}
<h1>Pivot Viewer</h1>
{title && <span className="pv-title">{title}</span>}
<span className="pv-count">{filteredCount} events</span>
</div>
<div className="pv-toolbar-right">
Expand Down
8 changes: 8 additions & 0 deletions Source/PivotViewer/components/pivot/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {};
60 changes: 60 additions & 0 deletions Source/PivotViewer/components/pivot/ellipsize.ts
Original file line number Diff line number Diff line change
@@ -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');
}
33 changes: 31 additions & 2 deletions Source/PivotViewer/components/pivot/sprites.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [];

Expand Down Expand Up @@ -341,16 +349,37 @@ export function updateCardContent<TItem extends object>(

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
if (!sprite.titleText || sprite.titleText.destroyed) return;
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;
Expand Down
18 changes: 18 additions & 0 deletions Source/PivotViewer/for_ellipsizeBlock/when_every_line_fits.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
21 changes: 21 additions & 0 deletions Source/PivotViewer/for_ellipsizeBlock/when_one_line_is_too_wide.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
13 changes: 13 additions & 0 deletions Source/PivotViewer/for_ellipsizeLine/given/a_measurer.ts
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 17 additions & 0 deletions Source/PivotViewer/for_ellipsizeLine/when_the_budget_is_zero.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
17 changes: 17 additions & 0 deletions Source/PivotViewer/for_ellipsizeLine/when_the_text_fits.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
17 changes: 17 additions & 0 deletions Source/PivotViewer/for_ellipsizeLine/when_the_text_is_empty.ts
Original file line number Diff line number Diff line change
@@ -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('');
});
});
Loading
Loading