commit b1e1df7e1d441d96f3001966e1913323c9eb6022 Author: zv Date: Tue Jun 23 18:56:59 2026 +0200 feat: build MapSift Overpass data explorer diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d63ebd4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules +dist +*.local +*.log +.DS_Store +Thumbs.db diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..befcb3b --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,18 @@ +# MapSift Agent Guidelines + +- The application is named MapSift. +- The application UI must be in English. +- Responses to the repository owner may be in Polish. +- Preferred stack: Vite + React + TypeScript + Leaflet + Vitest. +- Do not add a backend in the MVP. +- Do not add a database in the MVP. +- Do not add authentication in the MVP. +- Keep Overpass QL logic outside React components. +- Query generation must live in a pure `buildOverpassQuery(state)` function. +- Overpass API communication must live in a separate module. +- Overpass API response parsing must live in a separate module. +- UI components must not contain chaotic query string assembly. +- Add or update tests for query logic and parser changes. +- Do not use emoji in code, README, or UI. +- Use Conventional Commits when committing changes. +- Before committing, run tests and build when available. diff --git a/index.html b/index.html new file mode 100644 index 0000000..88e6c35 --- /dev/null +++ b/index.html @@ -0,0 +1,16 @@ + + + + + + + MapSift + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..42d261d --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1448 @@ +{ + "name": "mapsift", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mapsift", + "version": "0.1.0", + "dependencies": { + "leaflet": "latest", + "react": "latest", + "react-dom": "latest", + "react-leaflet": "latest" + }, + "devDependencies": { + "@types/leaflet": "latest", + "@types/react": "latest", + "@types/react-dom": "latest", + "@vitejs/plugin-react": "latest", + "typescript": "latest", + "vite": "latest", + "vitest": "latest" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@react-leaflet/core": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz", + "integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==", + "license": "Hippocratic-2.1", + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.2.tgz", + "integrity": "sha512-2cZ+7xRS+DBcuJBJKnfzsbleumJhBqSlJVpuzHC0nTqfd3QQ7Vx2/x5YR/D7cBamKSeWplwo82Fn9lqYUDEMfA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.2.tgz", + "integrity": "sha512-RkPMJnygxsgOYdkfqgpwY0/Fzm8d0VQe6HGU2/B00Xa9eqdLbrII+DOKAodbJAn3ZL1AJxGHkZRPYazgGY6Ljw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.2.tgz", + "integrity": "sha512-Uiczh6vFhwyfd7WNe7Q7mCA4KxAiLdz7jPE/WGizfRpIieoyFuNVMmM8HqZ9HwudTkY6/AeMQwlNJ9NJijguWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.2.tgz", + "integrity": "sha512-+TpdtTRgHiJFjCVFbw311SuLk3KfytPOQQn+VlAEv+gBxYPtL7E6JS9e/tk+8CwxhIZvemJKo4rTKgfWNsKkkA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.2.tgz", + "integrity": "sha512-4lv1/tkmi7ueIVHnyreaOeUpiZP26BH9rRy6hoYfR9310A2B9nUEVRDvBx69vx64Nr3eTPPRkyciqJJs+j9Jmw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.2.tgz", + "integrity": "sha512-gBSUVO0eaWgw1JMjK3gB8BMlX2Mk148s2lTiVT3e9vjVxbl7UDfMWWY8CfIaaqiXuM9fVTMxIpUz6CAo/B6Vlw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.2.tgz", + "integrity": "sha512-LjQP/iZLBu8o8PjIfk4x3At0/mT6h282pvz8Z5LAyhGbu/kDezyO7ea62rF5uoqmgnIYqbN/MqJ3Si3Aymi7xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.2.tgz", + "integrity": "sha512-X/7bVLWelEsbyWDUSXt7zVsTniLLPIY2n1rH58qr78l9i7MNbbxBWD8gI2vRfBWf4NUXJCUuQnfZDsp32LqsfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.2.tgz", + "integrity": "sha512-gb6dYKW/1KDorGXyy48glEBJs/sxVSC5pcVrox/pFGV4mvwSFeg2sK5L2tRkVsVlh7kueqOgg4GEcuipJcGuKg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.2.tgz", + "integrity": "sha512-JY4w85pU3iAiJVMh5nuk4/Mh9GjMsupe8MrIN53rwxAZW64GKrWeJBuN6SxQg9QTU5uB1cxyhDzW8jqRn1EABw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.2.tgz", + "integrity": "sha512-xvpA7o5KCYLB0Rwscmuylb1/zHHSUx4g4xilm4prC5jP76pEUlzBmMbgpbh7bVDbId4NcfT96gN5i6mE6UDaiw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.2.tgz", + "integrity": "sha512-p/ts6KBLjuk49Bp21XH77poQGt02iNz7ChgHep7tudPOaLinR/De/RHdxF8w8Yj4r/bF/bqXwH6PZrB2sA+Nvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.2.tgz", + "integrity": "sha512-VMu/wmrZ9hJzYlRhbw7jK5PODlugyKZ5mOdX78+lS8OvuFkWNQdz1pFLrI2p3P0pjXOmUZ7B48o5VnMH9QOGtg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.2.tgz", + "integrity": "sha512-xtUJqs8qEkuSviS0n1tsohaPuz3a1SPhZywOji4Oo+sgrJs8daEDMZ0QtqL0OS7dx8PoVpg2J/ZZycPY5I2+Zg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.2.tgz", + "integrity": "sha512-85YiLQqjUKgSO/Zjnf9e0XIn5Ymrh1fLDWBeAkZqpuBR/3R8TpfoHXuyblqyQrftSSgWO9qpcHN8mkyKsLraoA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/leaflet": { + "version": "1.9.21", + "resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.21.tgz", + "integrity": "sha512-TbAd9DaPGSnzp6QvtYngntMZgcRk+igFELwR2N99XZn7RXUdKgsXMR+28bUO0rPsWp8MIu/f47luLIQuSLYv/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/geojson": "*" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/leaflet": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz", + "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", + "license": "BSD-2-Clause" + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-leaflet": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz", + "integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==", + "license": "Hippocratic-2.1", + "dependencies": { + "@react-leaflet/core": "^3.0.0" + }, + "peerDependencies": { + "leaflet": "^1.9.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + } + }, + "node_modules/rolldown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.2.tgz", + "integrity": "sha512-x0CrQQqCXWGeI8dTvFfN/Dnv3yMKT9hv5jFjlOreKAx9wqLq9wz7VvLLHyaAXC90/CpggTu9SisSbsJJTPSjNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.2", + "@rolldown/binding-darwin-arm64": "1.1.2", + "@rolldown/binding-darwin-x64": "1.1.2", + "@rolldown/binding-freebsd-x64": "1.1.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.2", + "@rolldown/binding-linux-arm64-gnu": "1.1.2", + "@rolldown/binding-linux-arm64-musl": "1.1.2", + "@rolldown/binding-linux-ppc64-gnu": "1.1.2", + "@rolldown/binding-linux-s390x-gnu": "1.1.2", + "@rolldown/binding-linux-x64-gnu": "1.1.2", + "@rolldown/binding-linux-x64-musl": "1.1.2", + "@rolldown/binding-openharmony-arm64": "1.1.2", + "@rolldown/binding-wasm32-wasi": "1.1.2", + "@rolldown/binding-win32-arm64-msvc": "1.1.2", + "@rolldown/binding-win32-x64-msvc": "1.1.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..179dfe8 --- /dev/null +++ b/package.json @@ -0,0 +1,27 @@ +{ + "name": "mapsift", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run" + }, + "dependencies": { + "react": "latest", + "react-dom": "latest", + "leaflet": "latest", + "react-leaflet": "latest" + }, + "devDependencies": { + "@vitejs/plugin-react": "latest", + "@types/leaflet": "latest", + "@types/react": "latest", + "@types/react-dom": "latest", + "typescript": "latest", + "vite": "latest", + "vitest": "latest" + } +} diff --git a/src/app/App.tsx b/src/app/App.tsx new file mode 100644 index 0000000..021dd17 --- /dev/null +++ b/src/app/App.tsx @@ -0,0 +1,305 @@ +import { useCallback, useMemo, useRef, useState } from 'react'; +import { MapView } from '../features/map/MapView'; +import { ResultsList } from '../features/results/ResultsList'; +import { + buildOverpassQuery, + isBBoxTooLarge, +} from '../features/search/buildOverpassQuery'; +import { fetchOverpass } from '../features/search/overpassClient'; +import { parseOverpassResponse } from '../features/search/parseOverpassResponse'; +import { searchPresets } from '../features/search/presets'; +import type { + BBox, + ParsedOsmResult, + SearchArea, + SearchChoice, + SearchState, +} from '../features/search/types'; + +const DEFAULT_BBOX: BBox = { + south: 52.191, + west: 20.941, + north: 52.268, + east: 21.079, +}; + +const namedAreas = ['Warsaw', 'Berlin', 'Paris']; + +type RequestStatus = 'idle' | 'loading' | 'success' | 'error'; + +export function App() { + const [choice, setChoice] = useState('drinking_water'); + const [customKey, setCustomKey] = useState(''); + const [customValue, setCustomValue] = useState(''); + const [areaMode, setAreaMode] = useState('bbox'); + const [bbox, setBBox] = useState(DEFAULT_BBOX); + const [namedArea, setNamedArea] = useState(namedAreas[0]); + const [results, setResults] = useState([]); + const [selectedResultId, setSelectedResultId] = useState(null); + const [status, setStatus] = useState('idle'); + const [message, setMessage] = useState(null); + const activeQueryRef = useRef(null); + + const handleViewportChange = useCallback((nextBBox: BBox) => { + setBBox(nextBBox); + }, []); + + const searchState = useMemo(() => { + const area: SearchArea = + areaMode === 'namedArea' + ? { mode: 'namedArea', name: namedArea } + : { mode: 'bbox', bbox }; + + return { + choice, + customKey, + customValue, + area, + }; + }, [areaMode, bbox, choice, customKey, customValue, namedArea]); + + const queryPreview = useMemo(() => { + try { + return { + query: buildOverpassQuery(searchState), + error: null, + }; + } catch (error) { + return { + query: '', + error: error instanceof Error ? error.message : 'Unable to build query.', + }; + } + }, [searchState]); + + const canSearch = + status !== 'loading' && + Boolean(queryPreview.query) && + !(areaMode === 'bbox' && isBBoxTooLarge(bbox)); + + async function handleSearch() { + if (!queryPreview.query) { + setStatus('error'); + setMessage(queryPreview.error); + return; + } + + if (areaMode === 'bbox' && isBBoxTooLarge(bbox)) { + setStatus('error'); + setMessage('Zoom in before searching. The current viewport is too large.'); + return; + } + + if (status === 'loading' && activeQueryRef.current === queryPreview.query) { + return; + } + + setStatus('loading'); + setMessage(null); + activeQueryRef.current = queryPreview.query; + + try { + const response = await fetchOverpass(queryPreview.query); + const parsedResults = parseOverpassResponse(response); + setResults(parsedResults); + setSelectedResultId(parsedResults[0]?.id ?? null); + setStatus('success'); + setMessage( + parsedResults.length === 0 + ? 'No matching OSM objects found.' + : `Found ${parsedResults.length} OSM object${parsedResults.length === 1 ? '' : 's'}.`, + ); + } catch (error) { + setStatus('error'); + setMessage( + error instanceof Error + ? error.message + : 'The Overpass request failed. Try again in a moment.', + ); + } finally { + activeQueryRef.current = null; + } + } + + function handleClearResults() { + setResults([]); + setSelectedResultId(null); + setStatus('idle'); + setMessage(null); + } + + async function handleCopyQuery() { + if (queryPreview.query) { + await navigator.clipboard.writeText(queryPreview.query); + setMessage('Query copied to clipboard.'); + } + } + + function handleOpenOverpassTurbo() { + if (queryPreview.query) { + window.open( + `https://overpass-turbo.eu/?Q=${encodeURIComponent(queryPreview.query)}`, + '_blank', + 'noopener,noreferrer', + ); + } + } + + return ( +
+ + +
+
+ +
+ +
+
+

