cfahlgren1 HF Staff commited on
Commit
57eb07e
·
verified ·
1 Parent(s): 52dcba4

add private bucket pmtiles map served by bun

Browse files
Files changed (13) hide show
  1. .dockerignore +5 -0
  2. Dockerfile +15 -0
  3. README.md +62 -4
  4. bun.lock +93 -0
  5. env.example +6 -0
  6. package.json +19 -0
  7. public/index.html +89 -0
  8. public/styles.css +506 -0
  9. src/client.ts +353 -0
  10. src/ranges.ts +19 -0
  11. src/server.test.ts +91 -0
  12. src/server.ts +164 -0
  13. 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: pink
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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">↺ &nbsp; 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
+ }