Skip to content

Commit a817d60

Browse files
committed
docs: interactive map feature design
1 parent f217857 commit a817d60

File tree

1 file changed

+284
-0
lines changed

1 file changed

+284
-0
lines changed
Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
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

Comments
 (0)