update map

This commit is contained in:
Jesse Winton
2025-05-21 14:18:17 -04:00
parent c2bea57f04
commit e3dfff7130
10 changed files with 14291 additions and 161 deletions

View File

@@ -48,11 +48,13 @@
"@sveltejs/kit": "^2.20.2",
"@sveltejs/vite-plugin-svelte": "^5.0.3",
"@tailwindcss/postcss": "^4.1.4",
"@turf/boolean-point-in-polygon": "^7.2.0",
"@types/compression": "^1.7.5",
"@types/glob": "^8.1.0",
"@types/jsdom": "^21.1.7",
"@types/markdown-it": "^13.0.9",
"@types/morgan": "^1.9.9",
"@types/proj4": "^2.5.6",
"analytics": "^0.8.16",
"appwrite": "^17.0.1",
"bits-ui": "^1.3.19",
@@ -87,6 +89,7 @@
"prettier": "^3.4.2",
"prettier-plugin-svelte": "^3.3.3",
"prettier-plugin-tailwindcss": "^0.6.11",
"proj4": "^2.17.0",
"remeda": "^2.20.0",
"reodotdev": "^1.0.0",
"sass": "^1.83.4",

83
pnpm-lock.yaml generated
View File

@@ -69,6 +69,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4.1.4
version: 4.1.4
'@turf/boolean-point-in-polygon':
specifier: ^7.2.0
version: 7.2.0
'@types/compression':
specifier: ^1.7.5
version: 1.7.5
@@ -84,6 +87,9 @@ importers:
'@types/morgan':
specifier: ^1.9.9
version: 1.9.9
'@types/proj4':
specifier: ^2.5.6
version: 2.5.6
analytics:
specifier: ^0.8.16
version: 0.8.16(@types/dlv@1.1.5)
@@ -183,6 +189,9 @@ importers:
prettier-plugin-tailwindcss:
specifier: ^0.6.11
version: 0.6.11(prettier-plugin-svelte@3.3.3(prettier@3.5.3)(svelte@5.25.6))(prettier@3.5.3)
proj4:
specifier: ^2.17.0
version: 2.17.0
remeda:
specifier: ^2.20.0
version: 2.21.2
@@ -1427,6 +1436,15 @@ packages:
'@tsbb/copy-template-dir@1.4.0':
resolution: {integrity: sha512-WXezrpwkm+JGoH5eh/7bngabXriDe7bhqCATWV6e+um8Qw0nNCkE4hfQ791CoiIdSe4LLyzoIfomwH1kR0GYvQ==}
'@turf/boolean-point-in-polygon@7.2.0':
resolution: {integrity: sha512-lvEOjxeXIp+wPXgl9kJA97dqzMfNexjqHou+XHVcfxQgolctoJiRYmcVCWGpiZ9CBf/CJha1KmD1qQoRIsjLaA==}
'@turf/helpers@7.2.0':
resolution: {integrity: sha512-cXo7bKNZoa7aC7ydLmUR02oB3IgDe7MxiPuRz3cCtYQHn+BJ6h1tihmamYDWWUlPHgSNF0i3ATc4WmDECZafKw==}
'@turf/invariant@7.2.0':
resolution: {integrity: sha512-kV4u8e7Gkpq+kPbAKNC21CmyrXzlbBgFjO1PhrHPgEdNqXqDawoZ3i6ivE3ULJj2rSesCjduUaC/wyvH/sNr2Q==}
'@types/body-parser@1.19.5':
resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==}
@@ -1451,6 +1469,9 @@ packages:
'@types/express@5.0.0':
resolution: {integrity: sha512-DvZriSMehGHL1ZNLzi6MidnsDhUZM/x2pRdDIKdwbUNqqwHxMlRdkxtn6/EPKyqKpHqTl/4nRZsRNLpZxZRpPQ==}
'@types/geojson@7946.0.16':
resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
'@types/glob@8.1.0':
resolution: {integrity: sha512-IO+MJPVhoqz+28h1qLAcBEH2+xHMK6MTyHJc7MTnnYb6wsoLR29POVGJ7LycmVXIqyy/4/2ShP5sUwTXuOwb/w==}
@@ -1499,6 +1520,9 @@ packages:
'@types/node@22.13.10':
resolution: {integrity: sha512-I6LPUvlRH+O6VRUqYOcMudhaIdUVWfsjnZavnsraHvpBwaEyMN29ry+0UVJhImYL16xsscu0aske3yA+uPOWfw==}
'@types/proj4@2.5.6':
resolution: {integrity: sha512-zfMrPy9fx+8DchqM0kIUGeu2tTVB5ApO1KGAYcSGFS8GoqRIkyL41xq2yCx/iV3sOLzo7v4hEgViSLTiPI1L0w==}
'@types/qs@6.9.18':
resolution: {integrity: sha512-kK7dgTYDyGqS+e2Q4aK9X3D7q234CIZ1Bv0q/7Z5IwRDoADNU81xXJK/YVyLbLTZCoIwUoDoffFeF+p/eIklAA==}
@@ -2353,6 +2377,9 @@ packages:
engines: {node: ^12.13.0 || ^14.15.0 || >=16.0.0}
deprecated: This package is no longer supported.
geographiclib-geodesic@2.1.1:
resolution: {integrity: sha512-lkd8EUkPSByobWu9BPMHTdYA5AUZxOa8McmUNtBE9KrvUJEvSADnN6gTDmhXbi6NzdA16LtWLpSxLE/lIIRhyA==}
get-caller-file@2.0.5:
resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==}
engines: {node: 6.* || 8.* || >= 10.*}
@@ -2811,6 +2838,9 @@ packages:
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
engines: {node: '>= 8'}
mgrs@1.0.0:
resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==}
microbuffer@1.0.0:
resolution: {integrity: sha512-O/SUXauVN4x6RaEJFqSPcXNtLFL+QzJHKZlyDVYFwcDDRVca3Fa/37QXXC+4zAGGa4YhHrHxKXuuHvLDIQECtA==}
@@ -3171,6 +3201,9 @@ packages:
resolution: {integrity: sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==}
engines: {node: '>=4.0.0'}
point-in-polygon-hao@1.2.4:
resolution: {integrity: sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ==}
postcss-load-config@3.1.4:
resolution: {integrity: sha512-6DiM4E7v4coTE4uzA8U//WhtPwyhiim3eyjEMFCnUpzbrkK9wJHgKDT2mR+HbtSrd/NubVaYTOpSpjUl8NQeRg==}
engines: {node: '>= 10'}
@@ -3302,6 +3335,9 @@ packages:
resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
engines: {node: '>= 0.6.0'}
proj4@2.17.0:
resolution: {integrity: sha512-BqVoruVAOUgkw5U9Ns76+E2nHZG0Y42tbkC+0BpyqjhwPIai29hoivyQoyelEKFSfaV3zkR3NqPRD0EwPM4Wug==}
promise-inflight@1.0.1:
resolution: {integrity: sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==}
peerDependencies:
@@ -3398,6 +3434,9 @@ packages:
deprecated: Rimraf versions prior to v4 are no longer supported
hasBin: true
robust-predicates@3.0.2:
resolution: {integrity: sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg==}
rollup@4.35.0:
resolution: {integrity: sha512-kg6oI4g+vc41vePJyO6dHt/yl0Rz3Thv0kJeVQ3D1kS3E5XSuKbPc29G4IpT/Kv1KQwgHVcN+HtyS+HYLNSvQg==}
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
@@ -3931,6 +3970,9 @@ packages:
wide-align@1.1.5:
resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==}
wkt-parser@1.5.2:
resolution: {integrity: sha512-1ZUiV1FTwSiSrgWzV9KXJuOF2BVW91KY/mau04BhnmgOdroRQea7Q0s5TVqwGLm0D2tZwObd/tBYXW49sSxp3Q==}
word-wrap@1.2.5:
resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==}
engines: {node: '>=0.10.0'}
@@ -5074,6 +5116,25 @@ snapshots:
readdirp: 3.6.0
run-parallel: 1.2.0
'@turf/boolean-point-in-polygon@7.2.0':
dependencies:
'@turf/helpers': 7.2.0
'@turf/invariant': 7.2.0
'@types/geojson': 7946.0.16
point-in-polygon-hao: 1.2.4
tslib: 2.8.1
'@turf/helpers@7.2.0':
dependencies:
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@turf/invariant@7.2.0':
dependencies:
'@turf/helpers': 7.2.0
'@types/geojson': 7946.0.16
tslib: 2.8.1
'@types/body-parser@1.19.5':
dependencies:
'@types/connect': 3.4.38
@@ -5107,6 +5168,8 @@ snapshots:
'@types/qs': 6.9.18
'@types/serve-static': 1.15.7
'@types/geojson@7946.0.16': {}
'@types/glob@8.1.0':
dependencies:
'@types/minimatch': 5.1.2
@@ -5159,6 +5222,8 @@ snapshots:
dependencies:
undici-types: 6.20.0
'@types/proj4@2.5.6': {}
'@types/qs@6.9.18': {}
'@types/range-parser@1.2.7': {}
@@ -6090,6 +6155,8 @@ snapshots:
strip-ansi: 6.0.1
wide-align: 1.1.5
geographiclib-geodesic@2.1.1: {}
get-caller-file@2.0.5: {}
get-intrinsic@1.3.0:
@@ -6580,6 +6647,8 @@ snapshots:
merge2@1.4.1: {}
mgrs@1.0.0: {}
microbuffer@1.0.0: {}
micromatch@4.0.8:
@@ -6918,6 +6987,10 @@ snapshots:
pngjs@3.4.0: {}
point-in-polygon-hao@1.2.4:
dependencies:
robust-predicates: 3.0.2
postcss-load-config@3.1.4(postcss@8.5.3):
dependencies:
lilconfig: 2.1.0
@@ -6984,6 +7057,12 @@ snapshots:
process@0.11.10: {}
proj4@2.17.0:
dependencies:
geographiclib-geodesic: 2.1.1
mgrs: 1.0.0
wkt-parser: 1.5.2
promise-inflight@1.0.1: {}
promise-retry@2.0.1:
@@ -7070,6 +7149,8 @@ snapshots:
dependencies:
glob: 7.2.3
robust-predicates@3.0.2: {}
rollup@4.35.0:
dependencies:
'@types/estree': 1.0.6
@@ -7666,6 +7747,8 @@ snapshots:
dependencies:
string-width: 4.2.3
wkt-parser@1.5.2: {}
word-wrap@1.2.5: {}
wrap-ansi@7.0.0:

