diff --git a/.pnpm-store/v11/index.db b/.pnpm-store/v11/index.db new file mode 100644 index 0000000..341d902 Binary files /dev/null and b/.pnpm-store/v11/index.db differ diff --git a/components.json b/components.json index 7749d1c..40862fb 100644 --- a/components.json +++ b/components.json @@ -28,6 +28,7 @@ "Authorization": "Bearer ${SHADCNBLOCKS_API_KEY}" } }, - "@aceternity": "https://ui.aceternity.com/registry/{name}.json" + "@aceternity": "https://ui.aceternity.com/registry/{name}.json", + "@mapcn": "https://mapcn.dev/r/{name}.json" } } diff --git a/package.json b/package.json index 9b3a7b5..691c5c3 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "clsx": "^2.1.1", "framer-motion": "^12.38.0", "lucide-react": "^1.8.0", + "maplibre-gl": "^5.24.0", "motion": "^12.38.0", "radix-ui": "^1.4.3", "react": "^19.2.5", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a52fabf..e347978 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: lucide-react: specifier: ^1.8.0 version: 1.8.0(react@19.2.5) + maplibre-gl: + specifier: ^5.24.0 + version: 5.24.0 motion: specifier: ^12.38.0 version: 12.38.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) @@ -657,6 +660,42 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@mapbox/jsonlint-lines-primitives@2.0.2': + resolution: {integrity: sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==} + engines: {node: '>= 0.6'} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.2.0': + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.4': + resolution: {integrity: sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==} + + '@mapbox/whoots-js@3.1.0': + resolution: {integrity: sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==} + engines: {node: '>=6.0.0'} + + '@maplibre/geojson-vt@5.0.4': + resolution: {integrity: sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==} + + '@maplibre/geojson-vt@6.1.0': + resolution: {integrity: sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==} + + '@maplibre/maplibre-gl-style-spec@24.8.5': + resolution: {integrity: sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==} + hasBin: true + + '@maplibre/mlt@1.1.9': + resolution: {integrity: sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==} + + '@maplibre/vt-pbf@4.3.0': + resolution: {integrity: sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==} + '@modelcontextprotocol/sdk@1.29.0': resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} engines: {node: '>=18'} @@ -1706,6 +1745,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hast@3.0.4': resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} @@ -1735,6 +1777,9 @@ packages: '@types/statuses@2.0.6': resolution: {integrity: sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} @@ -1743,6 +1788,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@vitejs/plugin-react@5.2.0': resolution: {integrity: sha512-YmKkfhOAi3wsB1PhJq5Scj3GXMn3WvtQ/JC0xoopuHoXSdmtdStOpFrYaT1kie2YgFBcIe64ROzMYRjCrYOdYw==} @@ -2111,6 +2157,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eciesjs@0.4.18: resolution: {integrity: sha512-wG99Zcfcys9fZux7Cft8BAX/YrOJLJSZ3jyYPfhZHqN2E+Ffx+QXBDsv3gubEgPtV6dTzJMSQUwk1H98/t/0wQ==} engines: {bun: '>=1', deno: '>=2', node: '>=16'} @@ -2372,6 +2421,9 @@ packages: github-slugger@2.0.0: resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2601,6 +2653,9 @@ packages: json-schema-typed@8.0.2: resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -2609,6 +2664,9 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + kdbush@4.0.2: + resolution: {integrity: sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} @@ -2719,6 +2777,10 @@ packages: magicast@0.5.2: resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==} + maplibre-gl@5.24.0: + resolution: {integrity: sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==} + engines: {node: '>=16.14.0', npm: '>=8.1.0'} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -2934,6 +2996,9 @@ packages: typescript: optional: true + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + mute-stream@3.0.0: resolution: {integrity: sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3094,6 +3159,10 @@ packages: path-to-regexp@8.4.2: resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pbf@4.0.1: + resolution: {integrity: sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==} + hasBin: true + piccolore@0.1.3: resolution: {integrity: sha512-o8bTeDWjE086iwKrROaDf31K0qC/BENdm15/uH9usSC/uZjJOKb2YGiVHfLY4GhwsERiPI1jmwI2XrA7ACOxVw==} @@ -3120,6 +3189,9 @@ packages: resolution: {integrity: sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + powershell-utils@0.1.0: resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} engines: {node: '>=20'} @@ -3139,6 +3211,9 @@ packages: property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3150,6 +3225,9 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + radix-ui@1.4.3: resolution: {integrity: sha512-aWizCQiyeAenIdUbqEpXgRA1ya65P13NKn/W8rWkcN0OPkRDxdBVLWnIEDsS2RpwCK2nobI7oMUSmexzTDyAmA==} peerDependencies: @@ -3280,6 +3358,9 @@ packages: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + restore-cursor@5.1.0: resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} engines: {node: '>=18'} @@ -3459,6 +3540,9 @@ packages: resolution: {integrity: sha512-aulFJcD6YK8V1G7iRB5tigAP4TsHBZZrOV8pjV++zdUwmeV8uzbY7yn6h9MswN62adStNZFuCIx4haBnRuMDaw==} engines: {node: '>=18'} + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + svgo@4.0.1: resolution: {integrity: sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==} engines: {node: '>=16'} @@ -3496,6 +3580,9 @@ packages: resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tldts-core@7.0.28: resolution: {integrity: sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ==} @@ -4411,6 +4498,51 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@mapbox/jsonlint-lines-primitives@2.0.2': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.2.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.4': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.1 + + '@mapbox/whoots-js@3.1.0': {} + + '@maplibre/geojson-vt@5.0.4': {} + + '@maplibre/geojson-vt@6.1.0': + dependencies: + kdbush: 4.0.2 + + '@maplibre/maplibre-gl-style-spec@24.8.5': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/unitbezier': 0.0.1 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + '@maplibre/mlt@1.1.9': + dependencies: + '@mapbox/point-geometry': 1.1.0 + + '@maplibre/vt-pbf@4.3.0': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@mapbox/vector-tile': 2.0.4 + '@maplibre/geojson-vt': 5.0.4 + '@types/geojson': 7946.0.16 + '@types/supercluster': 7.1.3 + pbf: 4.0.1 + supercluster: 8.0.1 + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': dependencies: '@hono/node-server': 1.19.14(hono@4.12.14) @@ -5454,6 +5586,8 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson@7946.0.16': {} + '@types/hast@3.0.4': dependencies: '@types/unist': 3.0.3 @@ -5486,6 +5620,10 @@ snapshots: '@types/statuses@2.0.6': {} + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/unist@3.0.3': {} '@types/validate-npm-package-name@4.0.2': {} @@ -5891,6 +6029,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + eciesjs@0.4.18: dependencies: '@ecies/ciphers': 0.2.6(@noble/ciphers@1.3.0) @@ -6190,6 +6330,8 @@ snapshots: github-slugger@2.0.0: {} + gl-matrix@3.4.4: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -6424,6 +6566,8 @@ snapshots: json-schema-typed@8.0.2: {} + json-stringify-pretty-compact@4.0.0: {} + json5@2.2.3: {} jsonfile@6.2.1: @@ -6432,6 +6576,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + kdbush@4.0.2: {} + kleur@3.0.3: {} kleur@4.1.5: {} @@ -6514,6 +6660,28 @@ snapshots: '@babel/types': 7.29.0 source-map-js: 1.2.1 + maplibre-gl@5.24.0: + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.2 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.2.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.4 + '@mapbox/whoots-js': 3.1.0 + '@maplibre/geojson-vt': 6.1.0 + '@maplibre/maplibre-gl-style-spec': 24.8.5 + '@maplibre/mlt': 1.1.9 + '@maplibre/vt-pbf': 4.3.0 + '@types/geojson': 7946.0.16 + earcut: 3.0.2 + gl-matrix: 3.4.4 + kdbush: 4.0.2 + murmurhash-js: 1.0.0 + pbf: 4.0.1 + potpack: 2.1.0 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + markdown-table@3.0.4: {} math-intrinsics@1.1.0: {} @@ -6903,6 +7071,8 @@ snapshots: transitivePeerDependencies: - '@types/node' + murmurhash-js@1.0.0: {} + mute-stream@3.0.0: {} nanoid@3.3.11: {} @@ -7058,6 +7228,10 @@ snapshots: path-to-regexp@8.4.2: {} + pbf@4.0.1: + dependencies: + resolve-protobuf-schema: 2.1.0 + piccolore@0.1.3: {} picocolors@1.1.1: {} @@ -7079,6 +7253,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + powershell-utils@0.1.0: {} pretty-ms@9.3.0: @@ -7094,6 +7270,8 @@ snapshots: property-information@7.1.0: {} + protocol-buffers-schema@3.6.1: {} + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -7105,6 +7283,8 @@ snapshots: queue-microtask@1.2.3: {} + quickselect@3.0.0: {} + radix-ui@1.4.3(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5): dependencies: '@radix-ui/primitive': 1.1.3 @@ -7311,6 +7491,10 @@ snapshots: resolve-from@4.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + restore-cursor@5.1.0: dependencies: onetime: 7.0.0 @@ -7608,6 +7792,10 @@ snapshots: strip-final-newline@4.0.0: {} + supercluster@8.0.1: + dependencies: + kdbush: 4.0.2 + svgo@4.0.1: dependencies: commander: 11.1.0 @@ -7639,6 +7827,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.4) picomatch: 4.0.4 + tinyqueue@3.0.0: {} + tldts-core@7.0.28: {} tldts@7.0.28: diff --git a/src/components/landing-hero-section.tsx b/src/components/landing-hero-section.tsx index ea0e30d..3f59361 100644 --- a/src/components/landing-hero-section.tsx +++ b/src/components/landing-hero-section.tsx @@ -62,6 +62,7 @@ const LandingHeroSection = () => { @@ -76,9 +77,9 @@ const LandingHeroSection = () => {

- Strategie trifft Umsetzung + Ihr Betrieb im Netz — ohne Stress

-

+

Ich baue Websites für Handwerk, Praxen, Salons und Dienstleister aus der Region — klar genug, dass Besucher anrufen statt weiterklicken. @@ -118,7 +119,7 @@ const LandingHeroSection = () => {

- + Webdesign ©2026
@@ -177,7 +178,7 @@ const LandingHeroSection = () => { href="#kontakt" className="group relative z-10 inline-flex w-fit shrink-0 items-center gap-3 border border-primary-foreground px-5 py-4 text-sm font-semibold uppercase tracking-[0.18em] transition hover:bg-primary-foreground hover:text-primary" > - Projekt anfragen + Unverbindlich anfragen
diff --git a/src/components/landing-page-sections.tsx b/src/components/landing-page-sections.tsx index df01120..0bda6b1 100644 --- a/src/components/landing-page-sections.tsx +++ b/src/components/landing-page-sections.tsx @@ -1,15 +1,15 @@ -import { ContactSection } from "@/components/landing/contact-section"; -import { DeliverablesSection } from "@/components/landing/deliverables-section"; +import { AboutSection } from "@/components/landing/about-section"; import { PackagesSection } from "@/components/landing/packages-section"; +import { ProcessSection } from "@/components/landing/process-section"; import { ServicesSection } from "@/components/landing/services-section"; const LandingPageSections = () => { return ( <> - + + - ); }; diff --git a/src/components/landing/about-section.tsx b/src/components/landing/about-section.tsx new file mode 100644 index 0000000..66c084f --- /dev/null +++ b/src/components/landing/about-section.tsx @@ -0,0 +1,36 @@ +const AboutSection = () => { + return ( +
+
+

+ Über mich (03) +

+

+ Ein Mensch. Kein Ticketsystem. +

+
+
+

+ Ich bin Matthias — Webdesigner aus Sachsen. +

+
+

+ Ich baue Websites für Betriebe, die ihre Energie lieber in Kunden + stecken als in Technik. Kein Großraumbüro, kein Agentur-Overhead — + ein Ansprechpartner von der ersten Idee bis zum Go-live. +

+

+ Meine Kunden sind Handwerker, Praxen, Salons und Dienstleister aus + der Region. Menschen, die eine Website wollen, die funktioniert — + und sich dann wieder um ihren Betrieb kümmern möchten. +

+
+
+
+ ); +}; + +export { AboutSection }; diff --git a/src/components/landing/contact-section.tsx b/src/components/landing/contact-section.tsx index 9afe6df..2edfb5c 100644 --- a/src/components/landing/contact-section.tsx +++ b/src/components/landing/contact-section.tsx @@ -1,43 +1,65 @@ +"use client"; + import { CornerDownRight, Mail, MapPin, Phone } from "lucide-react"; +import { Map, MapControls, MapMarker, MarkerContent } from "@/components/ui/map"; + +/** Karl-Marx-Str. 22, 08451 Crimmitschau (OpenStreetMap) */ +const OFFICE: [number, number] = [12.3829769, 50.8131218]; const ContactSection = () => { return ( -
-
-

- Kontakt (05) -

-

- Erzählen Sie mir kurz von Ihrem Betrieb -

-

- Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für - Sie tun, und wann soll sie online sein? -

- - - Anfrage per Mail senden - +
+
+
+

+ Kontakt (06) +

+

+ Erzählen Sie mir kurz von Ihrem Betrieb +

+

+ Ein paar Sätze reichen: Was bieten Sie an, was soll die Website für + Sie tun, und wann soll sie online sein? +

+ + + Kurze Nachricht schreiben + +
+ +
+
+ + support@matthias-meister-webdesign.de +
+
+ + Rückmeldung innerhalb von 24 Stunden +
+
+ + Karl-Marx-Str. 22, 08451 Crimmitschau +
+
-
-
- - support@matthias-meister-webdesign.de -
-
- - Rückmeldung innerhalb von 24 Stunden -
-
- - Betriebe aus der Region -
+ +
+ + + + +
+ + +
); diff --git a/src/components/landing/packages-section.tsx b/src/components/landing/packages-section.tsx index 1545fc8..3b6d313 100644 --- a/src/components/landing/packages-section.tsx +++ b/src/components/landing/packages-section.tsx @@ -1,18 +1,51 @@ +import { Check } from "lucide-react"; + const packages = [ { name: "Basis", price: "799 EUR", detail: "Eine starke Seite für ein klares Angebot.", + highlighted: false, + features: [ + "One-Page-Website", + "Mobil optimiert", + "Kontaktformular", + "DSGVO & Impressum", + "Hosting für 1 Jahr", + ], }, { name: "Profi", price: "1.499 EUR", detail: "Mehrere Seiten für Betriebe mit mehr zu zeigen.", + highlighted: true, + features: [ + "Alles aus Basis", + "Bis zu 5 Unterseiten", + "Individuelles Design", + "SEO-Grundoptimierung", + "Google Maps Einbindung", + "Kontaktformular", + "DSGVO & Impressum", + "Hosting für 1 Jahr", + ], }, { name: "Maßarbeit", price: "2.499 EUR+", - detail: "Eigene Struktur, selbst pflegbar, für besondere Anforderungen.", + detail: "Ihr Betrieb, Ihre Regeln. Inhalte selbst ändern, Seiten ergänzen, wachsen.", + highlighted: false, + features: [ + "Alles aus Profi", + "Unbegrenzte Seiten", + "Inhalte selbst pflegbar (CMS)", + "Individuelles Design & Struktur", + "SEO-Optimierung", + "Blog oder News-Bereich", + "Erweiterte Funktionen", + "DSGVO & Impressum", + "Hosting für 1 Jahr", + ], }, ]; @@ -25,29 +58,47 @@ const PackagesSection = () => {

- Pakete (04) + Pakete (05)

- Kosten ohne Nebel + Festpreis. Punkt.

{packages.map((item) => (
-

- {item.name} -

+
+

+ {item.name} +

+ {item.highlighted ? ( + + Beliebteste Wahl + + ) : null} +

{item.price}

+

+ {item.detail} +

-

- {item.detail} -

+
    + {item.features.map((feature) => ( +
  • + + {feature} +
  • + ))} +
))}
diff --git a/src/components/landing/process-section.tsx b/src/components/landing/process-section.tsx new file mode 100644 index 0000000..a13f22a --- /dev/null +++ b/src/components/landing/process-section.tsx @@ -0,0 +1,61 @@ +const steps = [ + { + number: "01", + title: "Gespräch", + text: "15 Minuten telefonieren. Sie erzählen, ich höre zu.", + }, + { + number: "02", + title: "Konzept", + text: "Seitenstruktur und Design-Vorschlag innerhalb einer Woche.", + }, + { + number: "03", + title: "Umsetzung", + text: "Fertige Website in 2–4 Wochen. Feedback, Anpassung, fertig.", + }, + { + number: "04", + title: "Online", + text: "Ich schalte live, richte Hosting ein, kümmere mich um den Rest.", + }, +]; + +const ProcessSection = () => { + return ( +
+
+

+ Ablauf (04) +

+

+ Vier Schritte. Fertig. +

+
+ +
+ {steps.map((step, i) => ( +
+ + {step.number} + +

+ {step.title} +

+

+ {step.text} +

+
+ ))} +
+
+ ); +}; + +export { ProcessSection }; diff --git a/src/components/landing/services-section.tsx b/src/components/landing/services-section.tsx index 2d9aebd..a888563 100644 --- a/src/components/landing/services-section.tsx +++ b/src/components/landing/services-section.tsx @@ -2,17 +2,17 @@ const services = [ { number: "01", title: "Website", - text: "Eine klare Startseite oder ein kompletter Auftritt, der sofort zeigt, warum man Ihnen vertrauen kann.", + text: "Vom Elektriker bis zur Physiotherapie — eine Seite, die in drei Sekunden zeigt, was Sie machen und wie man Sie erreicht.", }, { number: "02", title: "Struktur", - text: "Angebot, Referenzen, Ablauf und Kontakt werden so sortiert, dass Besucher nicht suchen müssen.", + text: "Angebot, Leistungen, Ablauf und Kontakt — alles dort, wo Besucher es erwarten. Damit aus Klicks Anrufe werden.", }, { number: "03", title: "Technik", - text: "Schnell, mobil sauber, DSGVO-sauber und so gebaut, dass spätere Änderungen nicht zum Projekt werden.", + text: "Lädt in unter zwei Sekunden, sieht auf jedem Handy gut aus und ist rechtssicher. Änderungen später? Ein Anruf genügt.", }, ]; diff --git a/src/components/ui/map.tsx b/src/components/ui/map.tsx new file mode 100644 index 0000000..a69471c --- /dev/null +++ b/src/components/ui/map.tsx @@ -0,0 +1,1844 @@ +"use client"; + +import MapLibreGL, { type PopupOptions, type MarkerOptions } from "maplibre-gl"; +import "maplibre-gl/dist/maplibre-gl.css"; +import { + createContext, + forwardRef, + useCallback, + useContext, + useEffect, + useId, + useImperativeHandle, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { createPortal } from "react-dom"; +import { X, Minus, Plus, Locate, Maximize, Loader2 } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +const defaultStyles = { + dark: "https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json", + light: "https://basemaps.cartocdn.com/gl/positron-gl-style/style.json", +}; + +type Theme = "light" | "dark"; + +// Check document class for theme (works with next-themes, etc.) +function getDocumentTheme(): Theme | null { + if (typeof document === "undefined") return null; + if (document.documentElement.classList.contains("dark")) return "dark"; + if (document.documentElement.classList.contains("light")) return "light"; + return null; +} + +// Get system preference +function getSystemTheme(): Theme { + if (typeof window === "undefined") return "light"; + return window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; +} + +function useResolvedTheme(themeProp?: "light" | "dark"): Theme { + const [detectedTheme, setDetectedTheme] = useState( + () => getDocumentTheme() ?? getSystemTheme(), + ); + + useEffect(() => { + if (themeProp) return; // Skip detection if theme is provided via prop + + // Watch for document class changes (e.g., next-themes toggling dark class) + const observer = new MutationObserver(() => { + const docTheme = getDocumentTheme(); + if (docTheme) { + setDetectedTheme(docTheme); + } + }); + observer.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + + // Also watch for system preference changes + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleSystemChange = (e: MediaQueryListEvent) => { + // Only use system preference if no document class is set + if (!getDocumentTheme()) { + setDetectedTheme(e.matches ? "dark" : "light"); + } + }; + mediaQuery.addEventListener("change", handleSystemChange); + + return () => { + observer.disconnect(); + mediaQuery.removeEventListener("change", handleSystemChange); + }; + }, [themeProp]); + + return themeProp ?? detectedTheme; +} + +type MapContextValue = { + map: MapLibreGL.Map | null; + isLoaded: boolean; +}; + +const MapContext = createContext(null); + +function useMap() { + const context = useContext(MapContext); + if (!context) { + throw new Error("useMap must be used within a Map component"); + } + return context; +} + +/** Map viewport state */ +type MapViewport = { + /** Center coordinates [longitude, latitude] */ + center: [number, number]; + /** Zoom level */ + zoom: number; + /** Bearing (rotation) in degrees */ + bearing: number; + /** Pitch (tilt) in degrees */ + pitch: number; +}; + +type MapStyleOption = string | MapLibreGL.StyleSpecification; + +type MapRef = MapLibreGL.Map; + +type MapProps = { + children?: ReactNode; + /** Additional CSS classes for the map container */ + className?: string; + /** + * Theme for the map. If not provided, automatically detects system preference. + * Pass your theme value here. + */ + theme?: Theme; + /** Custom map styles for light and dark themes. Overrides the default Carto styles. */ + styles?: { + light?: MapStyleOption; + dark?: MapStyleOption; + }; + /** Map projection type. Use `{ type: "globe" }` for 3D globe view. */ + projection?: MapLibreGL.ProjectionSpecification; + /** + * Controlled viewport. When provided with onViewportChange, + * the map becomes controlled and viewport is driven by this prop. + */ + viewport?: Partial; + /** + * Callback fired continuously as the viewport changes (pan, zoom, rotate, pitch). + * Can be used standalone to observe changes, or with `viewport` prop + * to enable controlled mode where the map viewport is driven by your state. + */ + onViewportChange?: (viewport: MapViewport) => void; + /** Show a loading indicator on the map */ + loading?: boolean; +} & Omit; + +function DefaultLoader() { + return ( +
+
+ + + +
+
+ ); +} + +function getViewport(map: MapLibreGL.Map): MapViewport { + const center = map.getCenter(); + return { + center: [center.lng, center.lat], + zoom: map.getZoom(), + bearing: map.getBearing(), + pitch: map.getPitch(), + }; +} + +const Map = forwardRef(function Map( + { + children, + className, + theme: themeProp, + styles, + projection, + viewport, + onViewportChange, + loading = false, + ...props + }, + ref, +) { + const containerRef = useRef(null); + const [mapInstance, setMapInstance] = useState(null); + const [isLoaded, setIsLoaded] = useState(false); + const [isStyleLoaded, setIsStyleLoaded] = useState(false); + const currentStyleRef = useRef(null); + const styleTimeoutRef = useRef | null>(null); + const internalUpdateRef = useRef(false); + const resolvedTheme = useResolvedTheme(themeProp); + + const isControlled = viewport !== undefined && onViewportChange !== undefined; + + const onViewportChangeRef = useRef(onViewportChange); + onViewportChangeRef.current = onViewportChange; + + const mapStyles = useMemo( + () => ({ + dark: styles?.dark ?? defaultStyles.dark, + light: styles?.light ?? defaultStyles.light, + }), + [styles], + ); + + // Expose the map instance to the parent component + useImperativeHandle(ref, () => mapInstance as MapLibreGL.Map, [mapInstance]); + + const clearStyleTimeout = useCallback(() => { + if (styleTimeoutRef.current) { + clearTimeout(styleTimeoutRef.current); + styleTimeoutRef.current = null; + } + }, []); + + // Initialize the map + useEffect(() => { + if (!containerRef.current) return; + + const initialStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + currentStyleRef.current = initialStyle; + + const map = new MapLibreGL.Map({ + container: containerRef.current, + style: initialStyle, + renderWorldCopies: false, + attributionControl: { + compact: true, + }, + ...props, + ...viewport, + }); + + const styleDataHandler = () => { + clearStyleTimeout(); + // Delay to ensure style is fully processed before allowing layer operations + // This is a workaround to avoid race conditions with the style loading + // else we have to force update every layer on setStyle change + styleTimeoutRef.current = setTimeout(() => { + setIsStyleLoaded(true); + if (projection) { + map.setProjection(projection); + } + }, 100); + }; + const loadHandler = () => setIsLoaded(true); + + // Viewport change handler - skip if triggered by internal update + const handleMove = () => { + if (internalUpdateRef.current) return; + onViewportChangeRef.current?.(getViewport(map)); + }; + + map.on("load", loadHandler); + map.on("styledata", styleDataHandler); + map.on("move", handleMove); + setMapInstance(map); + + return () => { + clearStyleTimeout(); + map.off("load", loadHandler); + map.off("styledata", styleDataHandler); + map.off("move", handleMove); + map.remove(); + setIsLoaded(false); + setIsStyleLoaded(false); + setMapInstance(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Sync controlled viewport to map + useEffect(() => { + if (!mapInstance || !isControlled || !viewport) return; + if (mapInstance.isMoving()) return; + + const current = getViewport(mapInstance); + const next = { + center: viewport.center ?? current.center, + zoom: viewport.zoom ?? current.zoom, + bearing: viewport.bearing ?? current.bearing, + pitch: viewport.pitch ?? current.pitch, + }; + + if ( + next.center[0] === current.center[0] && + next.center[1] === current.center[1] && + next.zoom === current.zoom && + next.bearing === current.bearing && + next.pitch === current.pitch + ) { + return; + } + + internalUpdateRef.current = true; + mapInstance.jumpTo(next); + internalUpdateRef.current = false; + }, [mapInstance, isControlled, viewport]); + + // Handle style change + useEffect(() => { + if (!mapInstance || !resolvedTheme) return; + + const newStyle = + resolvedTheme === "dark" ? mapStyles.dark : mapStyles.light; + + if (currentStyleRef.current === newStyle) return; + + clearStyleTimeout(); + currentStyleRef.current = newStyle; + setIsStyleLoaded(false); + + mapInstance.setStyle(newStyle, { diff: true }); + }, [mapInstance, resolvedTheme, mapStyles, clearStyleTimeout]); + + const contextValue = useMemo( + () => ({ + map: mapInstance, + isLoaded: isLoaded && isStyleLoaded, + }), + [mapInstance, isLoaded, isStyleLoaded], + ); + + return ( + +
+ {(!isLoaded || loading) && } + {/* SSR-safe: children render only when map is loaded on client */} + {mapInstance && children} +
+
+ ); +}); + +type MarkerContextValue = { + marker: MapLibreGL.Marker; + map: MapLibreGL.Map | null; +}; + +const MarkerContext = createContext(null); + +function useMarkerContext() { + const context = useContext(MarkerContext); + if (!context) { + throw new Error("Marker components must be used within MapMarker"); + } + return context; +} + +type MapMarkerProps = { + /** Longitude coordinate for marker position */ + longitude: number; + /** Latitude coordinate for marker position */ + latitude: number; + /** Marker subcomponents (MarkerContent, MarkerPopup, MarkerTooltip, MarkerLabel) */ + children: ReactNode; + /** Callback when marker is clicked */ + onClick?: (e: MouseEvent) => void; + /** Callback when mouse enters marker */ + onMouseEnter?: (e: MouseEvent) => void; + /** Callback when mouse leaves marker */ + onMouseLeave?: (e: MouseEvent) => void; + /** Callback when marker drag starts (requires draggable: true) */ + onDragStart?: (lngLat: { lng: number; lat: number }) => void; + /** Callback during marker drag (requires draggable: true) */ + onDrag?: (lngLat: { lng: number; lat: number }) => void; + /** Callback when marker drag ends (requires draggable: true) */ + onDragEnd?: (lngLat: { lng: number; lat: number }) => void; +} & Omit; + +function MapMarker({ + longitude, + latitude, + children, + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + draggable = false, + ...markerOptions +}: MapMarkerProps) { + const { map } = useMap(); + + const callbacksRef = useRef({ + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }); + callbacksRef.current = { + onClick, + onMouseEnter, + onMouseLeave, + onDragStart, + onDrag, + onDragEnd, + }; + + const marker = useMemo(() => { + const markerInstance = new MapLibreGL.Marker({ + ...markerOptions, + element: document.createElement("div"), + draggable, + }).setLngLat([longitude, latitude]); + + const handleClick = (e: MouseEvent) => callbacksRef.current.onClick?.(e); + const handleMouseEnter = (e: MouseEvent) => + callbacksRef.current.onMouseEnter?.(e); + const handleMouseLeave = (e: MouseEvent) => + callbacksRef.current.onMouseLeave?.(e); + + markerInstance.getElement()?.addEventListener("click", handleClick); + markerInstance + .getElement() + ?.addEventListener("mouseenter", handleMouseEnter); + markerInstance + .getElement() + ?.addEventListener("mouseleave", handleMouseLeave); + + const handleDragStart = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragStart?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDrag = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDrag?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + const handleDragEnd = () => { + const lngLat = markerInstance.getLngLat(); + callbacksRef.current.onDragEnd?.({ lng: lngLat.lng, lat: lngLat.lat }); + }; + + markerInstance.on("dragstart", handleDragStart); + markerInstance.on("drag", handleDrag); + markerInstance.on("dragend", handleDragEnd); + + return markerInstance; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + marker.addTo(map); + + return () => { + marker.remove(); + }; + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if ( + marker.getLngLat().lng !== longitude || + marker.getLngLat().lat !== latitude + ) { + marker.setLngLat([longitude, latitude]); + } + if (marker.isDraggable() !== draggable) { + marker.setDraggable(draggable); + } + + const currentOffset = marker.getOffset(); + const newOffset = markerOptions.offset ?? [0, 0]; + const [newOffsetX, newOffsetY] = Array.isArray(newOffset) + ? newOffset + : [newOffset.x, newOffset.y]; + if (currentOffset.x !== newOffsetX || currentOffset.y !== newOffsetY) { + marker.setOffset(newOffset); + } + + if (marker.getRotation() !== markerOptions.rotation) { + marker.setRotation(markerOptions.rotation ?? 0); + } + if (marker.getRotationAlignment() !== markerOptions.rotationAlignment) { + marker.setRotationAlignment(markerOptions.rotationAlignment ?? "auto"); + } + if (marker.getPitchAlignment() !== markerOptions.pitchAlignment) { + marker.setPitchAlignment(markerOptions.pitchAlignment ?? "auto"); + } + + return ( + + {children} + + ); +} + +type MarkerContentProps = { + /** Custom marker content. Defaults to a blue dot if not provided */ + children?: ReactNode; + /** Additional CSS classes for the marker container */ + className?: string; +}; + +function MarkerContent({ children, className }: MarkerContentProps) { + const { marker } = useMarkerContext(); + + return createPortal( +
+ {children || } +
, + marker.getElement(), + ); +} + +function DefaultMarkerIcon() { + return ( +
+ ); +} + +function PopupCloseButton({ onClick }: { onClick: () => void }) { + return ( + + ); +} + +type MarkerPopupProps = { + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MarkerPopup({ + children, + className, + closeButton = false, + ...popupOptions +}: MarkerPopupProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const prevPopupOptions = useRef(popupOptions); + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setDOMContent(container); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + popup.setDOMContent(container); + marker.setPopup(popup); + + return () => { + marker.setPopup(null); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (popup.isOpen()) { + const prev = prevPopupOptions.current; + + if (prev.offset !== popupOptions.offset) { + popup.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + prevPopupOptions.current = popupOptions; + } + + const handleClose = () => popup.remove(); + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MarkerTooltipProps = { + /** Tooltip content */ + children: ReactNode; + /** Additional CSS classes for the tooltip container */ + className?: string; +} & Omit; + +function MarkerTooltip({ + children, + className, + ...popupOptions +}: MarkerTooltipProps) { + const { marker, map } = useMarkerContext(); + const container = useMemo(() => document.createElement("div"), []); + const prevTooltipOptions = useRef(popupOptions); + + const tooltip = useMemo(() => { + const tooltipInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeOnClick: true, + closeButton: false, + }).setMaxWidth("none"); + + return tooltipInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + tooltip.setDOMContent(container); + + const handleMouseEnter = () => { + tooltip.setLngLat(marker.getLngLat()).addTo(map); + }; + const handleMouseLeave = () => tooltip.remove(); + + marker.getElement()?.addEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.addEventListener("mouseleave", handleMouseLeave); + + return () => { + marker.getElement()?.removeEventListener("mouseenter", handleMouseEnter); + marker.getElement()?.removeEventListener("mouseleave", handleMouseLeave); + tooltip.remove(); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (tooltip.isOpen()) { + const prev = prevTooltipOptions.current; + + if (prev.offset !== popupOptions.offset) { + tooltip.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + tooltip.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + + prevTooltipOptions.current = popupOptions; + } + + return createPortal( +
+ {children} +
, + container, + ); +} + +type MarkerLabelProps = { + /** Label text content */ + children: ReactNode; + /** Additional CSS classes for the label */ + className?: string; + /** Position of the label relative to the marker (default: "top") */ + position?: "top" | "bottom"; +}; + +function MarkerLabel({ + children, + className, + position = "top", +}: MarkerLabelProps) { + const positionClasses = { + top: "bottom-full mb-1", + bottom: "top-full mt-1", + }; + + return ( +
+ {children} +
+ ); +} + +type MapControlsProps = { + /** Position of the controls on the map (default: "bottom-right") */ + position?: "top-left" | "top-right" | "bottom-left" | "bottom-right"; + /** Show zoom in/out buttons (default: true) */ + showZoom?: boolean; + /** Show compass button to reset bearing (default: false) */ + showCompass?: boolean; + /** Show locate button to find user's location (default: false) */ + showLocate?: boolean; + /** Show fullscreen toggle button (default: false) */ + showFullscreen?: boolean; + /** Additional CSS classes for the controls container */ + className?: string; + /** Callback with user coordinates when located */ + onLocate?: (coords: { longitude: number; latitude: number }) => void; +}; + +const positionClasses = { + "top-left": "top-2 left-2", + "top-right": "top-2 right-2", + "bottom-left": "bottom-2 left-2", + "bottom-right": "bottom-10 right-2", +}; + +function ControlGroup({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function ControlButton({ + onClick, + label, + children, + disabled = false, +}: { + onClick: () => void; + label: string; + children: React.ReactNode; + disabled?: boolean; +}) { + return ( + + ); +} + +function MapControls({ + position = "bottom-right", + showZoom = true, + showCompass = false, + showLocate = false, + showFullscreen = false, + className, + onLocate, +}: MapControlsProps) { + const { map } = useMap(); + const [waitingForLocation, setWaitingForLocation] = useState(false); + + const handleZoomIn = useCallback(() => { + map?.zoomTo(map.getZoom() + 1, { duration: 300 }); + }, [map]); + + const handleZoomOut = useCallback(() => { + map?.zoomTo(map.getZoom() - 1, { duration: 300 }); + }, [map]); + + const handleResetBearing = useCallback(() => { + map?.resetNorthPitch({ duration: 300 }); + }, [map]); + + const handleLocate = useCallback(() => { + setWaitingForLocation(true); + if ("geolocation" in navigator) { + navigator.geolocation.getCurrentPosition( + (pos) => { + const coords = { + longitude: pos.coords.longitude, + latitude: pos.coords.latitude, + }; + map?.flyTo({ + center: [coords.longitude, coords.latitude], + zoom: 14, + duration: 1500, + }); + onLocate?.(coords); + setWaitingForLocation(false); + }, + (error) => { + console.error("Error getting location:", error); + setWaitingForLocation(false); + }, + ); + } + }, [map, onLocate]); + + const handleFullscreen = useCallback(() => { + const container = map?.getContainer(); + if (!container) return; + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + container.requestFullscreen(); + } + }, [map]); + + return ( +
+ {showZoom && ( + + + + + + + + + )} + {showCompass && ( + + + + )} + {showLocate && ( + + + {waitingForLocation ? ( + + ) : ( + + )} + + + )} + {showFullscreen && ( + + + + + + )} +
+ ); +} + +function CompassButton({ onClick }: { onClick: () => void }) { + const { map } = useMap(); + const compassRef = useRef(null); + + useEffect(() => { + if (!map || !compassRef.current) return; + + const compass = compassRef.current; + + const updateRotation = () => { + const bearing = map.getBearing(); + const pitch = map.getPitch(); + compass.style.transform = `rotateX(${pitch}deg) rotateZ(${-bearing}deg)`; + }; + + map.on("rotate", updateRotation); + map.on("pitch", updateRotation); + updateRotation(); + + return () => { + map.off("rotate", updateRotation); + map.off("pitch", updateRotation); + }; + }, [map]); + + return ( + + + + + + + + + ); +} + +type MapPopupProps = { + /** Longitude coordinate for popup position */ + longitude: number; + /** Latitude coordinate for popup position */ + latitude: number; + /** Callback when popup is closed */ + onClose?: () => void; + /** Popup content */ + children: ReactNode; + /** Additional CSS classes for the popup container */ + className?: string; + /** Show a close button in the popup (default: false) */ + closeButton?: boolean; +} & Omit; + +function MapPopup({ + longitude, + latitude, + onClose, + children, + className, + closeButton = false, + ...popupOptions +}: MapPopupProps) { + const { map } = useMap(); + const popupOptionsRef = useRef(popupOptions); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const container = useMemo(() => document.createElement("div"), []); + + const popup = useMemo(() => { + const popupInstance = new MapLibreGL.Popup({ + offset: 16, + ...popupOptions, + closeButton: false, + }) + .setMaxWidth("none") + .setLngLat([longitude, latitude]); + + return popupInstance; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (!map) return; + + const onCloseProp = () => onCloseRef.current?.(); + + popup.on("close", onCloseProp); + + popup.setDOMContent(container); + popup.addTo(map); + + return () => { + popup.off("close", onCloseProp); + if (popup.isOpen()) { + popup.remove(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [map]); + + if (popup.isOpen()) { + const prev = popupOptionsRef.current; + + if ( + popup.getLngLat().lng !== longitude || + popup.getLngLat().lat !== latitude + ) { + popup.setLngLat([longitude, latitude]); + } + + if (prev.offset !== popupOptions.offset) { + popup.setOffset(popupOptions.offset ?? 16); + } + if (prev.maxWidth !== popupOptions.maxWidth && popupOptions.maxWidth) { + popup.setMaxWidth(popupOptions.maxWidth ?? "none"); + } + popupOptionsRef.current = popupOptions; + } + + const handleClose = () => { + popup.remove(); + }; + + return createPortal( +
+ {closeButton && } + {children} +
, + container, + ); +} + +type MapRouteProps = { + /** Optional unique identifier for the route layer */ + id?: string; + /** Array of [longitude, latitude] coordinate pairs defining the route */ + coordinates: [number, number][]; + /** Line color as CSS color value (default: "#4285F4") */ + color?: string; + /** Line width in pixels (default: 3) */ + width?: number; + /** Line opacity from 0 to 1 (default: 0.8) */ + opacity?: number; + /** Dash pattern [dash length, gap length] for dashed lines */ + dashArray?: [number, number]; + /** Callback when the route line is clicked */ + onClick?: () => void; + /** Callback when mouse enters the route line */ + onMouseEnter?: () => void; + /** Callback when mouse leaves the route line */ + onMouseLeave?: () => void; + /** Whether the route is interactive - shows pointer cursor on hover (default: true) */ + interactive?: boolean; +}; + +function MapRoute({ + id: propId, + coordinates, + color = "#4285F4", + width = 3, + opacity = 0.8, + dashArray, + onClick, + onMouseEnter, + onMouseLeave, + interactive = true, +}: MapRouteProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `route-source-${id}`; + const layerId = `route-layer-${id}`; + + // Add source and layer on mount + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: { + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates: [] }, + }, + }); + + map.addLayer({ + id: layerId, + type: "line", + source: sourceId, + layout: { "line-join": "round", "line-cap": "round" }, + paint: { + "line-color": color, + "line-width": width, + "line-opacity": opacity, + ...(dashArray && { "line-dasharray": dashArray }), + }, + }); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // When coordinates change, update the source data + useEffect(() => { + if (!isLoaded || !map || coordinates.length < 2) return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData({ + type: "Feature", + properties: {}, + geometry: { type: "LineString", coordinates }, + }); + } + }, [isLoaded, map, coordinates, sourceId]); + + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + + map.setPaintProperty(layerId, "line-color", color); + map.setPaintProperty(layerId, "line-width", width); + map.setPaintProperty(layerId, "line-opacity", opacity); + if (dashArray) { + map.setPaintProperty(layerId, "line-dasharray", dashArray); + } + }, [isLoaded, map, layerId, color, width, opacity, dashArray]); + + // Handle click and hover events + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + const handleClick = () => { + onClick?.(); + }; + const handleMouseEnter = () => { + map.getCanvas().style.cursor = "pointer"; + onMouseEnter?.(); + }; + const handleMouseLeave = () => { + map.getCanvas().style.cursor = ""; + onMouseLeave?.(); + }; + + map.on("click", layerId, handleClick); + map.on("mouseenter", layerId, handleMouseEnter); + map.on("mouseleave", layerId, handleMouseLeave); + + return () => { + map.off("click", layerId, handleClick); + map.off("mouseenter", layerId, handleMouseEnter); + map.off("mouseleave", layerId, handleMouseLeave); + }; + }, [ + isLoaded, + map, + layerId, + onClick, + onMouseEnter, + onMouseLeave, + interactive, + ]); + + return null; +} + +/** A single arc to render inside . */ +type MapArcDatum = { + /** Unique identifier for this arc. Required for hover state tracking and event payloads. */ + id: string | number; + /** Start coordinate as [longitude, latitude]. */ + from: [number, number]; + /** End coordinate as [longitude, latitude]. */ + to: [number, number]; +}; + +/** Event payload passed to MapArc interaction callbacks. */ +type MapArcEvent = { + /** The arc datum that was hovered or clicked. */ + arc: T; + /** Longitude of the cursor at the time of the event. */ + longitude: number; + /** Latitude of the cursor at the time of the event. */ + latitude: number; + /** The underlying MapLibre mouse event for advanced use cases. */ + originalEvent: MapLibreGL.MapMouseEvent; +}; + +type MapArcLinePaint = NonNullable; +type MapArcLineLayout = NonNullable< + MapLibreGL.LineLayerSpecification["layout"] +>; + +type MapArcProps = { + /** Array of arcs to render. Each arc must have a unique `id`. */ + data: T[]; + /** Optional unique identifier prefix for the arc source/layers. Auto-generated if not provided. */ + id?: string; + /** + * How far each arc bows away from a straight line. `0` renders straight + * lines; higher values bend further. Negative values bend to the opposite + * side. Arcs are computed as a quadratic Bézier in lng/lat space and do not + * account for the antimeridian. (default: 0.2) + */ + curvature?: number; + /** Number of samples used to render each curve. Higher = smoother. (default: 64) */ + samples?: number; + /** + * MapLibre paint properties for the arc layer. Merged on top of sensible + * defaults (`line-color: #4285F4`, `line-width: 2`, `line-opacity: 0.85`). + * Any value can be a MapLibre expression for per-feature styling, every + * field on each arc datum (besides `from`/`to`) is exposed via `["get", ...]`. + */ + paint?: MapArcLinePaint; + /** MapLibre layout properties for the arc layer. Defaults to rounded joins/caps. */ + layout?: MapArcLineLayout; + /** + * Paint properties applied to the arc currently under the cursor. Each key + * is merged into `paint` as a `case` expression keyed on per-feature hover + * state, so only the hovered arc changes appearance. + */ + hoverPaint?: MapArcLinePaint; + /** Callback when an arc is clicked. */ + onClick?: (e: MapArcEvent) => void; + /** + * Callback fired when the hovered arc changes. Receives the cursor's + * lng/lat at the moment of entry, and `null` when the cursor leaves the + * last hovered arc. + */ + onHover?: (e: MapArcEvent | null) => void; + /** Whether arcs respond to mouse events (default: true). */ + interactive?: boolean; + /** Optional MapLibre layer id to insert the arc layers before (z-order control). */ + beforeId?: string; +}; + +const DEFAULT_ARC_CURVATURE = 0.2; +const DEFAULT_ARC_SAMPLES = 64; +const ARC_HIT_MIN_WIDTH = 12; +const ARC_HIT_PADDING = 6; + +const DEFAULT_ARC_PAINT: MapArcLinePaint = { + "line-color": "#4285F4", + "line-width": 2, + "line-opacity": 0.85, +}; + +const DEFAULT_ARC_LAYOUT: MapArcLineLayout = { + "line-join": "round", + "line-cap": "round", +}; + +function mergeArcPaint( + paint: MapArcLinePaint, + hoverPaint: MapArcLinePaint | undefined, +): MapArcLinePaint { + if (!hoverPaint) return paint; + const merged: Record = { ...paint }; + for (const [key, hoverValue] of Object.entries(hoverPaint)) { + if (hoverValue === undefined) continue; + const baseValue = merged[key]; + merged[key] = + baseValue === undefined + ? hoverValue + : [ + "case", + ["boolean", ["feature-state", "hover"], false], + hoverValue, + baseValue, + ]; + } + return merged as MapArcLinePaint; +} + +function buildArcCoordinates( + from: [number, number], + to: [number, number], + curvature: number, + samples: number, +): [number, number][] { + const [x0, y0] = from; + const [x2, y2] = to; + const dx = x2 - x0; + const dy = y2 - y0; + const distance = Math.hypot(dx, dy); + + if (distance === 0 || curvature === 0) return [from, to]; + + const mx = (x0 + x2) / 2; + const my = (y0 + y2) / 2; + const nx = -dy / distance; + const ny = dx / distance; + const offset = distance * curvature; + const cx = mx + nx * offset; + const cy = my + ny * offset; + + const points: [number, number][] = []; + const segments = Math.max(2, Math.floor(samples)); + for (let i = 0; i <= segments; i += 1) { + const t = i / segments; + const inv = 1 - t; + const x = inv * inv * x0 + 2 * inv * t * cx + t * t * x2; + const y = inv * inv * y0 + 2 * inv * t * cy + t * t * y2; + points.push([x, y]); + } + return points; +} + +function MapArc({ + data, + id: propId, + curvature = DEFAULT_ARC_CURVATURE, + samples = DEFAULT_ARC_SAMPLES, + paint, + layout, + hoverPaint, + onClick, + onHover, + interactive = true, + beforeId, +}: MapArcProps) { + const { map, isLoaded } = useMap(); + const autoId = useId(); + const id = propId ?? autoId; + const sourceId = `arc-source-${id}`; + const layerId = `arc-layer-${id}`; + const hitLayerId = `arc-hit-layer-${id}`; + + const mergedPaint = useMemo( + () => mergeArcPaint({ ...DEFAULT_ARC_PAINT, ...paint }, hoverPaint), + [paint, hoverPaint], + ); + const mergedLayout = useMemo( + () => ({ ...DEFAULT_ARC_LAYOUT, ...layout }), + [layout], + ); + + const hitWidth = useMemo(() => { + const w = paint?.["line-width"] ?? DEFAULT_ARC_PAINT["line-width"]; + const base = typeof w === "number" ? w : ARC_HIT_MIN_WIDTH; + return Math.max(base + ARC_HIT_PADDING, ARC_HIT_MIN_WIDTH); + }, [paint]); + + const geoJSON = useMemo>( + () => ({ + type: "FeatureCollection", + features: data.map((arc) => { + const { from, to, ...properties } = arc; + return { + type: "Feature", + properties, + geometry: { + type: "LineString", + coordinates: buildArcCoordinates(from, to, curvature, samples), + }, + }; + }), + }), + [data, curvature, samples], + ); + + const latestRef = useRef({ data, onClick, onHover }); + latestRef.current = { data, onClick, onHover }; + + // Add source and layers on mount. + useEffect(() => { + if (!isLoaded || !map) return; + + map.addSource(sourceId, { + type: "geojson", + data: geoJSON, + promoteId: "id", + }); + + map.addLayer( + { + id: hitLayerId, + type: "line", + source: sourceId, + layout: DEFAULT_ARC_LAYOUT, + paint: { + "line-color": "rgba(0, 0, 0, 0)", + "line-width": hitWidth, + "line-opacity": 1, + }, + }, + beforeId, + ); + + map.addLayer( + { + id: layerId, + type: "line", + source: sourceId, + layout: mergedLayout, + paint: mergedPaint, + }, + beforeId, + ); + + return () => { + try { + if (map.getLayer(layerId)) map.removeLayer(layerId); + if (map.getLayer(hitLayerId)) map.removeLayer(hitLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map]); + + // Sync features when data / curvature / samples change. + useEffect(() => { + if (!isLoaded || !map) return; + const source = map.getSource(sourceId) as + | MapLibreGL.GeoJSONSource + | undefined; + source?.setData(geoJSON); + }, [isLoaded, map, geoJSON, sourceId]); + + // Sync paint/layout when they change. + useEffect(() => { + if (!isLoaded || !map || !map.getLayer(layerId)) return; + for (const [key, value] of Object.entries(mergedPaint)) { + map.setPaintProperty( + layerId, + key as keyof MapArcLinePaint, + value as never, + ); + } + for (const [key, value] of Object.entries(mergedLayout)) { + map.setLayoutProperty( + layerId, + key as keyof MapArcLineLayout, + value as never, + ); + } + if (map.getLayer(hitLayerId)) { + map.setPaintProperty(hitLayerId, "line-width", hitWidth); + } + }, [isLoaded, map, layerId, hitLayerId, mergedPaint, mergedLayout, hitWidth]); + + // Interaction handlers + useEffect(() => { + if (!isLoaded || !map || !interactive) return; + + let hoveredId: string | number | null = null; + + const setHover = (next: string | number | null) => { + if (next === hoveredId) return; + const sourceExists = !!map.getSource(sourceId); + if (hoveredId != null && sourceExists) { + map.setFeatureState( + { source: sourceId, id: hoveredId }, + { hover: false }, + ); + } + hoveredId = next; + if (next != null && sourceExists) { + map.setFeatureState({ source: sourceId, id: next }, { hover: true }); + } + }; + + const findArc = (featureId: string | number | undefined) => + featureId == null + ? undefined + : latestRef.current.data.find( + (arc) => String(arc.id) === String(featureId), + ); + + const handleMouseMove = (e: MapLibreGL.MapLayerMouseEvent) => { + const featureId = e.features?.[0]?.id as string | number | undefined; + if (featureId == null || featureId === hoveredId) return; + + setHover(featureId); + map.getCanvas().style.cursor = "pointer"; + + const arc = findArc(featureId); + if (arc) { + latestRef.current.onHover?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + } + }; + + const handleMouseLeave = () => { + setHover(null); + map.getCanvas().style.cursor = ""; + latestRef.current.onHover?.(null); + }; + + const handleClick = (e: MapLibreGL.MapLayerMouseEvent) => { + const arc = findArc(e.features?.[0]?.id as string | number | undefined); + if (!arc) return; + latestRef.current.onClick?.({ + arc: arc as T, + longitude: e.lngLat.lng, + latitude: e.lngLat.lat, + originalEvent: e, + }); + }; + + map.on("mousemove", hitLayerId, handleMouseMove); + map.on("mouseleave", hitLayerId, handleMouseLeave); + map.on("click", hitLayerId, handleClick); + + return () => { + map.off("mousemove", hitLayerId, handleMouseMove); + map.off("mouseleave", hitLayerId, handleMouseLeave); + map.off("click", hitLayerId, handleClick); + setHover(null); + map.getCanvas().style.cursor = ""; + }; + }, [isLoaded, map, hitLayerId, sourceId, interactive]); + + return null; +} + +type MapClusterLayerProps< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +> = { + /** GeoJSON FeatureCollection data or URL to fetch GeoJSON from */ + data: string | GeoJSON.FeatureCollection; + /** Maximum zoom level to cluster points on (default: 14) */ + clusterMaxZoom?: number; + /** Radius of each cluster when clustering points in pixels (default: 50) */ + clusterRadius?: number; + /** Colors for cluster circles: [small, medium, large] based on point count (default: ["#22c55e", "#eab308", "#ef4444"]) */ + clusterColors?: [string, string, string]; + /** Point count thresholds for color/size steps: [medium, large] (default: [100, 750]) */ + clusterThresholds?: [number, number]; + /** Color for unclustered individual points (default: "#3b82f6") */ + pointColor?: string; + /** Callback when an unclustered point is clicked */ + onPointClick?: ( + feature: GeoJSON.Feature, + coordinates: [number, number], + ) => void; + /** Callback when a cluster is clicked. If not provided, zooms into the cluster */ + onClusterClick?: ( + clusterId: number, + coordinates: [number, number], + pointCount: number, + ) => void; +}; + +function MapClusterLayer< + P extends GeoJSON.GeoJsonProperties = GeoJSON.GeoJsonProperties, +>({ + data, + clusterMaxZoom = 14, + clusterRadius = 50, + clusterColors = ["#22c55e", "#eab308", "#ef4444"], + clusterThresholds = [100, 750], + pointColor = "#3b82f6", + onPointClick, + onClusterClick, +}: MapClusterLayerProps

) { + const { map, isLoaded } = useMap(); + const id = useId(); + const sourceId = `cluster-source-${id}`; + const clusterLayerId = `clusters-${id}`; + const clusterCountLayerId = `cluster-count-${id}`; + const unclusteredLayerId = `unclustered-point-${id}`; + + const stylePropsRef = useRef({ + clusterColors, + clusterThresholds, + pointColor, + }); + + // Add source and layers on mount + useEffect(() => { + if (!isLoaded || !map) return; + + // Add clustered GeoJSON source + map.addSource(sourceId, { + type: "geojson", + data, + cluster: true, + clusterMaxZoom, + clusterRadius, + }); + + // Add cluster circles layer + map.addLayer({ + id: clusterLayerId, + type: "circle", + source: sourceId, + filter: ["has", "point_count"], + paint: { + "circle-color": [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ], + "circle-radius": [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ], + "circle-stroke-width": 1, + "circle-stroke-color": "#fff", + "circle-opacity": 0.85, + }, + }); + + // Add cluster count text layer + map.addLayer({ + id: clusterCountLayerId, + type: "symbol", + source: sourceId, + filter: ["has", "point_count"], + layout: { + "text-field": "{point_count_abbreviated}", + "text-font": ["Open Sans"], + "text-size": 12, + }, + paint: { + "text-color": "#fff", + }, + }); + + // Add unclustered point layer + map.addLayer({ + id: unclusteredLayerId, + type: "circle", + source: sourceId, + filter: ["!", ["has", "point_count"]], + paint: { + "circle-color": pointColor, + "circle-radius": 5, + "circle-stroke-width": 2, + "circle-stroke-color": "#fff", + }, + }); + + return () => { + try { + if (map.getLayer(clusterCountLayerId)) + map.removeLayer(clusterCountLayerId); + if (map.getLayer(unclusteredLayerId)) + map.removeLayer(unclusteredLayerId); + if (map.getLayer(clusterLayerId)) map.removeLayer(clusterLayerId); + if (map.getSource(sourceId)) map.removeSource(sourceId); + } catch { + // ignore + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoaded, map, sourceId]); + + // Update source data when data prop changes (only for non-URL data) + useEffect(() => { + if (!isLoaded || !map || typeof data === "string") return; + + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + if (source) { + source.setData(data); + } + }, [isLoaded, map, data, sourceId]); + + // Update layer styles when props change + useEffect(() => { + if (!isLoaded || !map) return; + + const prev = stylePropsRef.current; + const colorsChanged = + prev.clusterColors !== clusterColors || + prev.clusterThresholds !== clusterThresholds; + + // Update cluster layer colors and sizes + if (map.getLayer(clusterLayerId) && colorsChanged) { + map.setPaintProperty(clusterLayerId, "circle-color", [ + "step", + ["get", "point_count"], + clusterColors[0], + clusterThresholds[0], + clusterColors[1], + clusterThresholds[1], + clusterColors[2], + ]); + map.setPaintProperty(clusterLayerId, "circle-radius", [ + "step", + ["get", "point_count"], + 20, + clusterThresholds[0], + 30, + clusterThresholds[1], + 40, + ]); + } + + // Update unclustered point layer color + if (map.getLayer(unclusteredLayerId) && prev.pointColor !== pointColor) { + map.setPaintProperty(unclusteredLayerId, "circle-color", pointColor); + } + + stylePropsRef.current = { clusterColors, clusterThresholds, pointColor }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + clusterColors, + clusterThresholds, + pointColor, + ]); + + // Handle click events + useEffect(() => { + if (!isLoaded || !map) return; + + // Cluster click handler - zoom into cluster + const handleClusterClick = async ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + const features = map.queryRenderedFeatures(e.point, { + layers: [clusterLayerId], + }); + if (!features.length) return; + + const feature = features[0]; + const clusterId = feature.properties?.cluster_id as number; + const pointCount = feature.properties?.point_count as number; + const coordinates = (feature.geometry as GeoJSON.Point).coordinates as [ + number, + number, + ]; + + if (onClusterClick) { + onClusterClick(clusterId, coordinates, pointCount); + } else { + // Default behavior: zoom to cluster expansion zoom + const source = map.getSource(sourceId) as MapLibreGL.GeoJSONSource; + const zoom = await source.getClusterExpansionZoom(clusterId); + map.easeTo({ + center: coordinates, + zoom, + }); + } + }; + + // Unclustered point click handler + const handlePointClick = ( + e: MapLibreGL.MapMouseEvent & { + features?: MapLibreGL.MapGeoJSONFeature[]; + }, + ) => { + if (!onPointClick || !e.features?.length) return; + + const feature = e.features[0]; + const coordinates = ( + feature.geometry as GeoJSON.Point + ).coordinates.slice() as [number, number]; + + // Handle world copies + while (Math.abs(e.lngLat.lng - coordinates[0]) > 180) { + coordinates[0] += e.lngLat.lng > coordinates[0] ? 360 : -360; + } + + onPointClick( + feature as unknown as GeoJSON.Feature, + coordinates, + ); + }; + + // Cursor style handlers + const handleMouseEnterCluster = () => { + map.getCanvas().style.cursor = "pointer"; + }; + const handleMouseLeaveCluster = () => { + map.getCanvas().style.cursor = ""; + }; + const handleMouseEnterPoint = () => { + if (onPointClick) { + map.getCanvas().style.cursor = "pointer"; + } + }; + const handleMouseLeavePoint = () => { + map.getCanvas().style.cursor = ""; + }; + + map.on("click", clusterLayerId, handleClusterClick); + map.on("click", unclusteredLayerId, handlePointClick); + map.on("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.on("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.on("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.on("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + + return () => { + map.off("click", clusterLayerId, handleClusterClick); + map.off("click", unclusteredLayerId, handlePointClick); + map.off("mouseenter", clusterLayerId, handleMouseEnterCluster); + map.off("mouseleave", clusterLayerId, handleMouseLeaveCluster); + map.off("mouseenter", unclusteredLayerId, handleMouseEnterPoint); + map.off("mouseleave", unclusteredLayerId, handleMouseLeavePoint); + }; + }, [ + isLoaded, + map, + clusterLayerId, + unclusteredLayerId, + sourceId, + onClusterClick, + onPointClick, + ]); + + return null; +} + +export { + Map, + useMap, + MapMarker, + MarkerContent, + MarkerPopup, + MarkerTooltip, + MarkerLabel, + MapPopup, + MapControls, + MapRoute, + MapArc, + MapClusterLayer, +}; + +export type { MapRef, MapViewport, MapArcDatum, MapArcEvent }; diff --git a/src/pages/index.astro b/src/pages/index.astro index 6ac2efe..3ebfc9b 100644 --- a/src/pages/index.astro +++ b/src/pages/index.astro @@ -1,6 +1,7 @@ --- import { LandingHeroSection } from "@/components/landing-hero-section"; import { LandingPageSections } from "@/components/landing-page-sections"; +import { ContactSection } from "@/components/landing/contact-section"; import { Footer27 } from "@/components/footer27"; import "@/styles/global.css"; --- @@ -22,6 +23,7 @@ import "@/styles/global.css";

+