diff --git a/bun.lock b/bun.lock index dbbada6f6..2d357ea87 100644 --- a/bun.lock +++ b/bun.lock @@ -66,6 +66,7 @@ "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "mammoth": "^1.12.0", + "maplibre-gl": "^5.24.0", "marked": "^18.0.0", "posthog-js": "^1.288.1", "react": "^19.2.1", @@ -399,6 +400,26 @@ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + "@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.2", "", {}, "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ=="], + + "@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="], + + "@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="], + + "@mapbox/unitbezier": ["@mapbox/unitbezier@0.0.1", "", {}, "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="], + + "@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.5", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.2" } }, "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw=="], + + "@mapbox/whoots-js": ["@mapbox/whoots-js@3.1.0", "", {}, "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q=="], + + "@maplibre/geojson-vt": ["@maplibre/geojson-vt@6.1.0", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ=="], + + "@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@24.10.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw=="], + + "@maplibre/mlt": ["@maplibre/mlt@1.1.11", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-dKvjKdITw9d0y3ndGkSqLUEpWCizMtdq8NB06cHohH/JZ2sJoM7dClR9wzJLUWykjbw9RXDFmhjjNBnNW27mzw=="], + + "@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="], + "@mdx-js/react": ["@mdx-js/react@3.1.1", "", { "dependencies": { "@types/mdx": "^2.0.0" }, "peerDependencies": { "@types/react": ">=16", "react": ">=16" } }, "sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw=="], "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], @@ -871,6 +892,8 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], + "@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="], + "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], @@ -1177,6 +1200,8 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + "earcut": ["earcut@3.0.2", "", {}, "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ=="], + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], "ehbp": ["ehbp@0.2.0", "", { "dependencies": { "@panva/hpke-noble": "^1.0.3", "hpke": "^1.0.1" } }, "sha512-hYkeupwvY0S1h9RpcrCD8pAgc6yfxfMqbI0y6khtS+ZNEByZAf116LTA6aBFB2JJQFsUaOEO7convZVrVhyeZA=="], @@ -1331,6 +1356,8 @@ "get-tsconfig": ["get-tsconfig@4.14.0", "", { "dependencies": { "resolve-pkg-maps": "^1.0.0" } }, "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA=="], + "gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="], + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], @@ -1513,6 +1540,8 @@ "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + "json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="], + "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], "jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], @@ -1521,6 +1550,8 @@ "jszip": ["jszip@3.10.1", "", { "dependencies": { "lie": "~3.3.0", "pako": "~1.0.2", "readable-stream": "~2.3.6", "setimmediate": "^1.0.5" } }, "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g=="], + "kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="], + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], "kysely": ["kysely@0.28.17", "", {}, "sha512-nbD8lB9EB3wNdMhOCdx5Li8DxnLbvKByylRLcJ1h+4SkrowVeECAyZlyiKMThF7xFdRz0jSQ2MoJr+wXux2y0Q=="], @@ -1591,6 +1622,8 @@ "mammoth": ["mammoth@1.12.0", "", { "dependencies": { "@xmldom/xmldom": "^0.8.6", "argparse": "~1.0.3", "base64-js": "^1.5.1", "bluebird": "~3.4.0", "dingbat-to-unicode": "^1.0.1", "jszip": "^3.7.1", "lop": "^0.4.2", "path-is-absolute": "^1.0.0", "underscore": "^1.13.1", "xmlbuilder": "^10.0.0" }, "bin": { "mammoth": "bin/mammoth" } }, "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w=="], + "maplibre-gl": ["maplibre-gl@5.24.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.1.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.1", "@maplibre/mlt": "^1.1.8", "@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" } }, "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A=="], + "markdown-table": ["markdown-table@3.0.4", "", {}, "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw=="], "marked": ["marked@18.0.4", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-c/BTaKzg0G6ezQx97DAkYU7k0HM6ys0FqYeKBL6hlBByZwy+ycA1+f0vDdjMHKKeEjdgkx0GOv9Il6D+85cOqA=="], @@ -1715,6 +1748,8 @@ "msw": ["msw@2.14.6", "", { "dependencies": { "@inquirer/confirm": "^6.0.11", "@mswjs/interceptors": "^0.41.3", "@open-draft/deferred-promise": "^3.0.0", "@types/statuses": "^2.0.6", "cookie": "^1.1.1", "graphql": "^16.13.2", "headers-polyfill": "^5.0.1", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.11.11", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.1", "type-fest": "^5.5.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-ALe+N10S72cyx94cMcy3Zs4HhXCj35sgeAL4c+WTvKi0zWnbd8/h0lcFqv0mb2P+aSgAdD7p9HzvA0DiUPxsyg=="], + "murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="], + "mute-stream": ["mute-stream@3.0.0", "", {}, "sha512-dkEJPVvun4FryqBmZ5KhDo0K9iDXAwn08tMLDinNdRBNPcYEDiWYysLcc6k3mjTMlbP9KyylvRpd4wFtwrT9rw=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], @@ -1801,6 +1836,8 @@ "pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="], + "pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="], + "pdfjs-dist": ["pdfjs-dist@5.4.296", "", { "optionalDependencies": { "@napi-rs/canvas": "^0.1.80" } }, "sha512-DlOzet0HO7OEnmUmB6wWGJrrdvbyJKftI1bhMitK7O2N8W2gc757yyYBbINy9IDafXAV9wmKr9t7xsTaNKRG5Q=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], @@ -1823,6 +1860,8 @@ "posthog-js": ["posthog-js@1.375.0", "", { "dependencies": { "@opentelemetry/api": "^1.9.0", "@opentelemetry/api-logs": "^0.208.0", "@opentelemetry/exporter-logs-otlp-http": "^0.208.0", "@opentelemetry/resources": "^2.2.0", "@opentelemetry/sdk-logs": "^0.208.0", "@posthog/core": "1.29.8", "@posthog/types": "1.375.0", "core-js": "^3.38.1", "dompurify": "^3.3.2", "fflate": "^0.4.8", "preact": "^10.28.2", "query-selector-shadow-dom": "^1.0.1", "web-vitals": "^5.1.0" } }, "sha512-aHr9LPwEmL1/QDv+HpXBXuCtAdFaamDBqUqzTTVEIoHrN+ZZUQVr0kfv3v5w79TvUS7QezvHyUeigDQdCZRbSQ=="], + "potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="], + "preact": ["preact@10.29.2", "", {}, "sha512-7tNmwg/7mzzAoB/8kSg6Hl37JraAZw3Z3A0JSY7VXlZwo82Xn0G7wKbNNs2qoF4ZEEsQGTwDAroNdqKs1ofJxQ=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], @@ -1839,6 +1878,8 @@ "protobufjs": ["protobufjs@7.6.0", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-LtESOsMPTZgyYtwxhvdgdjGL0HmXEaRA/hVD6sol4zA60hVXXXP/SGmxnqDbgGE8gy7pYex7cym+5vYPcmaXBQ=="], + "protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="], + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], @@ -1847,6 +1888,8 @@ "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], + "quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="], + "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], @@ -1903,6 +1946,8 @@ "resolve-pkg-maps": ["resolve-pkg-maps@1.0.0", "", {}, "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw=="], + "resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="], + "restore-cursor": ["restore-cursor@5.1.0", "", { "dependencies": { "onetime": "^7.0.0", "signal-exit": "^4.1.0" } }, "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA=="], "rettime": ["rettime@0.11.11", "", {}, "sha512-ILJRqVWBCTlg9r42fFgwVZx1gnFAcQF8mRoMkbgQfIrjEDf9nbBFDFx00oloOa+Q869FUtaYDXZvEfnecQSCoQ=="], @@ -2057,6 +2102,8 @@ "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], + "tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="], + "tinyrainbow": ["tinyrainbow@3.1.0", "", {}, "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw=="], "tinyspy": ["tinyspy@4.0.4", "", {}, "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q=="], @@ -2245,6 +2292,10 @@ "@freedomofpress/crypto-browser/@noble/curves": ["@noble/curves@1.9.7", "", { "dependencies": { "@noble/hashes": "1.8.0" } }, "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw=="], + "@maplibre/maplibre-gl-style-spec/@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="], + + "@maplibre/vt-pbf/pbf": ["pbf@5.1.0", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg=="], + "@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "@mswjs/interceptors/@open-draft/deferred-promise": ["@open-draft/deferred-promise@2.2.0", "", {}, "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA=="], diff --git a/package.json b/package.json index d46891b91..04e1e4c38 100644 --- a/package.json +++ b/package.json @@ -116,6 +116,7 @@ "input-otp": "^1.4.2", "lucide-react": "^1.8.0", "mammoth": "^1.12.0", + "maplibre-gl": "^5.24.0", "marked": "^18.0.0", "posthog-js": "^1.288.1", "react": "^19.2.1", diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 3ed952a6c..9a20c2f48 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -22,6 +22,7 @@ import * as citation from './citation' import * as connectIntegration from './connect-integration' import * as documentResult from './document-result' import * as linkPreview from './link-preview' +import * as map from './map' import * as weatherForecast from './weather-forecast' // Re-export components for easy importing @@ -29,6 +30,7 @@ export { CitationBadge } from './citation' export { ConnectIntegrationWidget } from './connect-integration' export { DocumentResultWidget } from './document-result' export { LinkPreview, LinkPreviewSkeleton, LinkPreviewWidget } from './link-preview' +export { MapWidget } from './map' export { WeatherForecastWidget } from './weather-forecast' /** @@ -56,6 +58,10 @@ export const widgetRegistry = [ name: 'link-preview' as const, module: linkPreview, }, + { + name: 'map' as const, + module: map, + }, ] as const /** diff --git a/src/widgets/map/geojson.test.ts b/src/widgets/map/geojson.test.ts new file mode 100644 index 000000000..4f2c157b6 --- /dev/null +++ b/src/widgets/map/geojson.test.ts @@ -0,0 +1,132 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { describe, expect, test } from 'bun:test' +import { featureBounds, featureLabel, parseFeatureCollection } from './geojson' + +const point = (lng: number, lat: number, properties: Record | null = null) => ({ + type: 'Feature' as const, + geometry: { type: 'Point' as const, coordinates: [lng, lat] }, + properties, +}) + +const collection = (features: unknown[]) => JSON.stringify({ type: 'FeatureCollection', features }) + +describe('parseFeatureCollection', () => { + test('parses a valid Point FeatureCollection', () => { + const parsed = parseFeatureCollection(collection([point(-122.33, 47.61, { label: 'Seattle' })])) + expect(parsed?.features).toHaveLength(1) + expect(parsed?.features[0].geometry.type).toBe('Point') + }) + + test('parses LineString and Polygon geometries', () => { + const parsed = parseFeatureCollection( + collection([ + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [0, 0], + [1, 1], + ], + }, + properties: null, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [1, 0], + [1, 1], + [0, 0], + ], + ], + }, + properties: null, + }, + ]), + ) + expect(parsed?.features.map((f) => f.geometry.type)).toEqual(['LineString', 'Polygon']) + }) + + test('keeps unknown (domain-specific) properties via passthrough', () => { + const parsed = parseFeatureCollection(collection([point(8.68, 50.11, { label: 'X', target_priority: 'high' })])) + expect(parsed).not.toBeNull() + // passthrough preserves the raw value, but the widget never reads it. + expect((parsed?.features[0].properties as Record).target_priority).toBe('high') + }) + + test('returns null for invalid JSON', () => { + expect(parseFeatureCollection("{'type': 'FeatureCollection'}")).toBeNull() // single quotes = not JSON + expect(parseFeatureCollection('not json')).toBeNull() + }) + + test('returns null for non-FeatureCollection or empty features', () => { + expect(parseFeatureCollection(JSON.stringify({ type: 'Feature', geometry: null }))).toBeNull() + expect(parseFeatureCollection(collection([]))).toBeNull() // min(1) + }) + + test('returns null for malformed coordinates', () => { + expect( + parseFeatureCollection( + collection([{ type: 'Feature', geometry: { type: 'Point', coordinates: ['a', 'b'] }, properties: null }]), + ), + ).toBeNull() + }) +}) + +describe('featureLabel', () => { + test('prefers label, then name, then title', () => { + expect(featureLabel({ label: 'L', name: 'N', title: 'T' })).toBe('L') + expect(featureLabel({ name: 'N', title: 'T' })).toBe('N') + expect(featureLabel({ title: 'T' })).toBe('T') + }) + + test('ignores non-string and domain-specific fields, returns null when absent', () => { + expect(featureLabel({ description: 'd', target_priority: 'high' })).toBeNull() + expect(featureLabel({ label: 42 })).toBeNull() + expect(featureLabel(null)).toBeNull() + }) +}) + +describe('featureBounds', () => { + test('computes [[w,s],[e,n]] across points', () => { + const parsed = parseFeatureCollection(collection([point(-122, 47), point(8, 50)])) + expect(featureBounds(parsed!)).toEqual([ + [-122, 47], + [8, 50], + ]) + }) + + test('walks nested polygon coordinates', () => { + const parsed = parseFeatureCollection( + collection([ + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [0, 0], + [4, 0], + [4, 3], + [0, 3], + [0, 0], + ], + ], + }, + properties: null, + }, + ]), + ) + expect(featureBounds(parsed!)).toEqual([ + [0, 0], + [4, 3], + ]) + }) +}) diff --git a/src/widgets/map/geojson.ts b/src/widgets/map/geojson.ts new file mode 100644 index 000000000..417d2d22d --- /dev/null +++ b/src/widgets/map/geojson.ts @@ -0,0 +1,114 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { z } from 'zod' + +/** + * Minimal, neutral GeoJSON schema for the map widget. We validate the standard + * geometry shapes and read only generic display fields off `properties` + * (`label` / `name` / `title` / `description`). Any other properties pass + * through validation but are intentionally never rendered — the widget is a + * generic location renderer, not a domain-specific visualizer. + */ + +/** `[longitude, latitude]` with an optional trailing altitude. */ +const position = z.tuple([z.number(), z.number()]).rest(z.number()) + +const geometry = z.discriminatedUnion('type', [ + z.object({ type: z.literal('Point'), coordinates: position }), + z.object({ type: z.literal('MultiPoint'), coordinates: z.array(position) }), + z.object({ type: z.literal('LineString'), coordinates: z.array(position) }), + z.object({ type: z.literal('MultiLineString'), coordinates: z.array(z.array(position)) }), + z.object({ type: z.literal('Polygon'), coordinates: z.array(z.array(position)) }), + z.object({ type: z.literal('MultiPolygon'), coordinates: z.array(z.array(z.array(position))) }), +]) + +/** Only generic display fields are typed; `.passthrough()` tolerates (but the + * widget ignores) any domain-specific properties on a feature. */ +const featureProperties = z + .object({ + label: z.string().optional(), + name: z.string().optional(), + title: z.string().optional(), + description: z.string().optional(), + }) + .passthrough() + .nullable() + +const feature = z.object({ + type: z.literal('Feature'), + geometry, + properties: featureProperties, +}) + +export const featureCollectionSchema = z.object({ + type: z.literal('FeatureCollection'), + features: z.array(feature).min(1), +}) + +export type MapFeatureCollection = z.infer +export type MapFeature = z.infer +export type MapFeatureProperties = z.infer + +/** Parse + validate a raw JSON string into a FeatureCollection, or null. */ +export const parseFeatureCollection = (raw: string): MapFeatureCollection | null => { + let json: unknown + try { + json = JSON.parse(raw) + } catch { + return null + } + const result = featureCollectionSchema.safeParse(json) + return result.success ? result.data : null +} + +/** + * The neutral display label for a feature: first of `label` / `name` / `title`, + * else null. Deliberately limited to generic fields — domain-specific + * properties are never surfaced. Accepts a loose record so it works for both + * the zod-parsed properties and MapLibre's runtime feature properties. + */ +export const featureLabel = (properties: Record | null | undefined): string | null => { + const pick = (key: string): string | null => + typeof properties?.[key] === 'string' ? (properties[key] as string) : null + return pick('label') ?? pick('name') ?? pick('title') +} + +/** + * Bounding box `[[west, south], [east, north]]` over every coordinate in the + * collection, or null if it has none. Walks nested coordinate arrays so it + * handles points, lines, and polygons uniformly. + */ +export const featureBounds = (collection: MapFeatureCollection): [[number, number], [number, number]] | null => { + let minX = Infinity + let minY = Infinity + let maxX = -Infinity + let maxY = -Infinity + let found = false + + const walk = (node: unknown): void => { + if (Array.isArray(node) && typeof node[0] === 'number' && typeof node[1] === 'number') { + const [x, y] = node as [number, number] + minX = Math.min(minX, x) + minY = Math.min(minY, y) + maxX = Math.max(maxX, x) + maxY = Math.max(maxY, y) + found = true + return + } + if (Array.isArray(node)) { + node.forEach(walk) + } + } + + for (const f of collection.features) { + walk(f.geometry.coordinates) + } + return found + ? [ + [minX, minY], + [maxX, maxY], + ] + : null +} diff --git a/src/widgets/map/index.ts b/src/widgets/map/index.ts new file mode 100644 index 000000000..2a8826c6f --- /dev/null +++ b/src/widgets/map/index.ts @@ -0,0 +1,15 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +export { + featureBounds, + featureCollectionSchema, + featureLabel, + parseFeatureCollection, + type MapFeature, + type MapFeatureCollection, +} from './geojson' +export { instructions } from './instructions' +export { parse, schema, type MapWidget as MapWidgetType } from './schema' +export { MapSkeleton, MapWidget, MapWidget as Component } from './widget' diff --git a/src/widgets/map/instructions.ts b/src/widgets/map/instructions.ts new file mode 100644 index 000000000..606de9d27 --- /dev/null +++ b/src/widgets/map/instructions.ts @@ -0,0 +1,14 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +/** + * AI instructions for the map widget. Added to the widget system prompt so any + * built-in agent can render locations on a map. (ACP pipelines that want to use + * it emit the same tag from their own prompt.) + */ +export const instructions = `## Map + +Renders locations on an interactive map. \`data\` MUST be a **valid GeoJSON FeatureCollection** — strict JSON with double-quoted keys and strings, wrapped in single quotes. Supports Point, LineString, and Polygon geometries (and their Multi* variants). Each feature's \`properties\` may include \`label\`, \`name\`, \`title\`, or \`description\`, which are shown in the popup. +Optional per-feature styling follows the simplestyle-spec: \`marker-color\`, \`marker-size\` (small | medium | large), \`stroke\`, \`stroke-width\`, \`fill\`, \`fill-opacity\`. +Example: ` diff --git a/src/widgets/map/schema.ts b/src/widgets/map/schema.ts new file mode 100644 index 000000000..ee2e4d746 --- /dev/null +++ b/src/widgets/map/schema.ts @@ -0,0 +1,30 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { createParser } from '@/lib/create-parser' +import { z } from 'zod' +import { parseFeatureCollection } from './geojson' + +/** + * Zod schema for the map widget. `data` carries a GeoJSON FeatureCollection as + * a JSON string (kept as a string in args — the same pattern as the citation + * widget's `sources`); the `.refine` rejects anything that isn't a valid + * FeatureCollection so a malformed tag never renders an empty map. Parsing into + * the typed collection happens in the component. + */ +export const schema = z.object({ + widget: z.literal('map'), + args: z.object({ + data: z + .string() + .min(1, 'GeoJSON data is required') + .refine((value) => parseFeatureCollection(value) !== null, 'Invalid GeoJSON: must be a valid FeatureCollection'), + title: z.string().optional(), + }), +}) + +export type MapWidget = z.infer + +/** Parse function — auto-generated from schema. */ +export const parse = createParser(schema) diff --git a/src/widgets/map/stories.tsx b/src/widgets/map/stories.tsx new file mode 100644 index 000000000..f63439f24 --- /dev/null +++ b/src/widgets/map/stories.tsx @@ -0,0 +1,101 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import type { Meta, StoryObj } from '@storybook/react-vite' +import { MapWidget } from './widget' + +const mixedGeometries = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [8.6821, 50.1109] }, + properties: { label: 'Frankfurt' }, + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [8.66, 50.1], + [8.7, 50.12], + [8.74, 50.1], + ], + }, + properties: { label: 'Route' }, + }, + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [8.66, 50.09], + [8.74, 50.09], + [8.74, 50.13], + [8.66, 50.13], + [8.66, 50.09], + ], + ], + }, + properties: { label: 'Area of interest' }, + }, + ], +}) + +const meta = { + title: 'widgets/map', + component: MapWidget, + parameters: { layout: 'padded' }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta +type Story = StoryObj + +export const Points: Story = { + args: { + data: '{"type":"FeatureCollection","features":[{"type":"Feature","geometry":{"type":"Point","coordinates":[-122.3321,47.6062]},"properties":{"label":"Seattle","description":"Click a marker to see its popup."}},{"type":"Feature","geometry":{"type":"Point","coordinates":[-122.6765,45.5231]},"properties":{"label":"Portland"}}]}', + title: 'Office locations', + }, +} + +export const MixedGeometries: Story = { args: { data: mixedGeometries, title: 'Point, line, and polygon' } } + +// Per-feature styling via the generic simplestyle-spec keys (marker-color, +// marker-size, stroke, fill). No domain-specific properties involved. +const styled = JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-0.1276, 51.5072] }, + properties: { label: 'London', 'marker-color': '#16a34a', 'marker-size': 'large' }, + }, + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [2.3522, 48.8566] }, + properties: { label: 'Paris', 'marker-color': '#dc2626', 'marker-size': 'small' }, + }, + { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-0.1276, 51.5072], + [2.3522, 48.8566], + ], + }, + properties: { label: 'Leg', stroke: '#f59e0b', 'stroke-width': 3 }, + }, + ], +}) + +export const Styled: Story = { args: { data: styled, title: 'Per-feature simplestyle styling' } } diff --git a/src/widgets/map/widget.tsx b/src/widgets/map/widget.tsx new file mode 100644 index 000000000..6e8b5e1bb --- /dev/null +++ b/src/widgets/map/widget.tsx @@ -0,0 +1,254 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +import { Skeleton } from '@/components/ui/skeleton' +import type { Map as MaplibreMap, MapLayerMouseEvent, StyleSpecification } from 'maplibre-gl' +import { useEffect, useMemo, useRef, useState } from 'react' +import { featureBounds, featureLabel, parseFeatureCollection } from './geojson' + +type MapWidgetProps = { + /** GeoJSON FeatureCollection as a JSON string (validated by the schema). */ + data: string + title?: string +} + +/** + * CARTO "Positron" — the same clean light look as raster image tiles, no key. + */ +const cartoPositron: StyleSpecification = { + version: 8, + sources: { + carto: { + type: 'raster', + tiles: [ + 'https://a.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + 'https://b.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + 'https://c.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + 'https://d.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png', + ], + tileSize: 256, + attribution: '© OpenStreetMap contributors © CARTO', + }, + }, + layers: [{ id: 'carto', type: 'raster', source: 'carto' }], +} + +/** + * Basemaps to choose from — both no API key. `positron` is OpenFreeMap's clean + * light *vector* style (sharper at all zooms, restylable); `carto` is the + * equivalent look as plain raster tiles. Flip `basemap` below to switch. + * + * NOTE: heavy production use of either should move to a keyed provider + * (MapTiler / Stadia) or self-hosted tiles rather than the public endpoints. + */ +const basemaps = { + positron: 'https://tiles.openfreemap.org/styles/positron', + carto: cartoPositron, +} + +/** Active basemap — start with the OpenFreeMap vector Positron; swap to + * `basemaps.carto` to compare the raster version. */ +const basemap: string | StyleSpecification = basemaps.positron + +/** Layers a click/hover popup can originate from. */ +const interactiveLayers = ['points', 'lines', 'polygons-fill'] as const + +/** Fallback feature color when a feature carries no simplestyle override. */ +const defaultColor = '#3b82f6' + +/** Pulsing placeholder shown while MapLibre's chunk + tiles load. Mirrors the + * WeatherForecast / LinkPreview skeleton pattern. */ +export const MapSkeleton = () => + +/** + * Generic GeoJSON map widget: renders a FeatureCollection (points / lines / + * polygons) on an interactive map, fits the view to the data, and shows a + * popup with each feature's neutral display fields (`label` / `description`) + * on click. Domain-specific properties are never surfaced. + * + * MapLibre (a heavy dependency) and its stylesheet are lazy-imported on mount + * so they stay out of the entry/chat bundle until a map actually renders. + */ +export const MapWidget = ({ data, title }: MapWidgetProps) => { + const containerRef = useRef(null) + const [error, setError] = useState(null) + const [ready, setReady] = useState(false) + const collection = useMemo(() => parseFeatureCollection(data), [data]) + + useEffect(() => { + const container = containerRef.current + if (!collection || !container) { + return + } + // Reset to the skeleton whenever the data changes and we re-init the map. + setReady(false) + let map: MaplibreMap | null = null + let cancelled = false + + // Keep the canvas sized to its container. Without this, MapLibre renders a + // blank/white map if it initialized before layout settled or while briefly + // hidden (e.g. switching away and back to a chat) and is never told to + // resize. The observer fires on the size change and re-renders at size. + const resizeObserver = new ResizeObserver(() => map?.resize()) + resizeObserver.observe(container) + + const init = async () => { + const { Map: MapLib, Popup, NavigationControl } = await import('maplibre-gl') + await import('maplibre-gl/dist/maplibre-gl.css') + if (cancelled) { + return + } + map = new MapLib({ container, style: basemap }) + map.addControl(new NavigationControl({ showCompass: false }), 'top-right') + + map.on('load', () => { + if (!map) { + return + } + map.addSource('features', { type: 'geojson', data: collection }) + // Per-feature styling follows the simplestyle-spec (marker-color, + // marker-size, stroke, fill, …) read generically via `['get', …]` with + // fallbacks to the defaults. These are standard display keys — the + // widget reads no domain-specific properties. + map.addLayer({ + id: 'polygons-fill', + type: 'fill', + source: 'features', + filter: ['==', ['geometry-type'], 'Polygon'], + paint: { + 'fill-color': ['coalesce', ['get', 'fill'], defaultColor], + 'fill-opacity': ['coalesce', ['get', 'fill-opacity'], 0.2], + }, + }) + map.addLayer({ + id: 'lines', + type: 'line', + source: 'features', + filter: ['in', ['geometry-type'], ['literal', ['LineString', 'Polygon']]], + paint: { + 'line-color': ['coalesce', ['get', 'stroke'], defaultColor], + 'line-width': ['coalesce', ['get', 'stroke-width'], 2], + 'line-opacity': ['coalesce', ['get', 'stroke-opacity'], 1], + }, + }) + map.addLayer({ + id: 'points', + type: 'circle', + source: 'features', + filter: ['==', ['geometry-type'], 'Point'], + paint: { + 'circle-radius': ['match', ['get', 'marker-size'], 'small', 4, 'large', 9, 6], + 'circle-color': ['coalesce', ['get', 'marker-color'], defaultColor], + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2, + }, + }) + + const bounds = featureBounds(collection) + if (bounds) { + map.fitBounds(bounds, { padding: 48, maxZoom: 14, duration: 0 }) + } + + const showPopup = (event: MapLayerMouseEvent) => { + const feature = event.features?.[0] + if (!feature || !map) { + return + } + const props = feature.properties as Record | null + const label = featureLabel(props) + const description = typeof props?.description === 'string' ? props.description : null + if (!label && !description) { + return + } + // The card uses our design tokens (the same `--popover` tokens the + // app's Popover/Card use). Built via textContent — never innerHTML — + // so untrusted GeoJSON content (from a model/pipeline) can't inject + // markup. + const node = document.createElement('div') + node.className = + 'rounded-lg border border-border bg-popover px-3.5 py-2.5 text-[length:var(--font-size-sm)] text-popover-foreground shadow-md' + if (label) { + const labelNode = document.createElement('div') + labelNode.className = 'font-semibold text-[length:var(--font-size-body)]' + labelNode.textContent = label + node.appendChild(labelNode) + } + if (description) { + const descriptionNode = document.createElement('div') + descriptionNode.className = 'mt-1 text-muted-foreground' + descriptionNode.textContent = description + node.appendChild(descriptionNode) + } + + // Drop MapLibre's default chrome (white box, shadow, tip, and the + // unstyled close "×") and let our own card be the whole popup. Closes + // on map click (MapLibre's default closeOnClick). + const popup = new Popup({ closeButton: false, maxWidth: '300px' }) + .setLngLat(event.lngLat) + .setDOMContent(node) + .addTo(map) + const popupEl = popup.getElement() + const content = popupEl?.querySelector('.maplibregl-popup-content') + if (content) { + content.style.padding = '0' + content.style.background = 'transparent' + content.style.boxShadow = 'none' + content.style.borderRadius = '0' + } + popupEl?.querySelector('.maplibregl-popup-tip')?.style.setProperty('display', 'none') + } + + for (const layer of interactiveLayers) { + map.on('click', layer, showPopup) + map.on('mouseenter', layer, () => { + if (map) { + map.getCanvas().style.cursor = 'pointer' + } + }) + map.on('mouseleave', layer, () => { + if (map) { + map.getCanvas().style.cursor = '' + } + }) + } + + // Style + first layers are in — swap the skeleton for the map. + if (!cancelled) { + setReady(true) + } + }) + } + + init().catch((err) => { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Failed to load map') + } + }) + + return () => { + cancelled = true + resizeObserver.disconnect() + map?.remove() + } + }, [collection]) + + if (!collection) { + return null + } + + return ( +
+ {title &&
{title}
} +
+
+ {!ready && !error && } +
+ {error && ( +

+ Couldn’t load the map: {error} +

+ )} +
+ ) +}