Skip to content
Open
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
1 change: 1 addition & 0 deletions Source/Filter/FilterPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@ export function FilterPanel({
) : isNumeric && filter.numericRange ? (
<RangeHistogramFilter
values={filter.numericRange.values}
histogram={filter.numericRange.histogram}
min={filter.numericRange.min}
max={filter.numericRange.max}
buckets={filter.buckets ?? 20}
Expand Down
59 changes: 20 additions & 39 deletions Source/Filter/RangeHistogramFilter.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,21 @@
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { FilterValue } from './types';
import type { FilterValue, HistogramBucket } from './types';
import { buildHistogram } from './utils';
import type { RenderedHistogramBucket } from './utils';

export interface RangeHistogramFilterProps {
values: FilterValue[];
/**
* The raw values to count into buckets in the browser. Ignored when `histogram` is supplied.
*/
values?: FilterValue[];
/**
* Pre-counted buckets to render instead of counting `values`. Use this when the counts come from
* somewhere that can see more data than the browser holds - a server aggregating over a large
* table, for instance - so the picker reflects everything rather than the loaded page.
*/
histogram?: HistogramBucket[];
min: number;
max: number;
buckets?: number;
Expand All @@ -18,20 +29,14 @@ export interface RangeHistogramFilterProps {
formatValue?: (value: number) => string;
}

interface HistogramBucket {
start: number;
end: number;
count: number;
maxCount: number;
}

const defaultFormatValue = (value: number) => {
if (Number.isInteger(value)) return value.toString();
return value.toFixed(1);
};

export function RangeHistogramFilter({
values,
histogram: providedHistogram,
min,
max,
buckets = 20,
Expand All @@ -44,7 +49,7 @@ export function RangeHistogramFilter({
const [dragStart, setDragStart] = useState<{ x: number; range: [number, number] } | null>(null);

const numericValues = useMemo(() => {
return values
return (values ?? [])
.map((v) => {
if (typeof v === 'number') return v;
if (v instanceof Date) return v.getTime();
Expand All @@ -54,34 +59,10 @@ export function RangeHistogramFilter({
.filter((v): v is number => v !== null);
}, [values]);

const histogram = useMemo((): HistogramBucket[] => {
const range = max - min;
if (range <= 0 || numericValues.length === 0) {
return [];
}

const bucketSize = range / buckets;
const bucketCounts: number[] = Array(buckets).fill(0);

numericValues.forEach((value) => {
const bucketIndex = Math.min(
Math.floor((value - min) / bucketSize),
buckets - 1
);
if (bucketIndex >= 0 && bucketIndex < buckets) {
bucketCounts[bucketIndex]++;
}
});

const maxCount = Math.max(...bucketCounts, 1);

return bucketCounts.map((count, i) => ({
start: min + i * bucketSize,
end: min + (i + 1) * bucketSize,
count,
maxCount,
}));
}, [numericValues, min, max, buckets]);
const histogram = useMemo(
() => buildHistogram(numericValues, min, max, buckets, providedHistogram),
[providedHistogram, numericValues, min, max, buckets]
);

const currentRange = selectedRange ?? [min, max];

Expand Down Expand Up @@ -155,7 +136,7 @@ export function RangeHistogramFilter({
};
}, [isDragging, dragStart, min, max, onChange]);

const handleBarClick = (bucket: HistogramBucket) => {
const handleBarClick = (bucket: RenderedHistogramBucket) => {
onChange([bucket.start, bucket.end]);
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { buildHistogram } from '../utils';
import type { HistogramBucket } from '../types';

/**
* Pre-counted buckets come from a source that can see more data than the browser holds - a server
* aggregating over a whole table, for instance. Recounting them against the loaded values would
* understate the totals and make the picker misrepresent what a range actually selects.
*/
describe('when buckets were counted elsewhere', () => {
const provided: HistogramBucket[] = [
{ start: 0, end: 5, count: 3000 },
{ start: 5, end: 10, count: 1000 },
];
const result = buildHistogram([1, 2, 3], 0, 10, 20, provided);

it('should render exactly the buckets it was given', () => {
result.should.have.lengthOf(2);
});

it('should keep the counts it was given rather than recounting the values', () => {
result[0].count.should.equal(3000);
result[1].count.should.equal(1000);
});

it('should ignore the requested bucket count', () => {
result.should.not.have.lengthOf(20);
});

it('should scale the bars against the tallest provided bucket', () => {
result.every((bucket) => bucket.maxCount === 3000).should.be.true;
});

it('should produce no buckets when given an empty set', () => {
buildHistogram([1, 2, 3], 0, 10, 20, []).should.have.lengthOf(0);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import { buildHistogram } from '../utils';

describe('when counting values in the browser', () => {
const result = buildHistogram([0, 1, 5, 6, 7, 9], 0, 10, 2);

it('should produce the requested number of buckets', () => {
result.should.have.lengthOf(2);
});

it('should span the range evenly', () => {
result[0].start.should.equal(0);
result[0].end.should.equal(5);
result[1].start.should.equal(5);
result[1].end.should.equal(10);
});

it('should count each value into its bucket', () => {
result[0].count.should.equal(2);
result[1].count.should.equal(4);
});

it('should report the tallest count so bars can size themselves', () => {
result.every((bucket) => bucket.maxCount === 4).should.be.true;
});

it('should place a value on the upper bound in the last bucket', () => {
buildHistogram([10], 0, 10, 2)[1].count.should.equal(1);
});
});

describe('when there is nothing to count', () => {
it('should produce no buckets for an empty set of values', () => {
buildHistogram([], 0, 10, 5).should.have.lengthOf(0);
});

it('should produce no buckets when the range has no width', () => {
buildHistogram([5, 5], 5, 5, 5).should.have.lengthOf(0);
});
});
4 changes: 3 additions & 1 deletion Source/Filter/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ export { RangeHistogramFilter } from './RangeHistogramFilter';
export type { RangeHistogramFilterProps } from './RangeHistogramFilter';
export { useFilterState } from './useFilterState';
export type { UseFilterStateResult } from './useFilterState';
export { buildFilterValues, buildRangeValues } from './utils';
export { buildFilterValues, buildRangeValues, buildHistogram } from './utils';
export type { RenderedHistogramBucket } from './utils';
export type {
FilterValue,
FilterOption,
FilterEditorProps,
FilterDefinition,
HistogramBucket,
FilterValues,
RangeValues,
CustomFilterValues,
Expand Down
20 changes: 18 additions & 2 deletions Source/Filter/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export interface FilterEditorProps {
onChange: (value: unknown) => void;
}

/** One pre-counted bar of a range filter's histogram. */
export interface HistogramBucket {
/** Inclusive start of the bucket, on the same scale as the filter's range. */
start: number;
/** Exclusive end of the bucket, on the same scale as the filter's range. */
end: number;
/** Number of items that fall inside the bucket. */
count: number;
}

export interface FilterDefinition {
key: string;
label: string;
Expand All @@ -27,8 +37,14 @@ export interface FilterDefinition {
multi?: boolean;
/** Pre-computed options for string/date filters. */
options?: FilterOption[];
/** Numeric range data for 'number' type filters. */
numericRange?: { min: number; max: number; values: FilterValue[] };
/**
* Numeric range data for 'number' and 'date' type filters.
*
* Supply `values` to have the histogram counted in the browser, or `histogram` when the counts
* were produced elsewhere - by a server aggregating over more rows than are worth transferring,
* for instance. `histogram` wins when both are present.
*/
numericRange?: { min: number; max: number; values?: FilterValue[]; histogram?: HistogramBucket[] };
/** Number of histogram buckets. Defaults to 20. */
buckets?: number;
/** Show an inline search box that filters the displayed options for this group. */
Expand Down
48 changes: 47 additions & 1 deletion Source/Filter/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
// Copyright (c) Cratis. All rights reserved.
// Licensed under the MIT license. See LICENSE file in the project root for full license information.

import type { FilterDefinition, FilterValues, RangeValues } from './types';
import type { FilterDefinition, FilterValues, HistogramBucket, RangeValues } from './types';

/** A histogram bucket with the tallest count in its set, so a bar can size itself. */
export interface RenderedHistogramBucket extends HistogramBucket {
maxCount: number;
}

/** Initialise the string/option selection map for all string/date filters. */
export function buildFilterValues(filters: FilterDefinition[] | undefined): FilterValues {
Expand All @@ -24,3 +29,44 @@ export function buildRangeValues(filters: FilterDefinition[] | undefined): Range
});
return state;
}

/**
* Build the bars a range filter renders.
*
* Pre-counted buckets are rendered as given - they come from a source that saw more data than the
* browser holds, so recounting them against the loaded values would understate the real totals.
* Otherwise the raw values are counted into `bucketCount` evenly sized buckets across the range.
*/
export function buildHistogram(
values: number[],
min: number,
max: number,
bucketCount: number,
provided?: HistogramBucket[]
): RenderedHistogramBucket[] {
if (provided !== undefined) {
if (provided.length === 0) return [];
const providedMax = Math.max(...provided.map((bucket) => bucket.count), 1);
return provided.map((bucket) => ({ ...bucket, maxCount: providedMax }));
}

const range = max - min;
if (range <= 0 || values.length === 0) return [];

const bucketSize = range / bucketCount;
const counts: number[] = Array(bucketCount).fill(0);

values.forEach((value) => {
const index = Math.min(Math.floor((value - min) / bucketSize), bucketCount - 1);
if (index >= 0 && index < bucketCount) counts[index]++;
});

const maxCount = Math.max(...counts, 1);

return counts.map((count, index) => ({
start: min + index * bucketSize,
end: min + (index + 1) * bucketSize,
count,
maxCount,
}));
}
27 changes: 25 additions & 2 deletions Source/Toolbar/Toolbar.css
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,22 @@
pointer-events: auto;
}

/*
* Reveal complete. The clip-path did its job — the wipe cannot run without it,
* because `clip-path: none` is a discrete value that snaps instead of
* interpolating. But clip-path also clips every descendant, so leaving a zero
* inset in place slices off the hover tooltips of the outermost buttons, which
* paint outside the panel by design.
*
* ToolbarFanOutItem therefore adds this class once the reveal transition ends,
* and takes it off again (restoring an interpolable inset) before the close
* transition starts — so both wipes keep their exact timing and a settled panel
* clips nothing.
*/
.toolbar-fanout-panel--settled {
clip-path: none;
}

/* ── Toolbar folder ──────────────────────────────────────────────────────── */

.toolbar-folder-item {
Expand Down Expand Up @@ -288,18 +304,25 @@
/* ── Toolbar slot transition (ToolbarGroup slot content) ─────────────────── */

/*
* Size-morphing container for slot content inside a ToolbarGroup.
* Size-morphing container for slot content inside a ToolbarGroup or ToolbarLayout.
* Mirrors the .toolbar-section transition parameters for visual consistency —
* the container resizes smoothly while content cross-fades.
* Overflow stays visible in the settled state so absolutely-positioned children
* (fan-out and folder panels, and the hover tooltips inside them) can escape the
* section's bounds; it is only clipped while outgoing content is fading out, so
* that content does not bleed past the container mid-transition.
*/
.toolbar-slot-section {
position: relative;
overflow: hidden;
transition:
width 0.35s cubic-bezier(0.4, 0, 0.2, 1),
height 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}

.toolbar-slot-section--transitioning {
overflow: hidden;
}

@keyframes toolbar-slot-fade-in {
from { opacity: 0; }
to { opacity: 1; }
Expand Down
Loading
Loading