View File

@@ -1,64 +0,0 @@
<script lang="ts">
import { classNames } from '$lib/utils/classnames';
import { slugify } from '$lib/utils/slugify';
import { latLongToSvgPosition } from './utils/projections';
import { tooltipData } from './map-tooltip.svelte';
interface Props {
city: string;
code: string;
index: number;
lat: number;
lng: number;
bounds: {
north: number;
south: number;
west: number;
east: number;
};
available: boolean;
class?: string;
animate?: boolean;
}
const { city, code, index = 0, lat, lng, available, animate = false }: Props = $props();
const position = $derived(latLongToSvgPosition({ latitude: lat, longitude: lng }));
const handleSetActiveMarker = () => {
tooltipData.set({
city,
code,
available
});
};
const handleResetActiveMarker = () => {
tooltipData.set({
city: null,
code: null,
available: null
});
};
</script>
<button
class={classNames(
'group absolute z-10 flex size-2 cursor-pointer items-center justify-center opacity-0 [animation-delay:var(--delay)]',
{ 'animate-fade-in': animate }
)}
style="left: {position.x}%; top: {position.y}%;--delay: {index * 10}ms;"
data-region={slugify(city)}
onmouseenter={handleSetActiveMarker}
onfocus={handleSetActiveMarker}
onmouseleave={handleResetActiveMarker}
onblur={handleResetActiveMarker}
aria-label={city}
>
<span
class="from-accent/20 to-accent/10 border-gradient ease-spring pointer-events-none absolute inline-flex h-5 w-5 rounded-full bg-gradient-to-b opacity-0 transition-opacity group-hover:animate-ping group-hover:opacity-75 before:rounded-full"
style:animation-duration="1.5s"
></span>
<span class="bg-accent absolute inline-flex h-full w-full rounded-full"></span>
<span class="absolute size-1/2 rounded-full bg-white/80 transition-all"></span>
</button>

