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]); }); });