import { describe, expect, it } from 'vitest'; import { MAP_NO_DATA_FILL, MAP_RAMP, classIndex, classLabel, fillFor, quantileScale, quantileSorted, rampColor, sqrtRadius } from '@/lib/map-scale'; describe('quantileSorted', () => { it('interpolates like R-7 / d3.quantile', () => { const s = [1, 2, 3, 4, 5]; expect(quantileSorted(s, 0)).toBe(1); expect(quantileSorted(s, 0.5)).toBe(3); expect(quantileSorted(s, 0.25)).toBe(2); expect(quantileSorted(s, 1)).toBe(5); expect(quantileSorted([10, 20], 0.5)).toBe(15); expect(Number.isNaN(quantileSorted([], 0.5))).toBe(true); }); }); describe('quantileScale', () => { it('splits skewed counts into 5 equal-count classes with non-overlapping integer bounds', () => { const values = [608226, 68847, 68832, 49415, 47021, 44579, 32882, 30740, 28329, 19933, 500, 300, 120, 80, 40, 12, 9, 5, 2, 1]; const s = quantileScale(values); expect(s.method).toBe('quantile'); expect(s.classes).toHaveLength(5); expect(s.breaks).toHaveLength(4); // every value lands in exactly one class whose [lo, hi] contains it for (const v of values) { const c = s.classes[classIndex(v, s.breaks)]!; expect(v).toBeGreaterThanOrEqual(c.lo); expect(v).toBeLessThanOrEqual(c.hi); } // classes are ordered and disjoint for (let i = 1; i < s.classes.length; i++) expect(s.classes[i]!.lo).toBeGreaterThan(s.classes[i - 1]!.hi); // roughly equal counts (20 values → about 4 per class; integer thresholds shift one or two items) expect(s.classes.reduce((n, c) => n + c.n, 0)).toBe(20); for (const c of s.classes) expect(c.n).toBeGreaterThanOrEqual(3); for (const c of s.classes) expect(c.n).toBeLessThanOrEqual(5); expect(s.classes[4]!.hi).toBe(608226); expect(s.classes[0]!.fill).toBe(MAP_RAMP[0]); expect(s.classes[4]!.fill).toBe(MAP_RAMP[4]); }); it('ignores zeros, negatives and non-finite values', () => { const s = quantileScale([0, -3, NaN, Infinity, 5, 10]); expect(s.classes.reduce((n, c) => n + c.n, 0)).toBe(2); expect(fillFor(0, s)).toBe(MAP_NO_DATA_FILL); expect(fillFor(null, s)).toBe(MAP_NO_DATA_FILL); }); it('collapses to fewer classes when there are few distinct values', () => { expect(quantileScale([]).classes).toEqual([]); const one = quantileScale([7, 7, 7]); expect(one.classes).toHaveLength(1); expect(one.breaks).toEqual([]); expect(one.classes[0]).toMatchObject({ lo: 7, hi: 7, n: 3, fill: MAP_RAMP[MAP_RAMP.length - 1] }); const two = quantileScale([1, 1, 1, 1, 100]); expect(two.classes.length).toBeGreaterThanOrEqual(2); expect(two.classes.length).toBeLessThanOrEqual(5); expect(fillFor(100, two)).toBe(MAP_RAMP[MAP_RAMP.length - 1]); expect(fillFor(1, two)).toBe(MAP_RAMP[0]); }); }); describe('helpers', () => { it('classIndex uses inclusive upper thresholds', () => { expect(classIndex(1, [1, 10, 100])).toBe(0); expect(classIndex(2, [1, 10, 100])).toBe(1); expect(classIndex(100, [1, 10, 100])).toBe(2); expect(classIndex(101, [1, 10, 100])).toBe(3); expect(classIndex(5, [])).toBe(0); }); it('rampColor spreads n classes across the ramp', () => { expect(rampColor(0, 5)).toBe(MAP_RAMP[0]); expect(rampColor(4, 5)).toBe(MAP_RAMP[4]); expect(rampColor(0, 2)).toBe(MAP_RAMP[0]); expect(rampColor(1, 2)).toBe(MAP_RAMP[4]); expect(rampColor(0, 1)).toBe(MAP_RAMP[4]); }); it('classLabel formats ranges and singletons', () => { const fmt = (n: number) => n.toLocaleString('en-US'); expect(classLabel({ index: 0, lo: 1, hi: 12, n: 3, fill: '#000' }, fmt)).toBe('1–12'); expect(classLabel({ index: 4, lo: 608226, hi: 608226, n: 1, fill: '#000' }, fmt)).toBe('608,226'); }); it('sqrtRadius scales area with value and clamps', () => { expect(sqrtRadius(100, 100)).toBe(14); expect(sqrtRadius(25, 100)).toBe(7); expect(sqrtRadius(1, 1_000_000)).toBe(1.5); expect(sqrtRadius(0, 100)).toBe(0); expect(sqrtRadius(5, 0)).toBe(0); }); });