View File

@@ -1,8 +1,8 @@
<script lang="ts" module>
import { classNames } from '$lib/utils/classnames';
import { writable } from 'svelte/store';
import { animate } from 'motion';
export const tooltipData = writable<{
let tooltipData = $state<{
city: string | null;
code: string | null;
available: boolean | null;
@@ -11,40 +11,76 @@
code: null,
available: null
});
export const handleSetActiveTooltip = (city: string, code: string, available: boolean) => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
tooltipData = {
city,
code,
available
};
};
let timeoutId: ReturnType<typeof setTimeout> | null = null;
export const handleResetActiveTooltip = (delay?: number) => {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (delay) {
timeoutId = setTimeout(() => {
tooltipData = {
city: null,
code: null,
available: null
};
timeoutId = null;
}, delay);
} else {
tooltipData = {
city: null,
code: null,
available: null
};
}
};
</script>
<script lang="ts">
type Props = {
coords: {
interface TooltipProps {
x: number;
y: number;
};
}
theme: 'light' | 'dark';
};
const { x, y }: TooltipProps = $props();
const { coords, theme = 'dark' }: Props = $props();
let city = $state<HTMLElement | null>(null);
$effect(() => {
if (!city) return;
animate(city, { y: [-5, 0], filter: ['blur(4px)', '0px'] }, { duration: 0.2 });
});
</script>
{#if $tooltipData.city}
<div
class="pointer-events-none absolute"
style:left="{coords.x + 100}px"
style:top="{coords.y - 20}px"
style:transform="translate(-50%, -100%)"
>
<div class="pointer-events-none absolute z-10 hidden md:block">
{#if tooltipData.city}
<div
class={classNames(
'border-gradient relative z-100 flex w-[190px] flex-col gap-2 rounded-[10px] p-2 backdrop-blur-lg before:rounded-[10px] after:rounded-[10px]',
'data-[state="closed"]:animate-menu-out data-[state="instant-open"]:animate-menu-in data-[state="delayed-open"]:animate-menu-in',
theme === 'dark' ? 'bg-card/90' : 'bg[var(--card, rgba(255,255,255))]'
'border-gradient relative z-100 flex w-[190px] flex-col gap-2 rounded-[10px] p-2 backdrop-blur-lg before:rounded-[10px] after:rounded-[10px]'
)}
style:transform={`translateX(${x + 125}px) translateY(${y + 200}px)`}
>
<span class="text-primary text-caption w-fit">
{$tooltipData.city}
({$tooltipData.code})
{tooltipData.city}
({tooltipData.code})
</span>
{#if $tooltipData.available}
{#if tooltipData.available}
<div
class="text-caption flex h-5 items-center justify-center place-self-start rounded-md bg-[#10B981]/24 p-1 text-center text-[#B4F8E2]"
>
@@ -58,5 +94,5 @@
</div>
{/if}
</div>
</div>
{/if}
{/if}
</div>

View File

@@ -1,30 +1,20 @@
<script lang="ts" module>
export const MAP_BOUNDS = $state({
west: -138,
east: 167,
north: 74,
south: -62
});
</script>
<script lang="ts">
import MapMarker from './map-marker.svelte';
import { slugify } from '$lib/utils/slugify';
import { classNames } from '$lib/utils/classnames';
import MapNav from './map-nav.svelte';
import { useMousePosition } from '$lib/actions/mouse-position';
import { useAnimateInView } from '$lib/actions/animate-in-view';
import { pins, type PinSegment } from './data/pins';
import MapTooltip from './map-tooltip.svelte';
let dimensions = $state({
width: 0,
height: 0
});
import MapTooltip, {
handleSetActiveTooltip,
handleResetActiveTooltip
} from './map-tooltip.svelte';
import { createMap } from '$lib/map';
let activeRegion = $state<string | null>(null);
let activeMarker: HTMLElement | null = null;
let activeSegment = $state<string>('pop-locations');
let activeMarkers = $derived(pins[activeSegment as PinSegment]);
const { action: mousePosition, position } = useMousePosition();
const { action: inView, animate } = useAnimateInView({});
@@ -68,12 +58,34 @@
}
};
const height = 75;
let map: ReturnType<typeof createMap> = $state({
points: [],
markers: [],
base: ''
});
const getMarkers = () => {
return activeMarkers;
};
$effect(() => {
map = createMap({
width: height * 2,
height,
markers: getMarkers(),
skew: 1,
baseColor: '#dadadd',
markerColor: 'var(--color-accent)'
});
});
type Props = { theme: 'light' | 'dark' };
const { theme = 'dark' }: Props = $props();
</script>
<div class="relative -mt-8 w-full overflow-x-scroll [scrollbar-width:none] md:overflow-x-hidden">
<div class="-mt-8 w-full overflow-x-scroll [scrollbar-width:none] md:overflow-x-hidden">
<div
class="sticky left-0 mx-auto block max-w-[calc(100vw_-_calc(var(--spacing)_*-2))] md:hidden"
>
@@ -88,45 +100,38 @@
</div>
<div
class="relative mx-auto h-full w-[250vw] [scrollbar-width:none] md:w-fit"
class="relative mx-auto h-full w-[250vw] [scrollbar-width:none] md:w-full"
use:inView
use:mousePosition
>
<div
class="relative w-full origin-bottom transform-[perspective(25px)_rotateX(1deg)_scale3d(1.4,_1.4,_1)] transition-all [scrollbar-width:none]"
bind:clientWidth={dimensions.width}
bind:clientHeight={dimensions.height}
>
<div
class="absolute inset-0 mask-[image:url('/images/appwrite-network/map.svg')] mask-contain mask-no-repeat"
>
<div
class={classNames(
'relative block aspect-square size-40 rounded-full blur-3xl transition-opacity',
'from-accent bg-radial-[circle_at_center] via-white/70 to-white/70',
'transform-[translate3d(calc(var(--mouse-x,_-100%)_*_1_-_16rem),_calc(var(--mouse-y,_-100%)_*_1_-_28rem),0)]'
)}
style:--mouse-x="{$position.x}px"
style:--mouse-y="{$position.y}px"
></div>
</div>
<!-- TODO: reusing the same image but inverted! use a variable -->
<img
draggable="false"
alt="Map of the world"
src="/images/appwrite-network/map.svg"
style:filter={theme === 'light' ? 'invert()' : undefined}
class="pointer-events-none relative -z-10 w-full opacity-10 md:max-h-[525px]"
/>
{#each pins[activeSegment as PinSegment] as pin, index}
<MapMarker {...pin} animate={$animate} {index} bounds={MAP_BOUNDS} />
<svg viewBox={`0 0 ${height * 2} ${height}`}>
{#each map.points as point}
<circle cx={point.x} cy={point.y} r={point.size} fill={point.color} />
{/each}
<!-- {#each map.markers as marker}
<g
role="tooltip"
class="animate-fade-in outline-none"
onmouseover={() =>
handleSetActiveTooltip(marker.city, marker.code, marker.available)}
onfocus={() =>
handleSetActiveTooltip(marker.city, marker.code, marker.available)}
onblur={() => handleResetActiveTooltip(250)}
onmouseout={() => handleResetActiveTooltip(250)}
data-region={slugify(marker.city)}
>
<circle cx={marker.x} cy={marker.y} r={marker.size} fill={marker.color} />
<circle cx={marker.x} cy={marker.y} fill="white" />
</g>
{/each} -->
</svg>
</div>
</div>
<MapTooltip {theme} coords={$position} />
</div>
<MapTooltip {...$position} />
<MapNav {theme} onValueChange={(value) => (activeSegment = value)} />

View File

@@ -1,24 +0,0 @@
import { MAP_BOUNDS } from '../map.svelte';
const MAP_WIDTH = 1048.25;
const MAP_HEIGHT = 525;
type Coordinates = {
latitude: number;
longitude: number;
};
export const latLongToSvgPosition = ({ latitude, longitude }: Coordinates) => {
const { west, east, north, south } = MAP_BOUNDS;
const lngRatio = (longitude - west) / (east - west);
const latRatio = (latitude - south) / (north - south);
const clampedLngRatio = Math.max(0, Math.min(1, lngRatio));
const clampedLatRatio = Math.max(0, Math.min(1, latRatio));
const x = clampedLngRatio * 100;
const y = (1 - clampedLatRatio) * 100;
return { x, y }; // percentages, e.g., { x: 42.3, y: 71.8 }
};

13749
src/lib/map/countries.geo.json Normal file

File diff suppressed because it is too large Load Diff

204
src/lib/map/helpers.ts Normal file
View File

@@ -0,0 +1,204 @@
import proj4 from 'proj4';
import inside from '@turf/boolean-point-in-polygon';
import type {
GeoJSON,
GeoJsonFeature,
Geometry,
Region,
PolygonGeometry,
MultiPolygonGeometry,
CreateMapOptions,
Point
} from './types';
import geojsonWorld from './countries.geo.json';
export const DEFAULT_WORLD_REGION = {
lat: { min: -56, max: 71 },
lng: { min: -179, max: 179 }
};
export const computeGeojsonBox = (geojson: GeoJSON | GeoJsonFeature | Geometry): Region => {
if ('type' in geojson) {
if (geojson.type === 'FeatureCollection') {
const boxes = geojson.features.map((feature) => computeGeojsonBox(feature));
return {
lat: {
min: Math.min(...boxes.map((box) => box.lat.min)),
max: Math.max(...boxes.map((box) => box.lat.max))
},
lng: {
min: Math.min(...boxes.map((box) => box.lng.min)),
max: Math.max(...boxes.map((box) => box.lng.max))
}
};
} else if (geojson.type === 'Feature') {
return computeGeojsonBox(geojson.geometry);
} else if (geojson.type === 'MultiPolygon') {
const flattened: PolygonGeometry = {
type: 'Polygon',
coordinates: geojson.coordinates.flat()
};
return computeGeojsonBox(flattened);
} else if (geojson.type === 'Polygon') {
const coords = geojson.coordinates.flat();
const latitudes = coords.map(([_, lat]) => lat);
const longitudes = coords.map(([lng, _]) => lng);
return {
lat: {
min: Math.min(...latitudes),
max: Math.max(...latitudes)
},
lng: {
min: Math.min(...longitudes),
max: Math.max(...longitudes)
}
};
}
}
throw new Error(`Unknown or unsupported geojson structure`);
};
export const geojsonByCountry = (geojsonWorld as GeoJSON).features.reduce<
Record<string, GeoJsonFeature>
>((countries, feature) => {
countries[feature.id] = feature;
return countries;
}, {});
export const geojsonToMultiPolygons = (geojson: GeoJSON): GeoJsonFeature => {
const coordinates = geojson.features.reduce<MultiPolygonGeometry['coordinates']>(
(poly, feature) => {
if (feature.geometry.type === 'Polygon') {
return [...poly, feature.geometry.coordinates];
} else {
return [...poly, ...feature.geometry.coordinates];
}
},
[]
);
return {
type: 'Feature',
id: 'multipolygon',
properties: { name: 'Combined Polygons' },
geometry: { type: 'MultiPolygon', coordinates }
};
};
const getMapPointsKey = ({
height = 0,
width = 0,
countries = [],
region
}: Pick<CreateMapOptions, 'height' | 'width' | 'countries' | 'region'>) => {
const sortedCountries = [...countries].sort();
return JSON.stringify({
height,
width,
countries: sortedCountries,
region
});
};
type MapPointsResult = {
points: Record<string, Point>;
X_MIN: number;
Y_MAX: number;
X_RANGE: number;
Y_RANGE: number;
height: number;
width: number;
ystep: number;
};
const mapPointsCache = new Map<string, MapPointsResult>();
export const getMapPoints = ({
height = 0,
width = 0,
countries = [],
region
}: Pick<CreateMapOptions, 'height' | 'width' | 'countries' | 'region'>): MapPointsResult => {
if (height <= 0 && width <= 0) {
throw new Error('height or width is required');
}
const key = getMapPointsKey({
height,
width,
countries,
region
});
if (mapPointsCache.has(key)) {
return mapPointsCache.get(key)! as MapPointsResult;
}
let geojson: GeoJSON = geojsonWorld as GeoJSON;
if (countries.length > 0) {
geojson = {
type: 'FeatureCollection',
features: countries.map((country) => geojsonByCountry[country]).filter(Boolean)
};
if (!region) {
region = computeGeojsonBox(geojson);
}
} else if (!region) {
region = DEFAULT_WORLD_REGION;
}
const poly = geojsonToMultiPolygons(geojson);
const [X_MIN, Y_MIN] = proj4('GOOGLE', [region.lng.min, region.lat.min]);
const [X_MAX, Y_MAX] = proj4('GOOGLE', [region.lng.max, region.lat.max]);
const X_RANGE = X_MAX - X_MIN;
const Y_RANGE = Y_MAX - Y_MIN;
if (width <= 0) {
width = Math.round((height * X_RANGE) / Y_RANGE);
} else if (height <= 0) {
height = Math.round((width * Y_RANGE) / X_RANGE);
}
const points: Record<string, Point> = {};
const ystep = 1;
const TARGET_POINTS = 6000;
const aspect = width / height;
const NUM_ROWS = Math.round(Math.sqrt(TARGET_POINTS / aspect));
const NUM_COLS = Math.round(NUM_ROWS * aspect);
for (let row = 0; row < NUM_ROWS; row++) {
for (let col = 0; col < NUM_COLS; col++) {
const localx = (col / (NUM_COLS - 1)) * width;
const localy = (row / (NUM_ROWS - 1)) * height;
const pointGoogle = [
(localx / width) * X_RANGE + X_MIN,
Y_MAX - (localy / height) * Y_RANGE
];
const wgs84Point = proj4('GOOGLE', 'WGS84', pointGoogle);
if (inside(wgs84Point, poly)) {
const key = `${Math.round(localx)};${Math.round(localy)}`;
points[key] = {
x: localx,
y: localy
};
}
}
}
return {
points,
X_MIN,
Y_MAX,
X_RANGE,
Y_RANGE,
height,
width,
ystep
};
};

66
src/lib/map/index.ts Normal file
View File

@@ -0,0 +1,66 @@
import proj4 from 'proj4';
import type { CreateMapOptions } from './types';
import { getMapPoints } from './helpers';
export const createMap = ({
height,
width,
countries,
region,
markers,
markerColor,
baseColor
}: CreateMapOptions) => {
const {
points,
X_MIN,
Y_MAX,
X_RANGE,
Y_RANGE,
width: mapWidth,
height: mapHeight,
ystep
} = getMapPoints({ height, width, countries, region });
const defaultRadius = 0.35;
const markerPoints = markers.map((marker) => {
const { lat, lng, size, ...markerData } = marker;
const [googleX, googleY] = proj4('GOOGLE', [lng, lat]);
const rawY = (mapHeight * (Y_MAX - googleY)) / Y_RANGE;
const rawX = (mapWidth * (googleX - X_MIN)) / X_RANGE;
const y = Math.round(rawY / ystep);
const x = Math.round(rawX);
const localy = Math.round(y) * ystep;
const localx = x;
const key = [localx, localy].join(';');
if (!points[key]) {
const [localLng, localLat] = proj4('GOOGLE', 'WGS84', [
(localx * X_RANGE) / mapWidth + X_MIN,
Y_MAX - (localy * Y_RANGE) / mapHeight
]);
points[key] = {
x: localx,
y: localy
};
}
return {
x: localx,
y: localy,
color: markerColor,
size: size ?? defaultRadius,
...markerData
};
});
return {
points: Object.values(points).map((point) => ({
...point,
color: baseColor,
size: defaultRadius
})),
markers: markerPoints
};
};

72
src/lib/map/types.ts Normal file
View File

@@ -0,0 +1,72 @@
import { z } from 'zod';
const polygonSchema = z.object({
type: z.literal('Polygon'),
coordinates: z.array(z.array(z.tuple([z.number(), z.number()])))
});
const multiPolygonSchema = z.object({
type: z.literal('MultiPolygon'),
coordinates: z.array(z.array(z.array(z.tuple([z.number(), z.number()]))))
});
const geometrySchema = z.discriminatedUnion('type', [polygonSchema, multiPolygonSchema]);
export const geoJsonSchema = z.object({
type: z.literal('FeatureCollection'),
features: z.array(
z.object({
type: z.literal('Feature'),
id: z.string(),
properties: z.object({
name: z.string()
}),
geometry: geometrySchema
})
)
});
export type GeoJSON = z.infer<typeof geoJsonSchema>;
export type PolygonGeometry = z.infer<typeof polygonSchema>;
export type MultiPolygonGeometry = z.infer<typeof multiPolygonSchema>;
export type Geometry = z.infer<typeof geometrySchema>;
export type GeoJsonFeature = z.infer<typeof geoJsonSchema>['features'][number];
export interface Region {
lat: { min: number; max: number };
lng: { min: number; max: number };
}
export interface Marker {
lat: number;
lng: number;
size?: number;
city: string;
code: string;
available: boolean;
}
export interface CreateMapOptions {
height: number;
width: number;
countries?: string[];
region?: Region;
markers: Marker[];
baseColor: string;
markerColor: string;
skew?: number;
}
export interface Pin {
lat: number;
lng: number;
}
export type Point = {
x: number;
y: number;
};
export interface BoundingBox {
lat: { min: number; max: number };
lng: { min: number; max: number };
}