feat(countries): replace Map view with browse-by-country (like Tags)
Removes the maplibre-gl Map view and adds "Countries" as a sixth TagCategory, reusing the existing /tags/[category]/[[value]] browse machinery instead of a bespoke map UI. Backed by a new self-contained sidecar endpoint that aggregates photos.photo_country with BasePath scoping, mirroring handleLabels/handleScopedCounts. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
247
web/package-lock.json
generated
247
web/package-lock.json
generated
@@ -14,7 +14,6 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
@@ -148,110 +147,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/jsonlint-lines-primitives": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.2.tgz",
|
||||
"integrity": "sha512-rY0o9A5ECsTQRVhv7tL/OyDpGAoUB4tTvLiW1DSzQGq4bvTPhNw1VpSNjDJc5GFZ2XuyOtSWSVN05qOtcD71qQ==",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/point-geometry": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
|
||||
"integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
|
||||
"integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.8.5",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.8.5.tgz",
|
||||
"integrity": "sha512-EzEJmMt6thioRH7GI9LWS7ahXTcAhAPGWCe6oTP2Ps4YnsXOOAfeqx854lZaiDnwURfHmcCKV1mr6oo0i23x6w==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.9",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.9.tgz",
|
||||
"integrity": "sha512-g/tD8EYJB97udq33ipuJ9a4Q7fcbZnTEnUrgnEc/tLMmEL+zaCbR+X5fkDBO2dgpaAMsLH179qE3UXg2N0Nc/g==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.0.tgz",
|
||||
"integrity": "sha512-jIvp8F5hQCcreqOOpEt42TJMUlsrEcpf/kI1T2v85YrQRV6PPXUcEXUg5karKtH6oh47XJZ4kHu56pUkOuqA7w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@maplibre/geojson-vt": "^5.0.4",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"@types/supercluster": "^7.1.3",
|
||||
"pbf": "^4.0.1",
|
||||
"supercluster": "^8.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/@maplibre/geojson-vt": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz",
|
||||
"integrity": "sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz",
|
||||
@@ -998,12 +893,6 @@
|
||||
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
|
||||
"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==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.8.0.tgz",
|
||||
@@ -1014,15 +903,6 @@
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/supercluster": {
|
||||
"version": "7.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/supercluster/-/supercluster-7.1.3.tgz",
|
||||
"integrity": "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -1248,12 +1128,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/enhanced-resolve": {
|
||||
"version": "5.21.3",
|
||||
"resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.3.tgz",
|
||||
@@ -1451,12 +1325,6 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gl-matrix": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz",
|
||||
"integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
@@ -1553,18 +1421,6 @@
|
||||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/json-stringify-pretty-compact": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz",
|
||||
"integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/kdbush": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.0.2.tgz",
|
||||
"integrity": "sha512-WbCVYJ27Sz8zi9Q7Q0xHC+05iwkm3Znipc2XTlrnJbsHMYktW4hPhXUE8Ys1engBrvffoSCqbil1JQAa7clRpA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/kleur": {
|
||||
"version": "4.1.5",
|
||||
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
|
||||
@@ -1879,40 +1735,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"license": "BSD-3-Clause",
|
||||
"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"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.14.0",
|
||||
"npm": ">=8.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
@@ -1952,15 +1774,6 @@
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/minimist": {
|
||||
"version": "1.2.8",
|
||||
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
|
||||
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/mode-watcher": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mode-watcher/-/mode-watcher-1.1.0.tgz",
|
||||
@@ -2050,12 +1863,6 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/murmurhash-js": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz",
|
||||
"integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
@@ -2086,18 +1893,6 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.1",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
|
||||
"integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2147,18 +1942,6 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/potpack": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz",
|
||||
"integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/protocol-buffers-schema": {
|
||||
"version": "3.6.1",
|
||||
"resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz",
|
||||
"integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-from-env": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz",
|
||||
@@ -2168,12 +1951,6 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/quickselect": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz",
|
||||
"integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/readdirp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
|
||||
@@ -2188,15 +1965,6 @@
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-protobuf-schema": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
|
||||
"integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"protocol-buffers-schema": "^3.3.1"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.1.tgz",
|
||||
@@ -2309,15 +2077,6 @@
|
||||
"inline-style-parser": "0.2.7"
|
||||
}
|
||||
},
|
||||
"node_modules/supercluster": {
|
||||
"version": "8.0.1",
|
||||
"resolved": "https://registry.npmjs.org/supercluster/-/supercluster-8.0.1.tgz",
|
||||
"integrity": "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/svelte": {
|
||||
"version": "5.55.7",
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.55.7.tgz",
|
||||
@@ -2489,12 +2248,6 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyqueue": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz",
|
||||
"integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/totalist": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"bits-ui": "^2.18.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-svelte": "^1.0.1",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"svelte-sonner": "^1.1.1",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
|
||||
@@ -214,7 +214,8 @@
|
||||
keywords: 'Keywords',
|
||||
people: 'People',
|
||||
colors: 'Colors',
|
||||
ratings: 'Ratings'
|
||||
ratings: 'Ratings',
|
||||
countries: 'Countries'
|
||||
};
|
||||
|
||||
function isTagCategoryActive(cat: TagCategory): boolean {
|
||||
@@ -381,7 +382,7 @@
|
||||
//
|
||||
// `getCount` is a getter (not a snapshot) so the badge reads the latest
|
||||
// derived value on every render — the arrays themselves are constant.
|
||||
// Map and Tags intentionally render without a count badge; the count
|
||||
// Tags intentionally renders without a count badge; the count
|
||||
// columns inside the TagsBrowserSidebar are the canonical surface for
|
||||
// per-tag totals. Review rolls in the duplicates tabs hosted under
|
||||
// /review — stacks always contributes; cross-folder only contributes
|
||||
@@ -396,10 +397,9 @@
|
||||
// separate "everything regardless of folder" destination would just
|
||||
// duplicate it for users whose photos live under the root.
|
||||
const views: ViewItem[] = [
|
||||
{ kind: 'route', href: '/map', label: 'Map', getCount: () => undefined }
|
||||
// Tags is rendered as a bespoke expandable block below the
|
||||
// `views` loop — it has sub-categories (Labels/Keywords/Colors/
|
||||
// Ratings) and a chevron, neither of which fits the flat
|
||||
// Ratings/Countries) and a chevron, neither of which fits the flat
|
||||
// section/route ViewItem shape. Notes lives under that expandable
|
||||
// alongside the tag categories.
|
||||
];
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
PUT (Details fields need the full body).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { createMutation, createQuery, useQueryClient } from '@tanstack/svelte-query';
|
||||
import { toast } from 'svelte-sonner';
|
||||
@@ -16,10 +15,10 @@
|
||||
Calendar,
|
||||
File,
|
||||
Folder,
|
||||
Globe,
|
||||
HardDrive,
|
||||
ImageIcon,
|
||||
Loader2,
|
||||
Map as MapIcon,
|
||||
MapPin,
|
||||
Star,
|
||||
Tag,
|
||||
@@ -43,8 +42,9 @@
|
||||
import { push as pushUndo } from '$lib/stores/undo.svelte';
|
||||
import { getMetadataSectionOpen, setMetadataSection } from '$lib/stores/view.svelte';
|
||||
import { photoNameAndDir, primaryFile, type PpPhoto } from '$lib/types/photoprism';
|
||||
import { navigateToFolder } from '$lib/stores/filters.svelte';
|
||||
import { navigateToFolder, navigateToTag } from '$lib/stores/filters.svelte';
|
||||
import { COLOR_SWATCHES } from '$lib/utils/tagGroups';
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import { suggestDateFromPath } from '$lib/utils/suggestDateFromPath';
|
||||
|
||||
interface Props {
|
||||
@@ -432,28 +432,22 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Location (read-only label + open-on-map icon). The arrow-up-
|
||||
right icon flies the in-app map to the photo's coordinates at
|
||||
zoom 17 (close enough for the photo's marker to be its own,
|
||||
out of any cluster). Hidden when the photo has no
|
||||
coordinates. -->
|
||||
<!-- Location (read-only label + jump-to-country icon). Hidden when
|
||||
the photo has no resolved country. -->
|
||||
<div class="flex items-center gap-2">
|
||||
<MapPin class="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<span class="min-w-0 flex-1 truncate px-1 py-0.5 text-muted-foreground">
|
||||
{placeLabel || 'No location'}
|
||||
</span>
|
||||
{#if photo.Lat && photo.Lng}
|
||||
{#if photo.Country && photo.Country !== 'zz'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted-foreground hover:text-foreground"
|
||||
onclick={() =>
|
||||
void goto(
|
||||
`/map?lat=${photo.Lat}&lng=${photo.Lng}&zoom=17&focus=${photo.UID}`
|
||||
)}
|
||||
title="Open on map"
|
||||
aria-label="Open on map"
|
||||
onclick={() => void navigateToTag('countries', photo.Country ?? null)}
|
||||
title={`View other photos from ${countryName(photo.Country)}`}
|
||||
aria-label={`View other photos from ${countryName(photo.Country)}`}
|
||||
>
|
||||
<MapIcon class="h-3 w-3" />
|
||||
<Globe class="h-3 w-3" />
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,11 +3,13 @@
|
||||
import {
|
||||
aggregateKeywords,
|
||||
getAllMarks,
|
||||
listCountries,
|
||||
listLabels,
|
||||
listPhotosByUids,
|
||||
listSubjects,
|
||||
type AggregatedKeyword,
|
||||
type PhotoMarksMap,
|
||||
type PpCountry,
|
||||
type PpLabel,
|
||||
type PpSubject
|
||||
} from '$lib/services/photoprism';
|
||||
@@ -20,9 +22,10 @@
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import { countryFlag, countryName } from '$lib/utils/countries';
|
||||
import type { PpPhoto } from '$lib/types/photoprism';
|
||||
import { EmptyState, InlineLoader } from '$lib/components/feedback';
|
||||
import { Hash, Tag, User } from 'lucide-svelte';
|
||||
import { Globe, Hash, Tag, User } from 'lucide-svelte';
|
||||
|
||||
interface Props {
|
||||
category: TagCategory;
|
||||
@@ -64,6 +67,12 @@
|
||||
enabled: isAuthenticated() && category === 'people'
|
||||
}));
|
||||
|
||||
const countriesQuery = createQuery<PpCountry[]>(() => ({
|
||||
queryKey: ['countries'],
|
||||
queryFn: listCountries,
|
||||
enabled: isAuthenticated() && category === 'countries'
|
||||
}));
|
||||
|
||||
const marksQuery = createQuery<PhotoMarksMap>(() => ({
|
||||
queryKey: ['marks'],
|
||||
queryFn: getAllMarks,
|
||||
@@ -125,6 +134,19 @@
|
||||
);
|
||||
});
|
||||
|
||||
// PhotoPrism returns countries unsorted; sort by photo count descending so
|
||||
// the most-photographed countries surface first (mirrors labels/people).
|
||||
const countriesSorted = $derived(
|
||||
[...(countriesQuery.data ?? [])].sort((a, b) => b.PhotoCount - a.PhotoCount)
|
||||
);
|
||||
const filteredCountries = $derived.by(() => {
|
||||
const q = filterText.trim().toLowerCase();
|
||||
if (!q) return countriesSorted;
|
||||
return countriesSorted.filter((c) =>
|
||||
countryName(c.Code).toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
const ratingGroups = $derived(
|
||||
buildRatingGroups(marksQuery.data, marksPoolQuery.data)
|
||||
);
|
||||
@@ -152,9 +174,11 @@
|
||||
const visibleLabels = $derived(filteredLabels.slice(0, visibleCount));
|
||||
const visibleKeywords = $derived(filteredKeywords.slice(0, visibleCount));
|
||||
const visibleSubjects = $derived(filteredSubjects.slice(0, visibleCount));
|
||||
const visibleCountries = $derived(filteredCountries.slice(0, visibleCount));
|
||||
const hasMoreLabels = $derived(visibleCount < filteredLabels.length);
|
||||
const hasMoreKeywords = $derived(visibleCount < filteredKeywords.length);
|
||||
const hasMoreSubjects = $derived(visibleCount < filteredSubjects.length);
|
||||
const hasMoreCountries = $derived(visibleCount < filteredCountries.length);
|
||||
|
||||
function loadMore() {
|
||||
visibleCount += PAGE_SIZE;
|
||||
@@ -179,6 +203,9 @@
|
||||
const s = String(r);
|
||||
if (selectedValue !== s) onSelect(s);
|
||||
}
|
||||
function pickCountry(code: string) {
|
||||
if (selectedValue !== code) onSelect(code);
|
||||
}
|
||||
|
||||
// First non-empty entry for the active category. Labels/keywords are
|
||||
// already sorted by count desc, so [0] is the most-used tag; colors
|
||||
@@ -203,6 +230,9 @@
|
||||
const g = ratingGroups[0];
|
||||
return g ? String(g.rating) : null;
|
||||
}
|
||||
if (category === 'countries') {
|
||||
return countriesSorted[0]?.Code ?? null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -228,11 +258,16 @@
|
||||
? 'People'
|
||||
: category === 'colors'
|
||||
? 'Colors'
|
||||
: 'Ratings'
|
||||
: category === 'countries'
|
||||
? 'Countries'
|
||||
: 'Ratings'
|
||||
);
|
||||
|
||||
const showFilterInput = $derived(
|
||||
category === 'labels' || category === 'keywords' || category === 'people'
|
||||
category === 'labels' ||
|
||||
category === 'keywords' ||
|
||||
category === 'people' ||
|
||||
category === 'countries'
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -448,6 +483,69 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'countries'}
|
||||
{#if countriesQuery.isPending}
|
||||
<InlineLoader size="sm" label="Loading countries…" />
|
||||
{:else if countriesQuery.isError}
|
||||
<EmptyState size="compact" tone="destructive" title="Failed to load countries" />
|
||||
{:else if filteredCountries.length === 0}
|
||||
<EmptyState
|
||||
size="compact"
|
||||
icon={Globe}
|
||||
title={filterText ? 'No countries match the filter' : 'No geotagged photos yet'}
|
||||
/>
|
||||
{:else}
|
||||
<div bind:this={scrollEl} class="min-h-0 flex-1 overflow-y-auto">
|
||||
{#each visibleCountries as countryRow (countryRow.Code)}
|
||||
{@const active = countryRow.Code === selectedValue}
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-8 w-full items-center gap-2 px-3 text-left text-[12px] leading-tight hover:bg-accent"
|
||||
class:bg-primary={active}
|
||||
class:text-primary-foreground={active}
|
||||
class:hover:bg-primary={active}
|
||||
onclick={() => pickCountry(countryRow.Code)}
|
||||
title={countryName(countryRow.Code)}
|
||||
>
|
||||
{#if countryRow.Thumb}
|
||||
<img
|
||||
src={thumbUrl(countryRow.Thumb, 'tile_50')}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
class="h-5 w-5 shrink-0 rounded object-cover"
|
||||
/>
|
||||
{:else}
|
||||
<span class="flex h-5 w-5 shrink-0 items-center justify-center text-[14px]">
|
||||
{countryFlag(countryRow.Code)}
|
||||
</span>
|
||||
{/if}
|
||||
<span class="min-w-0 flex-1 truncate">{countryName(countryRow.Code)}</span>
|
||||
<span
|
||||
class="flex h-4 min-w-[20px] shrink-0 items-center justify-center rounded px-1 text-[10px] tabular-nums {active
|
||||
? 'bg-primary-foreground/15 text-primary-foreground'
|
||||
: 'bg-secondary text-muted-foreground'}"
|
||||
>
|
||||
{countryRow.PhotoCount}
|
||||
</span>
|
||||
</button>
|
||||
{/each}
|
||||
<div
|
||||
use:nearBottom={{
|
||||
onHit: loadMore,
|
||||
enabled: hasMoreCountries,
|
||||
root: scrollEl ?? null,
|
||||
preloadPx: 400
|
||||
}}
|
||||
class="h-px"
|
||||
aria-hidden="true"
|
||||
></div>
|
||||
{#if hasMoreCountries}
|
||||
<p class="px-3 py-2 text-center text-[10px] text-muted-foreground/70">
|
||||
Loading more… ({visibleCount} / {filteredCountries.length})
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{:else if category === 'colors'}
|
||||
{#if marksQuery.isPending || marksPoolQuery.isPending}
|
||||
<p class="px-3 py-2 text-[11px] text-muted-foreground">Loading colors…</p>
|
||||
|
||||
@@ -560,36 +560,19 @@ export async function listFolderCounts(paths: string[]): Promise<Record<string,
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── Geo ──────────────────────────────────────────────────────────────────────
|
||||
// ── Countries ────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PpGeoFeature {
|
||||
type: 'Feature';
|
||||
id: string;
|
||||
geometry: { type: 'Point'; coordinates: [number, number] };
|
||||
properties: {
|
||||
UID: string;
|
||||
Hash: string;
|
||||
Title?: string;
|
||||
TakenAt?: string;
|
||||
FavId?: number;
|
||||
};
|
||||
export interface PpCountry {
|
||||
Code: string;
|
||||
PhotoCount: number;
|
||||
Thumb?: string;
|
||||
}
|
||||
|
||||
export interface PpGeoCollection {
|
||||
type: 'FeatureCollection';
|
||||
features: PpGeoFeature[];
|
||||
bbox?: number[];
|
||||
}
|
||||
|
||||
export async function listGeo(q = ''): Promise<PpGeoCollection> {
|
||||
// PhotoPrism's `/geo` returns a GeoJSON FeatureCollection of every
|
||||
// matching geocoded photo. MapLibre's native clustering handles 50k+
|
||||
// points without breaking a sweat (PhotoPrism upstream documents
|
||||
// 500k); we ask for a generous cap that covers realistic libraries.
|
||||
const { data } = await http.get<PpGeoCollection>('/geo', {
|
||||
params: { count: 50000, q: q || undefined }
|
||||
});
|
||||
|
||||
export async function listCountries(): Promise<PpCountry[]> {
|
||||
// Self-contained sidecar aggregation (groups photos.photo_country directly,
|
||||
// no PhotoPrism proxy round-trip) so counts/thumbs are scoped to the
|
||||
// caller's BasePath the same way /labels and /counts are.
|
||||
const { data } = await sidecar.get<PpCountry[]>('/api/sidecar/countries');
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,14 +17,21 @@ export type Section =
|
||||
| 'hidden'
|
||||
| 'heap';
|
||||
|
||||
export type TagCategory = 'labels' | 'keywords' | 'people' | 'colors' | 'ratings';
|
||||
export type TagCategory =
|
||||
| 'labels'
|
||||
| 'keywords'
|
||||
| 'people'
|
||||
| 'colors'
|
||||
| 'ratings'
|
||||
| 'countries';
|
||||
|
||||
export const TAG_CATEGORIES: readonly TagCategory[] = [
|
||||
'labels',
|
||||
'keywords',
|
||||
'people',
|
||||
'colors',
|
||||
'ratings'
|
||||
'ratings',
|
||||
'countries'
|
||||
] as const;
|
||||
|
||||
export function isTagCategory(v: unknown): v is TagCategory {
|
||||
@@ -239,6 +246,8 @@ export function filtersToQ(f: FilterState = filters): string {
|
||||
parts.push(`keywords:${quoteIfNeeded(f.tagValue)}`);
|
||||
} else if (f.tagCategory === 'people') {
|
||||
parts.push(`person:${quoteIfNeeded(f.tagValue)}`);
|
||||
} else if (f.tagCategory === 'countries') {
|
||||
parts.push(`country:${quoteIfNeeded(f.tagValue)}`);
|
||||
}
|
||||
}
|
||||
if (f.search) parts.push(quoteIfNeeded(f.search));
|
||||
|
||||
29
web/src/lib/utils/countries.ts
Normal file
29
web/src/lib/utils/countries.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Country code (ISO 3166-1 alpha-2, lowercase — PhotoPrism's `Country` field
|
||||
// shape) → display helpers for the Countries tag-browser category.
|
||||
|
||||
let regionNames: Intl.DisplayNames | undefined;
|
||||
function getRegionNames(): Intl.DisplayNames | undefined {
|
||||
if (regionNames) return regionNames;
|
||||
try {
|
||||
regionNames = new Intl.DisplayNames(['en'], { type: 'region' });
|
||||
} catch {
|
||||
regionNames = undefined;
|
||||
}
|
||||
return regionNames;
|
||||
}
|
||||
|
||||
export function countryName(code: string): string {
|
||||
if (!code) return code;
|
||||
const name = getRegionNames()?.of(code.toUpperCase());
|
||||
return name ?? code;
|
||||
}
|
||||
|
||||
const REGIONAL_INDICATOR_OFFSET = 0x1f1a5; // 0x1f1e6 ('A') - 'A'.charCodeAt(0)
|
||||
|
||||
export function countryFlag(code: string): string {
|
||||
if (!code || code.length !== 2) return '';
|
||||
const upper = code.toUpperCase();
|
||||
return Array.from(upper)
|
||||
.map((ch) => String.fromCodePoint(ch.charCodeAt(0) + REGIONAL_INDICATOR_OFFSET))
|
||||
.join('');
|
||||
}
|
||||
@@ -1,435 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { createQuery } from '@tanstack/svelte-query';
|
||||
import maplibregl, {
|
||||
type GeoJSONSource,
|
||||
type MapMouseEvent,
|
||||
type MapSourceDataEvent
|
||||
} from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { goto } from '$app/navigation';
|
||||
import { listGeo, type PpGeoCollection, type PpGeoFeature } from '$lib/services/photoprism';
|
||||
import { isAuthenticated, thumbUrl, userBasePath } from '$lib/stores/session.svelte';
|
||||
import { setAnchor, setFocused, setOrder } from '$lib/stores/selection.svelte';
|
||||
import Toolbar from '$lib/components/layout/Toolbar.svelte';
|
||||
|
||||
// Helper to quote if needed (matches filters.svelte.ts logic)
|
||||
function quoteIfNeeded(v: string): string {
|
||||
if (!v) return '';
|
||||
if (/^[A-Za-z0-9_\-./]+$/.test(v)) return v;
|
||||
return `"${v.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
const geoQuery = createQuery<PpGeoCollection>(() => {
|
||||
const bp = userBasePath();
|
||||
// Build path filter: add wildcard first, then quote if needed
|
||||
const q = bp ? `path:${quoteIfNeeded(bp + '*')}` : '';
|
||||
return {
|
||||
queryKey: ['geo', bp],
|
||||
queryFn: () => listGeo(q),
|
||||
enabled: isAuthenticated()
|
||||
};
|
||||
});
|
||||
|
||||
let mapEl: HTMLDivElement | undefined = $state();
|
||||
let map: maplibregl.Map | undefined;
|
||||
/** Reactive flag flipped on once the MapLibre `load` event has fired
|
||||
* and the `photos` source has been installed. The data-push `$effect`
|
||||
* depends on this — otherwise, if the geoQuery resolves before the
|
||||
* basemap style finishes loading, the effect runs with no source
|
||||
* available and never re-runs (since `map` itself is not `$state`),
|
||||
* leaving the map permanently empty. */
|
||||
let mapReady = $state(false);
|
||||
|
||||
/** Markers currently attached to the map, keyed by feature id (UIDs
|
||||
* for photos, `cluster:<clusterId>` for clusters). Diffed against the
|
||||
* current `querySourceFeatures` set on every render to add markers
|
||||
* that came into view and remove ones that scrolled out / got
|
||||
* swallowed by a cluster — PhotoPrism's `markersOnScreen` pattern.
|
||||
* See: https://github.com/photoprism/photoprism/blob/develop/frontend/src/page/places.vue */
|
||||
const markers = new Map<string, maplibregl.Marker>();
|
||||
const markersOnScreen = new Map<string, maplibregl.Marker>();
|
||||
|
||||
onMount(() => {
|
||||
if (!mapEl) return;
|
||||
map = new maplibregl.Map({
|
||||
container: mapEl,
|
||||
// PhotoPrism's default basemap style (CDN-hosted, no key required).
|
||||
// The style JSON already references the correct glyphs URL, so
|
||||
// no explicit override is needed here.
|
||||
style: 'https://cdn.photoprism.app/maps/default.json',
|
||||
center: [0, 20],
|
||||
zoom: 1,
|
||||
attributionControl: { compact: true }
|
||||
});
|
||||
map.addControl(
|
||||
new maplibregl.NavigationControl({ visualizePitch: true, showZoom: true, showCompass: true }),
|
||||
'top-right'
|
||||
);
|
||||
map.addControl(new maplibregl.ScaleControl({ maxWidth: 120, unit: 'metric' }), 'bottom-left');
|
||||
|
||||
map.on('load', () => {
|
||||
addPhotoLayers();
|
||||
mapReady = true;
|
||||
});
|
||||
|
||||
// PhotoPrism's update strategy: re-reconcile markers on every map
|
||||
// movement, on resize (so cluster bubbles re-balance when the
|
||||
// viewport changes), on idle (catches the post-`fitBounds` settle),
|
||||
// and on `sourcedata` filtered to "source fully loaded" — that's
|
||||
// the moment MapLibre has processed clustering and
|
||||
// `querySourceFeatures` returns meaningful results.
|
||||
const onSourceData = (e: MapSourceDataEvent) => {
|
||||
if (e.sourceId === 'photos' && e.isSourceLoaded) updateMarkers();
|
||||
};
|
||||
map.on('sourcedata', onSourceData);
|
||||
map.on('move', updateMarkers);
|
||||
map.on('moveend', updateMarkers);
|
||||
map.on('resize', updateMarkers);
|
||||
map.on('idle', updateMarkers);
|
||||
|
||||
return () => {
|
||||
map?.off('sourcedata', onSourceData);
|
||||
map?.off('move', updateMarkers);
|
||||
map?.off('moveend', updateMarkers);
|
||||
map?.off('resize', updateMarkers);
|
||||
map?.off('idle', updateMarkers);
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
map?.remove();
|
||||
map = undefined;
|
||||
mapReady = false;
|
||||
};
|
||||
});
|
||||
|
||||
function addPhotoLayers() {
|
||||
if (!map) return;
|
||||
map.addSource('photos', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] },
|
||||
cluster: true,
|
||||
// PhotoPrism's clustering parameters — points within ~80px merge
|
||||
// below zoom 17, individual photos render above that.
|
||||
clusterMaxZoom: 17,
|
||||
clusterRadius: 80
|
||||
});
|
||||
// Invisible layer for clusters — PhotoPrism does this so the source
|
||||
// reports cluster features via `querySourceFeatures` (which only
|
||||
// returns features actually rendered by some layer) while the
|
||||
// visual presentation is owned by HTML markers below.
|
||||
map.addLayer({
|
||||
id: 'clusters',
|
||||
type: 'circle',
|
||||
source: 'photos',
|
||||
filter: ['has', 'point_count'],
|
||||
paint: { 'circle-color': '#ffffff', 'circle-opacity': 0, 'circle-radius': 0 }
|
||||
});
|
||||
// Click an (invisible) cluster anywhere on the map → zoom to its
|
||||
// expansion level. The marker DOM also has a click handler, but
|
||||
// pointer-through to the map needs this as a fallback.
|
||||
map.on('click', 'clusters', (e: MapMouseEvent) => {
|
||||
const features = map!.queryRenderedFeatures(e.point, { layers: ['clusters'] });
|
||||
const clusterId = features[0]?.properties?.cluster_id;
|
||||
if (clusterId == null) return;
|
||||
const source = map!.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
const geometry = features[0]?.geometry;
|
||||
if (!geometry || geometry.type !== 'Point') return;
|
||||
map!.easeTo({ center: geometry.coordinates as [number, number], zoom });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** Cluster bubble diameter, scaled by the number of contained photos
|
||||
* — mirrors PhotoPrism's `getClusterSizeFromItemCount`. */
|
||||
function clusterSize(count: number): number {
|
||||
if (count >= 10000) return 74;
|
||||
if (count >= 1000) return 70;
|
||||
if (count >= 750) return 68;
|
||||
if (count >= 200) return 66;
|
||||
if (count >= 100) return 64;
|
||||
return 60;
|
||||
}
|
||||
|
||||
/** `1234` → `"1k"`, matching PhotoPrism's `abbreviateCount`. */
|
||||
function abbreviateCount(value: number): string {
|
||||
if (value >= 1000) return `${Math.round(value / 1000)}k`;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function buildPhotoMarker(uid: string, hash: string, title: string | undefined, allUids: string[]) {
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
if (title) el.title = title;
|
||||
el.style.width = '50px';
|
||||
el.style.height = '50px';
|
||||
el.style.backgroundImage = `url(${thumbUrl(hash, 'tile_50')})`;
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
setOrder(allUids);
|
||||
setFocused(uid);
|
||||
setAnchor(uid);
|
||||
void goto('/');
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
function buildClusterMarker(clusterId: number, count: number) {
|
||||
const size = clusterSize(count);
|
||||
const el = document.createElement('div');
|
||||
el.className = 'marker';
|
||||
el.style.width = `${size}px`;
|
||||
el.style.height = `${size}px`;
|
||||
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'cluster-marker';
|
||||
el.appendChild(grid);
|
||||
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'badge';
|
||||
badge.textContent = abbreviateCount(count);
|
||||
el.appendChild(badge);
|
||||
|
||||
// Fetch up to 4 sample thumbnails from the cluster's leaves and lay
|
||||
// them out as a 1 / 2 / 4-image grid (PhotoPrism's pattern). The
|
||||
// source is captured once here; `getClusterLeaves` returns a
|
||||
// Promise, so this populates asynchronously and the bubble shows a
|
||||
// dark placeholder until the thumbs arrive.
|
||||
if (map) {
|
||||
const source = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (source && typeof source.getClusterLeaves === 'function') {
|
||||
source
|
||||
.getClusterLeaves(clusterId, 4, 0)
|
||||
.then((leaves) => {
|
||||
const previewCount = leaves.length >= 4 ? 4 : leaves.length > 1 ? 2 : 1;
|
||||
grid.style.gridTemplateColumns = previewCount === 1 ? '1fr' : '1fr 1fr';
|
||||
for (let i = 0; i < previewCount; i++) {
|
||||
const leaf = leaves[Math.floor((leaves.length * i) / previewCount)];
|
||||
const props = (leaf?.properties ?? {}) as { Hash?: string };
|
||||
if (!props.Hash) continue;
|
||||
const tile = document.createElement('div');
|
||||
tile.style.backgroundImage = `url(${thumbUrl(props.Hash, 'tile_50')})`;
|
||||
grid.appendChild(tile);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
el.addEventListener('click', (ev) => {
|
||||
ev.stopPropagation();
|
||||
if (!map) return;
|
||||
const source = map.getSource('photos') as GeoJSONSource;
|
||||
source.getClusterExpansionZoom(clusterId).then((zoom) => {
|
||||
// Use the marker's current LngLat — set just below in updateMarkers.
|
||||
const m = markers.get(`cluster:${clusterId}`);
|
||||
const ll = m?.getLngLat();
|
||||
if (!ll) return;
|
||||
map!.easeTo({ center: ll, zoom });
|
||||
});
|
||||
});
|
||||
return el;
|
||||
}
|
||||
|
||||
/** Reconcile HTML markers against what's currently in the rendered
|
||||
* source. PhotoPrism's `updateMarkers`. */
|
||||
function updateMarkers() {
|
||||
if (!map || !map.isStyleLoaded() || !map.getSource('photos')) return;
|
||||
const features = map.querySourceFeatures('photos');
|
||||
const allUids = (geoQuery.data?.features ?? []).map((f) => f.properties.UID);
|
||||
const seen = new Set<string>();
|
||||
|
||||
for (const f of features) {
|
||||
const props = (f.properties ?? {}) as Record<string, unknown> & {
|
||||
cluster?: boolean;
|
||||
cluster_id?: number;
|
||||
point_count?: number;
|
||||
UID?: string;
|
||||
Hash?: string;
|
||||
Title?: string;
|
||||
};
|
||||
const geom = f.geometry;
|
||||
if (geom.type !== 'Point') continue;
|
||||
const coords = geom.coordinates as [number, number];
|
||||
|
||||
let key: string;
|
||||
let buildEl: () => HTMLElement;
|
||||
if (props.cluster) {
|
||||
if (props.cluster_id == null) continue;
|
||||
key = `cluster:${props.cluster_id}`;
|
||||
const cid = props.cluster_id;
|
||||
const count = props.point_count ?? 0;
|
||||
buildEl = () => buildClusterMarker(cid, count);
|
||||
} else {
|
||||
if (!props.UID || !props.Hash) continue;
|
||||
key = props.UID;
|
||||
const uid = props.UID;
|
||||
const hash = props.Hash;
|
||||
const title = props.Title;
|
||||
buildEl = () => buildPhotoMarker(uid, hash, title, allUids);
|
||||
}
|
||||
|
||||
seen.add(key);
|
||||
let marker = markers.get(key);
|
||||
if (!marker) {
|
||||
marker = new maplibregl.Marker({ element: buildEl(), anchor: 'center' }).setLngLat(coords);
|
||||
markers.set(key, marker);
|
||||
} else {
|
||||
marker.setLngLat(coords);
|
||||
}
|
||||
if (!markersOnScreen.has(key)) {
|
||||
marker.addTo(map);
|
||||
markersOnScreen.set(key, marker);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, marker] of markersOnScreen) {
|
||||
if (!seen.has(key)) {
|
||||
marker.remove();
|
||||
markersOnScreen.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Push new geo data into the source whenever the query resolves AND
|
||||
// the map is ready. Both orderings are handled: if data arrives first,
|
||||
// the effect re-runs when `mapReady` flips; if the map is ready first,
|
||||
// it re-runs when `data` arrives.
|
||||
$effect(() => {
|
||||
const data = geoQuery.data as
|
||||
| (PpGeoCollection & { bbox?: number[] })
|
||||
| undefined;
|
||||
if (!map || !mapReady || !data) return;
|
||||
const src = map.getSource('photos') as GeoJSONSource | undefined;
|
||||
if (!src) return;
|
||||
src.setData(data as GeoJSON.FeatureCollection);
|
||||
|
||||
// Drop stale markers; updateMarkers will rebuild for the current
|
||||
// visible set on the next `sourcedata` (fired by setData) or `idle`.
|
||||
markersOnScreen.forEach((m) => m.remove());
|
||||
markersOnScreen.clear();
|
||||
markers.clear();
|
||||
|
||||
// Deep-link from the RightSidebar's location open icon: `?lat=&lng=`
|
||||
// (+ optional `zoom`, `focus`) flies the map directly to the photo
|
||||
// rather than fitting to the full library extent. Strip the params
|
||||
// afterwards so a manual zoom-out + reload doesn't snap back. Falls
|
||||
// through to the default fitBounds when the params aren't present.
|
||||
const sp = new URL(window.location.href).searchParams;
|
||||
const latParam = Number(sp.get('lat'));
|
||||
const lngParam = Number(sp.get('lng'));
|
||||
if (
|
||||
(data.features?.length ?? 0) > 0 &&
|
||||
Number.isFinite(latParam) &&
|
||||
Number.isFinite(lngParam) &&
|
||||
sp.has('lat') &&
|
||||
sp.has('lng')
|
||||
) {
|
||||
const zoom = Number(sp.get('zoom')) || 17;
|
||||
map.jumpTo({ center: [lngParam, latParam], zoom });
|
||||
const stripped = new URL(window.location.href);
|
||||
stripped.searchParams.delete('lat');
|
||||
stripped.searchParams.delete('lng');
|
||||
stripped.searchParams.delete('zoom');
|
||||
stripped.searchParams.delete('focus');
|
||||
const qs = stripped.searchParams.toString();
|
||||
void goto(`/map${qs ? `?${qs}` : ''}`, {
|
||||
replaceState: true,
|
||||
keepFocus: true,
|
||||
noScroll: true
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Fit to data extent on the first non-empty load — prefer the
|
||||
// server-provided bbox (PhotoPrism returns one), else compute from
|
||||
// the features.
|
||||
if ((data.features?.length ?? 0) > 0) {
|
||||
let bounds: maplibregl.LngLatBoundsLike | null = null;
|
||||
if (Array.isArray(data.bbox) && data.bbox.length === 4) {
|
||||
bounds = [
|
||||
[data.bbox[0], data.bbox[1]],
|
||||
[data.bbox[2], data.bbox[3]]
|
||||
];
|
||||
} else {
|
||||
const b = new maplibregl.LngLatBounds();
|
||||
for (const f of data.features as PpGeoFeature[]) {
|
||||
const c = f.geometry.coordinates as [number, number];
|
||||
if (Number.isFinite(c[0]) && Number.isFinite(c[1])) b.extend(c);
|
||||
}
|
||||
if (!b.isEmpty()) bounds = b;
|
||||
}
|
||||
if (bounds) map.fitBounds(bounds, { padding: 60, maxZoom: 17, animate: false });
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<Toolbar>
|
||||
<span class="rounded-md border border-border px-2 py-0.5 text-[11px] text-muted-foreground">
|
||||
Map
|
||||
</span>
|
||||
{#snippet trailing()}
|
||||
<span class="text-[11px] text-muted-foreground">
|
||||
{geoQuery.data?.features?.length ?? 0} geotagged
|
||||
</span>
|
||||
{/snippet}
|
||||
</Toolbar>
|
||||
|
||||
<div bind:this={mapEl} class="min-h-0 w-full flex-1"></div>
|
||||
|
||||
<style>
|
||||
/* PhotoPrism's marker / cluster styling, ported from
|
||||
frontend/src/css/places.css. `:global` because MapLibre appends
|
||||
markers outside Svelte's scoped CSS reach. */
|
||||
:global(.maplibregl-map .marker) {
|
||||
display: block;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: 1px solid #ffffff99;
|
||||
background-color: rgba(23, 23, 23, 0.23);
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker) {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-gap: 1px;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 50%;
|
||||
}
|
||||
:global(.maplibregl-map .cluster-marker > div) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
:global(.maplibregl-map .badge) {
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
right: -5px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
background: #53478a;
|
||||
box-shadow:
|
||||
0px 3px 1px -2px rgba(0, 0, 0, 0.2),
|
||||
0px 2px 2px 0px rgba(0, 0, 0, 0.14),
|
||||
0px 1px 5px 0px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
</style>
|
||||
@@ -29,6 +29,7 @@
|
||||
COLOR_SWATCHES,
|
||||
starLabel
|
||||
} from '$lib/utils/tagGroups';
|
||||
import { countryName } from '$lib/utils/countries';
|
||||
import BulkActionBar from '$lib/components/timeline/BulkActionBar.svelte';
|
||||
import BulkMetadataSidebar from '$lib/components/sidebar/BulkMetadataSidebar.svelte';
|
||||
import PhotoGrid from '$lib/components/timeline/PhotoGrid.svelte';
|
||||
@@ -69,7 +70,10 @@
|
||||
// label badge of 157 could otherwise drill into 0 photos because the
|
||||
// session is scoped to a folder that has none of them).
|
||||
const useServer = $derived(
|
||||
category === 'labels' || category === 'keywords' || category === 'people'
|
||||
category === 'labels' ||
|
||||
category === 'keywords' ||
|
||||
category === 'people' ||
|
||||
category === 'countries'
|
||||
);
|
||||
const drillQ = $derived(
|
||||
useServer && selectedValue
|
||||
@@ -161,6 +165,7 @@
|
||||
COLOR_SWATCHES.find((c) => c.key === selectedValue)?.title ?? selectedValue
|
||||
);
|
||||
}
|
||||
if (category === 'countries') return countryName(selectedValue);
|
||||
return selectedValue;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user