diff --git a/docs/DECISION_RECORD_PLAYABLE_MAP.md b/docs/DECISION_RECORD_PLAYABLE_MAP.md new file mode 100644 index 0000000..55db27e --- /dev/null +++ b/docs/DECISION_RECORD_PLAYABLE_MAP.md @@ -0,0 +1,56 @@ +# Decision Record: Playable Top-Down Territory Map + +**Date:** 2026-08-21 +**Author:** Manus AI +**Status:** Accepted for implementation + +## Problem + +The current no-token map fallback is a canvas radar: a dark grid with synthetic straight lines and territory pins. It does not render real roads, sidewalks, water, building footprints, or meaningful block geometry. This breaks the game’s central promise that a selected place influences strategy. + +## Decision + +Adopt **MapLibre GL JS** behind a project-owned map component and use a token-free OpenFreeMap vector style as the development/default basemap. Keep Mapbox geocoding and static-image support where already configured; do not make the playable map disappear when a Mapbox token is absent. MapLibre renders interactive vector-tile maps and exposes map camera, sources, layers, and interaction APIs.[1] OpenFreeMap documents a MapLibre-compatible style URL and OpenStreetMap-derived attribution.[2] + +| Candidate | License / access | Player impact | Stack fit | Cost / risk | Decision | +|---|---|---:|---:|---|---| +| Existing radar canvas | Project code | 1/5 | 5/5 | No network, but not a real map | **Replace** | +| Existing Mapbox-only surface | Existing dependency; token required | 5/5 | 5/5 | Hidden when token is missing; vendor account required | **Keep as optional provider/data path** | +| MapLibre + OpenFreeMap | MapLibre BSD-3-Clause; token-free public style | 5/5 | 4.5/5 | New bundle/runtime dependency; public service has no game-specific SLA | **Adopt behind an adapter** | + +For production scale, the style/tile URL remains configurable so the game can move to a paid MapTiler/Mapbox deployment or self-hosted tiles without rewriting gameplay code. + +## Player Camera Contract + +| Mode | Camera | Visible detail | Primary actions | +|---|---|---|---| +| Territory | Zoom 14–16, north-up, top-down | Neighborhood streets, water, districts, player/rival/open blocks | Pan, pinch/scroll zoom, search, select, attack, claim | +| Block inspect | Zoom 17–18 | Selected street block, building footprints, labels, risk/value overlay | Inspect block, open placement, return to territory | +| Placement | Zoom 18.5–20, selected block locked in view | Streets, building massing, sidewalk/street tactical cells, members, cover/exposure | Select member, select valid cell, undo, confirm deployment | + +The map initially centers the player’s selected/owned block. Panning does not silently change ownership. A **Home Block** control always returns to it. Selecting another block centers that block for inspection without losing the owned-block identity. + +## Tactical Geometry Contract + +The basemap is geographic context, not an authoritative property survey. Game placement uses a deterministic, fictionalized tactical overlay derived from the block’s existing grid and scene seed. The overlay is anchored to the selected coordinates and visually aligns street-side, sidewalk, yard/building, and safer rear cells. It must display a fiction notice and must never claim exact private parcel boundaries, entrances, resident identities, or real-world criminal activity. + +At close zoom, the player sees: + +- basemap roads, water, labels, and building footprints; +- a highlighted selected-block footprint; +- distinct placement zones for street edge, sidewalk, building/frontage, and rear/cover; +- member markers, valid/invalid occupancy states, cover, exposure, projected income, and escape direction; +- an accessible list alternative for placement. + +## Implementation Boundary + +The map component owns rendering and camera only. Territory state, placement validity, economy, combat preparation, and outcomes remain in existing stores/domain functions. Vendor map objects must not enter Zustand state or persisted saves. + +## Acceptance Criteria + +The radar fallback is no longer shown. With no Mapbox token, a genuine vector map still renders. The selected Las Olas block remains identifiable after zooming away and returning. Territory zoom exposes nearby blocks; placement zoom exposes streets/buildings and game placement zones. Mouse, touch, keyboard controls, reduced motion, attribution, loading/error recovery, and a non-visual placement alternative remain available. The complete validation suite passes, and mobile performance is measured before merge. + +## References + +[1]: https://maplibre.org/maplibre-gl-js/docs/ "MapLibre GL JS Documentation" +[2]: https://openfreemap.org/quick_start/ "OpenFreeMap Quick Start Guide" diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 102fedb..14b4f41 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -14,6 +14,7 @@ "framer-motion": "^10.16.16", "lucide-react": "^1.23.0", "mapbox-gl": "^3.0.1", + "maplibre-gl": "^6.5.0", "phaser": "^3.70.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -1822,6 +1823,15 @@ "xtend": "^4.0.1" } }, + "node_modules/@mapbox/jsonlint-lines-primitives": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@mapbox/jsonlint-lines-primitives/-/jsonlint-lines-primitives-2.0.3.tgz", + "integrity": "sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==", + "license": "MIT", + "engines": { + "node": ">= 22" + } + }, "node_modules/@mapbox/mapbox-gl-geocoder": { "version": "5.1.2", "resolved": "https://registry.npmjs.org/@mapbox/mapbox-gl-geocoder/-/mapbox-gl-geocoder-5.1.2.tgz", @@ -1868,6 +1878,12 @@ "base-64": "^0.1.0" } }, + "node_modules/@mapbox/point-geometry": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz", + "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==", + "license": "ISC" + }, "node_modules/@mapbox/polyline": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@mapbox/polyline/-/polyline-1.2.1.tgz", @@ -1879,6 +1895,77 @@ "polyline": "bin/polyline.bin.js" } }, + "node_modules/@mapbox/tiny-sdf": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz", + "integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/unitbezier": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz", + "integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==", + "license": "BSD-2-Clause" + }, + "node_modules/@mapbox/vector-tile": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz", + "integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "~1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.0.0" + } + }, + "node_modules/@maplibre/geojson-vt": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.1.tgz", + "integrity": "sha512-FVMOcmSP/yqol45t7StApEyTL5/vmqBCuFhH9n+fFuINenhaX+YgHHIt1yJ86S8kln3uJLcMvmEU2cfn6E2eCQ==", + "license": "ISC", + "dependencies": { + "kdbush": "^4.1.0" + } + }, + "node_modules/@maplibre/maplibre-gl-style-spec": { + "version": "26.2.1", + "resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.2.1.tgz", + "integrity": "sha512-QFKCXkOeSzOr8jF75jm6kySOg+dUvOehPhRi68gcOYPHb7U5JloUq0dJW0Y5/fZV8ygfT0Vp2RWodvq+fyxFWA==", + "license": "ISC", + "dependencies": { + "@mapbox/jsonlint-lines-primitives": "^2.0.3", + "@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" + } + }, + "node_modules/@maplibre/mlt": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.2.0.tgz", + "integrity": "sha512-g45M8gEI4sMO3X9ib4K7n3ZKFf0qQOL+NMkHCnEK51lOrYFHiEssOuZncx1+miIgi1IEE2NcbRMcq8RBkL+QHA==", + "license": "(MIT OR Apache-2.0)", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0" + } + }, + "node_modules/@maplibre/vt-pbf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@maplibre/vt-pbf/-/vt-pbf-4.3.2.tgz", + "integrity": "sha512-j6p0AdjvAR19Z3XaCysle7A4ZSo08tYOzxD0Y9NQylwPAkwJJeYub5b2eVucdeDh7erhv69DahoLOevDRERRUw==", + "license": "MIT", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@types/geojson": "^7946.0.16", + "pbf": "^5.1.0" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2587,6 +2674,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/geojson": { + "version": "7946.0.16", + "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz", + "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==", + "license": "MIT" + }, "node_modules/@types/http-cache-semantics": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", @@ -3989,6 +4082,12 @@ "node": ">= 0.4" } }, + "node_modules/earcut": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.3.tgz", + "integrity": "sha512-vnS4AVwp1KHAF13i1vp1/2D5evWy3k5u/iW/B81QVsUZtV8cv2tU0b2VNFlqvh4kYwrFMDdjPCfAmfyJW9y14Q==", + "license": "ISC" + }, "node_modules/electron-to-chromium": { "version": "1.5.402", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.402.tgz", @@ -4717,6 +4816,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gl-matrix": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/gl-matrix/-/gl-matrix-3.4.4.tgz", + "integrity": "sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==", + "license": "MIT" + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -5303,6 +5408,12 @@ "dev": true, "license": "MIT" }, + "node_modules/json-stringify-pretty-compact": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/json-stringify-pretty-compact/-/json-stringify-pretty-compact-4.0.0.tgz", + "integrity": "sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5316,6 +5427,12 @@ "node": ">=6" } }, + "node_modules/kdbush": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/kdbush/-/kdbush-4.1.0.tgz", + "integrity": "sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==", + "license": "ISC" + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -5495,6 +5612,38 @@ "test/build/typings" ] }, + "node_modules/maplibre-gl": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.5.0.tgz", + "integrity": "sha512-kVStPz9Rw/ATjWV5tQ3iCR0tY+viz16Nh3E14iZNlBj0HloMAzFaDNtFYqPGkZSFRnv56txMh3ImjR0g6oClTw==", + "license": "BSD-3-Clause", + "dependencies": { + "@mapbox/point-geometry": "^1.1.0", + "@mapbox/tiny-sdf": "^2.2.0", + "@mapbox/unitbezier": "^1.0.0", + "@mapbox/vector-tile": "^3.0.0", + "@maplibre/geojson-vt": "^6.1.1", + "@maplibre/maplibre-gl-style-spec": "^26.2.1", + "@maplibre/mlt": "^1.2.0", + "@maplibre/vt-pbf": "^4.3.2", + "@types/geojson": "^7946.0.16", + "earcut": "^3.2.3", + "gl-matrix": "^3.4.4", + "kdbush": "^4.1.0", + "murmurhash-js": "^1.0.0", + "pbf": "^5.1.2", + "potpack": "^2.1.0", + "quickselect": "^3.0.0", + "tinyqueue": "^3.0.0" + }, + "engines": { + "node": ">=16.14.0", + "npm": ">=8.1.0" + }, + "funding": { + "url": "https://github.com/maplibre/maplibre-gl-js?sponsor=1" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -5628,6 +5777,15 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minimist-options": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", @@ -5648,6 +5806,12 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/murmurhash-js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/murmurhash-js/-/murmurhash-js-1.0.0.tgz", + "integrity": "sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==", + "license": "MIT" + }, "node_modules/mz": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", @@ -5935,6 +6099,18 @@ "node": ">= 14.16" } }, + "node_modules/pbf": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz", + "integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==", + "license": "BSD-3-Clause", + "dependencies": { + "resolve-protobuf-schema": "^2.1.0" + }, + "bin": { + "pbf": "bin/pbf" + } + }, "node_modules/phaser": { "version": "3.90.0", "resolved": "https://registry.npmjs.org/phaser/-/phaser-3.90.0.tgz", @@ -6152,6 +6328,12 @@ "dev": true, "license": "MIT" }, + "node_modules/potpack": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-2.1.0.tgz", + "integrity": "sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==", + "license": "ISC" + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6192,6 +6374,12 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/protocol-buffers-schema": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.1.tgz", + "integrity": "sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==", + "license": "MIT" + }, "node_modules/proxy-from-env": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", @@ -6254,6 +6442,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/quickselect": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/quickselect/-/quickselect-3.0.0.tgz", + "integrity": "sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==", + "license": "ISC" + }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -6551,6 +6745,15 @@ "node": ">=4" } }, + "node_modules/resolve-protobuf-schema": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz", + "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==", + "license": "MIT", + "dependencies": { + "protocol-buffers-schema": "^3.3.1" + } + }, "node_modules/responselike": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz", @@ -7212,6 +7415,12 @@ "node": "^18.0.0 || >=20.0.0" } }, + "node_modules/tinyqueue": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tinyqueue/-/tinyqueue-3.0.0.tgz", + "integrity": "sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==", + "license": "ISC" + }, "node_modules/tinyrainbow": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 37f75f1..7b56e05 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "framer-motion": "^10.16.16", "lucide-react": "^1.23.0", "mapbox-gl": "^3.0.1", + "maplibre-gl": "^6.5.0", "phaser": "^3.70.0", "react": "^18.2.0", "react-dom": "^18.2.0", diff --git a/frontend/src/components/map/AddressSearchBar.tsx b/frontend/src/components/map/AddressSearchBar.tsx index 800d297..5232a81 100644 --- a/frontend/src/components/map/AddressSearchBar.tsx +++ b/frontend/src/components/map/AddressSearchBar.tsx @@ -34,8 +34,8 @@ interface AddressSearchBarProps { const MOCK_RESULTS: AddressResult[] = [ { address: '1208 W Las Olas Blvd, Fort Lauderdale, FL 33312', - lat: 26.1224, - lng: -80.1373, + lat: 26.1186239, + lng: -80.1574818, placeId: 'mock-las-olas-1208', city: 'Fort Lauderdale', state: 'FL', diff --git a/frontend/src/components/map/BlockOverlay.tsx b/frontend/src/components/map/BlockOverlay.tsx index f77eb87..bb3097b 100644 --- a/frontend/src/components/map/BlockOverlay.tsx +++ b/frontend/src/components/map/BlockOverlay.tsx @@ -1,10 +1,5 @@ -// ============================================================ -// BlockOverlay - Renders claimed/NPC/unclaimed blocks as -// colored polygons on the Mapbox map using GeoJSON sources -// ============================================================ - import { useEffect } from 'react'; -import mapboxgl from '../../utils/mapboxClient'; +import type { GeoJSONSource, Map as MapLibreMap, MapLayerMouseEvent } from 'maplibre-gl'; interface BlockData { id: string; @@ -12,135 +7,205 @@ interface BlockData { lat: number; lng: number; owner: 'player' | 'npc' | 'unclaimed'; + ownerGangName?: string; income?: number; heat?: number; members?: number; } interface BlockOverlayProps { - map: mapboxgl.Map | null; + map: MapLibreMap | null; blocks: BlockData[]; + selectedId?: string | null; onBlockClick?: (block: BlockData) => void; } -// Generate a small polygon around a point (simulates a city block) -function blockPolygon(lat: number, lng: number): number[][] { - const size = 0.0004; // ~44m at equator +const BLOCK_COLORS: Record = { + player: '#16a34a', + npc: '#e11d48', + unclaimed: '#64748b', +}; + +function metersToLongitude(meters: number, latitude: number): number { + const cos = Math.max(0.2, Math.cos((latitude * Math.PI) / 180)); + return meters / (111_320 * cos); +} + +export function blockFootprint(lat: number, lng: number): number[][] { + const halfHeight = 26 / 110_540; + const halfWidth = metersToLongitude(38, lat); return [ - [lng - size, lat - size], - [lng + size, lat - size], - [lng + size, lat + size], - [lng - size, lat + size], - [lng - size, lat - size], + [lng - halfWidth, lat - halfHeight], + [lng + halfWidth, lat - halfHeight], + [lng + halfWidth, lat + halfHeight], + [lng - halfWidth, lat + halfHeight], + [lng - halfWidth, lat - halfHeight], ]; } -const BLOCK_COLORS: Record = { - player: '#4ade80', - npc: '#ef4444', - unclaimed: '#6b7280', -}; - -const BlockOverlay: React.FC = ({ map, blocks, onBlockClick }) => { - useEffect(() => { - if (!map || blocks.length === 0) return; - - const sourceId = 'blocks-source'; - const fillLayerId = 'blocks-fill'; - const outlineLayerId = 'blocks-outline'; - - // Build GeoJSON - const geojson: any = { - type: 'FeatureCollection', - features: blocks.map((block) => ({ - type: 'Feature', - properties: { - id: block.id, - owner: block.owner, - address: block.address, - income: block.income || 0, - heat: block.heat || 0, - members: block.members || 0, - color: BLOCK_COLORS[block.owner] || BLOCK_COLORS.unclaimed, +export function toFeatureCollection(blocks: BlockData[], selectedId?: string | null) { + return { + type: 'FeatureCollection' as const, + features: blocks.flatMap((block) => { + const common = { + id: block.id, + owner: block.owner, + address: block.address, + label: block.owner === 'player' ? 'Your block' : block.ownerGangName ?? (block.owner === 'npc' ? 'Rival block' : 'Open block'), + income: block.income ?? 0, + heat: block.heat ?? 0, + members: block.members ?? 0, + color: BLOCK_COLORS[block.owner], + selected: block.id === selectedId ? 1 : 0, + }; + return [ + { + type: 'Feature' as const, + properties: { ...common, kind: 'footprint' }, + geometry: { + type: 'Polygon' as const, + coordinates: [blockFootprint(block.lat, block.lng)], + }, }, - geometry: { - type: 'Polygon', - coordinates: [blockPolygon(block.lat, block.lng)], + { + type: 'Feature' as const, + properties: { ...common, kind: 'pin' }, + geometry: { + type: 'Point' as const, + coordinates: [block.lng, block.lat], + }, }, - })), - }; + ]; + }), + }; +} - // Add or update source - const existingSource = map.getSource(sourceId) as mapboxgl.GeoJSONSource | undefined; +const SOURCE_ID = 'slide-territories'; +const FOOTPRINT_FILL_ID = 'slide-territories-footprint-fill'; +const FOOTPRINT_LINE_ID = 'slide-territories-footprint-line'; +const PIN_HALO_ID = 'slide-territories-pin-halo'; +const PIN_ID = 'slide-territories-pin'; +const LABEL_ID = 'slide-territories-label'; + +const BlockOverlay: React.FC = ({ map, blocks, selectedId, onBlockClick }) => { + useEffect(() => { + if (!map || !map.isStyleLoaded()) return; + + const data = toFeatureCollection(blocks, selectedId); + const existingSource = map.getSource(SOURCE_ID) as GeoJSONSource | undefined; if (existingSource) { - existingSource.setData(geojson); + existingSource.setData(data); return; } - map.addSource(sourceId, { - type: 'geojson', - data: geojson, - }); + map.addSource(SOURCE_ID, { type: 'geojson', data }); - // Fill layer map.addLayer({ - id: fillLayerId, + id: FOOTPRINT_FILL_ID, type: 'fill', - source: sourceId, + source: SOURCE_ID, + minzoom: 16.25, + filter: ['==', ['get', 'kind'], 'footprint'], paint: { 'fill-color': ['get', 'color'], 'fill-opacity': [ - 'match', - ['get', 'owner'], - 'player', 0.5, - 'npc', 0.4, - 0.2, + 'case', + ['==', ['get', 'selected'], 1], 0.12, + ['==', ['get', 'owner'], 'player'], 0.16, + 0.1, ], }, }); - // Outline layer map.addLayer({ - id: outlineLayerId, + id: FOOTPRINT_LINE_ID, type: 'line', - source: sourceId, + source: SOURCE_ID, + minzoom: 16, + filter: ['==', ['get', 'kind'], 'footprint'], paint: { 'line-color': ['get', 'color'], - 'line-width': 2, - 'line-opacity': 0.8, + 'line-width': ['case', ['==', ['get', 'selected'], 1], 4, 2], + 'line-opacity': 0.94, }, }); - // Click handler - if (onBlockClick) { - map.on('click', fillLayerId, (e) => { - if (e.features && e.features.length > 0) { - const props = (e.features[0] as any).properties; - if (props) { - const block = blocks.find((b) => b.id === props.id); - if (block) onBlockClick(block); - } - } - }); + map.addLayer({ + id: PIN_HALO_ID, + type: 'circle', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'pin'], + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 12, 11, 16, 17, 19, 12], + 'circle-color': ['get', 'color'], + 'circle-opacity': ['case', ['==', ['get', 'selected'], 1], 0.2, 0.08], + 'circle-blur': 0.35, + }, + }); - // Cursor change on hover - map.on('mouseenter', fillLayerId, () => { - map.getCanvas().style.cursor = 'pointer'; - }); - map.on('mouseleave', fillLayerId, () => { - map.getCanvas().style.cursor = ''; - }); - } + map.addLayer({ + id: PIN_ID, + type: 'circle', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'pin'], + paint: { + 'circle-radius': ['interpolate', ['linear'], ['zoom'], 12, 4.5, 16, 7.5, 19, 6], + 'circle-color': ['get', 'color'], + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': ['case', ['==', ['get', 'selected'], 1], 3, 1.5], + 'circle-opacity': 0.98, + }, + }); + + map.addLayer({ + id: LABEL_ID, + type: 'symbol', + source: SOURCE_ID, + minzoom: 13.5, + filter: ['==', ['get', 'kind'], 'pin'], + layout: { + 'text-field': ['get', 'label'], + 'text-size': ['interpolate', ['linear'], ['zoom'], 13, 10, 17, 12], + 'text-offset': [0, 1.25], + 'text-anchor': 'top', + 'text-allow-overlap': false, + 'text-optional': true, + }, + paint: { + 'text-color': '#111827', + 'text-halo-color': 'rgba(255,255,255,0.96)', + 'text-halo-width': 2, + }, + }); + + const resolveClick = (event: MapLayerMouseEvent) => { + const id = event.features?.[0]?.properties?.id as string | undefined; + const block = id ? blocks.find((candidate) => candidate.id === id) : undefined; + if (block) onBlockClick?.(block); + }; + const enter = () => { map.getCanvas().style.cursor = 'pointer'; }; + const leave = () => { map.getCanvas().style.cursor = ''; }; + + [PIN_ID, FOOTPRINT_FILL_ID].forEach((layerId) => { + map.on('click', layerId, resolveClick); + map.on('mouseenter', layerId, enter); + map.on('mouseleave', layerId, leave); + }); - // Cleanup return () => { - if (map.getLayer(fillLayerId)) map.removeLayer(fillLayerId); - if (map.getLayer(outlineLayerId)) map.removeLayer(outlineLayerId); - if (map.getSource(sourceId)) map.removeSource(sourceId); + [PIN_ID, FOOTPRINT_FILL_ID].forEach((layerId) => { + map.off('click', layerId, resolveClick); + map.off('mouseenter', layerId, enter); + map.off('mouseleave', layerId, leave); + }); + [LABEL_ID, PIN_ID, PIN_HALO_ID, FOOTPRINT_LINE_ID, FOOTPRINT_FILL_ID].forEach((layerId) => { + if (map.getLayer(layerId)) map.removeLayer(layerId); + }); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); }; - }, [map, blocks, onBlockClick]); + }, [map, blocks, onBlockClick, selectedId]); - return null; // This component only manipulates the map imperatively + return null; }; export default BlockOverlay; diff --git a/frontend/src/components/map/MemberDropSheet.css b/frontend/src/components/map/MemberDropSheet.css index 7f77cb9..1730ca3 100644 --- a/frontend/src/components/map/MemberDropSheet.css +++ b/frontend/src/components/map/MemberDropSheet.css @@ -1,13 +1,19 @@ .drop-sheet { - position: absolute; - left: 0; - right: 0; + position: fixed; + left: 50%; bottom: 0; - max-height: 58%; + width: min(100vw, 430px); + max-height: min(58vh, 520px); + transform: translateX(-50%); + overflow: hidden; background: rgba(22, 22, 24, 0.97); + border: 1px solid rgba(255, 255, 255, 0.08); + border-bottom: none; border-radius: 18px 18px 0 0; - z-index: 41; - padding: 8px 14px 16px; + box-shadow: 0 -18px 48px rgba(0, 0, 0, 0.48); + z-index: 1200; + padding: 8px 14px max(16px, env(safe-area-inset-bottom)); + backdrop-filter: blur(18px) saturate(1.2); } .drop-handle { @@ -36,7 +42,7 @@ cursor: pointer; } -.drop-list { list-style: none; margin: 12px 0 0; padding: 0; overflow-y: auto; max-height: 40vh; } +.drop-list { list-style: none; margin: 12px 0 0; padding: 0; overflow-y: auto; max-height: min(40vh, 360px); overscroll-behavior: contain; } .drop-list button { width: 100%; display: grid; diff --git a/frontend/src/components/map/MemberDropSheet.tsx b/frontend/src/components/map/MemberDropSheet.tsx index 5136af5..b1990a1 100644 --- a/frontend/src/components/map/MemberDropSheet.tsx +++ b/frontend/src/components/map/MemberDropSheet.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { createPortal } from 'react-dom'; import { useGangStore } from '../../stores/gameStore'; import { useBlockStore } from '../../stores/blockStore'; import type { MemberRole } from '../../types/block.types'; @@ -19,7 +20,7 @@ const MemberDropSheet: React.FC = ({ open, onClose, onDrop if (!open) return null; - return ( + return createPortal(
@@ -50,7 +51,8 @@ const MemberDropSheet: React.FC = ({ open, onClose, onDrop
  • Everyone’s already on this strip. Recruit from CREW.
  • )} -
    +
    , + document.body, ); }; diff --git a/frontend/src/components/map/PlayableMap.css b/frontend/src/components/map/PlayableMap.css new file mode 100644 index 0000000..c74995d --- /dev/null +++ b/frontend/src/components/map/PlayableMap.css @@ -0,0 +1,181 @@ +.playable-map { + position: absolute; + inset: 0; + overflow: hidden; + background: #d7d9d5; + color: #111318; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "Inter", sans-serif; +} + +.playable-map__canvas { + width: 100%; + height: 100%; +} + +.playable-map .maplibregl-map { + font: inherit; +} + +.playable-map .maplibregl-canvas { + outline: none; +} + +.playable-map .maplibregl-ctrl-top-right { + top: 64px; + right: 8px; +} + +.playable-map .maplibregl-ctrl-group { + overflow: hidden; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.9); + box-shadow: 0 6px 24px rgba(15, 23, 42, 0.18); + backdrop-filter: blur(16px) saturate(1.25); +} + +.playable-map .maplibregl-ctrl-group button { + width: 38px; + height: 38px; +} + +.playable-map .maplibregl-ctrl-scale { + margin: 0 0 40px 10px; + border-color: rgba(15, 23, 42, 0.55); + background: rgba(255, 255, 255, 0.78); + color: #111827; + font-weight: 700; +} + +.playable-map .maplibregl-ctrl-attrib { + border-radius: 8px 0 0; + background: rgba(255, 255, 255, 0.82); + color: #334155; +} + +.playable-map__status { + position: absolute; + inset: 0; + z-index: 8; + display: flex; + align-items: center; + justify-content: center; + gap: 10px; + padding: 24px; + background: linear-gradient(145deg, #e8e8e4, #cfd5d2); + color: #364152; + font-size: 13px; + font-weight: 700; + text-align: center; +} + +.playable-map__status--error { + flex-direction: column; +} + +.playable-map__status--error strong { + color: #991b1b; + font-size: 15px; +} + +.playable-map__status--error span { + max-width: 280px; + font-weight: 500; + line-height: 1.45; +} + +.playable-map__pulse { + width: 12px; + height: 12px; + border-radius: 999px; + background: #0a84ff; + box-shadow: 0 0 0 0 rgba(10, 132, 255, 0.42); + animation: playable-map-pulse 1.4s ease-out infinite; +} + +.playable-map__location { + position: absolute; + z-index: 7; + top: 60px; + left: 10px; + display: flex; + max-width: min(72%, 310px); + align-items: center; + gap: 9px; + padding: 9px 12px; + border: 1px solid rgba(255, 255, 255, 0.54); + border-radius: 14px; + background: rgba(250, 250, 250, 0.9); + box-shadow: 0 8px 30px rgba(15, 23, 42, 0.18); + backdrop-filter: blur(18px) saturate(1.3); + pointer-events: none; +} + +.playable-map__location-dot { + flex: 0 0 auto; + width: 11px; + height: 11px; + border: 3px solid #fff; + border-radius: 999px; + background: #0a84ff; + box-shadow: 0 0 0 2px rgba(10, 132, 255, 0.28); +} + +.playable-map__location span:last-child { + min-width: 0; +} + +.playable-map__location strong, +.playable-map__location small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.playable-map__location strong { + color: #111827; + font-size: 12px; + letter-spacing: 0.01em; +} + +.playable-map__location small { + margin-top: 2px; + color: #475569; + font-size: 10px; +} + +.playable-map__notice { + position: absolute; + z-index: 6; + right: 8px; + bottom: 28px; + max-width: 210px; + padding: 6px 8px; + border-radius: 8px; + background: rgba(15, 23, 42, 0.76); + color: rgba(255, 255, 255, 0.88); + font-size: 8px; + line-height: 1.3; + pointer-events: none; +} + +.playable-map--placement .playable-map__location { + border-color: rgba(34, 197, 94, 0.44); +} + +.playable-map--placement .playable-map__location-dot { + background: #22c55e; + box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.24); +} + +@keyframes playable-map-pulse { + 70% { box-shadow: 0 0 0 10px rgba(10, 132, 255, 0); } + 100% { box-shadow: 0 0 0 0 rgba(10, 132, 255, 0); } +} + +@media (prefers-reduced-motion: reduce) { + .playable-map__pulse { + animation: none; + } +} diff --git a/frontend/src/components/map/PlayableMap.tsx b/frontend/src/components/map/PlayableMap.tsx new file mode 100644 index 0000000..bdc26a9 --- /dev/null +++ b/frontend/src/components/map/PlayableMap.tsx @@ -0,0 +1,171 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Map as MapLibreMap, + NavigationControl, + ScaleControl, + setWorkerUrl, + type MapOptions, +} from 'maplibre-gl'; +import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'; +import 'maplibre-gl/dist/maplibre-gl.css'; +import './PlayableMap.css'; + +setWorkerUrl(workerUrl); + +export type PlayableMapMode = 'territory' | 'inspect' | 'placement'; + +export interface PlayableMapController { + flyTo: (opts: { center: [number, number]; zoom?: number; duration?: number }) => void; + getMap: () => MapLibreMap | null; +} + +interface PlayableMapProps { + center: [number, number]; + zoom?: number; + mode?: PlayableMapMode; + selectedAddress?: string; + onMapLoad?: (map: MapLibreMap) => void; + onControllerReady?: (controller: PlayableMapController) => void; + onZoomChange?: (zoom: number) => void; +} + +const DEFAULT_STYLE = 'https://tiles.openfreemap.org/styles/liberty'; + +const MODE_ZOOM: Record = { + territory: 15, + inspect: 17.25, + placement: 19, +}; + +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' + && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true; +} + +const PlayableMap: React.FC = ({ + center, + zoom, + mode = 'territory', + selectedAddress, + onMapLoad, + onControllerReady, + onZoomChange, +}) => { + const containerRef = useRef(null); + const mapRef = useRef(null); + const callbacksRef = useRef({ onMapLoad, onControllerReady, onZoomChange }); + const [status, setStatus] = useState<'loading' | 'ready' | 'error'>('loading'); + + callbacksRef.current = { onMapLoad, onControllerReady, onZoomChange }; + + useEffect(() => { + if (!containerRef.current || mapRef.current) return; + + const style = ((import.meta.env as Record).VITE_SLIDE_MAP_STYLE_URL || DEFAULT_STYLE).trim(); + const options: MapOptions = { + container: containerRef.current, + style, + center, + zoom: zoom ?? MODE_ZOOM[mode], + minZoom: 11, + maxZoom: 20, + pitch: 0, + bearing: 0, + maxPitch: 0, + renderWorldCopies: false, + attributionControl: { compact: true }, + cooperativeGestures: false, + }; + + let map: MapLibreMap; + try { + map = new MapLibreMap(options); + } catch { + setStatus('error'); + return; + } + + mapRef.current = map; + map.dragRotate.disable(); + map.touchZoomRotate.disableRotation(); + map.addControl(new NavigationControl({ showCompass: false }), 'top-right'); + map.addControl(new ScaleControl({ maxWidth: 90, unit: 'imperial' }), 'bottom-left'); + + const controller: PlayableMapController = { + flyTo: ({ center: nextCenter, zoom: nextZoom, duration = 700 }) => { + const reduced = prefersReducedMotion(); + map.easeTo({ + center: nextCenter, + zoom: nextZoom ?? map.getZoom(), + duration: reduced ? 0 : duration, + essential: false, + pitch: 0, + bearing: 0, + }); + }, + getMap: () => mapRef.current, + }; + + map.once('load', () => { + setStatus('ready'); + callbacksRef.current.onMapLoad?.(map); + callbacksRef.current.onControllerReady?.(controller); + }); + + map.on('zoomend', () => callbacksRef.current.onZoomChange?.(map.getZoom())); + map.on('error', (event) => { + if (!event.error) return; + setStatus((current) => current === 'ready' ? current : 'error'); + }); + + return () => { + map.remove(); + mapRef.current = null; + }; + }, []); + + useEffect(() => { + const map = mapRef.current; + if (!map || status !== 'ready') return; + map.easeTo({ + center, + zoom: zoom ?? MODE_ZOOM[mode], + duration: prefersReducedMotion() ? 0 : 700, + essential: false, + pitch: 0, + bearing: 0, + }); + }, [center, mode, status, zoom]); + + return ( +
    +
    + {status === 'loading' && ( +
    + + Loading streets and buildings… +
    + )} + {status === 'error' && ( +
    + Map connection interrupted + The selected block is still safe. Check your connection and reopen Maps. +
    + )} + {selectedAddress && ( +
    + + + {mode === 'territory' ? 'Home block' : mode === 'inspect' ? 'Inspecting block' : 'Placing crew'} + {selectedAddress} + +
    + )} +
    + Streets are geographic context. Tactical cells and territory boundaries are fictionalized for gameplay. +
    +
    + ); +}; + +export default PlayableMap; diff --git a/frontend/src/components/map/TacticalPlacementOverlay.tsx b/frontend/src/components/map/TacticalPlacementOverlay.tsx new file mode 100644 index 0000000..27c6a99 --- /dev/null +++ b/frontend/src/components/map/TacticalPlacementOverlay.tsx @@ -0,0 +1,209 @@ +import { useEffect } from 'react'; +import type { GeoJSONSource, Map as MapLibreMap, MapLayerMouseEvent } from 'maplibre-gl'; +import type { BlockData, BlockZone } from '../../types/block.types'; + +interface TacticalPlacementOverlayProps { + map: MapLibreMap | null; + block: BlockData | null; + active: boolean; + onChooseCell: (zone: BlockZone) => void; +} + +const SOURCE_ID = 'slide-placement-grid'; +const FILL_ID = 'slide-placement-grid-fill'; +const LINE_ID = 'slide-placement-grid-line'; +const MEMBER_ID = 'slide-placement-members'; +const MEMBER_LABEL_ID = 'slide-placement-member-labels'; + +const ZONE_COLORS: Record = { + street: '#ef4444', + curb: '#f97316', + sidewalk: '#facc15', + storefront: '#22c55e', + alley: '#38bdf8', + parking: '#94a3b8', + rooftop: '#a78bfa', + building: '#334155', +}; + +function lngPerMeter(latitude: number): number { + const cos = Math.max(0.2, Math.cos((latitude * Math.PI) / 180)); + return 1 / (111_320 * cos); +} + +function latPerMeter(): number { + return 1 / 110_540; +} + +export function buildPlacementGeoJson(block: BlockData) { + const rows = block.grid.length || 8; + const columns = Math.max(1, ...block.grid.map((row) => row.length)); + const cellMeters = 8; + const east = lngPerMeter(block.lat); + const north = latPerMeter(); + const xOrigin = -((columns * cellMeters) / 2); + const yOrigin = -((rows * cellMeters) / 2); + + const cellFeatures = block.grid.flatMap((row, y) => row.map((zone, x) => { + const westMeters = xOrigin + x * cellMeters; + const eastMeters = westMeters + cellMeters; + const southMeters = yOrigin + y * cellMeters; + const northMeters = southMeters + cellMeters; + return { + type: 'Feature' as const, + properties: { + kind: 'cell', + x: zone.x, + y: zone.y, + zoneType: zone.zoneType, + passable: zone.passable ? 1 : 0, + occupied: zone.occupantId ? 1 : 0, + income: zone.incomeModifier, + exposure: zone.exposureRisk, + color: ZONE_COLORS[zone.zoneType], + }, + geometry: { + type: 'Polygon' as const, + coordinates: [[ + [block.lng + westMeters * east, block.lat + southMeters * north], + [block.lng + eastMeters * east, block.lat + southMeters * north], + [block.lng + eastMeters * east, block.lat + northMeters * north], + [block.lng + westMeters * east, block.lat + northMeters * north], + [block.lng + westMeters * east, block.lat + southMeters * north], + ]], + }, + }; + })); + + const memberFeatures = block.placements.map((placement) => { + const eastMeters = xOrigin + (placement.x + 0.5) * cellMeters; + const northMeters = yOrigin + (placement.y + 0.5) * cellMeters; + return { + type: 'Feature' as const, + properties: { + kind: 'member', + id: placement.memberId, + name: placement.memberName, + role: placement.role, + }, + geometry: { + type: 'Point' as const, + coordinates: [block.lng + eastMeters * east, block.lat + northMeters * north], + }, + }; + }); + + return { + type: 'FeatureCollection' as const, + features: [...cellFeatures, ...memberFeatures], + }; +} + +const TacticalPlacementOverlay: React.FC = ({ map, block, active, onChooseCell }) => { + useEffect(() => { + if (!map || !block || !active || !map.isStyleLoaded()) return; + + const data = buildPlacementGeoJson(block); + map.addSource(SOURCE_ID, { type: 'geojson', data }); + + map.addLayer({ + id: FILL_ID, + type: 'fill', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'cell'], + paint: { + 'fill-color': ['get', 'color'], + 'fill-opacity': [ + 'case', + ['==', ['get', 'passable'], 0], 0.12, + ['==', ['get', 'occupied'], 1], 0.32, + 0.5, + ], + }, + }); + + map.addLayer({ + id: LINE_ID, + type: 'line', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'cell'], + paint: { + 'line-color': [ + 'case', + ['==', ['get', 'passable'], 0], 'rgba(15,23,42,0.34)', + ['==', ['get', 'occupied'], 1], '#ffffff', + 'rgba(255,255,255,0.82)', + ], + 'line-width': ['case', ['==', ['get', 'occupied'], 1], 3, 2], + }, + }); + + map.addLayer({ + id: MEMBER_ID, + type: 'circle', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'member'], + paint: { + 'circle-radius': 8, + 'circle-color': '#111827', + 'circle-stroke-color': '#ffffff', + 'circle-stroke-width': 2.5, + }, + }); + + map.addLayer({ + id: MEMBER_LABEL_ID, + type: 'symbol', + source: SOURCE_ID, + filter: ['==', ['get', 'kind'], 'member'], + layout: { + 'text-field': ['get', 'name'], + 'text-size': 10, + 'text-offset': [0, 1.1], + 'text-anchor': 'top', + 'text-optional': true, + }, + paint: { + 'text-color': '#0f172a', + 'text-halo-color': 'rgba(255,255,255,0.96)', + 'text-halo-width': 2, + }, + }); + + const click = (event: MapLayerMouseEvent) => { + const properties = event.features?.[0]?.properties; + if (!properties || properties.passable !== 1 || properties.occupied === 1) return; + const zone = block.grid[Number(properties.y)]?.[Number(properties.x)]; + if (zone) onChooseCell(zone); + }; + const enter = (event: MapLayerMouseEvent) => { + const properties = event.features?.[0]?.properties; + map.getCanvas().style.cursor = properties?.passable === 1 && properties?.occupied !== 1 ? 'crosshair' : 'not-allowed'; + }; + const leave = () => { map.getCanvas().style.cursor = ''; }; + + map.on('click', FILL_ID, click); + map.on('mousemove', FILL_ID, enter); + map.on('mouseleave', FILL_ID, leave); + + return () => { + map.off('click', FILL_ID, click); + map.off('mousemove', FILL_ID, enter); + map.off('mouseleave', FILL_ID, leave); + [MEMBER_LABEL_ID, MEMBER_ID, LINE_ID, FILL_ID].forEach((id) => { + if (map.getLayer(id)) map.removeLayer(id); + }); + if (map.getSource(SOURCE_ID)) map.removeSource(SOURCE_ID); + }; + }, [active, block, map, onChooseCell]); + + useEffect(() => { + if (!map || !block || !active) return; + const source = map.getSource(SOURCE_ID) as GeoJSONSource | undefined; + source?.setData(buildPlacementGeoJson(block)); + }, [active, block, map]); + + return null; +}; + +export default TacticalPlacementOverlay; diff --git a/frontend/src/components/map/TerritoryMap.css b/frontend/src/components/map/TerritoryMap.css index 76c3186..9fad2d4 100644 --- a/frontend/src/components/map/TerritoryMap.css +++ b/frontend/src/components/map/TerritoryMap.css @@ -368,8 +368,48 @@ .hood-map-container.maps-stage { height: min(68vh, 640px); + min-height: 430px; border-radius: 18px; border: none; + background: #d7d9d5; +} + +.map-camera-actions { + position: absolute; + z-index: 22; + top: 116px; + right: 10px; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 7px; +} + +.map-camera-actions button { + display: inline-flex; + min-height: 36px; + align-items: center; + justify-content: center; + gap: 6px; + padding: 7px 11px; + border: 1px solid rgba(15, 23, 42, 0.14); + border-radius: 12px; + background: rgba(255, 255, 255, 0.92); + box-shadow: 0 6px 22px rgba(15, 23, 42, 0.18); + color: #0f172a; + font: 700 11px/1.1 -apple-system, BlinkMacSystemFont, "SF Pro Display", sans-serif; + backdrop-filter: blur(16px) saturate(1.2); + cursor: pointer; +} + +.map-camera-actions button:focus-visible { + outline: 3px solid rgba(10, 132, 255, 0.42); + outline-offset: 2px; +} + +.map-camera-actions .map-camera-actions__cancel { + border-color: rgba(220, 38, 38, 0.2); + color: #b91c1c; } .block-claim-btn { diff --git a/frontend/src/components/map/TerritoryMap.tsx b/frontend/src/components/map/TerritoryMap.tsx index 30416ab..4f2ca2f 100644 --- a/frontend/src/components/map/TerritoryMap.tsx +++ b/frontend/src/components/map/TerritoryMap.tsx @@ -8,8 +8,10 @@ import { useNavigationStore, usePlayerStore, useGangStore } from '../../stores/g import { useBlockStore } from '../../stores/blockStore'; import { useShoeboxStore } from '../../stores/useShoeboxStore'; import { useTutorialProgressStore } from '../../stores/tutorialProgressStore'; -import MapboxMap from './MapboxMap'; +import PlayableMap from './PlayableMap'; import BlockOverlay, { type BlockData as MapBlockData } from './BlockOverlay'; +import TacticalPlacementOverlay from './TacticalPlacementOverlay'; +import type { Map as MapLibreMap } from 'maplibre-gl'; import BlockDetailPanel from './BlockDetailPanel'; import BlockModeView from './BlockModeView'; import AddressSearchBar, { type AddressResult } from './AddressSearchBar'; @@ -17,21 +19,19 @@ import MapsSearchField from './MapsSearchField'; import NearbyReconSheet from './NearbyReconSheet'; import MemberDropSheet from './MemberDropSheet'; import EmpireCommandBar from './EmpireCommandBar'; -import TacticalHoodMap from './TacticalHoodMap'; import WarBriefingCard from './WarBriefingCard'; import { resolveBlockDNA } from '../../utils/blockDNAResolver'; import { buildStaticImageUrl } from '../../config/mapbox.config'; import { CLAIM_BLOCK_COST } from '../../config/gameEconomy'; -import { isUsableMapboxToken, LAS_OLAS_CENTER } from '../../config/mapboxToken'; +import { LAS_OLAS_CENTER } from '../../config/mapboxToken'; import { attackableNearby, buildNearbyRecon, rankThreats, recommendHit, type ReconBlock } from '../../utils/nearbyBlocks'; import { computeEmpirePnl } from '../../utils/shoeboxAnalytics'; import { vaultDeposit } from '../../utils/moneyRouter'; import { useCombatIntentStore } from '../../stores/combatIntentStore'; -import type { BlockData, BlockZoneType, MemberRole } from '../../types/block.types'; +import type { BlockData, BlockZone, MemberRole } from '../../types/block.types'; import './TerritoryMap.css'; import './EmpireCommandBar.css'; import './MapsChrome.css'; -import './TacticalHoodMap.css'; import './WarBriefingCard.css'; type MapView = 'block' | 'hood' | 'roster'; @@ -47,18 +47,6 @@ const ROLE_COLORS: Record = { boss: '#fbbf24', }; -function firstOpenTile(block: BlockData): { x: number; y: number; zoneType: BlockZoneType } { - for (let y = 0; y < block.grid.length; y++) { - for (let x = 0; x < (block.grid[y]?.length ?? 0); x++) { - const zone = block.grid[y][x]; - if (zone.passable && !zone.occupantId) { - return { x, y, zoneType: zone.zoneType }; - } - } - } - return { x: 2, y: 2, zoneType: 'sidewalk' }; -} - function toOverlay(block: { id: string; address: string; @@ -92,19 +80,25 @@ const TerritoryMap: React.FC = () => { const [view, setView] = useState('hood'); const { completeStep: completeTutorialStep } = useTutorialProgressStore(); const [notification, setNotification] = useState(null); - const [mapInstance, setMapInstance] = useState(null); + const [mapInstance, setMapInstance] = useState(null); const [selectedMapBlock, setSelectedMapBlock] = useState(null); const [searchPins, setSearchPins] = useState([]); const [showBlockSearch, setShowBlockSearch] = useState(false); const [showRecon, setShowRecon] = useState(false); const [showDrop, setShowDrop] = useState(false); - const liveMapbox = isUsableMapboxToken(); + const [placementDraft, setPlacementDraft] = useState<{ + memberId: string; + memberName: string; + role: MemberRole; + level: number; + } | null>(null); const liveList = useMemo(() => Object.values(blocks), [blocks]); const origin = useMemo(() => { - const home = liveList.find((b) => b.owner === 'player'); + const selected = selectedBlockId ? blocks[selectedBlockId] : null; + const home = selected?.owner === 'player' ? selected : liveList.find((block) => block.owner === 'player'); return home ? { lat: home.lat, lng: home.lng } : { lat: LAS_OLAS_CENTER[1], lng: LAS_OLAS_CENTER[0] }; - }, [liveList]); + }, [blocks, liveList, selectedBlockId]); const recon = useMemo(() => { const livePins: ReconBlock[] = liveList.map((b) => ({ @@ -209,7 +203,7 @@ const TerritoryMap: React.FC = () => { } }, [player, vault, updatePlayer, upsertBlock, selectBlock, notify, completeTutorialStep]); - const handleMapLoad = useCallback((map: any) => setMapInstance(map), []); + const handleMapLoad = useCallback((map: MapLibreMap) => setMapInstance(map), []); const handleHoodSearchResult = useCallback((result: AddressResult) => { flyTo(result.lat, result.lng); @@ -272,30 +266,55 @@ const TerritoryMap: React.FC = () => { setView('block'); return; } - const tile = firstOpenTile(block); - placeMember(block.id, { + selectBlock(block.id); + setPlacementDraft({ memberId, memberName, role: (role as MemberRole) || 'dealer', - x: tile.x, - y: tile.y, - zoneType: tile.zoneType, - incomePerTick: 0, - exposureRisk: 40, level, - health: 100, }); - selectBlock(block.id); + setSelectedMapBlock(toOverlay(block)); setShowDrop(false); - setView('block'); - notify(`${memberName} dropped on ${block.address}`); + setView('hood'); + flyTo(block.lat, block.lng, 19); + notify(`Tap a highlighted sidewalk, storefront, or cover cell for ${memberName}.`, 3500); }, - [selectedBlockId, liveList, blocks, placeMember, selectBlock, notify], + [selectedBlockId, liveList, blocks, selectBlock, flyTo, notify], ); + const completeMapPlacement = useCallback((zone: BlockZone) => { + if (!placementDraft) return; + const targetId = selectedBlockId ?? liveList.find((block) => block.owner === 'player')?.id; + const block = targetId ? blocks[targetId] : null; + if (!block || !zone.passable || zone.occupantId) { + notify('That position is blocked. Choose another highlighted cell.'); + return; + } + + placeMember(block.id, { + memberId: placementDraft.memberId, + memberName: placementDraft.memberName, + role: placementDraft.role, + x: zone.x, + y: zone.y, + zoneType: zone.zoneType, + incomePerTick: 0, + exposureRisk: zone.exposureRisk, + level: placementDraft.level, + health: 100, + }); + setPlacementDraft(null); + notify(`${placementDraft.memberName} placed on ${zone.zoneType} · ${zone.exposureRisk}% exposure.`, 3500); + }, [blocks, liveList, notify, placeMember, placementDraft, selectedBlockId]); + const activeBlockId = selectedBlockId ?? liveList.find((b) => b.owner === 'player')?.id ?? 'home'; const activeBlock = blocks[activeBlockId]; const activeBlockAddress = activeBlock?.address ?? 'Your Home Block'; + const mapCenter = useMemo<[number, number]>(() => { + if (placementDraft && activeBlock) return [activeBlock.lng, activeBlock.lat]; + if (selectedMapBlock) return [selectedMapBlock.lng, selectedMapBlock.lat]; + return [origin.lng, origin.lat]; + }, [activeBlock, origin.lat, origin.lng, placementDraft, selectedMapBlock]); const headerIncome = activeBlock?.pendingIncome ?? 0; const headerHeat = activeBlock?.heat ?? player?.heat ?? 0; const headerMorale = activeBlock?.morale ?? 80; @@ -428,31 +447,59 @@ const TerritoryMap: React.FC = () => { {view === 'hood' && (
    - {liveMapbox ? ( - - ) : ( - setSelectedMapBlock(toOverlay(block))} - /> - )} + setShowRecon(true)} /> - {liveMapbox && ( - setSelectedMapBlock(blockData)} - /> - )} - {!showRecon && !showDrop && ( + { + setSelectedMapBlock(blockData); + flyTo(blockData.lat, blockData.lng, 17.25); + if (blockData.owner === 'player') selectBlock(blockData.id); + }} + /> + +
    + + {placementDraft && ( + + )} +
    + {selectedMapBlock && !placementDraft && ( setSelectedMapBlock(null)} diff --git a/frontend/src/components/map/__tests__/playableMapGeometry.test.ts b/frontend/src/components/map/__tests__/playableMapGeometry.test.ts new file mode 100644 index 0000000..9f801c4 --- /dev/null +++ b/frontend/src/components/map/__tests__/playableMapGeometry.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import { blockFootprint, toFeatureCollection } from '../BlockOverlay'; +import { buildPlacementGeoJson } from '../TacticalPlacementOverlay'; +import type { BlockData, BlockZone } from '../../../types/block.types'; + +function zone(x: number, y: number, overrides: Partial = {}): BlockZone { + return { + x, + y, + zoneType: y === 0 ? 'street' : 'sidewalk', + incomeModifier: 50, + exposureRisk: y === 0 ? 80 : 35, + coverScore: y === 0 ? 0.1 : 0.45, + passable: true, + occupantId: null, + ...overrides, + }; +} + +function block(): BlockData { + return { + id: 'las-olas-home', + address: '1208 W Las Olas Blvd, Fort Lauderdale, FL 33312', + lat: 26.1201, + lng: -80.1348, + owner: 'player', + grid: [ + [zone(0, 0), zone(1, 0)], + [zone(0, 1), zone(1, 1, { occupantId: 'crew-1' })], + ], + placements: [{ + memberId: 'crew-1', + memberName: 'Scout', + role: 'lookout', + x: 1, + y: 1, + zoneType: 'sidewalk', + incomePerTick: 0, + exposureRisk: 35, + level: 1, + health: 100, + }], + incomePerTick: 0, + heat: 1, + morale: 80, + members: 1, + viewMode: 'topdown', + pendingIncome: 0, + }; +} + +describe('playable map geometry', () => { + it('builds a closed, non-zero fictionalized block footprint around the selected coordinates', () => { + const polygon = blockFootprint(26.1201, -80.1348); + expect(polygon).toHaveLength(5); + expect(polygon[0]).toEqual(polygon[4]); + expect(polygon[0][0]).toBeLessThan(polygon[1][0]); + expect(polygon[0][1]).toBeLessThan(polygon[3][1]); + }); + + it('marks the selected block consistently on both footprint and pin features', () => { + const collection = toFeatureCollection([block()], 'las-olas-home'); + expect(collection.features).toHaveLength(2); + expect(collection.features.every((feature) => feature.properties.selected === 1)).toBe(true); + expect(collection.features.map((feature) => feature.properties.kind).sort()).toEqual(['footprint', 'pin']); + }); + + it('anchors every tactical cell and current member to the selected block', () => { + const collection = buildPlacementGeoJson(block()); + const cells = collection.features.filter((feature) => feature.properties.kind === 'cell'); + const members = collection.features.filter((feature) => feature.properties.kind === 'member'); + + expect(cells).toHaveLength(4); + expect(members).toHaveLength(1); + const cellProperties = cells.map((feature) => feature.properties as { + x: number; + y: number; + occupied: number; + }); + expect(cellProperties.find((properties) => properties.x === 1 && properties.y === 1)?.occupied).toBe(1); + + const allCoordinates = cells.flatMap((feature) => ( + feature.geometry.type === 'Polygon' ? feature.geometry.coordinates[0] : [] + )); + expect(allCoordinates.every(([lng, lat]) => Math.abs(lng + 80.1348) < 0.001 && Math.abs(lat - 26.1201) < 0.001)).toBe(true); + }); +}); diff --git a/frontend/src/config/blockDNA.ts b/frontend/src/config/blockDNA.ts index 1959505..9b2b899 100644 --- a/frontend/src/config/blockDNA.ts +++ b/frontend/src/config/blockDNA.ts @@ -84,14 +84,14 @@ export const BLOCK_DNA_LIBRARY: BlockDNA[] = [ { id: 'las-olas-1208', name: '1208 Las Olas', - address: '1208 E Las Olas Blvd', + address: '1208 W Las Olas Blvd', city: 'Fort Lauderdale', state: 'FL', - lat: 26.1201, - lng: -80.1348, + lat: 26.1186239, + lng: -80.1574818, tier: 'mid', tags: ['corner-store', 'open-air'], - flavour: 'Tourist strip with deep pockets. High visibility, high risk.', + flavour: 'Sailboat Bend corridor near the river. Low-rise frontage, palms, water-adjacent escape routes, and neighborhood traffic.', zoneOverrides: { 0: 'street', 1: 'curb', diff --git a/frontend/src/config/mapbox.config.ts b/frontend/src/config/mapbox.config.ts index 7a84b9a..a5fada5 100644 --- a/frontend/src/config/mapbox.config.ts +++ b/frontend/src/config/mapbox.config.ts @@ -218,7 +218,7 @@ export const CITY_CONFIGS: Record = { east: -80.0500, west: -80.3500, }, - centerCoords: { lat: 26.1224, lng: -80.1373 }, + centerCoords: { lat: 26.1186239, lng: -80.1574818 }, timezone: 'America/New_York', gangStyle: { primaryColor: '#0a0a1a', diff --git a/frontend/src/config/mapboxToken.ts b/frontend/src/config/mapboxToken.ts index c21412d..14192c5 100644 --- a/frontend/src/config/mapboxToken.ts +++ b/frontend/src/config/mapboxToken.ts @@ -30,6 +30,6 @@ export function isUsableMapboxToken(token: string = getMapboxToken()): boolean { return lower.startsWith('pk.') || lower.startsWith('sk.'); } -export const LAS_OLAS_CENTER: [number, number] = [-80.1373, 26.1224]; -export const LAS_OLAS_LAT = 26.1224; -export const LAS_OLAS_LNG = -80.1373; +export const LAS_OLAS_CENTER: [number, number] = [-80.1574818, 26.1186239]; +export const LAS_OLAS_LAT = 26.1186239; +export const LAS_OLAS_LNG = -80.1574818; diff --git a/frontend/src/utils/demoSeed.ts b/frontend/src/utils/demoSeed.ts index 45b78dc..97f5e6c 100644 --- a/frontend/src/utils/demoSeed.ts +++ b/frontend/src/utils/demoSeed.ts @@ -218,9 +218,9 @@ export function applyDemoSeed(): void { const demoBlock = { id: DEMO_BLOCK_ID, - address: '1208 W Las Olas Blvd, Fort Lauderdale, FL', - lat: 26.1224, - lng: -80.1373, + address: '1208 W Las Olas Blvd, Fort Lauderdale, FL 33312', + lat: 26.1186239, + lng: -80.1574818, owner: 'player' as const, ownerGangName: 'The Demo Crew', grid,