Results

+ {results.length} +
+ +
+
+
+ ); +} diff --git a/src/features/map/MapView.tsx b/src/features/map/MapView.tsx new file mode 100644 index 0000000..e80a77a --- /dev/null +++ b/src/features/map/MapView.tsx @@ -0,0 +1,126 @@ +import { useEffect, useMemo } from 'react'; +import L from 'leaflet'; +import { + MapContainer, + Marker, + Popup, + TileLayer, + useMap, + useMapEvents, +} from 'react-leaflet'; +import type { BBox, ParsedOsmResult } from '../search/types'; + +type MapViewProps = { + results: ParsedOsmResult[]; + selectedResultId: string | null; + onResultSelect: (id: string) => void; + onViewportChange: (bbox: BBox) => void; +}; + +const DEFAULT_CENTER: [number, number] = [52.2297, 21.0122]; +const DEFAULT_ZOOM = 13; + +export function MapView({ + results, + selectedResultId, + onResultSelect, + onViewportChange, +}: MapViewProps) { + const markerIcon = useMemo( + () => + L.divIcon({ + className: 'result-marker', + html: '', + iconSize: [18, 18], + iconAnchor: [9, 9], + }), + [], + ); + + const selectedMarkerIcon = useMemo( + () => + L.divIcon({ + className: 'result-marker result-marker-selected', + html: '', + iconSize: [24, 24], + iconAnchor: [12, 12], + }), + [], + ); + + return ( + + + + item.id === selectedResultId) ?? null} /> + {results.map((result) => ( + onResultSelect(result.id), + }} + > + + {result.name ?? 'Unnamed place'} +
+ {result.type} {result.osmId} +
+
+ ))} +
+ ); +} + +function ViewportReporter({ + onViewportChange, +}: { + onViewportChange: (bbox: BBox) => void; +}) { + const map = useMapEvents({ + moveend: () => onViewportChange(boundsToBBox(map.getBounds())), + zoomend: () => onViewportChange(boundsToBBox(map.getBounds())), + }); + + useEffect(() => { + onViewportChange(boundsToBBox(map.getBounds())); + }, [map, onViewportChange]); + + return null; +} + +function SelectedResultFocus({ result }: { result: ParsedOsmResult | null }) { + const map = useMap(); + + useEffect(() => { + if (result) { + map.setView([result.lat, result.lon], Math.max(map.getZoom(), 15), { + animate: true, + }); + } + }, [map, result]); + + return null; +} + +function boundsToBBox(bounds: L.LatLngBounds): BBox { + const southWest = bounds.getSouthWest(); + const northEast = bounds.getNorthEast(); + + return { + south: southWest.lat, + west: southWest.lng, + north: northEast.lat, + east: northEast.lng, + }; +} diff --git a/src/features/results/ResultsList.tsx b/src/features/results/ResultsList.tsx new file mode 100644 index 0000000..922218d --- /dev/null +++ b/src/features/results/ResultsList.tsx @@ -0,0 +1,63 @@ +import type { ParsedOsmResult } from '../search/types'; + +type ResultsListProps = { + results: ParsedOsmResult[]; + selectedResultId: string | null; + isLoading: boolean; + onResultSelect: (id: string) => void; +}; + +export function ResultsList({ + results, + selectedResultId, + isLoading, + onResultSelect, +}: ResultsListProps) { + if (isLoading) { + return
Searching OpenStreetMap data...
; + } + + if (results.length === 0) { + return ( +
+ Search the visible map area or a named area to see matching OSM objects here. +
+ ); + } + + return ( +
    + {results.map((result) => ( +
  1. + + + Open OSM object + +
  2. + ))} +
+ ); +} + +function formatTags(tags: Record): string { + const visibleTags = Object.entries(tags) + .filter(([key]) => key !== 'name') + .slice(0, 5) + .map(([key, value]) => `${key}=${value}`); + + return visibleTags.length > 0 ? visibleTags.join(' ยท ') : 'No descriptive tags'; +} diff --git a/src/features/search/buildOverpassQuery.test.ts b/src/features/search/buildOverpassQuery.test.ts new file mode 100644 index 0000000..c6028c3 --- /dev/null +++ b/src/features/search/buildOverpassQuery.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest'; +import { buildOverpassQuery } from './buildOverpassQuery'; +import type { BBox, SearchState } from './types'; + +const bbox: BBox = { + south: 52.1, + west: 20.9, + north: 52.2, + east: 21.1, +}; + +describe('buildOverpassQuery', () => { + it('builds a viewport query for amenity=drinking_water', () => { + const query = buildOverpassQuery({ + choice: 'drinking_water', + area: { mode: 'bbox', bbox }, + }); + + expect(query).toContain('node["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);'); + expect(query).toContain('way["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);'); + expect(query).toContain('relation["amenity"="drinking_water"](52.100000,20.900000,52.200000,21.100000);'); + expect(query).toContain('[out:json][timeout:25];'); + expect(query).toContain('out center 100;'); + }); + + it('builds a wildcard shop preset query', () => { + const query = buildOverpassQuery({ + choice: 'shops', + area: { mode: 'bbox', bbox }, + }); + + expect(query).toContain('node["shop"](52.100000,20.900000,52.200000,21.100000);'); + }); + + it('builds a custom tag query with key and value', () => { + const query = buildOverpassQuery({ + choice: 'custom', + customKey: 'amenity', + customValue: 'library', + area: { mode: 'bbox', bbox }, + }); + + expect(query).toContain('node["amenity"="library"](52.100000,20.900000,52.200000,21.100000);'); + }); + + it('builds a named area query', () => { + const query = buildOverpassQuery({ + choice: 'hotels', + area: { mode: 'namedArea', name: 'Warsaw' }, + }); + + expect(query).toBe( + [ + '[out:json][timeout:25];', + 'area["name"="Warsaw"]->.searchArea;', + '(', + ' nwr["tourism"="hotel"](area.searchArea);', + ');', + 'out center 100;', + ].join('\n'), + ); + }); + + it('escapes quotation marks in Overpass string values', () => { + const state: SearchState = { + choice: 'custom', + customKey: 'name', + customValue: 'Joe "Coffee"', + area: { mode: 'namedArea', name: 'Paris "Centre"' }, + }; + + const query = buildOverpassQuery(state); + + expect(query).toContain('area["name"="Paris \\"Centre\\""]->.searchArea;'); + expect(query).toContain('nwr["name"="Joe \\"Coffee\\""](area.searchArea);'); + }); +}); diff --git a/src/features/search/buildOverpassQuery.ts b/src/features/search/buildOverpassQuery.ts new file mode 100644 index 0000000..40203b1 --- /dev/null +++ b/src/features/search/buildOverpassQuery.ts @@ -0,0 +1,83 @@ +import { getPresetById } from './presets'; +import type { BBox, OsmTagFilter, SearchState } from './types'; + +const MAX_BBOX_AREA = 1.5; + +export function isBBoxTooLarge(bbox: BBox): boolean { + const height = Math.abs(bbox.north - bbox.south); + const width = Math.abs(bbox.east - bbox.west); + + return height * width > MAX_BBOX_AREA; +} + +export function buildOverpassQuery(state: SearchState): string { + const tag = resolveTagFilter(state); + const filter = buildTagFilter(tag); + + if (state.area.mode === 'namedArea') { + const areaName = state.area.name.trim(); + + if (!areaName) { + throw new Error('Named area is required.'); + } + + return [ + '[out:json][timeout:25];', + `area["name"="${escapeOverpassString(areaName)}"]->.searchArea;`, + '(', + ` nwr${filter}(area.searchArea);`, + ');', + 'out center 100;', + ].join('\n'); + } + + const bbox = formatBBox(state.area.bbox); + + return [ + '[out:json][timeout:25];', + '(', + ` node${filter}(${bbox});`, + ` way${filter}(${bbox});`, + ` relation${filter}(${bbox});`, + ');', + 'out center 100;', + ].join('\n'); +} + +function resolveTagFilter(state: SearchState): OsmTagFilter { + if (state.choice !== 'custom') { + return getPresetById(state.choice).tag; + } + + const key = state.customKey?.trim(); + const value = state.customValue?.trim(); + + if (!key) { + throw new Error('Custom tag key is required.'); + } + + return value ? { key, value } : { key }; +} + +function buildTagFilter(tag: OsmTagFilter): string { + const key = escapeOverpassString(tag.key); + + if (!tag.value) { + return `["${key}"]`; + } + + return `["${key}"="${escapeOverpassString(tag.value)}"]`; +} + +function escapeOverpassString(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function formatBBox(bbox: BBox): string { + return [ + bbox.south.toFixed(6), + bbox.west.toFixed(6), + bbox.north.toFixed(6), + bbox.east.toFixed(6), + ].join(','); +} diff --git a/src/features/search/overpassClient.ts b/src/features/search/overpassClient.ts new file mode 100644 index 0000000..219d539 --- /dev/null +++ b/src/features/search/overpassClient.ts @@ -0,0 +1,21 @@ +import type { OverpassResponse } from './types'; + +const OVERPASS_ENDPOINT = 'https://overpass-api.de/api/interpreter'; + +export async function fetchOverpass( + query: string, + signal?: AbortSignal, +): Promise { + const body = new URLSearchParams({ data: query }); + const response = await fetch(OVERPASS_ENDPOINT, { + method: 'POST', + body, + signal, + }); + + if (!response.ok) { + throw new Error(`Overpass API returned ${response.status}.`); + } + + return response.json() as Promise; +} diff --git a/src/features/search/parseOverpassResponse.test.ts b/src/features/search/parseOverpassResponse.test.ts new file mode 100644 index 0000000..d753864 --- /dev/null +++ b/src/features/search/parseOverpassResponse.test.ts @@ -0,0 +1,98 @@ +import { describe, expect, it } from 'vitest'; +import { parseOverpassResponse } from './parseOverpassResponse'; + +describe('parseOverpassResponse', () => { + it('parses nodes with lat and lon', () => { + const results = parseOverpassResponse({ + elements: [ + { + type: 'node', + id: 123, + lat: 52.2, + lon: 21.0, + tags: { + name: 'Water point', + amenity: 'drinking_water', + }, + }, + ], + }); + + expect(results).toEqual([ + { + id: 'node/123', + osmId: 123, + type: 'node', + lat: 52.2, + lon: 21.0, + name: 'Water point', + tags: { + name: 'Water point', + amenity: 'drinking_water', + }, + osmUrl: 'https://www.openstreetmap.org/node/123', + }, + ]); + }); + + it('parses ways and relations with center points', () => { + const results = parseOverpassResponse({ + elements: [ + { + type: 'way', + id: 456, + center: { + lat: 52.21, + lon: 21.01, + }, + tags: { + amenity: 'toilets', + }, + }, + { + type: 'relation', + id: 789, + center: { + lat: 52.22, + lon: 21.02, + }, + tags: { + tourism: 'hotel', + }, + }, + ], + }); + + expect(results).toHaveLength(2); + expect(results[0]).toMatchObject({ + id: 'way/456', + type: 'way', + lat: 52.21, + lon: 21.01, + osmUrl: 'https://www.openstreetmap.org/way/456', + }); + expect(results[1]).toMatchObject({ + id: 'relation/789', + type: 'relation', + lat: 52.22, + lon: 21.02, + osmUrl: 'https://www.openstreetmap.org/relation/789', + }); + }); + + it('skips elements without a point or center', () => { + const results = parseOverpassResponse({ + elements: [ + { + type: 'way', + id: 456, + tags: { + amenity: 'toilets', + }, + }, + ], + }); + + expect(results).toEqual([]); + }); +}); diff --git a/src/features/search/parseOverpassResponse.ts b/src/features/search/parseOverpassResponse.ts new file mode 100644 index 0000000..7c10ab1 --- /dev/null +++ b/src/features/search/parseOverpassResponse.ts @@ -0,0 +1,47 @@ +import type { OverpassElement, OverpassResponse, ParsedOsmResult } from './types'; + +export function parseOverpassResponse(response: OverpassResponse): ParsedOsmResult[] { + return (response.elements ?? []).flatMap((element) => { + const point = getElementPoint(element); + + if (!point) { + return []; + } + + const tags = element.tags ?? {}; + + return [ + { + id: `${element.type}/${element.id}`, + osmId: element.id, + type: element.type, + lat: point.lat, + lon: point.lon, + name: tags.name ?? null, + tags, + osmUrl: `https://www.openstreetmap.org/${element.type}/${element.id}`, + }, + ]; + }); +} + +function getElementPoint(element: OverpassElement) { + if (typeof element.lat === 'number' && typeof element.lon === 'number') { + return { + lat: element.lat, + lon: element.lon, + }; + } + + if ( + typeof element.center?.lat === 'number' && + typeof element.center?.lon === 'number' + ) { + return { + lat: element.center.lat, + lon: element.center.lon, + }; + } + + return null; +} diff --git a/src/features/search/presets.ts b/src/features/search/presets.ts new file mode 100644 index 0000000..56467b0 --- /dev/null +++ b/src/features/search/presets.ts @@ -0,0 +1,65 @@ +import type { OsmTagFilter, SearchPresetId } from './types'; + +export type SearchPreset = { + id: SearchPresetId; + label: string; + tag: OsmTagFilter; +}; + +export const searchPresets: SearchPreset[] = [ + { + id: 'drinking_water', + label: 'Drinking water', + tag: { key: 'amenity', value: 'drinking_water' }, + }, + { + id: 'toilets', + label: 'Public toilets', + tag: { key: 'amenity', value: 'toilets' }, + }, + { + id: 'bicycle_parking', + label: 'Bicycle parking', + tag: { key: 'amenity', value: 'bicycle_parking' }, + }, + { + id: 'hotels', + label: 'Hotels', + tag: { key: 'tourism', value: 'hotel' }, + }, + { + id: 'restaurants', + label: 'Restaurants', + tag: { key: 'amenity', value: 'restaurant' }, + }, + { + id: 'cafes', + label: 'Cafes', + tag: { key: 'amenity', value: 'cafe' }, + }, + { + id: 'shops', + label: 'Shops', + tag: { key: 'shop' }, + }, + { + id: 'benches', + label: 'Benches', + tag: { key: 'amenity', value: 'bench' }, + }, + { + id: 'atms', + label: 'ATMs', + tag: { key: 'amenity', value: 'atm' }, + }, +]; + +export function getPresetById(id: SearchPresetId): SearchPreset { + const preset = searchPresets.find((item) => item.id === id); + + if (!preset) { + throw new Error(`Unknown preset: ${id}`); + } + + return preset; +} diff --git a/src/features/search/types.ts b/src/features/search/types.ts new file mode 100644 index 0000000..8e35944 --- /dev/null +++ b/src/features/search/types.ts @@ -0,0 +1,70 @@ +export type OsmElementType = 'node' | 'way' | 'relation'; + +export type SearchPresetId = + | 'drinking_water' + | 'toilets' + | 'bicycle_parking' + | 'hotels' + | 'restaurants' + | 'cafes' + | 'shops' + | 'benches' + | 'atms'; + +export type SearchChoice = SearchPresetId | 'custom'; + +export type BBox = { + south: number; + west: number; + north: number; + east: number; +}; + +export type SearchArea = + | { + mode: 'bbox'; + bbox: BBox; + } + | { + mode: 'namedArea'; + name: string; + }; + +export type SearchState = { + choice: SearchChoice; + customKey?: string; + customValue?: string; + area: SearchArea; +}; + +export type OsmTagFilter = { + key: string; + value?: string; +}; + +export type OverpassElement = { + type: OsmElementType; + id: number; + lat?: number; + lon?: number; + center?: { + lat?: number; + lon?: number; + }; + tags?: Record; +}; + +export type OverpassResponse = { + elements?: OverpassElement[]; +}; + +export type ParsedOsmResult = { + id: string; + osmId: number; + type: OsmElementType; + lat: number; + lon: number; + name: string | null; + tags: Record; + osmUrl: string; +}; diff --git a/src/main.tsx b/src/main.tsx new file mode 100644 index 0000000..18c33ac --- /dev/null +++ b/src/main.tsx @@ -0,0 +1,11 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import 'leaflet/dist/leaflet.css'; +import './styles.css'; +import { App } from './app/App'; + +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/src/shared/types.ts b/src/shared/types.ts new file mode 100644 index 0000000..cfdac4c --- /dev/null +++ b/src/shared/types.ts @@ -0,0 +1,4 @@ +export type Coordinates = { + lat: number; + lon: number; +}; diff --git a/src/styles.css b/src/styles.css new file mode 100644 index 0000000..53def0c --- /dev/null +++ b/src/styles.css @@ -0,0 +1,369 @@ +:root { + font-family: + Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", + sans-serif; + color: #17211c; + background: #eef1ed; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-width: 320px; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 1px solid #b8c2b5; + border-radius: 6px; + background: #ffffff; + color: #17211c; + cursor: pointer; + min-height: 38px; + padding: 0.55rem 0.8rem; +} + +button:hover:not(:disabled) { + border-color: #5d7f86; + background: #f4f8f7; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +input, +select { + width: 100%; + border: 1px solid #b8c2b5; + border-radius: 6px; + background: #ffffff; + color: #17211c; + min-height: 38px; + padding: 0.5rem 0.65rem; +} + +label, +legend { + color: #394840; + font-size: 0.84rem; + font-weight: 650; +} + +a { + color: #0b6977; +} + +.app-shell { + display: grid; + grid-template-columns: minmax(320px, 390px) minmax(0, 1fr); + min-height: 100vh; +} + +.control-panel { + display: flex; + flex-direction: column; + gap: 1rem; + border-right: 1px solid #cdd5c9; + background: #fafbf8; + padding: 1rem; + overflow-y: auto; +} + +.brand-header h1 { + margin: 0; + color: #17211c; + font-size: 1.65rem; + line-height: 1.1; +} + +.brand-header p { + margin: 0.25rem 0 0; + color: #66726a; + font-size: 0.92rem; + line-height: 1.4; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 0.42rem; + margin: 0; + min-width: 0; + border: 0; + padding: 0; +} + +.custom-fields { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +.segmented-control { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.5rem; +} + +.segmented-control label { + display: flex; + align-items: center; + gap: 0.45rem; + border: 1px solid #b8c2b5; + border-radius: 6px; + background: #ffffff; + min-height: 38px; + padding: 0.45rem 0.55rem; + font-weight: 560; +} + +.segmented-control input { + width: auto; + min-height: auto; +} + +.viewport-note { + margin: -0.25rem 0 0; + color: #66726a; + font-size: 0.86rem; + line-height: 1.4; +} + +.button-row, +.query-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.primary-button { + border-color: #226a5d; + background: #226a5d; + color: #ffffff; + font-weight: 700; +} + +.primary-button:hover:not(:disabled) { + border-color: #174d44; + background: #174d44; +} + +.query-panel, +.results-panel { + display: flex; + flex-direction: column; + min-height: 0; +} + +.panel-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + margin-bottom: 0.55rem; +} + +.panel-heading h2 { + margin: 0; + font-size: 1rem; + line-height: 1.2; +} + +.panel-heading span { + color: #66726a; + font-weight: 700; +} + +.query-preview { + overflow: auto; + max-height: 260px; + margin: 0; + border: 1px solid #cbd6cf; + border-radius: 6px; + background: #16201d; + color: #eaf4ef; + padding: 0.75rem; + font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; + font-size: 0.78rem; + line-height: 1.5; + white-space: pre-wrap; + word-break: break-word; +} + +.message { + margin: 0; + border-left: 3px solid #2e7a70; + background: #e8f3ef; + color: #29433b; + padding: 0.65rem 0.75rem; + font-size: 0.9rem; + line-height: 1.4; +} + +.error-message { + border-left-color: #a13f30; + background: #f9e9e5; + color: #6d2d23; +} + +.workspace { + display: grid; + grid-template-rows: minmax(420px, 1fr) minmax(180px, 34vh); + min-width: 0; + min-height: 100vh; +} + +.map-region { + min-width: 0; + min-height: 420px; +} + +.map-view { + width: 100%; + height: 100%; +} + +.result-marker { + display: grid; + place-items: center; + border: 2px solid #ffffff; + border-radius: 50%; + background: #0b6977; + box-shadow: 0 2px 10px rgb(0 0 0 / 25%); +} + +.result-marker span { + display: block; + width: 7px; + height: 7px; + border-radius: 50%; + background: #ffffff; +} + +.result-marker-selected { + background: #c84830; + box-shadow: 0 0 0 4px rgb(200 72 48 / 20%); +} + +.results-panel { + border-top: 1px solid #cdd5c9; + background: #f7f8f5; + padding: 0.9rem 1rem 1rem; + overflow: hidden; +} + +.results-list { + display: grid; + gap: 0.55rem; + overflow: auto; + margin: 0; + padding: 0; + list-style: none; +} + +.results-list li { + display: grid; + gap: 0.35rem; + border: 1px solid #d7ded5; + border-radius: 8px; + background: #ffffff; + padding: 0.5rem; +} + +.result-row { + display: grid; + gap: 0.25rem; + width: 100%; + border: 0; + background: transparent; + padding: 0.25rem; + text-align: left; +} + +.result-row:hover:not(:disabled), +.result-row-selected { + background: #eef6f4; +} + +.result-title { + color: #17211c; + font-weight: 750; +} + +.result-meta, +.tag-list { + color: #66726a; + font-size: 0.82rem; + line-height: 1.35; +} + +.osm-link { + justify-self: start; + padding: 0 0.25rem 0.2rem; + font-size: 0.82rem; + font-weight: 650; +} + +.empty-state { + display: grid; + place-items: center; + min-height: 120px; + border: 1px dashed #b8c2b5; + border-radius: 8px; + color: #66726a; + padding: 1rem; + text-align: center; + line-height: 1.45; +} + +@media (max-width: 900px) { + .app-shell { + grid-template-columns: 1fr; + } + + .control-panel { + border-right: 0; + border-bottom: 1px solid #cdd5c9; + } + + .workspace { + grid-template-rows: 62vh minmax(260px, auto); + min-height: auto; + } +} + +@media (max-width: 560px) { + .control-panel, + .results-panel { + padding: 0.8rem; + } + + .custom-fields, + .segmented-control { + grid-template-columns: 1fr; + } + + .query-actions, + .button-row { + flex-direction: column; + } + + .button-row button, + .query-actions button { + width: 100%; + } +} diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/tsconfig.app.json b/tsconfig.app.json new file mode 100644 index 0000000..f276b9c --- /dev/null +++ b/tsconfig.app.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "allowJs": false, + "skipLibCheck": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "module": "ESNext", + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx" + }, + "include": ["src"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/tsconfig.node.json b/tsconfig.node.json new file mode 100644 index 0000000..787b3d7 --- /dev/null +++ b/tsconfig.node.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "moduleResolution": "Bundler", + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["vite.config.ts"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..858f695 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + }, +});