238 lines
8.4 KiB
TypeScript
238 lines
8.4 KiB
TypeScript
import { describe, expect, it } from 'vitest';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
import { join } from 'path';
|
|
|
|
import {
|
|
DENSITY_GRADIENT,
|
|
ENUM_PALETTE,
|
|
FEATURE_GRADIENT,
|
|
BUFFER_MULTIPLIER,
|
|
MAP_BOUNDS,
|
|
POI_CATEGORY_LOGOS,
|
|
SMALLEST_VISIBLE_HEXAGON_RESOLUTION,
|
|
} from './consts';
|
|
import {
|
|
emojiToTwemojiUrl,
|
|
enumIndexToColor,
|
|
getBoundsFromViewState,
|
|
getBoundsWithBottomScreenInset,
|
|
getVisibleBoundsFromViewState,
|
|
getLatitudeAtVerticalPixelOffset,
|
|
getFeatureFillColor,
|
|
getMapCenterForTargetScreenPoint,
|
|
getPoiIconUrl,
|
|
selectSpacedItems,
|
|
zoomToResolution,
|
|
} from './map-utils';
|
|
|
|
describe('selectSpacedItems', () => {
|
|
const mk = (id: string, x: number, y: number) => ({ item: id, x, y });
|
|
|
|
it('keeps every item when none overlap', () => {
|
|
const items = [mk('a', 0, 0), mk('b', 200, 0), mk('c', 0, 200)];
|
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
|
expect(kept.map((k) => k.item)).toEqual(['a', 'b', 'c']);
|
|
});
|
|
|
|
it('culls items that fall within the spacing of an already-kept item', () => {
|
|
// b and c sit on top of a; d is clear.
|
|
const items = [mk('a', 100, 100), mk('b', 120, 110), mk('c', 150, 100), mk('d', 400, 400)];
|
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
|
expect(kept.map((k) => k.item)).toEqual(['a', 'd']);
|
|
});
|
|
|
|
it('treats dx and dy independently (far enough on either axis is kept)', () => {
|
|
const items = [mk('a', 0, 0), mk('b', 10, 40)]; // dx<130 but dy>=34
|
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
|
expect(kept.map((k) => k.item)).toEqual(['a', 'b']);
|
|
});
|
|
|
|
it('stops at the max cap', () => {
|
|
const items = Array.from({ length: 100 }, (_, i) => mk(String(i), i * 500, 0));
|
|
const kept = selectSpacedItems(items, 130, 34, 40);
|
|
expect(kept).toHaveLength(40);
|
|
});
|
|
});
|
|
|
|
describe('map utilities', () => {
|
|
it('maps zoom levels to H3 resolutions at configured thresholds', () => {
|
|
expect(zoomToResolution(6.9)).toBe(5);
|
|
expect(zoomToResolution(7)).toBe(6);
|
|
expect(zoomToResolution(10.6)).toBe(8);
|
|
expect(zoomToResolution(12)).toBe(9);
|
|
expect(zoomToResolution(14)).toBe(9);
|
|
expect(SMALLEST_VISIBLE_HEXAGON_RESOLUTION).toBe(9);
|
|
});
|
|
|
|
it('computes exact viewport bounds by default', () => {
|
|
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
|
|
const bounds = getBoundsFromViewState(viewState, 1200, 800);
|
|
const exactBounds = getBoundsFromViewState(viewState, 1200, 800, 1);
|
|
const bufferedBounds = getBoundsFromViewState(viewState, 1200, 800, 1.5);
|
|
|
|
expect(BUFFER_MULTIPLIER).toBe(1);
|
|
expect(bounds).toEqual(exactBounds);
|
|
expect(bounds.south).toBeLessThan(51.5);
|
|
expect(bounds.north).toBeGreaterThan(51.5);
|
|
expect(bounds.west).toBeLessThan(-0.1);
|
|
expect(bounds.east).toBeGreaterThan(-0.1);
|
|
expect(bufferedBounds.south).toBeLessThan(bounds.south);
|
|
expect(bufferedBounds.north).toBeGreaterThan(bounds.north);
|
|
expect(bufferedBounds.west).toBeLessThan(bounds.west);
|
|
expect(bufferedBounds.east).toBeGreaterThan(bounds.east);
|
|
});
|
|
|
|
it('excludes mobile bottom-sheet covered map area from visible bounds', () => {
|
|
const viewState = { latitude: 51.5, longitude: -0.1, zoom: 12, pitch: 0 };
|
|
const fullBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 0);
|
|
const visibleBounds = getVisibleBoundsFromViewState(viewState, 390, 800, 352);
|
|
|
|
expect(visibleBounds.west).toBeCloseTo(fullBounds.west, 6);
|
|
expect(visibleBounds.east).toBeCloseTo(fullBounds.east, 6);
|
|
expect(visibleBounds.north).toBeCloseTo(fullBounds.north, 6);
|
|
expect(visibleBounds.south).toBeGreaterThan(fullBounds.south);
|
|
});
|
|
|
|
it('moves the map center so a target lands in the requested screen position', () => {
|
|
const centered = getMapCenterForTargetScreenPoint(51.5, -0.1, 17, 390, 844, 195, 42.2);
|
|
|
|
expect(centered.longitude).toBeCloseTo(-0.1, 6);
|
|
expect(centered.latitude).toBeLessThan(51.5);
|
|
});
|
|
|
|
it('expands the southern map bound by a covered screen area', () => {
|
|
const shiftedSouth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, 320);
|
|
const shiftedNorth = getLatitudeAtVerticalPixelOffset(MAP_BOUNDS[1], 5.5, -320);
|
|
const expandedBounds = getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 320);
|
|
|
|
expect(shiftedSouth).toBeLessThan(MAP_BOUNDS[1]);
|
|
expect(shiftedNorth).toBeGreaterThan(MAP_BOUNDS[1]);
|
|
expect(expandedBounds[0]).toBe(MAP_BOUNDS[0]);
|
|
expect(expandedBounds[1]).toBeCloseTo(shiftedSouth, 6);
|
|
expect(expandedBounds[2]).toBe(MAP_BOUNDS[2]);
|
|
expect(expandedBounds[3]).toBe(MAP_BOUNDS[3]);
|
|
expect(getBoundsWithBottomScreenInset(MAP_BOUNDS, 5.5, 0)).toEqual(MAP_BOUNDS);
|
|
});
|
|
|
|
it('builds twemoji URLs and wraps enum colors', () => {
|
|
expect(emojiToTwemojiUrl('🛒')).toBe('/assets/twemoji/1f6d2.png');
|
|
expect(() => emojiToTwemojiUrl('')).toThrow('Cannot build a Twemoji URL without an emoji');
|
|
expect(enumIndexToColor(ENUM_PALETTE.length, ENUM_PALETTE)).toEqual(ENUM_PALETTE[0]);
|
|
});
|
|
|
|
it('resolves POI category logos and generates a fallback for unknown chains', () => {
|
|
expect(getPoiIconUrl('Waitrose', '🛒')).toBe('/assets/poi-icons/logos/waitrose.svg');
|
|
expect(getPoiIconUrl('Iceland', '🛒', 'The Food Warehouse')).toBe(
|
|
'/assets/poi-icons/logos/the_food_warehouse.png'
|
|
);
|
|
expect(getPoiIconUrl("Sainsbury's", '🛒', undefined, 'Sainsburys Earlsfield Local')).toBe(
|
|
'/assets/poi-icons/brands_2024/sainsburys_local.svg'
|
|
);
|
|
expect(getPoiIconUrl('Costco', '🛒')).toBe('/assets/poi-icons/logos/costco.svg');
|
|
expect(getPoiIconUrl('M&S', '🛒', undefined, 'M&S Simply Food')).toBe(
|
|
'/assets/poi-icons/visuals/mns.svg'
|
|
);
|
|
expect(getPoiIconUrl('Tian Tian', '🛒')).toMatch(/^data:image\/svg\+xml;charset=utf-8,/);
|
|
});
|
|
|
|
it('keeps POI icon URLs bundled locally', () => {
|
|
expect(Object.values(POI_CATEGORY_LOGOS).filter((url) => /^https?:\/\//.test(url))).toEqual([]);
|
|
expect(
|
|
Object.values(POI_CATEGORY_LOGOS)
|
|
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
|
.filter((url) => !existsSync(join(process.cwd(), 'public', url.slice(1))))
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('does not use pin-shaped SVGs for branded POI logos', () => {
|
|
const pinSignatures = ['viewBox="0 0 400 520"', 'C 18.914 185.931'];
|
|
const svgUrls = [
|
|
...new Set(
|
|
Object.values(POI_CATEGORY_LOGOS)
|
|
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
|
.filter((url) => url.endsWith('.svg'))
|
|
),
|
|
];
|
|
|
|
expect(
|
|
svgUrls.filter((url) => {
|
|
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
|
return pinSignatures.some((signature) => content.includes(signature));
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('keeps bundled SVG logos large enough for the map icon atlas', () => {
|
|
const svgUrls = [
|
|
...new Set(
|
|
Object.values(POI_CATEGORY_LOGOS)
|
|
.filter((url) => url.startsWith('/assets/poi-icons/'))
|
|
.filter((url) => url.endsWith('.svg'))
|
|
),
|
|
];
|
|
|
|
expect(
|
|
svgUrls.filter((url) => {
|
|
const content = readFileSync(join(process.cwd(), 'public', url.slice(1)), 'utf8');
|
|
const width = Number(content.match(/width="([0-9.]+)/)?.[1]);
|
|
const height = Number(content.match(/height="([0-9.]+)/)?.[1]);
|
|
return Math.max(width, height) < 256;
|
|
})
|
|
).toEqual([]);
|
|
});
|
|
|
|
it('returns fallback, filtered, enum, feature, and density colors', () => {
|
|
expect(
|
|
getFeatureFillColor(
|
|
null,
|
|
undefined,
|
|
undefined,
|
|
[0, 100],
|
|
null,
|
|
0,
|
|
DENSITY_GRADIENT,
|
|
false,
|
|
180
|
|
)
|
|
).toEqual([128, 128, 128, 80]);
|
|
|
|
expect(
|
|
getFeatureFillColor(50, 50, 60, [0, 100], [70, 90], 0, DENSITY_GRADIENT, true, 180)
|
|
).toEqual([60, 55, 50, 60]);
|
|
|
|
expect(
|
|
getFeatureFillColor(1, 1, 1, [0, 2], null, 0, DENSITY_GRADIENT, false, 180, 3, ENUM_PALETTE)
|
|
).toEqual([...ENUM_PALETTE[1], 180]);
|
|
|
|
expect(
|
|
getFeatureFillColor(
|
|
0,
|
|
0,
|
|
100,
|
|
[0, 100],
|
|
null,
|
|
0,
|
|
DENSITY_GRADIENT,
|
|
false,
|
|
200,
|
|
0,
|
|
undefined,
|
|
FEATURE_GRADIENT
|
|
)
|
|
).toEqual([...FEATURE_GRADIENT[0].color, 200]);
|
|
|
|
expect(
|
|
getFeatureFillColor(
|
|
undefined,
|
|
undefined,
|
|
undefined,
|
|
null,
|
|
null,
|
|
0,
|
|
DENSITY_GRADIENT,
|
|
false,
|
|
150
|
|
)
|
|
).toEqual([...DENSITY_GRADIENT[0].color, 150]);
|
|
});
|
|
});
|