Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

ref: RangeMap class #83259

Merged
merged 13 commits into from
Jan 22, 2025
77 changes: 77 additions & 0 deletions static/app/utils/number/rangeMap.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {RangeMap} from './rangeMap';

describe('RangeMap', () => {
test('matches an integer range', () => {
const ladder = new RangeMap([
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 20, max: 50, value: 'third'},
Comment on lines +6 to +8
Copy link
Member

@JonasBa JonasBa Jan 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about something like this to indicate that values are inclusive?

Suggested change
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 20, max: 50, value: 'third'},
{min: 0, max: 10, value: 'first'},
{min: 11, max: 20, value: 'second'},
{min: 21, max: 50, value: 'third'},

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a little wary of that because it only works for integers, and the class doesn't do anything to enforce integer-only use. Should it? It seems like there's a use-case for matching ranges to floats though I don't see it in the code right now.

Another option is maybe to make the boundary matching explicit, i.e., allow configuring which boundary is inclusive, and have it be min by default

]);

expect(ladder.get(-10)).toBeUndefined();
expect(ladder.get(5)).toBe('first');
expect(ladder.get(15)).toBe('second');
expect(ladder.get(25)).toBe('third');
expect(ladder.get(58)).toBeUndefined();
});

test('must provide at least one range', () => {
// @ts-ignore
expect(() => new RangeMap([])).toThrow();
});

test('cannot have overlapping ranges', () => {
expect(
() =>
new RangeMap([
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 15, max: 25, value: 'third'},
])
).toThrow();
});

test('may have range gaps', () => {
const ladder = new RangeMap([
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 50, max: 100, value: 'third'},
]);

expect(ladder.get(25)).toBeUndefined();
});

test('range minimum is inclusive', () => {
const ladder = new RangeMap([
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 20, max: 50, value: 'third'},
]);

expect(ladder.get(0)).toBe('first');
expect(ladder.get(10)).toBe('second');
});

test('provides the min and max value', () => {
const ladder = new RangeMap([
{min: 0, max: 10, value: 'first'},
{min: 10, max: 20, value: 'second'},
{min: 20, max: 50, value: 'third'},
]);

expect(ladder.min).toBe('first');
expect(ladder.max).toBe('third');
});

test('enforces type', () => {
type Salutation = 'Hello' | 'Hi';

const ladder = new RangeMap([
{min: 0, max: 10, value: 'Hello' as Salutation},
{min: 10, max: 20, value: 'Hi' as Salutation},
]);

const salutation = ladder.get(0);
expect(salutation === 'Hi').toBeFalsy();
});
});
69 changes: 69 additions & 0 deletions static/app/utils/number/rangeMap.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import orderBy from 'lodash/orderBy';

export type Range<T> = {
max: number;
min: number;
value: T;
};

/**
* Maps a set of integer ranges to the corresponding values.
*
* @example
* ```javascript
* // Map plan price to support tier
* const teamSizeToSupportTierMap = new RangeMap([
* {min: 0, max: 10, value: 'basic'},
* {min: 10, max: 30, value: 'premium'},
* {min: 30, max: 50, value: 'ultra-premium'},
* ]);
*
* const quota = quotaToTierMap.get(0); // Tier for small teams is "basic"
* const quota = quotaToTierMap.get(12); // Tier for a 10-30 person team is "premium"
* ```
*/
export class RangeMap<T> {
ranges: Range<T>[];

constructor(ranges: Range<T>[]) {
// Filter out sparse array slots just in case
const filteredRanges = ranges.filter(Boolean);

if (filteredRanges.length === 0) {
throw new Error('No ranges provided');
}

const sortedRanges = orderBy(filteredRanges, range => range.min, 'asc');

for (let i = 1; i < sortedRanges.length; i += 1) {
const previousRange = sortedRanges[i - 1]!;
const range = sortedRanges[i]!;

if (previousRange.max > range.min) {
throw new Error(
`${rangeToString(range)} overlaps with ${rangeToString(previousRange)}`
);
}
}

this.ranges = sortedRanges;
}

get(value: number) {
return this.ranges.find(r => {
return value >= r.min && value < r.max;
})?.value;
}

get min() {
return this.ranges.at(0)!.value;
}

get max() {
return this.ranges.at(-1)!.value;
}
}

function rangeToString(range: Range<any>): string {
return `Range min:${range.min}, max:${range.max}, value:${range.value}`;
}
Loading