Spaces:
Running
Running
add private bucket pmtiles map served by bun
Browse files- .dockerignore +5 -0
- Dockerfile +15 -0
- README.md +62 -4
- bun.lock +93 -0
- env.example +6 -0
- package.json +19 -0
- public/index.html +89 -0
- public/styles.css +506 -0
- src/client.ts +353 -0
- src/ranges.ts +19 -0
- src/server.test.ts +91 -0
- src/server.ts +164 -0
- tsconfig.json +14 -0
.dockerignore
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
node_modules
|
| 3 |
+
data
|
| 4 |
+
.env*
|
| 5 |
+
proofs
|
Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM oven/bun:1.2.14 AS build
|
| 2 |
+
WORKDIR /app
|
| 3 |
+
COPY package.json bun.lock ./
|
| 4 |
+
RUN bun install --frozen-lockfile
|
| 5 |
+
COPY src ./src
|
| 6 |
+
COPY public ./public
|
| 7 |
+
RUN bun run build
|
| 8 |
+
|
| 9 |
+
FROM oven/bun:1.2.14
|
| 10 |
+
WORKDIR /app
|
| 11 |
+
COPY --from=build --chown=bun:bun /app /app
|
| 12 |
+
USER bun
|
| 13 |
+
ENV PORT=7860
|
| 14 |
+
EXPOSE 7860
|
| 15 |
+
CMD ["bun", "src/server.ts"]
|
README.md
CHANGED
|
@@ -1,10 +1,68 @@
|
|
| 1 |
---
|
| 2 |
title: Bucket Atlas
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Bucket Atlas
|
| 3 |
+
emoji: 🗺️
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: yellow
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# Bucket Atlas
|
| 13 |
+
|
| 14 |
+
Explore Florence with MapLibre and PMTiles, served from a private Hugging Face bucket through Bun's S3 client. Pan around the city, visit landmarks, change map layers, inspect buildings, and see how many archive bytes the browser reads.
|
| 15 |
+
|
| 16 |
+
## Storage configuration
|
| 17 |
+
|
| 18 |
+
The initial Space and bucket are private. Set these **Space secrets**:
|
| 19 |
+
|
| 20 |
+
- `HF_S3_ACCESS_KEY_ID`
|
| 21 |
+
- `HF_S3_SECRET_ACCESS_KEY`
|
| 22 |
+
|
| 23 |
+
Use a credential with read access to `cfahlgren1/bucket-atlas`. S3 requires both values. An access-key ID by itself is insufficient. No HF token or S3 secret is sent to the browser.
|
| 24 |
+
|
| 25 |
+
Optional Space variables default to:
|
| 26 |
+
|
| 27 |
+
```text
|
| 28 |
+
HF_S3_NAMESPACE=cfahlgren1
|
| 29 |
+
HF_S3_BUCKET=bucket-atlas
|
| 30 |
+
PMTILES_KEY=maps/florence.pmtiles
|
| 31 |
+
```
|
| 32 |
+
|
| 33 |
+
The app serves only the configured archive through `/maps/florence.pmtiles`. The Bun server signs an S3 request, verifies the upstream returns the requested range, and streams the bytes to the browser. A single request is limited to 8 MiB. Requests for full archives and multiple ranges are rejected. Responses use private, no-store caching. The browser still caches PMTiles directory and tile data in memory.
|
| 34 |
+
|
| 35 |
+
Viewer access is provided by the private Space. Making the Space public makes the selected map readable through the app, even though the bucket itself remains private.
|
| 36 |
+
|
| 37 |
+
## Local development
|
| 38 |
+
|
| 39 |
+
```sh
|
| 40 |
+
bun install --frozen-lockfile
|
| 41 |
+
bun run build
|
| 42 |
+
# Use the S3 secrets from your environment:
|
| 43 |
+
bun start
|
| 44 |
+
```
|
| 45 |
+
|
| 46 |
+
For explicit local development, download the sample and set `LOCAL_PMTILES`:
|
| 47 |
+
|
| 48 |
+
```sh
|
| 49 |
+
mkdir -p data
|
| 50 |
+
curl -fL 'https://pmtiles.io/protomaps(vector)ODbL_firenze.pmtiles' -o data/florence.pmtiles
|
| 51 |
+
LOCAL_PMTILES=data/florence.pmtiles bun start
|
| 52 |
+
bun test
|
| 53 |
+
bun run check
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
The local archive is not included in the Docker image. There is no automatic local fallback in the deployed Space. Without valid S3 credentials, the UI reports that storage needs setup.
|
| 57 |
+
|
| 58 |
+
## Map data and credits
|
| 59 |
+
|
| 60 |
+
The [Protomaps Florence sample](https://github.com/protomaps/PMTiles/blob/main/js/examples/maplibre.html) contains a regional OpenStreetMap basemap snapshot from August 2024. It is 6,601,156 bytes, with native zoom levels through 15. Higher display zooms reuse those tiles. It covers central Florence, not all of Tuscany. Building heights use the source value when present and a 7 m visual default otherwise.
|
| 61 |
+
|
| 62 |
+
Map data © OpenStreetMap contributors, distributed under ODbL. [OpenStreetMap copyright and license](https://www.openstreetmap.org/copyright). Tiles distributed by Protomaps. Fonts are loaded from Google Fonts and the Protomaps font assets host. The byte counter measures PMTiles response payloads only, not scripts, fonts, HTTP overhead, or total network traffic.
|
| 63 |
+
|
| 64 |
+
## Verification
|
| 65 |
+
|
| 66 |
+
`bun test` uses the real sample file and a real HTTP server. It verifies byte equality and headers, HEAD, invalid and multiple ranges, fixed-path access, PMTiles metadata parsing, and actual tile decoding. These tests use the local development backend; they do not prove live S3 credentials or HF gateway behavior.
|
| 67 |
+
|
| 68 |
+
The browser UI was also exercised locally, including navigation, popups and layer controls. The S3 deployment requires a final authenticated range/read and browser check once the Space secret is available.
|
bun.lock
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"lockfileVersion": 1,
|
| 3 |
+
"workspaces": {
|
| 4 |
+
"": {
|
| 5 |
+
"name": "bucket-atlas",
|
| 6 |
+
"dependencies": {
|
| 7 |
+
"maplibre-gl": "5.13.0",
|
| 8 |
+
"pmtiles": "4.5.0",
|
| 9 |
+
},
|
| 10 |
+
"devDependencies": {
|
| 11 |
+
"@types/bun": "1.2.14",
|
| 12 |
+
"typescript": "5.9.3",
|
| 13 |
+
},
|
| 14 |
+
},
|
| 15 |
+
},
|
| 16 |
+
"packages": {
|
| 17 |
+
"@mapbox/geojson-rewind": ["@mapbox/geojson-rewind@0.5.2", "", { "dependencies": { "get-stream": "^6.0.1", "minimist": "^1.2.6" }, "bin": { "geojson-rewind": "geojson-rewind" } }, "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA=="],
|
| 18 |
+
|
| 19 |
+
"@mapbox/jsonlint-lines-primitives": ["@mapbox/jsonlint-lines-primitives@2.0.3", "", {}, "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg=="],
|
| 20 |
+
|
| 21 |
+
"@mapbox/point-geometry": ["@mapbox/point-geometry@1.1.0", "", {}, "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ=="],
|
| 22 |
+
|
| 23 |
+
"@mapbox/tiny-sdf": ["@mapbox/tiny-sdf@2.2.0", "", {}, "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg=="],
|
| 24 |
+
|
| 25 |
+
"@mapbox/unitbezier": ["@mapbox/unitbezier@0.0.1", "", {}, "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw=="],
|
| 26 |
+
|
| 27 |
+
"@mapbox/vector-tile": ["@mapbox/vector-tile@2.0.5", "", { "dependencies": { "@mapbox/point-geometry": "~1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^4.0.2" } }, "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw=="],
|
| 28 |
+
|
| 29 |
+
"@mapbox/whoots-js": ["@mapbox/whoots-js@3.1.0", "", {}, "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q=="],
|
| 30 |
+
|
| 31 |
+
"@maplibre/maplibre-gl-style-spec": ["@maplibre/maplibre-gl-style-spec@24.10.0", "", { "dependencies": { "@mapbox/jsonlint-lines-primitives": "~2.0.2", "@mapbox/unitbezier": "^1.0.0", "json-stringify-pretty-compact": "^4.0.0", "minimist": "^1.2.8", "quickselect": "^3.0.0", "tinyqueue": "^3.0.0" }, "bin": { "gl-style-format": "dist/gl-style-format.mjs", "gl-style-migrate": "dist/gl-style-migrate.mjs", "gl-style-validate": "dist/gl-style-validate.mjs" } }, "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw=="],
|
| 32 |
+
|
| 33 |
+
"@maplibre/mlt": ["@maplibre/mlt@1.2.1", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0" } }, "sha512-5n5dgolE2EYxwCKgx8vlwURCB8A+kyfJJyThlYimjlGgOcOe2Bhw9VxxnnHC91OC9PgHg+nVdgVPTYPkIo62vg=="],
|
| 34 |
+
|
| 35 |
+
"@maplibre/vt-pbf": ["@maplibre/vt-pbf@4.3.2", "", { "dependencies": { "@mapbox/point-geometry": "^1.1.0", "@types/geojson": "^7946.0.16", "pbf": "^5.1.0" } }, "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw=="],
|
| 36 |
+
|
| 37 |
+
"@types/bun": ["@types/bun@1.2.14", "", { "dependencies": { "bun-types": "1.2.14" } }, "sha512-VsFZKs8oKHzI7zwvECiAJ5oSorWndIWEVhfbYqZd4HI/45kzW7PN2Rr5biAzvGvRuNmYLSANY+H59ubHq8xw7Q=="],
|
| 38 |
+
|
| 39 |
+
"@types/geojson": ["@types/geojson@7946.0.16", "", {}, "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg=="],
|
| 40 |
+
|
| 41 |
+
"@types/geojson-vt": ["@types/geojson-vt@3.2.5", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g=="],
|
| 42 |
+
|
| 43 |
+
"@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="],
|
| 44 |
+
|
| 45 |
+
"@types/supercluster": ["@types/supercluster@7.1.3", "", { "dependencies": { "@types/geojson": "*" } }, "sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA=="],
|
| 46 |
+
|
| 47 |
+
"bun-types": ["bun-types@1.2.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-Kuh4Ub28ucMRWeiUUWMHsT9Wcbr4H3kLIO72RZZElSDxSu7vpetRvxIUDUaW6QtaIeixIpm7OXtNnZPf82EzwA=="],
|
| 48 |
+
|
| 49 |
+
"earcut": ["earcut@3.2.3", "", {}, "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q=="],
|
| 50 |
+
|
| 51 |
+
"fflate": ["fflate@0.8.3", "", {}, "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA=="],
|
| 52 |
+
|
| 53 |
+
"geojson-vt": ["geojson-vt@4.0.3", "", {}, "sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA=="],
|
| 54 |
+
|
| 55 |
+
"get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="],
|
| 56 |
+
|
| 57 |
+
"gl-matrix": ["gl-matrix@3.4.4", "", {}, "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ=="],
|
| 58 |
+
|
| 59 |
+
"json-stringify-pretty-compact": ["json-stringify-pretty-compact@4.0.0", "", {}, "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q=="],
|
| 60 |
+
|
| 61 |
+
"kdbush": ["kdbush@4.1.0", "", {}, "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ=="],
|
| 62 |
+
|
| 63 |
+
"maplibre-gl": ["maplibre-gl@5.13.0", "", { "dependencies": { "@mapbox/geojson-rewind": "^0.5.2", "@mapbox/jsonlint-lines-primitives": "^2.0.2", "@mapbox/point-geometry": "^1.1.0", "@mapbox/tiny-sdf": "^2.0.7", "@mapbox/unitbezier": "^0.0.1", "@mapbox/vector-tile": "^2.0.4", "@mapbox/whoots-js": "^3.1.0", "@maplibre/maplibre-gl-style-spec": "^24.3.1", "@maplibre/mlt": "^1.1.0", "@maplibre/vt-pbf": "^4.0.3", "@types/geojson": "^7946.0.16", "@types/geojson-vt": "3.2.5", "@types/supercluster": "^7.1.3", "earcut": "^3.0.2", "geojson-vt": "^4.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", "supercluster": "^8.0.1", "tinyqueue": "^3.0.0" } }, "sha512-UsIVP34rZdM4TjrjhwBAhbC3HT7AzFx9p/draiAPlLr8/THozZF6WmJnZ9ck4q94uO55z7P7zoGCh+AZVoagsQ=="],
|
| 64 |
+
|
| 65 |
+
"minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="],
|
| 66 |
+
|
| 67 |
+
"murmurhash-js": ["murmurhash-js@1.0.0", "", {}, "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw=="],
|
| 68 |
+
|
| 69 |
+
"pbf": ["pbf@4.0.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg=="],
|
| 70 |
+
|
| 71 |
+
"pmtiles": ["pmtiles@4.5.0", "", { "dependencies": { "fflate": "^0.8.2" } }, "sha512-CBeD4SoUluFziGdy/8k7FOjQxQy486n+929W/tWophauvMICpkZ26vGBWhDt/6b1FZQWNaf4jFdT/5+q0Wii5w=="],
|
| 72 |
+
|
| 73 |
+
"potpack": ["potpack@2.1.0", "", {}, "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ=="],
|
| 74 |
+
|
| 75 |
+
"protocol-buffers-schema": ["protocol-buffers-schema@3.6.1", "", {}, "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ=="],
|
| 76 |
+
|
| 77 |
+
"quickselect": ["quickselect@3.0.0", "", {}, "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g=="],
|
| 78 |
+
|
| 79 |
+
"resolve-protobuf-schema": ["resolve-protobuf-schema@2.1.0", "", { "dependencies": { "protocol-buffers-schema": "^3.3.1" } }, "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ=="],
|
| 80 |
+
|
| 81 |
+
"supercluster": ["supercluster@8.0.1", "", { "dependencies": { "kdbush": "^4.0.2" } }, "sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ=="],
|
| 82 |
+
|
| 83 |
+
"tinyqueue": ["tinyqueue@3.0.0", "", {}, "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g=="],
|
| 84 |
+
|
| 85 |
+
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
| 86 |
+
|
| 87 |
+
"undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="],
|
| 88 |
+
|
| 89 |
+
"@maplibre/maplibre-gl-style-spec/@mapbox/unitbezier": ["@mapbox/unitbezier@1.0.0", "", {}, "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ=="],
|
| 90 |
+
|
| 91 |
+
"@maplibre/vt-pbf/pbf": ["pbf@5.1.2", "", { "dependencies": { "resolve-protobuf-schema": "^2.1.0" }, "bin": { "pbf": "bin/pbf" } }, "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w=="],
|
| 92 |
+
}
|
| 93 |
+
}
|
env.example
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
HF_S3_NAMESPACE=cfahlgren1
|
| 2 |
+
HF_S3_BUCKET=bucket-atlas
|
| 3 |
+
PMTILES_KEY=maps/florence.pmtiles
|
| 4 |
+
HF_S3_ACCESS_KEY_ID=
|
| 5 |
+
HF_S3_SECRET_ACCESS_KEY=
|
| 6 |
+
PORT=7860
|
package.json
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "bucket-atlas",
|
| 3 |
+
"private": true,
|
| 4 |
+
"type": "module",
|
| 5 |
+
"scripts": {
|
| 6 |
+
"build": "bun build src/client.ts --outdir public/assets --target browser --minify",
|
| 7 |
+
"start": "bun src/server.ts",
|
| 8 |
+
"test": "bun test",
|
| 9 |
+
"check": "tsc --noEmit"
|
| 10 |
+
},
|
| 11 |
+
"dependencies": {
|
| 12 |
+
"maplibre-gl": "5.13.0",
|
| 13 |
+
"pmtiles": "4.5.0"
|
| 14 |
+
},
|
| 15 |
+
"devDependencies": {
|
| 16 |
+
"@types/bun": "1.2.14",
|
| 17 |
+
"typescript": "5.9.3"
|
| 18 |
+
}
|
| 19 |
+
}
|
public/index.html
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
| 6 |
+
<title>Bucket Atlas · Florence</title>
|
| 7 |
+
<link rel="stylesheet" href="/assets/client.css" />
|
| 8 |
+
<link rel="stylesheet" href="/styles.css" />
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<div id="map" aria-label="Interactive map of Florence"></div>
|
| 12 |
+
<header>
|
| 13 |
+
<a class="brand" href="/" aria-label="Bucket Atlas home"
|
| 14 |
+
><span class="brand-icon">◈</span> bucket<span>atlas</span></a
|
| 15 |
+
>
|
| 16 |
+
<div class="header-right">
|
| 17 |
+
<span class="pill"><i></i> PRIVATE BUCKET</span
|
| 18 |
+
><a
|
| 19 |
+
href="https://huggingface.co/docs/hub/storage-buckets"
|
| 20 |
+
target="_blank"
|
| 21 |
+
rel="noreferrer"
|
| 22 |
+
>Built on Hugging Face ↗</a
|
| 23 |
+
>
|
| 24 |
+
</div>
|
| 25 |
+
</header>
|
| 26 |
+
<main class="panel">
|
| 27 |
+
<div class="eyebrow">THE WORLD, ONE FILE AT A TIME</div>
|
| 28 |
+
<h1>Find your<br /><em>Florence.</em></h1>
|
| 29 |
+
<p class="intro">
|
| 30 |
+
Follow the Arno. Wander the old streets.<br />A little corner of
|
| 31 |
+
Tuscany, ready to explore.
|
| 32 |
+
</p>
|
| 33 |
+
<div class="location">
|
| 34 |
+
<span>◎</span>
|
| 35 |
+
<div>
|
| 36 |
+
<strong>Florence</strong
|
| 37 |
+
><small>Tuscany, Italy · 43.77° N, 11.25° E</small>
|
| 38 |
+
</div>
|
| 39 |
+
<span class="country">IT</span>
|
| 40 |
+
</div>
|
| 41 |
+
<div class="section-label">PLACES TO EXPLORE</div>
|
| 42 |
+
<div class="places">
|
| 43 |
+
<button data-place="duomo">
|
| 44 |
+
<span>01</span>
|
| 45 |
+
<div>Under the dome<small>Piazza del Duomo</small></div>
|
| 46 |
+
<b>↗</b></button
|
| 47 |
+
><button data-place="bridge">
|
| 48 |
+
<span>02</span>
|
| 49 |
+
<div>Across the Arno<small>Ponte Vecchio</small></div>
|
| 50 |
+
<b>↗</b></button
|
| 51 |
+
><button data-place="garden">
|
| 52 |
+
<span>03</span>
|
| 53 |
+
<div>A greener perspective<small>Boboli Gardens</small></div>
|
| 54 |
+
<b>↗</b>
|
| 55 |
+
</button>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="section-label">MAKE IT YOUR MAP</div>
|
| 58 |
+
<div class="toggles">
|
| 59 |
+
<label><input type="checkbox" id="buildings" checked /> Buildings</label
|
| 60 |
+
><label><input type="checkbox" id="parks" checked /> Green spaces</label
|
| 61 |
+
><label
|
| 62 |
+
><input type="checkbox" id="labels" checked /> Street names</label
|
| 63 |
+
>
|
| 64 |
+
</div>
|
| 65 |
+
<button class="reset" id="reset">↺ Back to the city</button>
|
| 66 |
+
<div id="status" role="status">Connecting to the map…</div>
|
| 67 |
+
</main>
|
| 68 |
+
<div class="map-tools">
|
| 69 |
+
<button id="tilt" aria-label="Toggle tilted map">3D</button
|
| 70 |
+
><button id="theme" aria-label="Toggle dark map">◐</button>
|
| 71 |
+
</div>
|
| 72 |
+
<div class="legend">
|
| 73 |
+
<span><i class="water"></i>Water</span
|
| 74 |
+
><span><i class="park"></i>Green space</span
|
| 75 |
+
><span><i class="building"></i>Buildings</span><span>OSM · Aug 2024</span>
|
| 76 |
+
</div>
|
| 77 |
+
<aside class="transfer">
|
| 78 |
+
<div class="transfer-icon">⇣</div>
|
| 79 |
+
<div><strong id="bytes">0 KB</strong><span>read this session</span></div>
|
| 80 |
+
<div class="divider"></div>
|
| 81 |
+
<div><strong id="requests">0</strong><span>range requests</span></div>
|
| 82 |
+
<div class="file-note">
|
| 83 |
+
<strong>ONE PMTILES FILE</strong
|
| 84 |
+
><span id="archive">Loading archive details…</span>
|
| 85 |
+
</div>
|
| 86 |
+
</aside>
|
| 87 |
+
<script type="module" src="/assets/client.js"></script>
|
| 88 |
+
</body>
|
| 89 |
+
</html>
|
public/styles.css
ADDED
|
@@ -0,0 +1,506 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
@import url("https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Playfair+Display:ital,wght@1,500&display=swap");
|
| 2 |
+
:root {
|
| 3 |
+
font-family: "DM Sans", sans-serif;
|
| 4 |
+
color: #263d36;
|
| 5 |
+
background: #e7ece2;
|
| 6 |
+
font-synthesis: none;
|
| 7 |
+
}
|
| 8 |
+
* {
|
| 9 |
+
box-sizing: border-box;
|
| 10 |
+
}
|
| 11 |
+
body {
|
| 12 |
+
margin: 0;
|
| 13 |
+
}
|
| 14 |
+
button,
|
| 15 |
+
a,
|
| 16 |
+
input {
|
| 17 |
+
-webkit-tap-highlight-color: transparent;
|
| 18 |
+
}
|
| 19 |
+
button {
|
| 20 |
+
font: inherit;
|
| 21 |
+
cursor: pointer;
|
| 22 |
+
}
|
| 23 |
+
a {
|
| 24 |
+
color: inherit;
|
| 25 |
+
text-decoration: none;
|
| 26 |
+
}
|
| 27 |
+
button:focus-visible,
|
| 28 |
+
a:focus-visible,
|
| 29 |
+
input:focus-visible {
|
| 30 |
+
outline: 3px solid #e0a642;
|
| 31 |
+
outline-offset: 3px;
|
| 32 |
+
}
|
| 33 |
+
#map {
|
| 34 |
+
position: fixed;
|
| 35 |
+
inset: 0;
|
| 36 |
+
}
|
| 37 |
+
header {
|
| 38 |
+
position: fixed;
|
| 39 |
+
top: 0;
|
| 40 |
+
left: 0;
|
| 41 |
+
right: 0;
|
| 42 |
+
height: 82px;
|
| 43 |
+
background: #fffffff2;
|
| 44 |
+
border-bottom: 1px solid #263d3613;
|
| 45 |
+
display: flex;
|
| 46 |
+
align-items: center;
|
| 47 |
+
justify-content: space-between;
|
| 48 |
+
padding: 0 36px;
|
| 49 |
+
z-index: 3;
|
| 50 |
+
}
|
| 51 |
+
.brand {
|
| 52 |
+
font-size: 25px;
|
| 53 |
+
letter-spacing: -1.3px;
|
| 54 |
+
font-weight: 700;
|
| 55 |
+
display: flex;
|
| 56 |
+
align-items: center;
|
| 57 |
+
}
|
| 58 |
+
.brand > span:last-child {
|
| 59 |
+
font-weight: 400;
|
| 60 |
+
}
|
| 61 |
+
.brand-icon {
|
| 62 |
+
width: 38px;
|
| 63 |
+
height: 38px;
|
| 64 |
+
display: grid;
|
| 65 |
+
place-items: center;
|
| 66 |
+
background: #274c40;
|
| 67 |
+
color: #f9f3df;
|
| 68 |
+
border-radius: 11px;
|
| 69 |
+
margin-right: 11px;
|
| 70 |
+
font-size: 30px;
|
| 71 |
+
}
|
| 72 |
+
.header-right {
|
| 73 |
+
display: flex;
|
| 74 |
+
align-items: center;
|
| 75 |
+
gap: 24px;
|
| 76 |
+
font-size: 12px;
|
| 77 |
+
}
|
| 78 |
+
.pill {
|
| 79 |
+
font-size: 10px;
|
| 80 |
+
letter-spacing: 1px;
|
| 81 |
+
border: 1px solid #dde6df;
|
| 82 |
+
border-radius: 20px;
|
| 83 |
+
padding: 9px 13px;
|
| 84 |
+
display: flex;
|
| 85 |
+
align-items: center;
|
| 86 |
+
gap: 7px;
|
| 87 |
+
}
|
| 88 |
+
.pill i {
|
| 89 |
+
background: #5c9678;
|
| 90 |
+
width: 6px;
|
| 91 |
+
height: 6px;
|
| 92 |
+
border-radius: 50%;
|
| 93 |
+
}
|
| 94 |
+
.panel {
|
| 95 |
+
position: fixed;
|
| 96 |
+
left: 26px;
|
| 97 |
+
top: 108px;
|
| 98 |
+
width: 340px;
|
| 99 |
+
padding: 29px;
|
| 100 |
+
background: #fffcf6f5;
|
| 101 |
+
backdrop-filter: blur(12px);
|
| 102 |
+
border: 1px solid #ffffff;
|
| 103 |
+
border-radius: 18px;
|
| 104 |
+
box-shadow: 0 12px 50px #203c3020;
|
| 105 |
+
z-index: 2;
|
| 106 |
+
}
|
| 107 |
+
.eyebrow {
|
| 108 |
+
font-size: 9px;
|
| 109 |
+
letter-spacing: 1.8px;
|
| 110 |
+
font-weight: 700;
|
| 111 |
+
color: #718478;
|
| 112 |
+
}
|
| 113 |
+
h1 {
|
| 114 |
+
font-size: 49px;
|
| 115 |
+
line-height: 1.06;
|
| 116 |
+
letter-spacing: -2.2px;
|
| 117 |
+
font-weight: 500;
|
| 118 |
+
margin: 22px 0 16px;
|
| 119 |
+
}
|
| 120 |
+
h1 em {
|
| 121 |
+
font-family: "Playfair Display", Georgia, serif;
|
| 122 |
+
font-weight: 500;
|
| 123 |
+
color: #45745a;
|
| 124 |
+
}
|
| 125 |
+
.intro {
|
| 126 |
+
font-size: 12px;
|
| 127 |
+
line-height: 1.8;
|
| 128 |
+
color: #7a847b;
|
| 129 |
+
margin: 0 0 22px;
|
| 130 |
+
}
|
| 131 |
+
.location {
|
| 132 |
+
border-top: 1px solid #e1e6dc;
|
| 133 |
+
border-bottom: 1px solid #e1e6dc;
|
| 134 |
+
padding: 17px 0;
|
| 135 |
+
display: flex;
|
| 136 |
+
gap: 12px;
|
| 137 |
+
align-items: center;
|
| 138 |
+
}
|
| 139 |
+
.location > span:first-child {
|
| 140 |
+
font-size: 26px;
|
| 141 |
+
color: #7c987e;
|
| 142 |
+
}
|
| 143 |
+
.location strong {
|
| 144 |
+
font-size: 13px;
|
| 145 |
+
}
|
| 146 |
+
.location small {
|
| 147 |
+
display: block;
|
| 148 |
+
font-size: 9px;
|
| 149 |
+
margin-top: 5px;
|
| 150 |
+
color: #8a9187;
|
| 151 |
+
}
|
| 152 |
+
.country {
|
| 153 |
+
margin-left: auto;
|
| 154 |
+
font-size: 10px;
|
| 155 |
+
background: #eceee4;
|
| 156 |
+
padding: 6px;
|
| 157 |
+
border-radius: 5px;
|
| 158 |
+
}
|
| 159 |
+
.section-label {
|
| 160 |
+
font-size: 9px;
|
| 161 |
+
font-weight: 700;
|
| 162 |
+
letter-spacing: 1.5px;
|
| 163 |
+
color: #83917f;
|
| 164 |
+
margin: 24px 0 12px;
|
| 165 |
+
}
|
| 166 |
+
.places {
|
| 167 |
+
display: grid;
|
| 168 |
+
gap: 4px;
|
| 169 |
+
}
|
| 170 |
+
.places button {
|
| 171 |
+
display: flex;
|
| 172 |
+
align-items: center;
|
| 173 |
+
text-align: left;
|
| 174 |
+
border: 0;
|
| 175 |
+
background: transparent;
|
| 176 |
+
padding: 11px 5px;
|
| 177 |
+
border-radius: 8px;
|
| 178 |
+
color: inherit;
|
| 179 |
+
}
|
| 180 |
+
.places button:hover,
|
| 181 |
+
.places button.active {
|
| 182 |
+
background: #edf0e5;
|
| 183 |
+
}
|
| 184 |
+
.places button > span {
|
| 185 |
+
font-size: 10px;
|
| 186 |
+
color: #94a18d;
|
| 187 |
+
margin-right: 17px;
|
| 188 |
+
}
|
| 189 |
+
.places button div {
|
| 190 |
+
font-size: 12px;
|
| 191 |
+
font-weight: 500;
|
| 192 |
+
}
|
| 193 |
+
.places small {
|
| 194 |
+
display: block;
|
| 195 |
+
font-size: 10px;
|
| 196 |
+
font-weight: 400;
|
| 197 |
+
color: #8a9287;
|
| 198 |
+
margin-top: 4px;
|
| 199 |
+
}
|
| 200 |
+
.places b {
|
| 201 |
+
margin-left: auto;
|
| 202 |
+
color: #859a83;
|
| 203 |
+
font-size: 18px;
|
| 204 |
+
font-weight: 400;
|
| 205 |
+
}
|
| 206 |
+
.toggles {
|
| 207 |
+
display: flex;
|
| 208 |
+
flex-wrap: wrap;
|
| 209 |
+
gap: 10px;
|
| 210 |
+
font-size: 11px;
|
| 211 |
+
}
|
| 212 |
+
.toggles label {
|
| 213 |
+
display: flex;
|
| 214 |
+
align-items: center;
|
| 215 |
+
gap: 5px;
|
| 216 |
+
}
|
| 217 |
+
.toggles input {
|
| 218 |
+
accent-color: #3b7356;
|
| 219 |
+
width: 13px;
|
| 220 |
+
height: 13px;
|
| 221 |
+
}
|
| 222 |
+
.reset {
|
| 223 |
+
border: 1px solid #dce3d4;
|
| 224 |
+
border-radius: 7px;
|
| 225 |
+
background: transparent;
|
| 226 |
+
padding: 10px;
|
| 227 |
+
width: 100%;
|
| 228 |
+
color: #677c65;
|
| 229 |
+
font-size: 11px;
|
| 230 |
+
margin-top: 20px;
|
| 231 |
+
}
|
| 232 |
+
#status {
|
| 233 |
+
font-size: 10px;
|
| 234 |
+
line-height: 1.5;
|
| 235 |
+
color: #6f826b;
|
| 236 |
+
margin-top: 15px;
|
| 237 |
+
}
|
| 238 |
+
#status.error {
|
| 239 |
+
color: #a74936;
|
| 240 |
+
}
|
| 241 |
+
.transfer {
|
| 242 |
+
position: fixed;
|
| 243 |
+
bottom: 42px;
|
| 244 |
+
left: 50%;
|
| 245 |
+
transform: translateX(-25%);
|
| 246 |
+
display: flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
gap: 20px;
|
| 249 |
+
background: #fffcf6f5;
|
| 250 |
+
border: 1px solid white;
|
| 251 |
+
border-radius: 13px;
|
| 252 |
+
padding: 17px 24px;
|
| 253 |
+
box-shadow: 0 5px 25px #203c3020;
|
| 254 |
+
z-index: 2;
|
| 255 |
+
}
|
| 256 |
+
.transfer-icon {
|
| 257 |
+
background: #e6eddd;
|
| 258 |
+
color: #6b875e;
|
| 259 |
+
width: 34px;
|
| 260 |
+
height: 34px;
|
| 261 |
+
display: grid;
|
| 262 |
+
place-items: center;
|
| 263 |
+
border-radius: 9px;
|
| 264 |
+
font-size: 23px;
|
| 265 |
+
}
|
| 266 |
+
.transfer strong {
|
| 267 |
+
display: block;
|
| 268 |
+
font-size: 17px;
|
| 269 |
+
font-weight: 500;
|
| 270 |
+
white-space: nowrap;
|
| 271 |
+
}
|
| 272 |
+
.transfer span {
|
| 273 |
+
display: block;
|
| 274 |
+
font-size: 9px;
|
| 275 |
+
color: #899180;
|
| 276 |
+
margin-top: 4px;
|
| 277 |
+
white-space: nowrap;
|
| 278 |
+
}
|
| 279 |
+
.divider {
|
| 280 |
+
width: 1px;
|
| 281 |
+
height: 30px;
|
| 282 |
+
background: #e1e6dc;
|
| 283 |
+
}
|
| 284 |
+
.file-note {
|
| 285 |
+
padding-left: 17px;
|
| 286 |
+
border-left: 1px solid #e1e6dc;
|
| 287 |
+
}
|
| 288 |
+
.file-note strong {
|
| 289 |
+
font-size: 8px;
|
| 290 |
+
letter-spacing: 1px;
|
| 291 |
+
color: #7a8970;
|
| 292 |
+
}
|
| 293 |
+
.legend {
|
| 294 |
+
position: fixed;
|
| 295 |
+
bottom: 123px;
|
| 296 |
+
left: calc(50% + 95px);
|
| 297 |
+
transform: translateX(-50%);
|
| 298 |
+
display: flex;
|
| 299 |
+
gap: 14px;
|
| 300 |
+
font-size: 9px;
|
| 301 |
+
z-index: 2;
|
| 302 |
+
background: #fffaf0db;
|
| 303 |
+
border-radius: 20px;
|
| 304 |
+
padding: 9px 15px;
|
| 305 |
+
white-space: nowrap;
|
| 306 |
+
}
|
| 307 |
+
.legend span {
|
| 308 |
+
display: flex;
|
| 309 |
+
align-items: center;
|
| 310 |
+
gap: 5px;
|
| 311 |
+
}
|
| 312 |
+
.legend i {
|
| 313 |
+
width: 7px;
|
| 314 |
+
height: 7px;
|
| 315 |
+
border-radius: 50%;
|
| 316 |
+
}
|
| 317 |
+
.water {
|
| 318 |
+
background: #8dbbb7;
|
| 319 |
+
}
|
| 320 |
+
.park {
|
| 321 |
+
background: #a8bf8b;
|
| 322 |
+
}
|
| 323 |
+
.building {
|
| 324 |
+
background: #c9bda9;
|
| 325 |
+
}
|
| 326 |
+
.map-tools {
|
| 327 |
+
position: fixed;
|
| 328 |
+
top: 111px;
|
| 329 |
+
right: 26px;
|
| 330 |
+
z-index: 2;
|
| 331 |
+
display: grid;
|
| 332 |
+
gap: 10px;
|
| 333 |
+
}
|
| 334 |
+
.map-tools button {
|
| 335 |
+
width: 40px;
|
| 336 |
+
height: 40px;
|
| 337 |
+
border-radius: 10px;
|
| 338 |
+
background: #fffcf6;
|
| 339 |
+
border: 1px solid #fff;
|
| 340 |
+
color: #435b47;
|
| 341 |
+
box-shadow: 0 4px 12px #203c3015;
|
| 342 |
+
}
|
| 343 |
+
.maplibregl-ctrl-top-right {
|
| 344 |
+
top: 210px;
|
| 345 |
+
right: 16px;
|
| 346 |
+
}
|
| 347 |
+
.maplibregl-popup-content {
|
| 348 |
+
font-family: "DM Sans", sans-serif;
|
| 349 |
+
border-radius: 10px;
|
| 350 |
+
padding: 14px 20px;
|
| 351 |
+
color: #344e40;
|
| 352 |
+
max-width: 240px;
|
| 353 |
+
}
|
| 354 |
+
.maplibregl-popup-content h3 {
|
| 355 |
+
font-size: 14px;
|
| 356 |
+
margin: 0 0 5px;
|
| 357 |
+
}
|
| 358 |
+
.maplibregl-popup-content p {
|
| 359 |
+
font-size: 11px;
|
| 360 |
+
margin: 0;
|
| 361 |
+
line-height: 1.5;
|
| 362 |
+
}
|
| 363 |
+
.maplibregl-ctrl-attrib {
|
| 364 |
+
font-size: 9px !important;
|
| 365 |
+
}
|
| 366 |
+
.maplibregl-canvas {
|
| 367 |
+
outline: none;
|
| 368 |
+
}
|
| 369 |
+
@media (max-height: 840px) and (min-width: 761px) {
|
| 370 |
+
.panel {
|
| 371 |
+
padding: 22px;
|
| 372 |
+
top: 96px;
|
| 373 |
+
}
|
| 374 |
+
h1 {
|
| 375 |
+
font-size: 40px;
|
| 376 |
+
margin: 14px 0 12px;
|
| 377 |
+
}
|
| 378 |
+
.intro {
|
| 379 |
+
margin-bottom: 14px;
|
| 380 |
+
}
|
| 381 |
+
.section-label {
|
| 382 |
+
margin-top: 17px;
|
| 383 |
+
}
|
| 384 |
+
.places button {
|
| 385 |
+
padding: 8px 5px;
|
| 386 |
+
}
|
| 387 |
+
.reset {
|
| 388 |
+
margin-top: 14px;
|
| 389 |
+
}
|
| 390 |
+
.panel .location {
|
| 391 |
+
padding: 12px 0;
|
| 392 |
+
}
|
| 393 |
+
}
|
| 394 |
+
@media (max-width: 760px) {
|
| 395 |
+
header {
|
| 396 |
+
height: 65px;
|
| 397 |
+
padding: 0 17px;
|
| 398 |
+
}
|
| 399 |
+
.brand {
|
| 400 |
+
font-size: 22px;
|
| 401 |
+
}
|
| 402 |
+
.header-right > a {
|
| 403 |
+
display: none;
|
| 404 |
+
}
|
| 405 |
+
.header-right .pill {
|
| 406 |
+
font-size: 8px;
|
| 407 |
+
padding: 7px 9px;
|
| 408 |
+
}
|
| 409 |
+
.panel {
|
| 410 |
+
left: 12px;
|
| 411 |
+
top: 80px;
|
| 412 |
+
width: 232px;
|
| 413 |
+
padding: 19px;
|
| 414 |
+
border-radius: 13px;
|
| 415 |
+
}
|
| 416 |
+
h1 {
|
| 417 |
+
font-size: 36px;
|
| 418 |
+
margin: 15px 0 12px;
|
| 419 |
+
}
|
| 420 |
+
.eyebrow {
|
| 421 |
+
font-size: 7px;
|
| 422 |
+
}
|
| 423 |
+
.intro {
|
| 424 |
+
font-size: 10px;
|
| 425 |
+
margin-bottom: 12px;
|
| 426 |
+
}
|
| 427 |
+
.location {
|
| 428 |
+
padding: 12px 0;
|
| 429 |
+
}
|
| 430 |
+
.location small {
|
| 431 |
+
font-size: 8px;
|
| 432 |
+
}
|
| 433 |
+
.section-label {
|
| 434 |
+
margin-top: 17px;
|
| 435 |
+
font-size: 8px;
|
| 436 |
+
}
|
| 437 |
+
.places button {
|
| 438 |
+
padding: 8px 3px;
|
| 439 |
+
}
|
| 440 |
+
.places button div {
|
| 441 |
+
font-size: 11px;
|
| 442 |
+
}
|
| 443 |
+
.places small {
|
| 444 |
+
font-size: 9px;
|
| 445 |
+
}
|
| 446 |
+
.toggles {
|
| 447 |
+
font-size: 10px;
|
| 448 |
+
gap: 7px;
|
| 449 |
+
}
|
| 450 |
+
.reset {
|
| 451 |
+
margin-top: 12px;
|
| 452 |
+
}
|
| 453 |
+
.transfer {
|
| 454 |
+
left: 12px;
|
| 455 |
+
right: 12px;
|
| 456 |
+
bottom: 31px;
|
| 457 |
+
transform: none;
|
| 458 |
+
gap: 14px;
|
| 459 |
+
padding: 13px 15px;
|
| 460 |
+
}
|
| 461 |
+
.transfer strong {
|
| 462 |
+
font-size: 14px;
|
| 463 |
+
}
|
| 464 |
+
.file-note {
|
| 465 |
+
margin-left: auto;
|
| 466 |
+
padding-left: 12px;
|
| 467 |
+
}
|
| 468 |
+
.file-note strong {
|
| 469 |
+
font-size: 7px;
|
| 470 |
+
}
|
| 471 |
+
.transfer span {
|
| 472 |
+
font-size: 8px;
|
| 473 |
+
}
|
| 474 |
+
.transfer-icon {
|
| 475 |
+
display: none;
|
| 476 |
+
}
|
| 477 |
+
.legend {
|
| 478 |
+
display: none;
|
| 479 |
+
}
|
| 480 |
+
.map-tools {
|
| 481 |
+
top: 81px;
|
| 482 |
+
right: 12px;
|
| 483 |
+
}
|
| 484 |
+
.maplibregl-ctrl-top-right {
|
| 485 |
+
top: 180px;
|
| 486 |
+
right: 2px;
|
| 487 |
+
}
|
| 488 |
+
}
|
| 489 |
+
@media (max-height: 650px) {
|
| 490 |
+
.intro,
|
| 491 |
+
.location,
|
| 492 |
+
.section-label,
|
| 493 |
+
.toggles,
|
| 494 |
+
.reset {
|
| 495 |
+
display: none;
|
| 496 |
+
}
|
| 497 |
+
.panel {
|
| 498 |
+
width: 235px;
|
| 499 |
+
}
|
| 500 |
+
h1 {
|
| 501 |
+
font-size: 34px;
|
| 502 |
+
}
|
| 503 |
+
.transfer {
|
| 504 |
+
bottom: 27px;
|
| 505 |
+
}
|
| 506 |
+
}
|
src/client.ts
ADDED
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import maplibregl, { type LayerSpecification } from "maplibre-gl";
|
| 2 |
+
import "maplibre-gl/dist/maplibre-gl.css";
|
| 3 |
+
import { FetchSource, PMTiles, Protocol } from "pmtiles";
|
| 4 |
+
|
| 5 |
+
function element(id: string) {
|
| 6 |
+
const node = document.getElementById(id);
|
| 7 |
+
if (!node) throw new Error(`Missing ${id}`);
|
| 8 |
+
return node;
|
| 9 |
+
}
|
| 10 |
+
const status = element("status");
|
| 11 |
+
let bytes = 0;
|
| 12 |
+
let requests = 0;
|
| 13 |
+
const formatBytes = (value: number) =>
|
| 14 |
+
value < 1024 * 1024
|
| 15 |
+
? `${(value / 1024).toFixed(1)} KB`
|
| 16 |
+
: `${(value / 1024 / 1024).toFixed(2)} MB`;
|
| 17 |
+
class MeteredSource extends FetchSource {
|
| 18 |
+
override async getBytes(...args: Parameters<FetchSource["getBytes"]>) {
|
| 19 |
+
const result = await super.getBytes(...args);
|
| 20 |
+
bytes += result.data.byteLength;
|
| 21 |
+
requests++;
|
| 22 |
+
element("bytes").textContent = formatBytes(bytes);
|
| 23 |
+
element("requests").textContent = String(requests);
|
| 24 |
+
return result;
|
| 25 |
+
}
|
| 26 |
+
}
|
| 27 |
+
const archive = new PMTiles(
|
| 28 |
+
new MeteredSource(`${location.origin}/maps/florence.pmtiles`),
|
| 29 |
+
);
|
| 30 |
+
const protocol = new Protocol();
|
| 31 |
+
protocol.add(archive);
|
| 32 |
+
maplibregl.addProtocol("pmtiles", protocol.tile);
|
| 33 |
+
const places = {
|
| 34 |
+
duomo: {
|
| 35 |
+
name: "Piazza del Duomo",
|
| 36 |
+
description: "The cathedral at the heart of Florence.",
|
| 37 |
+
center: [11.2566, 43.7731],
|
| 38 |
+
},
|
| 39 |
+
bridge: {
|
| 40 |
+
name: "Ponte Vecchio",
|
| 41 |
+
description: "Florence's historic bridge over the Arno.",
|
| 42 |
+
center: [11.2531, 43.7679],
|
| 43 |
+
},
|
| 44 |
+
garden: {
|
| 45 |
+
name: "Boboli Gardens",
|
| 46 |
+
description: "The terraced gardens behind Palazzo Pitti.",
|
| 47 |
+
center: [11.2483, 43.7625],
|
| 48 |
+
},
|
| 49 |
+
};
|
| 50 |
+
let dark = false;
|
| 51 |
+
const styleLayers: LayerSpecification[] = [
|
| 52 |
+
{
|
| 53 |
+
id: "background",
|
| 54 |
+
type: "background",
|
| 55 |
+
paint: { "background-color": "#efece0" },
|
| 56 |
+
},
|
| 57 |
+
{
|
| 58 |
+
id: "green",
|
| 59 |
+
type: "fill",
|
| 60 |
+
source: "city",
|
| 61 |
+
"source-layer": "landuse",
|
| 62 |
+
filter: [
|
| 63 |
+
"in",
|
| 64 |
+
["get", "pmap:kind"],
|
| 65 |
+
[
|
| 66 |
+
"literal",
|
| 67 |
+
[
|
| 68 |
+
"park",
|
| 69 |
+
"forest",
|
| 70 |
+
"wood",
|
| 71 |
+
"garden",
|
| 72 |
+
"nature_reserve",
|
| 73 |
+
"grass",
|
| 74 |
+
"recreation_ground",
|
| 75 |
+
"cemetery",
|
| 76 |
+
],
|
| 77 |
+
],
|
| 78 |
+
],
|
| 79 |
+
paint: { "fill-color": "#b8caa0", "fill-opacity": 0.8 },
|
| 80 |
+
},
|
| 81 |
+
{
|
| 82 |
+
id: "natural",
|
| 83 |
+
type: "fill",
|
| 84 |
+
source: "city",
|
| 85 |
+
"source-layer": "natural",
|
| 86 |
+
paint: { "fill-color": "#bccba8", "fill-opacity": 0.7 },
|
| 87 |
+
},
|
| 88 |
+
{
|
| 89 |
+
id: "water",
|
| 90 |
+
type: "fill",
|
| 91 |
+
source: "city",
|
| 92 |
+
"source-layer": "water",
|
| 93 |
+
filter: ["==", ["geometry-type"], "Polygon"],
|
| 94 |
+
paint: { "fill-color": "#91bbb6" },
|
| 95 |
+
},
|
| 96 |
+
{
|
| 97 |
+
id: "waterways",
|
| 98 |
+
type: "line",
|
| 99 |
+
source: "city",
|
| 100 |
+
"source-layer": "physical_line",
|
| 101 |
+
filter: ["==", ["get", "pmap:kind"], "waterway"],
|
| 102 |
+
paint: { "line-color": "#91bbb6", "line-width": 2 },
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
id: "roads-casing",
|
| 106 |
+
type: "line",
|
| 107 |
+
source: "city",
|
| 108 |
+
"source-layer": "roads",
|
| 109 |
+
paint: {
|
| 110 |
+
"line-color": "#d5cfbc",
|
| 111 |
+
"line-width": ["interpolate", ["linear"], ["zoom"], 11, 1, 15, 6, 18, 14],
|
| 112 |
+
},
|
| 113 |
+
},
|
| 114 |
+
{
|
| 115 |
+
id: "roads",
|
| 116 |
+
type: "line",
|
| 117 |
+
source: "city",
|
| 118 |
+
"source-layer": "roads",
|
| 119 |
+
paint: {
|
| 120 |
+
"line-color": "#fff9ec",
|
| 121 |
+
"line-width": [
|
| 122 |
+
"interpolate",
|
| 123 |
+
["linear"],
|
| 124 |
+
["zoom"],
|
| 125 |
+
11,
|
| 126 |
+
0.5,
|
| 127 |
+
15,
|
| 128 |
+
3.5,
|
| 129 |
+
18,
|
| 130 |
+
10,
|
| 131 |
+
],
|
| 132 |
+
},
|
| 133 |
+
},
|
| 134 |
+
{
|
| 135 |
+
id: "buildings",
|
| 136 |
+
type: "fill-extrusion",
|
| 137 |
+
source: "city",
|
| 138 |
+
"source-layer": "buildings",
|
| 139 |
+
paint: {
|
| 140 |
+
"fill-extrusion-color": "#c9bda9",
|
| 141 |
+
"fill-extrusion-height": ["coalesce", ["get", "height"], 7],
|
| 142 |
+
"fill-extrusion-opacity": 0.82,
|
| 143 |
+
},
|
| 144 |
+
},
|
| 145 |
+
{
|
| 146 |
+
id: "street-labels",
|
| 147 |
+
type: "symbol",
|
| 148 |
+
source: "city",
|
| 149 |
+
"source-layer": "roads",
|
| 150 |
+
minzoom: 14,
|
| 151 |
+
layout: {
|
| 152 |
+
"symbol-placement": "line",
|
| 153 |
+
"text-field": ["coalesce", ["get", "name"], ""],
|
| 154 |
+
"text-font": ["Noto Sans Regular"],
|
| 155 |
+
"text-size": 11,
|
| 156 |
+
},
|
| 157 |
+
paint: {
|
| 158 |
+
"text-color": "#7e8070",
|
| 159 |
+
"text-halo-color": "#f8f5e9",
|
| 160 |
+
"text-halo-width": 2,
|
| 161 |
+
},
|
| 162 |
+
},
|
| 163 |
+
];
|
| 164 |
+
const map = new maplibregl.Map({
|
| 165 |
+
container: "map",
|
| 166 |
+
center: [11.2543, 43.77],
|
| 167 |
+
zoom: 13.8,
|
| 168 |
+
minZoom: 12,
|
| 169 |
+
maxZoom: 18,
|
| 170 |
+
maxBounds: [
|
| 171 |
+
[11.205, 43.733],
|
| 172 |
+
[11.305, 43.801],
|
| 173 |
+
],
|
| 174 |
+
pitch: 0,
|
| 175 |
+
bearing: 0,
|
| 176 |
+
attributionControl: false,
|
| 177 |
+
style: {
|
| 178 |
+
version: 8,
|
| 179 |
+
glyphs:
|
| 180 |
+
"https://protomaps.github.io/basemaps-assets/fonts/{fontstack}/{range}.pbf",
|
| 181 |
+
sources: {
|
| 182 |
+
city: {
|
| 183 |
+
type: "vector",
|
| 184 |
+
url: `pmtiles://${location.origin}/maps/florence.pmtiles`,
|
| 185 |
+
attribution:
|
| 186 |
+
'<a href="https://protomaps.com">Protomaps</a> · © <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
|
| 187 |
+
},
|
| 188 |
+
},
|
| 189 |
+
layers: styleLayers,
|
| 190 |
+
},
|
| 191 |
+
});
|
| 192 |
+
map.addControl(new maplibregl.NavigationControl(), "top-right");
|
| 193 |
+
map.addControl(
|
| 194 |
+
new maplibregl.AttributionControl({ compact: true }),
|
| 195 |
+
"bottom-right",
|
| 196 |
+
);
|
| 197 |
+
function padding() {
|
| 198 |
+
return {
|
| 199 |
+
left: innerWidth > 760 ? 380 : 180,
|
| 200 |
+
right: innerWidth > 760 ? 70 : 20,
|
| 201 |
+
top: 95,
|
| 202 |
+
bottom: 120,
|
| 203 |
+
};
|
| 204 |
+
}
|
| 205 |
+
map.on("load", () => {
|
| 206 |
+
map.easeTo({ padding: padding(), duration: 0 });
|
| 207 |
+
status.textContent = "Ready to explore. Click a building to inspect it.";
|
| 208 |
+
});
|
| 209 |
+
map.on("error", () => {
|
| 210 |
+
status.textContent =
|
| 211 |
+
"The map could not load. Check the Space's storage credentials and try again.";
|
| 212 |
+
status.classList.add("error");
|
| 213 |
+
});
|
| 214 |
+
fetch("/api/map")
|
| 215 |
+
.then((r) => r.json())
|
| 216 |
+
.then((data: unknown) => {
|
| 217 |
+
if (typeof data !== "object" || data === null) return;
|
| 218 |
+
if ("size" in data && typeof data.size === "number")
|
| 219 |
+
element("archive").textContent =
|
| 220 |
+
`${formatBytes(data.size)} · Florence snapshot`;
|
| 221 |
+
else element("archive").textContent = "Storage not connected";
|
| 222 |
+
if ("available" in data && data.available === false) {
|
| 223 |
+
status.textContent =
|
| 224 |
+
"Setup needed: add the S3 access key and secret in Space settings.";
|
| 225 |
+
status.classList.add("error");
|
| 226 |
+
}
|
| 227 |
+
})
|
| 228 |
+
.catch(() => {
|
| 229 |
+
status.textContent = "Could not reach the map service.";
|
| 230 |
+
status.classList.add("error");
|
| 231 |
+
});
|
| 232 |
+
for (const button of document.querySelectorAll<HTMLButtonElement>(
|
| 233 |
+
"[data-place]",
|
| 234 |
+
))
|
| 235 |
+
button.addEventListener("click", () => {
|
| 236 |
+
const id = button.dataset.place;
|
| 237 |
+
const place =
|
| 238 |
+
id === "duomo"
|
| 239 |
+
? places.duomo
|
| 240 |
+
: id === "bridge"
|
| 241 |
+
? places.bridge
|
| 242 |
+
: places.garden;
|
| 243 |
+
const lng = place.center[0] ?? 11.2543;
|
| 244 |
+
const lat = place.center[1] ?? 43.77;
|
| 245 |
+
map.flyTo({
|
| 246 |
+
center: [lng, lat],
|
| 247 |
+
zoom: 16,
|
| 248 |
+
pitch: 35,
|
| 249 |
+
padding: padding(),
|
| 250 |
+
duration: 1400,
|
| 251 |
+
});
|
| 252 |
+
document
|
| 253 |
+
.querySelectorAll("[data-place]")
|
| 254 |
+
.forEach((node) => node.classList.remove("active"));
|
| 255 |
+
button.classList.add("active");
|
| 256 |
+
const content = document.createElement("div");
|
| 257 |
+
const title = document.createElement("h3");
|
| 258 |
+
title.textContent = place.name;
|
| 259 |
+
const text = document.createElement("p");
|
| 260 |
+
text.textContent = place.description;
|
| 261 |
+
content.append(title, text);
|
| 262 |
+
new maplibregl.Popup({ offset: 10 })
|
| 263 |
+
.setLngLat([lng, lat])
|
| 264 |
+
.setDOMContent(content)
|
| 265 |
+
.addTo(map);
|
| 266 |
+
});
|
| 267 |
+
for (const [id, layers] of [
|
| 268 |
+
["buildings", ["buildings"]],
|
| 269 |
+
["parks", ["green", "natural"]],
|
| 270 |
+
["labels", ["street-labels"]],
|
| 271 |
+
] satisfies [string, string[]][])
|
| 272 |
+
element(id).addEventListener("change", (event) => {
|
| 273 |
+
if (!(event.target instanceof HTMLInputElement)) return;
|
| 274 |
+
for (const layer of layers)
|
| 275 |
+
if (map.getLayer(layer))
|
| 276 |
+
map.setLayoutProperty(
|
| 277 |
+
layer,
|
| 278 |
+
"visibility",
|
| 279 |
+
event.target.checked ? "visible" : "none",
|
| 280 |
+
);
|
| 281 |
+
});
|
| 282 |
+
element("reset").addEventListener("click", () => {
|
| 283 |
+
map.flyTo({
|
| 284 |
+
center: [11.2543, 43.77],
|
| 285 |
+
zoom: 13.8,
|
| 286 |
+
pitch: 0,
|
| 287 |
+
bearing: 0,
|
| 288 |
+
padding: padding(),
|
| 289 |
+
});
|
| 290 |
+
document
|
| 291 |
+
.querySelectorAll("[data-place]")
|
| 292 |
+
.forEach((node) => node.classList.remove("active"));
|
| 293 |
+
});
|
| 294 |
+
element("tilt").addEventListener("click", () =>
|
| 295 |
+
map.easeTo({ pitch: map.getPitch() > 10 ? 0 : 50, duration: 700 }),
|
| 296 |
+
);
|
| 297 |
+
element("theme").addEventListener("click", () => {
|
| 298 |
+
if (!map.isStyleLoaded()) return;
|
| 299 |
+
dark = !dark;
|
| 300 |
+
map.setPaintProperty(
|
| 301 |
+
"background",
|
| 302 |
+
"background-color",
|
| 303 |
+
dark ? "#243a36" : "#efece0",
|
| 304 |
+
);
|
| 305 |
+
map.setPaintProperty("water", "fill-color", dark ? "#152e32" : "#91bbb6");
|
| 306 |
+
map.setPaintProperty("green", "fill-color", dark ? "#3c5845" : "#b8caa0");
|
| 307 |
+
map.setPaintProperty("natural", "fill-color", dark ? "#385041" : "#bccba8");
|
| 308 |
+
map.setPaintProperty("roads", "line-color", dark ? "#688071" : "#fff9ec");
|
| 309 |
+
map.setPaintProperty(
|
| 310 |
+
"roads-casing",
|
| 311 |
+
"line-color",
|
| 312 |
+
dark ? "#263e35" : "#d5cfbc",
|
| 313 |
+
);
|
| 314 |
+
map.setPaintProperty(
|
| 315 |
+
"buildings",
|
| 316 |
+
"fill-extrusion-color",
|
| 317 |
+
dark ? "#7b8875" : "#c9bda9",
|
| 318 |
+
);
|
| 319 |
+
map.setPaintProperty(
|
| 320 |
+
"street-labels",
|
| 321 |
+
"text-color",
|
| 322 |
+
dark ? "#d7dec8" : "#7e8070",
|
| 323 |
+
);
|
| 324 |
+
map.setPaintProperty(
|
| 325 |
+
"street-labels",
|
| 326 |
+
"text-halo-color",
|
| 327 |
+
dark ? "#243a36" : "#f8f5e9",
|
| 328 |
+
);
|
| 329 |
+
});
|
| 330 |
+
map.on("click", "buildings", (event) => {
|
| 331 |
+
const feature = event.features?.[0];
|
| 332 |
+
if (!feature) return;
|
| 333 |
+
const content = document.createElement("div");
|
| 334 |
+
const title = document.createElement("h3");
|
| 335 |
+
title.textContent = String(feature.properties?.name || "Florentine building");
|
| 336 |
+
const text = document.createElement("p");
|
| 337 |
+
const height = feature.properties?.height;
|
| 338 |
+
text.textContent =
|
| 339 |
+
typeof height === "number"
|
| 340 |
+
? `Mapped height: ${height} m`
|
| 341 |
+
: "Building footprint from OpenStreetMap. Height is not recorded.";
|
| 342 |
+
content.append(title, text);
|
| 343 |
+
new maplibregl.Popup()
|
| 344 |
+
.setLngLat(event.lngLat)
|
| 345 |
+
.setDOMContent(content)
|
| 346 |
+
.addTo(map);
|
| 347 |
+
});
|
| 348 |
+
map.on("mouseenter", "buildings", () => {
|
| 349 |
+
map.getCanvas().style.cursor = "pointer";
|
| 350 |
+
});
|
| 351 |
+
map.on("mouseleave", "buildings", () => {
|
| 352 |
+
map.getCanvas().style.cursor = "";
|
| 353 |
+
});
|
src/ranges.ts
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export function parseRange(value: string | null, size: number) {
|
| 2 |
+
if (!value) return null;
|
| 3 |
+
const match = /^bytes=(\d*)-(\d*)$/.exec(value);
|
| 4 |
+
if (!match || (!match[1] && !match[2])) return null;
|
| 5 |
+
const start = match[1]
|
| 6 |
+
? Number(match[1])
|
| 7 |
+
: Math.max(0, size - Number(match[2]));
|
| 8 |
+
const end =
|
| 9 |
+
match[1] && match[2] ? Math.min(Number(match[2]), size - 1) : size - 1;
|
| 10 |
+
if (
|
| 11 |
+
!Number.isSafeInteger(start) ||
|
| 12 |
+
!Number.isSafeInteger(end) ||
|
| 13 |
+
start < 0 ||
|
| 14 |
+
start > end ||
|
| 15 |
+
start >= size
|
| 16 |
+
)
|
| 17 |
+
return null;
|
| 18 |
+
return { start, end, length: end - start + 1 };
|
| 19 |
+
}
|
src/server.test.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, test, expect, afterAll } from "bun:test";
|
| 2 |
+
import { resolve } from "node:path";
|
| 3 |
+
import { PMTiles } from "pmtiles";
|
| 4 |
+
import { parseRange } from "./ranges";
|
| 5 |
+
|
| 6 |
+
process.env.LOCAL_PMTILES = resolve("data/florence.pmtiles");
|
| 7 |
+
const { handler } = await import("./server");
|
| 8 |
+
const server = Bun.serve({ port: 0, fetch: handler });
|
| 9 |
+
const url = server.url.toString();
|
| 10 |
+
afterAll(() => server.stop(true));
|
| 11 |
+
const file = Bun.file(process.env.LOCAL_PMTILES);
|
| 12 |
+
describe("real archive HTTP serving", () => {
|
| 13 |
+
test("byte ranges match archive bytes and headers", async () => {
|
| 14 |
+
for (const [start, end] of [
|
| 15 |
+
[0, 126],
|
| 16 |
+
[1000, 8191],
|
| 17 |
+
[file.size - 10, file.size - 1],
|
| 18 |
+
]) {
|
| 19 |
+
const r = await fetch(`${url}maps/florence.pmtiles`, {
|
| 20 |
+
headers: { Range: `bytes=${start}-${end}` },
|
| 21 |
+
});
|
| 22 |
+
expect(r.status).toBe(206);
|
| 23 |
+
expect(r.headers.get("content-range")).toBe(
|
| 24 |
+
`bytes ${start}-${end}/${file.size}`,
|
| 25 |
+
);
|
| 26 |
+
expect(new Uint8Array(await r.arrayBuffer())).toEqual(
|
| 27 |
+
new Uint8Array(await file.slice(start, end + 1).arrayBuffer()),
|
| 28 |
+
);
|
| 29 |
+
expect(r.headers.get("cache-control")).toBe("private, no-store");
|
| 30 |
+
}
|
| 31 |
+
});
|
| 32 |
+
test("HEAD exposes size without a body", async () => {
|
| 33 |
+
const r = await fetch(`${url}maps/florence.pmtiles`, { method: "HEAD" });
|
| 34 |
+
expect(r.status).toBe(200);
|
| 35 |
+
expect(r.headers.get("content-length")).toBe(String(file.size));
|
| 36 |
+
expect(await r.text()).toBe("");
|
| 37 |
+
});
|
| 38 |
+
test("invalid, multiple, and missing ranges are rejected", async () => {
|
| 39 |
+
for (const value of [
|
| 40 |
+
"bytes=9-1",
|
| 41 |
+
"bytes=0-1,5-6",
|
| 42 |
+
`bytes=${file.size}-`,
|
| 43 |
+
"bytes=-0",
|
| 44 |
+
"",
|
| 45 |
+
]) {
|
| 46 |
+
const r = await fetch(`${url}maps/florence.pmtiles`, {
|
| 47 |
+
headers: { Range: value },
|
| 48 |
+
});
|
| 49 |
+
expect(r.status).toBe(416);
|
| 50 |
+
}
|
| 51 |
+
});
|
| 52 |
+
test("only fixed archive path is accessible", async () => {
|
| 53 |
+
expect(
|
| 54 |
+
(
|
| 55 |
+
await fetch(`${url}maps/another.pmtiles`, {
|
| 56 |
+
headers: { Range: "bytes=0-10" },
|
| 57 |
+
})
|
| 58 |
+
).status,
|
| 59 |
+
).toBe(404);
|
| 60 |
+
expect(
|
| 61 |
+
(await fetch(`${url}maps/florence.pmtiles`, { method: "POST" })).status,
|
| 62 |
+
).toBe(405);
|
| 63 |
+
});
|
| 64 |
+
test("PMTiles client reads actual metadata and a tile", async () => {
|
| 65 |
+
const p = new PMTiles(`${url}maps/florence.pmtiles`);
|
| 66 |
+
const header = await p.getHeader();
|
| 67 |
+
expect(header.specVersion).toBe(3);
|
| 68 |
+
const tile = await p.getZxy(14, 8704, 5971);
|
| 69 |
+
expect(tile?.data.byteLength).toBeGreaterThan(0);
|
| 70 |
+
const meta = await p.getMetadata();
|
| 71 |
+
expect(meta).toHaveProperty("vector_layers");
|
| 72 |
+
});
|
| 73 |
+
});
|
| 74 |
+
test("range edge cases", () => {
|
| 75 |
+
expect(parseRange("bytes=-10", 100)).toEqual({
|
| 76 |
+
start: 90,
|
| 77 |
+
end: 99,
|
| 78 |
+
length: 10,
|
| 79 |
+
});
|
| 80 |
+
expect(parseRange("bytes=95-999", 100)).toEqual({
|
| 81 |
+
start: 95,
|
| 82 |
+
end: 99,
|
| 83 |
+
length: 5,
|
| 84 |
+
});
|
| 85 |
+
expect(parseRange("bytes=1-", 100)).toEqual({
|
| 86 |
+
start: 1,
|
| 87 |
+
end: 99,
|
| 88 |
+
length: 99,
|
| 89 |
+
});
|
| 90 |
+
expect(parseRange("bytes=9007199254740993-", 100)).toBeNull();
|
| 91 |
+
});
|
src/server.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { S3Client } from "bun";
|
| 2 |
+
import { join } from "node:path";
|
| 3 |
+
import { parseRange } from "./ranges";
|
| 4 |
+
|
| 5 |
+
const root = join(import.meta.dir, "..");
|
| 6 |
+
const localPath = process.env.LOCAL_PMTILES;
|
| 7 |
+
const key = process.env.PMTILES_KEY || "maps/florence.pmtiles";
|
| 8 |
+
const configured =
|
| 9 |
+
!!localPath ||
|
| 10 |
+
!!(process.env.HF_S3_ACCESS_KEY_ID && process.env.HF_S3_SECRET_ACCESS_KEY);
|
| 11 |
+
const client =
|
| 12 |
+
configured && !localPath
|
| 13 |
+
? new S3Client({
|
| 14 |
+
endpoint: `https://s3.hf.co/${process.env.HF_S3_NAMESPACE || "cfahlgren1"}`,
|
| 15 |
+
bucket: process.env.HF_S3_BUCKET || "bucket-atlas",
|
| 16 |
+
region: "us-east-1",
|
| 17 |
+
virtualHostedStyle: false,
|
| 18 |
+
accessKeyId: process.env.HF_S3_ACCESS_KEY_ID,
|
| 19 |
+
secretAccessKey: process.env.HF_S3_SECRET_ACCESS_KEY,
|
| 20 |
+
})
|
| 21 |
+
: null;
|
| 22 |
+
const local = localPath ? Bun.file(localPath) : null;
|
| 23 |
+
const object = client?.file(key);
|
| 24 |
+
let metadata: Promise<{ size: number; etag: string }> | undefined;
|
| 25 |
+
function getMetadata() {
|
| 26 |
+
metadata ??= (async () => {
|
| 27 |
+
if (local)
|
| 28 |
+
return {
|
| 29 |
+
size: local.size,
|
| 30 |
+
etag: `"local-${local.size}-${local.lastModified}"`,
|
| 31 |
+
};
|
| 32 |
+
if (!object) throw new Error("Storage is not configured");
|
| 33 |
+
const stat = await object.stat();
|
| 34 |
+
return { size: stat.size, etag: stat.etag };
|
| 35 |
+
})().catch((error) => {
|
| 36 |
+
metadata = undefined;
|
| 37 |
+
throw error;
|
| 38 |
+
});
|
| 39 |
+
return metadata;
|
| 40 |
+
}
|
| 41 |
+
const commonHeaders = {
|
| 42 |
+
"Cache-Control": "private, no-store",
|
| 43 |
+
"X-Content-Type-Options": "nosniff",
|
| 44 |
+
};
|
| 45 |
+
export async function handler(req: Request): Promise<Response> {
|
| 46 |
+
const path = new URL(req.url).pathname;
|
| 47 |
+
if (req.method !== "GET" && req.method !== "HEAD")
|
| 48 |
+
return new Response("Method not allowed", {
|
| 49 |
+
status: 405,
|
| 50 |
+
headers: { Allow: "GET, HEAD" },
|
| 51 |
+
});
|
| 52 |
+
if (path === "/healthz")
|
| 53 |
+
return Response.json({
|
| 54 |
+
status: configured ? "ready" : "needs-storage-credentials",
|
| 55 |
+
});
|
| 56 |
+
if (path === "/api/map") {
|
| 57 |
+
let size: number | null = null;
|
| 58 |
+
let available = false;
|
| 59 |
+
if (configured) {
|
| 60 |
+
try {
|
| 61 |
+
size = (await getMetadata()).size;
|
| 62 |
+
available = true;
|
| 63 |
+
} catch {
|
| 64 |
+
/* The frontend displays the storage error. */
|
| 65 |
+
}
|
| 66 |
+
}
|
| 67 |
+
return Response.json(
|
| 68 |
+
{
|
| 69 |
+
name: "Florence",
|
| 70 |
+
region: "Tuscany, Italy",
|
| 71 |
+
path: "/maps/florence.pmtiles",
|
| 72 |
+
size,
|
| 73 |
+
available,
|
| 74 |
+
storage: local
|
| 75 |
+
? "local development file"
|
| 76 |
+
: "private Hugging Face bucket",
|
| 77 |
+
snapshot: "August 2024",
|
| 78 |
+
},
|
| 79 |
+
{ headers: commonHeaders },
|
| 80 |
+
);
|
| 81 |
+
}
|
| 82 |
+
if (path === "/maps/florence.pmtiles") {
|
| 83 |
+
if (!configured)
|
| 84 |
+
return new Response(
|
| 85 |
+
"Add HF_S3_ACCESS_KEY_ID and HF_S3_SECRET_ACCESS_KEY in Space secrets.",
|
| 86 |
+
{ status: 503, headers: commonHeaders },
|
| 87 |
+
);
|
| 88 |
+
try {
|
| 89 |
+
const meta = await getMetadata();
|
| 90 |
+
const headers = new Headers({
|
| 91 |
+
...commonHeaders,
|
| 92 |
+
"Accept-Ranges": "bytes",
|
| 93 |
+
"Content-Type": "application/octet-stream",
|
| 94 |
+
ETag: meta.etag,
|
| 95 |
+
});
|
| 96 |
+
if (req.method === "HEAD") {
|
| 97 |
+
headers.set("Content-Length", String(meta.size));
|
| 98 |
+
return new Response(null, { headers });
|
| 99 |
+
}
|
| 100 |
+
const range = parseRange(req.headers.get("Range"), meta.size);
|
| 101 |
+
if (!range) {
|
| 102 |
+
headers.set("Content-Range", `bytes */${meta.size}`);
|
| 103 |
+
return new Response("A valid single byte range is required.", {
|
| 104 |
+
status: 416,
|
| 105 |
+
headers,
|
| 106 |
+
});
|
| 107 |
+
}
|
| 108 |
+
if (range.length > 8 * 1024 * 1024)
|
| 109 |
+
return new Response("Range exceeds 8 MiB", {
|
| 110 |
+
status: 413,
|
| 111 |
+
headers: commonHeaders,
|
| 112 |
+
});
|
| 113 |
+
const contentRange = `bytes ${range.start}-${range.end}/${meta.size}`;
|
| 114 |
+
let body: ReadableStream<Uint8Array>;
|
| 115 |
+
if (local)
|
| 116 |
+
body = new Blob([
|
| 117 |
+
await local.slice(range.start, range.end + 1).arrayBuffer(),
|
| 118 |
+
]).stream();
|
| 119 |
+
else {
|
| 120 |
+
if (!object) throw new Error("Missing storage");
|
| 121 |
+
const upstream = await fetch(object.presign({ expiresIn: 60 }), {
|
| 122 |
+
headers: { Range: `bytes=${range.start}-${range.end}` },
|
| 123 |
+
signal: AbortSignal.any([req.signal, AbortSignal.timeout(20000)]),
|
| 124 |
+
redirect: "error",
|
| 125 |
+
});
|
| 126 |
+
if (
|
| 127 |
+
upstream.status !== 206 ||
|
| 128 |
+
upstream.headers.get("content-range") !== contentRange ||
|
| 129 |
+
!upstream.body
|
| 130 |
+
) {
|
| 131 |
+
await upstream.body?.cancel();
|
| 132 |
+
throw new Error("Invalid upstream byte range");
|
| 133 |
+
}
|
| 134 |
+
body = upstream.body;
|
| 135 |
+
}
|
| 136 |
+
headers.set("Content-Range", contentRange);
|
| 137 |
+
headers.set("Content-Length", String(range.length));
|
| 138 |
+
return new Response(body, { status: 206, headers });
|
| 139 |
+
} catch {
|
| 140 |
+
return new Response(
|
| 141 |
+
"Map storage is unavailable. Check the Space's S3 configuration.",
|
| 142 |
+
{ status: 502, headers: commonHeaders },
|
| 143 |
+
);
|
| 144 |
+
}
|
| 145 |
+
}
|
| 146 |
+
const assets: Record<string, string> = {
|
| 147 |
+
"/": "public/index.html",
|
| 148 |
+
"/styles.css": "public/styles.css",
|
| 149 |
+
"/assets/client.js": "public/assets/client.js",
|
| 150 |
+
"/assets/client.css": "public/assets/client.css",
|
| 151 |
+
};
|
| 152 |
+
const asset = assets[path];
|
| 153 |
+
if (!asset) return new Response("Not found", { status: 404 });
|
| 154 |
+
const file = Bun.file(join(root, asset));
|
| 155 |
+
return new Response(req.method === "HEAD" ? null : file, {
|
| 156 |
+
headers: { "Content-Type": file.type, ...commonHeaders },
|
| 157 |
+
});
|
| 158 |
+
}
|
| 159 |
+
if (import.meta.main)
|
| 160 |
+
Bun.serve({
|
| 161 |
+
hostname: "0.0.0.0",
|
| 162 |
+
port: Number(process.env.PORT || 7860),
|
| 163 |
+
fetch: handler,
|
| 164 |
+
});
|
tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"module": "ESNext",
|
| 5 |
+
"moduleResolution": "bundler",
|
| 6 |
+
"strict": true,
|
| 7 |
+
"noEmit": true,
|
| 8 |
+
"allowImportingTsExtensions": true,
|
| 9 |
+
"types": ["bun"],
|
| 10 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 11 |
+
"skipLibCheck": true
|
| 12 |
+
},
|
| 13 |
+
"include": ["src/**/*.ts"]
|
| 14 |
+
}
|