diff --git a/.env.example b/.env.example index 60f6e4b..87cd927 100644 --- a/.env.example +++ b/.env.example @@ -8,8 +8,7 @@ PUBLIC_URL=http://${PUBLIC_HOST}:8096 # link from it. Mirrors Ingress's "you must be at the portal" rule. CLAIM_RADIUS_METERS=40 -# BTC Map sync area — defaults to Madeira, Portugal (Funchal-centered). -# See DESIGN.md for why this scope was chosen first. -BTCMAP_CENTER_LAT=32.7607 -BTCMAP_CENTER_LON=-16.9595 -BTCMAP_RADIUS_KM=45 +# BTC Map API base — global sync pulls every place worldwide (paginated, +# incremental via a stored watermark). Override only for pointing at a mock +# server in tests. +BTCMAP_API_BASE_URL=https://api.btcmap.org diff --git a/README.md b/README.md index 8ed0dfb..9010c65 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,10 @@ A location-based capture game in the spirit of *Ingress*, built as a nostr-nativ web app. Real Bitcoin-accepting businesses from [BTC Map](https://btcmap.org) stand in for Ingress's portals — players physically visit them to claim them for their team (orange or green), then link claimed places into triangular -control fields. First scope: **Madeira, Portugal**. +control fields. **Global** — every BTC Map place worldwide (~29k live, plus +historical deletions tracked so delisted businesses correctly drop out). +Started as a Madeira, Portugal-only pilot; see git history/`DESIGN.md` for +that phase. See [`DESIGN.md`](./DESIGN.md) for the full research/design writeup. @@ -12,7 +15,9 @@ See [`DESIGN.md`](./DESIGN.md) for the full research/design writeup. - `server/` — Fastify + better-sqlite3 + nostr-tools (TypeScript), same pattern as [podsteadr](http://146.59.87.168:3000/ssmithx/podsteadr). -- `frontend/` — Vue 3 + Vite + Pinia + Tailwind + Leaflet. +- `frontend/` — Vue 3 + Vite + Pinia + Tailwind + Leaflet (+ + `leaflet.markercluster` — required at global scale; tens of thousands of + raw markers would choke the DOM otherwise). - Auth is NIP-98 (signed-event login → session cookie), same as podsteadr. `frontend/public/nostr-provider.js` is vendored so the app can also be launched identity-aware from inside an Archipelago dashboard, the same way @@ -33,8 +38,43 @@ npm run dev # http://localhost:5173, proxies /api to :8096 ``` On first run, the `places` table is empty — call `POST /api/sync` (or click -"Sync from BTC Map" in the UI) to pull the current Madeira dataset from BTC -Map's public API. +"Sync from BTC Map" in the UI) to pull the current worldwide dataset from BTC +Map's public API (~29k places, a few seconds, single request — see "Global +sync" below for why it's one request rather than paginated). + +## Global sync + +`POST /api/sync` pulls **every** BTC Map place worldwide via the +chronological `/v4/places` endpoint, incrementally: a watermark +(`btcmap_synced_since` in the `settings` table) tracks the last sync's +newest `updated_at`, and each subsequent call only fetches what changed +since then — call it as often as you like (e.g. a cron hitting it hourly) +without re-pulling the full dataset every time. + +Deleted places (BTC Map returns these too, via `include_deleted=true`) are +evicted locally — `ON DELETE CASCADE` on `claims`/`links` means a delisted +business correctly loses its claim and any links through it, not just the +place row. + +**Why one unpaginated request, not BTC Map's own recommended +`updated_since`+`limit` pagination loop:** tried that first. BTC Map's +`updated_at` timestamps mix millisecond-precision and whole-second values, +and naive string comparison across mixed precision isn't chronologically +safe (`"...27Z"` sorts *after* `"...27.137Z"` lexicographically, even though +`.137` happened later within that same second) — this silently truncated a +real sync to the first ~4,000 of ~42,000 records with no error, since the +pagination cursor got stuck comparing strings instead of real timestamps. +Measured the alternative instead: omitting `limit` entirely just returns the +whole dataset — currently ~42k records including historical deletions, ~9MB +uncompressed, in 2-3 seconds. Simpler and correct beats "matches the docs' +suggested pattern" here. `services/btcmap.ts` has the full writeup; +`services/btcmap.test.ts` has a regression test for the specific +mixed-precision comparison bug. Revisit with real pagination (numeric/parsed +timestamp cursor, not string comparison) if the dataset ever grows enough to +make a single request unwieldy. + +Response payloads (`/api/places` is the big one, tens of thousands of rows) +are compressed (`@fastify/compress`, gzip/brotli/zstd auto-negotiated). ## Game rules (v0) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index c03f400..8dd128d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,12 +9,14 @@ "version": "0.1.0", "dependencies": { "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "nostr-tools": "^2.24.1", "pinia": "^3.0.0", "vue": "^3.5.0" }, "devDependencies": { "@types/leaflet": "^1.9.12", + "@types/leaflet.markercluster": "^1.5.6", "@vitejs/plugin-vue": "^6.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.5.0", @@ -1074,6 +1076,16 @@ "@types/geojson": "*" } }, + "node_modules/@types/leaflet.markercluster": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/leaflet.markercluster/-/leaflet.markercluster-1.5.6.tgz", + "integrity": "sha512-I7hZjO2+isVXGYWzKxBp8PsCzAYCJBc29qBdFpquOCkS7zFDqUsUvkEOyQHedsk/Cy5tocQzf+Ndorm5W9YKTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/leaflet": "^1.9" + } + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.8", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.8.tgz", @@ -1862,6 +1874,15 @@ "integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==", "license": "BSD-2-Clause" }, + "node_modules/leaflet.markercluster": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/leaflet.markercluster/-/leaflet.markercluster-1.5.3.tgz", + "integrity": "sha512-vPTw/Bndq7eQHjLBVlWpnGeLa3t+3zGiuM7fJwCkiMFq+nmRuG3RI3f7f4N4TDX7T4NpbAXpR2+NTRSEGfCSeA==", + "license": "MIT", + "peerDependencies": { + "leaflet": "^1.3.1" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 759f90c..126ec7b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,12 +10,14 @@ }, "dependencies": { "leaflet": "^1.9.4", + "leaflet.markercluster": "^1.5.3", "nostr-tools": "^2.24.1", "pinia": "^3.0.0", "vue": "^3.5.0" }, "devDependencies": { "@types/leaflet": "^1.9.12", + "@types/leaflet.markercluster": "^1.5.6", "@vitejs/plugin-vue": "^6.0.0", "autoprefixer": "^10.4.20", "postcss": "^8.5.0", diff --git a/frontend/src/main.ts b/frontend/src/main.ts index a068dc5..a84c21b 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -1,6 +1,8 @@ import { createApp } from 'vue'; import { createPinia } from 'pinia'; import 'leaflet/dist/leaflet.css'; +import 'leaflet.markercluster/dist/MarkerCluster.css'; +import 'leaflet.markercluster/dist/MarkerCluster.Default.css'; import App from './App.vue'; import './style.css'; diff --git a/frontend/src/style.css b/frontend/src/style.css index 495743a..45fcdfe 100644 --- a/frontend/src/style.css +++ b/frontend/src/style.css @@ -83,3 +83,21 @@ h1, h2, .font-display { .regress-link-green { filter: drop-shadow(0 0 4px #39ff14); } .regress-field-orange { filter: drop-shadow(0 0 6px #ff5f1f); } .regress-field-green { filter: drop-shadow(0 0 6px #39ff14); } + +/* Reskin leaflet.markercluster's default light-green bubble look to match + the HUD theme — overrides MarkerCluster.Default.css. */ +.regress-cluster div { + width: 100%; + height: 100%; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + background: radial-gradient(circle at 35% 35%, rgba(0, 255, 242, 0.35), rgba(0, 255, 242, 0.12) 70%); + border: 2px solid #00fff2; + color: #d8fbff; + font-family: 'Share Tech Mono', monospace; + font-weight: bold; + font-size: 12px; + box-shadow: 0 0 10px rgba(0, 255, 242, 0.5); +} diff --git a/frontend/src/views/MapView.vue b/frontend/src/views/MapView.vue index dfc6e75..385d9ca 100644 --- a/frontend/src/views/MapView.vue +++ b/frontend/src/views/MapView.vue @@ -1,6 +1,7 @@