|
| 1 | +# Interactive Map Feature Design |
| 2 | + |
| 3 | +**Date:** 2025-10-30 |
| 4 | +**Feature:** Interactive location map with MVT tiles |
| 5 | +**Route:** `/map` |
| 6 | + |
| 7 | +## Overview |
| 8 | + |
| 9 | +Add dedicated map page showing all crypto-friendly locations using Maplibre GL JS with custom MVT tiles served from PostGIS. Includes search integration, category filtering, clustering, and location details via bottom drawer. |
| 10 | + |
| 11 | +## Architecture |
| 12 | + |
| 13 | +### Frontend Stack |
| 14 | +- **Maplibre GL JS** - Vector map rendering with MVT support |
| 15 | +- **Vaul Vue** - Bottom drawer for location details |
| 16 | +- **Supercluster** - Marker clustering at high zoom levels |
| 17 | +- **Existing components** - Reuse search input and category filters |
| 18 | + |
| 19 | +### Backend Stack |
| 20 | +- **PostGIS ST_AsMVT()** - Generate MVT tiles from location geometry |
| 21 | +- **Custom API** `/api/tiles/{z}/{x}/{y}.mvt` - Serve vector tiles |
| 22 | +- **NuxtHub caching** - 7-day tile cache with long TTL |
| 23 | +- **Existing search API** - Reuse `/api/search` for map search |
| 24 | + |
| 25 | +### Data Flow |
| 26 | +1. User opens `/map` → Maplibre initializes with OSM base tiles |
| 27 | +2. Map requests location tiles from `/api/tiles/{z}/{x}/{y}.mvt` |
| 28 | +3. PostGIS generates MVT tiles from `locations` table geometry |
| 29 | +4. Tiles cached in NuxtHub with 7-day TTL |
| 30 | +5. Client renders markers with Naka logo + name labels |
| 31 | +6. User searches → Reuse `/api/search` → Highlight matching locations |
| 32 | +7. User clicks marker → Vaul drawer opens with location details |
| 33 | + |
| 34 | +## API Design |
| 35 | + |
| 36 | +### `GET /api/tiles/{z}/{x}/{y}.mvt` |
| 37 | + |
| 38 | +**Parameters:** |
| 39 | +- `z` - Zoom level (10-18, enforced server-side) |
| 40 | +- `x` - Tile X coordinate |
| 41 | +- `y` - Tile Y coordinate |
| 42 | + |
| 43 | +**PostGIS Query:** |
| 44 | +```sql |
| 45 | +SELECT ST_AsMVT(tile, 'locations', 4096, 'geom') |
| 46 | +FROM ( |
| 47 | + SELECT |
| 48 | + uuid, |
| 49 | + name, |
| 50 | + (SELECT array_agg(categoryId) |
| 51 | + FROM location_categories |
| 52 | + WHERE locationUuid = uuid) as categoryIds, |
| 53 | + ST_AsMVTGeom( |
| 54 | + location, |
| 55 | + ST_TileEnvelope(z, x, y), |
| 56 | + 4096, |
| 57 | + 256, -- buffer for label rendering |
| 58 | + true -- clip geometry |
| 59 | + ) AS geom |
| 60 | + FROM locations |
| 61 | + WHERE location && ST_TileEnvelope(z, x, y) |
| 62 | + AND z >= 10 -- min zoom: regional view |
| 63 | + AND z <= 18 -- max zoom: street level |
| 64 | +) AS tile |
| 65 | +``` |
| 66 | + |
| 67 | +**Response:** |
| 68 | +- Content-Type: `application/vnd.mapbox-vector-tile` |
| 69 | +- Cache-Control: `public, max-age=604800` (7 days) |
| 70 | + |
| 71 | +**Tile Layer Structure:** |
| 72 | +```json |
| 73 | +{ |
| 74 | + "locations": { |
| 75 | + "features": [ |
| 76 | + { |
| 77 | + "type": "Feature", |
| 78 | + "geometry": {"type": "Point", "coordinates": [lng, lat]}, |
| 79 | + "properties": { |
| 80 | + "uuid": "...", |
| 81 | + "name": "Naka Lugano", |
| 82 | + "categoryIds": ["restaurant", "cafe"] |
| 83 | + } |
| 84 | + } |
| 85 | + ] |
| 86 | + } |
| 87 | +} |
| 88 | +``` |
| 89 | + |
| 90 | +## Frontend Components |
| 91 | + |
| 92 | +### Page Structure (`/pages/map.vue`) |
| 93 | +``` |
| 94 | +<MapContainer> |
| 95 | + <SearchInput floating top /> |
| 96 | + <CategoryFilters floating top-right /> |
| 97 | + <MaplibreMap> |
| 98 | + <OSMTileLayer /> |
| 99 | + <LocationMVTLayer source="/api/tiles/{z}/{x}/{y}.mvt" /> |
| 100 | + <ClusterLayer /> <!-- zoom < 14 --> |
| 101 | + <MarkerLayer /> <!-- zoom >= 14 --> |
| 102 | + </MaplibreMap> |
| 103 | + <VaulDrawer /> |
| 104 | +</MapContainer> |
| 105 | +``` |
| 106 | + |
| 107 | +### Marker Rendering |
| 108 | +- **Zoom 10-13**: Supercluster groups nearby locations into numbered clusters |
| 109 | +- **Zoom 14-18**: Individual markers with Naka logo + name label |
| 110 | +- **Label collision**: Maplibre's `text-allow-overlap: false` and `text-optional: true` |
| 111 | + |
| 112 | +### Marker Style (Maplibre JSON) |
| 113 | +```json |
| 114 | +{ |
| 115 | + "layout": { |
| 116 | + "icon-image": "naka-logo", |
| 117 | + "icon-size": 0.4, |
| 118 | + "text-field": ["get", "name"], |
| 119 | + "text-size": 10, |
| 120 | + "text-offset": [0, 1.5], |
| 121 | + "text-anchor": "top", |
| 122 | + "text-optional": true |
| 123 | + }, |
| 124 | + "paint": { |
| 125 | + "text-color": "#1F2348", |
| 126 | + "text-halo-color": "#fff", |
| 127 | + "text-halo-width": 2 |
| 128 | + } |
| 129 | +} |
| 130 | +``` |
| 131 | + |
| 132 | +## Filtering & State |
| 133 | + |
| 134 | +### Category Filters |
| 135 | +- Reuse `ToggleGroupRoot` component from main search |
| 136 | +- Selected categories in URL: `/map?categories=restaurant,cafe` |
| 137 | +- Maplibre filter expression: |
| 138 | +```js |
| 139 | +map.setFilter('locations-layer', [ |
| 140 | + 'in', |
| 141 | + ['get', 'categoryIds'], |
| 142 | + ['literal', selectedCategories] |
| 143 | +]) |
| 144 | +``` |
| 145 | + |
| 146 | +### Open Now Filter |
| 147 | +- Client-side only (no server involvement) |
| 148 | +- Fetch all locations' opening hours on page load |
| 149 | +- Calculate open/closed in browser based on current time |
| 150 | +- Apply as additional Maplibre filter layer |
| 151 | + |
| 152 | +### State Management |
| 153 | +- **URL as source of truth**: `/map?q=coffee&categories=cafe&openNow=true&lat=46.01&lng=8.96&zoom=15` |
| 154 | +- Vue composable `useMapState()` for reactive filters |
| 155 | +- Sync map viewport (center, zoom) with URL for shareable links |
| 156 | + |
| 157 | +## Interactions |
| 158 | + |
| 159 | +### Marker Click Flow |
| 160 | +1. User clicks marker → Extract `uuid` from feature properties |
| 161 | +2. Fetch full data from `/api/locations/{uuid}` |
| 162 | +3. Open Vaul drawer with location card (name, address, rating, photo, opening hours) |
| 163 | +4. Drawer includes "Get Directions" (Google Maps link) and "View Details" (navigate to `/locations/{uuid}`) |
| 164 | + |
| 165 | +### Search Integration |
| 166 | +- Search input uses existing `/api/search` endpoint |
| 167 | +- Results highlighted on map by filtering visible features |
| 168 | +- Option: Fly to first result location |
| 169 | +- Clear search shows all markers again |
| 170 | + |
| 171 | +## Error Handling |
| 172 | + |
| 173 | +### Tile Loading |
| 174 | +- Loading state: Maplibre's default loading spinner |
| 175 | +- Tile 404s: Return empty MVT (no error to user) |
| 176 | +- Network errors: Maplibre retries 3x, then shows error icon |
| 177 | +- Zoom constraint violations: Server returns 400 for z < 10 or z > 18 |
| 178 | + |
| 179 | +### Data Fetching |
| 180 | +- Location detail fetch fails: Toast "Unable to load location details" |
| 181 | +- Search API fails: Fallback to showing all markers, display warning banner |
| 182 | +- Opening hours missing: Treat as "hours unknown", exclude from openNow filter |
| 183 | + |
| 184 | +### User Experience |
| 185 | +- Empty states: "No locations found in this area" |
| 186 | +- Geolocation denied: Default to Lugano center (46.0037°N, 8.9511°E, zoom 13) |
| 187 | +- Slow 3G: Tiles cached aggressively, OSM tiles load progressively |
| 188 | + |
| 189 | +## Performance |
| 190 | + |
| 191 | +### Caching |
| 192 | +- **Tile caching**: NuxtHub Blob storage for 7-day cache |
| 193 | +- **CloudFlare edge caching** in production |
| 194 | +- **Debounce viewport changes**: Request new tiles after 150ms map idle |
| 195 | + |
| 196 | +### Optimization |
| 197 | +- **Lazy load Maplibre**: Code-split map library (~450KB) |
| 198 | +- **Marker limit**: Supercluster prevents rendering 1000+ individual markers |
| 199 | +- **Tile generation target**: < 100ms per tile |
| 200 | + |
| 201 | +### Monitoring |
| 202 | +- Log tile generation time in server |
| 203 | +- Track tile cache hit rate |
| 204 | +- Monitor Maplibre WebGL errors |
| 205 | + |
| 206 | +## Implementation Phases |
| 207 | + |
| 208 | +### Phase 1: Core Map Infrastructure |
| 209 | +- Set up `/map` route and page component |
| 210 | +- Install and configure Maplibre GL JS |
| 211 | +- Implement `/api/tiles/{z}/{x}/{y}.mvt` endpoint with PostGIS |
| 212 | +- Add OSM base tiles + custom location MVT layer |
| 213 | +- Basic marker rendering (no clustering yet) |
| 214 | + |
| 215 | +### Phase 2: Search & Filters |
| 216 | +- Float search input on map page (reuse component) |
| 217 | +- Integrate `/api/search` API with map highlighting |
| 218 | +- Add category filter UI (reuse toggle group) |
| 219 | +- Implement client-side filter expressions for Maplibre |
| 220 | +- Add categoryIds to tile properties |
| 221 | + |
| 222 | +### Phase 3: Clustering & Labels |
| 223 | +- Integrate Supercluster for zoom < 14 |
| 224 | +- Configure smart label collision detection |
| 225 | +- Add Naka logo as custom icon |
| 226 | +- Style clusters and individual markers |
| 227 | +- Handle zoom transitions smoothly |
| 228 | + |
| 229 | +### Phase 4: Details & Interactions |
| 230 | +- Install Vaul Vue for bottom drawer |
| 231 | +- Implement marker click → drawer open flow |
| 232 | +- Fetch location details from `/api/locations/{uuid}` |
| 233 | +- Add "Get Directions" and "View Details" actions |
| 234 | +- URL state management for shareability |
| 235 | + |
| 236 | +### Phase 5: Polish & Performance |
| 237 | +- Open now client-side filtering |
| 238 | +- Loading states and error handling |
| 239 | +- Tile caching with NuxtHub |
| 240 | +- Code-split Maplibre bundle |
| 241 | +- Responsive mobile layout |
| 242 | + |
| 243 | +## Design Decisions |
| 244 | + |
| 245 | +### Why Custom MVT Tiles? |
| 246 | +- Best performance for vector rendering |
| 247 | +- Standard approach for interactive maps |
| 248 | +- Efficient caching and bandwidth usage |
| 249 | +- Native Maplibre support |
| 250 | + |
| 251 | +### Why Client-Side Filtering? |
| 252 | +- Simpler implementation (no per-filter tile generation) |
| 253 | +- Tiles are immutable and cacheable |
| 254 | +- Fast filter toggling (no server roundtrip) |
| 255 | +- Trade-off: Slightly larger tiles with categoryIds array (~30% increase) |
| 256 | + |
| 257 | +### Why Separate `/map` Route? |
| 258 | +- Clean separation from search UI |
| 259 | +- Different interaction model (exploration vs search) |
| 260 | +- Easier to optimize each experience independently |
| 261 | +- Simpler state management |
| 262 | + |
| 263 | +### Why Supercluster? |
| 264 | +- Industry standard for marker clustering |
| 265 | +- Efficient performance with thousands of markers |
| 266 | +- Smooth zoom transitions |
| 267 | +- Small bundle size (~15KB) |
| 268 | + |
| 269 | +## Technical Constraints |
| 270 | + |
| 271 | +- **Zoom limits**: z=10 (min) to z=18 (max) - prevents loading too many markers or too wide area |
| 272 | +- **Tile extent**: 4096 (standard MVT resolution) |
| 273 | +- **Buffer**: 256px (prevents label clipping at tile edges) |
| 274 | +- **Max markers without clustering**: ~100 (enforced by Supercluster threshold) |
| 275 | +- **Tile cache TTL**: 7 days (locations rarely change) |
| 276 | + |
| 277 | +## Future Enhancements |
| 278 | + |
| 279 | +- Distance-based sorting using user geolocation |
| 280 | +- Heatmap layer for location density |
| 281 | +- Custom map style with Nimiq branding |
| 282 | +- Offline map caching with service worker |
| 283 | +- Directions routing integration |
| 284 | +- Share specific map view via URL |
0 commit comments