2025-11-15 15:07:40 -05:00
|
|
|
# Phase 6: Fog of War + Scratch Map + Advanced Features
|
|
|
|
|
|
|
|
|
|
**Timeline**: Week 6
|
|
|
|
|
**Goal**: Add advanced visualization layers and keyboard shortcuts
|
|
|
|
|
**Dependencies**: Phases 1-5 complete
|
|
|
|
|
**Status**: Ready for implementation
|
|
|
|
|
|
|
|
|
|
## 🎯 Phase Objectives
|
|
|
|
|
|
|
|
|
|
Build on Phases 1-5 by adding:
|
|
|
|
|
- ✅ Fog of war layer (canvas-based)
|
|
|
|
|
- ✅ Scratch map (visited countries)
|
|
|
|
|
- ✅ Keyboard shortcuts
|
|
|
|
|
- ✅ Centralized click handler
|
|
|
|
|
- ✅ Toast notifications
|
|
|
|
|
- ✅ E2E tests
|
|
|
|
|
|
|
|
|
|
**Deploy Decision**: 100% feature parity with V1, all visualization features complete.
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 📋 Features Checklist
|
|
|
|
|
|
|
|
|
|
- [ ] Fog of war layer with canvas overlay
|
|
|
|
|
- [ ] Scratch map highlighting visited countries
|
|
|
|
|
- [ ] Keyboard shortcuts (arrows, +/-, L, S, F, Esc)
|
|
|
|
|
- [ ] Unified click handler for all features
|
|
|
|
|
- [ ] Toast notification system
|
|
|
|
|
- [ ] Country detection from points
|
|
|
|
|
- [ ] E2E tests passing
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🏗️ New Files (Phase 6)
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
app/javascript/maps_v2/
|
|
|
|
|
├── layers/
|
|
|
|
|
│ ├── fog_layer.js # NEW: Fog of war
|
|
|
|
|
│ └── scratch_layer.js # NEW: Visited countries
|
|
|
|
|
├── controllers/
|
|
|
|
|
│ ├── keyboard_shortcuts_controller.js # NEW: Keyboard nav
|
|
|
|
|
│ └── click_handler_controller.js # NEW: Unified clicks
|
|
|
|
|
├── components/
|
|
|
|
|
│ └── toast.js # NEW: Notifications
|
|
|
|
|
└── utils/
|
|
|
|
|
└── country_boundaries.js # NEW: Country polygons
|
|
|
|
|
|
|
|
|
|
e2e/v2/
|
2025-11-20 16:36:58 -05:00
|
|
|
└── phase-6-advanced.spec.js # NEW: E2E tests
|
2025-11-15 15:07:40 -05:00
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.1 Fog Layer
|
|
|
|
|
|
|
|
|
|
Canvas-based fog of war effect.
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/layers/fog_layer.js`
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
import { BaseLayer } from './base_layer'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Fog of war layer
|
|
|
|
|
* Shows explored vs unexplored areas using canvas
|
|
|
|
|
*/
|
|
|
|
|
export class FogLayer extends BaseLayer {
|
|
|
|
|
constructor(map, options = {}) {
|
|
|
|
|
super(map, { id: 'fog', ...options })
|
|
|
|
|
this.canvas = null
|
|
|
|
|
this.ctx = null
|
|
|
|
|
this.clearRadius = options.clearRadius || 1000 // meters
|
|
|
|
|
this.points = []
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
add(data) {
|
|
|
|
|
this.points = data.features || []
|
|
|
|
|
this.createCanvas()
|
|
|
|
|
this.render()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
update(data) {
|
|
|
|
|
this.points = data.features || []
|
|
|
|
|
this.render()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createCanvas() {
|
|
|
|
|
if (this.canvas) return
|
|
|
|
|
|
|
|
|
|
// Create canvas overlay
|
|
|
|
|
this.canvas = document.createElement('canvas')
|
|
|
|
|
this.canvas.className = 'fog-canvas'
|
|
|
|
|
this.canvas.style.position = 'absolute'
|
|
|
|
|
this.canvas.style.top = '0'
|
|
|
|
|
this.canvas.style.left = '0'
|
|
|
|
|
this.canvas.style.pointerEvents = 'none'
|
|
|
|
|
this.canvas.style.zIndex = '10'
|
|
|
|
|
|
|
|
|
|
this.ctx = this.canvas.getContext('2d')
|
|
|
|
|
|
|
|
|
|
// Add to map container
|
|
|
|
|
const mapContainer = this.map.getContainer()
|
|
|
|
|
mapContainer.appendChild(this.canvas)
|
|
|
|
|
|
|
|
|
|
// Update on map move/zoom
|
|
|
|
|
this.map.on('move', () => this.render())
|
|
|
|
|
this.map.on('zoom', () => this.render())
|
|
|
|
|
this.map.on('resize', () => this.resizeCanvas())
|
|
|
|
|
|
|
|
|
|
this.resizeCanvas()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
resizeCanvas() {
|
|
|
|
|
const container = this.map.getContainer()
|
|
|
|
|
this.canvas.width = container.offsetWidth
|
|
|
|
|
this.canvas.height = container.offsetHeight
|
|
|
|
|
this.render()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
render() {
|
|
|
|
|
if (!this.canvas || !this.ctx) return
|
|
|
|
|
|
|
|
|
|
const { width, height } = this.canvas
|
|
|
|
|
|
|
|
|
|
// Clear canvas
|
|
|
|
|
this.ctx.clearRect(0, 0, width, height)
|
|
|
|
|
|
|
|
|
|
// Draw fog
|
|
|
|
|
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'
|
|
|
|
|
this.ctx.fillRect(0, 0, width, height)
|
|
|
|
|
|
|
|
|
|
// Clear circles around points
|
|
|
|
|
this.ctx.globalCompositeOperation = 'destination-out'
|
|
|
|
|
|
|
|
|
|
this.points.forEach(feature => {
|
|
|
|
|
const coords = feature.geometry.coordinates
|
|
|
|
|
const point = this.map.project(coords)
|
|
|
|
|
|
|
|
|
|
// Calculate pixel radius based on zoom
|
|
|
|
|
const metersPerPixel = this.getMetersPerPixel(coords[1])
|
|
|
|
|
const radiusPixels = this.clearRadius / metersPerPixel
|
|
|
|
|
|
|
|
|
|
this.ctx.beginPath()
|
|
|
|
|
this.ctx.arc(point.x, point.y, radiusPixels, 0, Math.PI * 2)
|
|
|
|
|
this.ctx.fill()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.ctx.globalCompositeOperation = 'source-over'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getMetersPerPixel(latitude) {
|
|
|
|
|
const earthCircumference = 40075017 // meters
|
|
|
|
|
const latitudeRadians = latitude * Math.PI / 180
|
|
|
|
|
return earthCircumference * Math.cos(latitudeRadians) / (256 * Math.pow(2, this.map.getZoom()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
remove() {
|
|
|
|
|
if (this.canvas) {
|
|
|
|
|
this.canvas.remove()
|
|
|
|
|
this.canvas = null
|
|
|
|
|
this.ctx = null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggle(visible = !this.visible) {
|
|
|
|
|
this.visible = visible
|
|
|
|
|
if (this.canvas) {
|
|
|
|
|
this.canvas.style.display = visible ? 'block' : 'none'
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getLayerConfigs() {
|
|
|
|
|
return [] // Canvas layer doesn't use MapLibre layers
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getSourceConfig() {
|
|
|
|
|
return null
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.2 Scratch Layer
|
|
|
|
|
|
|
|
|
|
Highlight visited countries.
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/layers/scratch_layer.js`
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
import { BaseLayer } from './base_layer'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Scratch map layer
|
|
|
|
|
* Highlights countries that have been visited
|
|
|
|
|
*/
|
|
|
|
|
export class ScratchLayer extends BaseLayer {
|
|
|
|
|
constructor(map, options = {}) {
|
|
|
|
|
super(map, { id: 'scratch', ...options })
|
|
|
|
|
this.visitedCountries = new Set()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async add(data) {
|
|
|
|
|
// Calculate visited countries from points
|
|
|
|
|
const points = data.features || []
|
|
|
|
|
this.visitedCountries = await this.detectCountries(points)
|
|
|
|
|
|
|
|
|
|
// Load country boundaries
|
|
|
|
|
await this.loadCountryBoundaries()
|
|
|
|
|
|
|
|
|
|
super.add(this.createCountriesGeoJSON())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async loadCountryBoundaries() {
|
|
|
|
|
// Load simplified country boundaries from CDN
|
|
|
|
|
const response = await fetch('https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json')
|
|
|
|
|
const data = await response.json()
|
|
|
|
|
|
|
|
|
|
// Convert TopoJSON to GeoJSON
|
|
|
|
|
this.countries = topojson.feature(data, data.objects.countries)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async detectCountries(points) {
|
|
|
|
|
// This would use reverse geocoding or point-in-polygon
|
|
|
|
|
// For now, return empty set
|
|
|
|
|
// TODO: Implement country detection
|
|
|
|
|
return new Set()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
createCountriesGeoJSON() {
|
|
|
|
|
if (!this.countries) {
|
|
|
|
|
return { type: 'FeatureCollection', features: [] }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const visitedFeatures = this.countries.features.filter(country => {
|
|
|
|
|
const countryCode = country.properties.iso_a2 || country.id
|
|
|
|
|
return this.visitedCountries.has(countryCode)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: visitedFeatures
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getSourceConfig() {
|
|
|
|
|
return {
|
|
|
|
|
type: 'geojson',
|
|
|
|
|
data: this.data || { type: 'FeatureCollection', features: [] }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getLayerConfigs() {
|
|
|
|
|
return [
|
|
|
|
|
{
|
|
|
|
|
id: this.id,
|
|
|
|
|
type: 'fill',
|
|
|
|
|
source: this.sourceId,
|
|
|
|
|
paint: {
|
|
|
|
|
'fill-color': '#fbbf24',
|
|
|
|
|
'fill-opacity': 0.3
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: `${this.id}-outline`,
|
|
|
|
|
type: 'line',
|
|
|
|
|
source: this.sourceId,
|
|
|
|
|
paint: {
|
|
|
|
|
'line-color': '#f59e0b',
|
|
|
|
|
'line-width': 1
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
getLayerIds() {
|
|
|
|
|
return [this.id, `${this.id}-outline`]
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.3 Keyboard Shortcuts Controller
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/controllers/keyboard_shortcuts_controller.js`
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
import { Controller } from '@hotwired/stimulus'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Keyboard shortcuts controller
|
|
|
|
|
* Handles keyboard navigation and shortcuts
|
|
|
|
|
*/
|
|
|
|
|
export default class extends Controller {
|
|
|
|
|
static outlets = ['map', 'settingsPanel', 'layerControls']
|
|
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
document.addEventListener('keydown', this.handleKeydown)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
document.removeEventListener('keydown', this.handleKeydown)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleKeydown = (e) => {
|
|
|
|
|
// Ignore if typing in input
|
|
|
|
|
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!this.hasMapOutlet) return
|
|
|
|
|
|
|
|
|
|
switch (e.key) {
|
|
|
|
|
// Pan map
|
|
|
|
|
case 'ArrowUp':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.panMap(0, -50)
|
|
|
|
|
break
|
|
|
|
|
case 'ArrowDown':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.panMap(0, 50)
|
|
|
|
|
break
|
|
|
|
|
case 'ArrowLeft':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.panMap(-50, 0)
|
|
|
|
|
break
|
|
|
|
|
case 'ArrowRight':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.panMap(50, 0)
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Zoom
|
|
|
|
|
case '+':
|
|
|
|
|
case '=':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.zoomIn()
|
|
|
|
|
break
|
|
|
|
|
case '-':
|
|
|
|
|
case '_':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.zoomOut()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Toggle layers
|
|
|
|
|
case 'l':
|
|
|
|
|
case 'L':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.toggleLayerControls()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Toggle settings
|
|
|
|
|
case 's':
|
|
|
|
|
case 'S':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.toggleSettings()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Toggle fullscreen
|
|
|
|
|
case 'f':
|
|
|
|
|
case 'F':
|
|
|
|
|
e.preventDefault()
|
|
|
|
|
this.toggleFullscreen()
|
|
|
|
|
break
|
|
|
|
|
|
|
|
|
|
// Escape - close dialogs
|
|
|
|
|
case 'Escape':
|
|
|
|
|
this.closeDialogs()
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
panMap(x, y) {
|
|
|
|
|
this.mapOutlet.map.panBy([x, y], {
|
|
|
|
|
duration: 300
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zoomIn() {
|
|
|
|
|
this.mapOutlet.map.zoomIn({ duration: 300 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
zoomOut() {
|
|
|
|
|
this.mapOutlet.map.zoomOut({ duration: 300 })
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleLayerControls() {
|
|
|
|
|
// Show/hide layer controls
|
|
|
|
|
const controls = document.querySelector('.layer-controls')
|
|
|
|
|
if (controls) {
|
|
|
|
|
controls.classList.toggle('hidden')
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleSettings() {
|
|
|
|
|
if (this.hasSettingsPanelOutlet) {
|
|
|
|
|
this.settingsPanelOutlet.toggle()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
toggleFullscreen() {
|
|
|
|
|
if (!document.fullscreenElement) {
|
|
|
|
|
document.documentElement.requestFullscreen()
|
|
|
|
|
} else {
|
|
|
|
|
document.exitFullscreen()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
closeDialogs() {
|
|
|
|
|
// Close all open dialogs
|
|
|
|
|
if (this.hasSettingsPanelOutlet) {
|
|
|
|
|
this.settingsPanelOutlet.close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.4 Click Handler Controller
|
|
|
|
|
|
|
|
|
|
Centralized feature click handling.
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/controllers/click_handler_controller.js`
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
import { Controller } from '@hotwired/stimulus'
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Centralized click handler
|
|
|
|
|
* Detects which feature was clicked and shows appropriate popup
|
|
|
|
|
*/
|
|
|
|
|
export default class extends Controller {
|
|
|
|
|
static outlets = ['map']
|
|
|
|
|
|
|
|
|
|
connect() {
|
|
|
|
|
if (this.hasMapOutlet) {
|
|
|
|
|
this.mapOutlet.map.on('click', this.handleMapClick)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
disconnect() {
|
|
|
|
|
if (this.hasMapOutlet) {
|
|
|
|
|
this.mapOutlet.map.off('click', this.handleMapClick)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleMapClick = (e) => {
|
|
|
|
|
const features = this.mapOutlet.map.queryRenderedFeatures(e.point)
|
|
|
|
|
|
|
|
|
|
if (features.length === 0) return
|
|
|
|
|
|
|
|
|
|
// Priority order for overlapping features
|
|
|
|
|
const priorities = [
|
|
|
|
|
'photos',
|
|
|
|
|
'visits',
|
|
|
|
|
'points',
|
|
|
|
|
'areas-fill',
|
|
|
|
|
'routes',
|
|
|
|
|
'tracks'
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const layerId of priorities) {
|
|
|
|
|
const feature = features.find(f => f.layer.id === layerId)
|
|
|
|
|
if (feature) {
|
|
|
|
|
this.handleFeatureClick(feature, e)
|
|
|
|
|
break
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
handleFeatureClick(feature, e) {
|
|
|
|
|
const layerId = feature.layer.id
|
|
|
|
|
const coordinates = e.lngLat
|
|
|
|
|
|
|
|
|
|
// Dispatch custom event for specific feature type
|
|
|
|
|
this.dispatch('feature-clicked', {
|
|
|
|
|
detail: {
|
|
|
|
|
layerId,
|
|
|
|
|
feature,
|
|
|
|
|
coordinates
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.5 Toast Component
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/components/toast.js`
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
/**
|
|
|
|
|
* Toast notification system
|
|
|
|
|
*/
|
|
|
|
|
export class Toast {
|
|
|
|
|
static container = null
|
|
|
|
|
|
|
|
|
|
static init() {
|
|
|
|
|
if (this.container) return
|
|
|
|
|
|
|
|
|
|
this.container = document.createElement('div')
|
|
|
|
|
this.container.className = 'toast-container'
|
|
|
|
|
this.container.style.cssText = `
|
|
|
|
|
position: fixed;
|
|
|
|
|
top: 20px;
|
|
|
|
|
right: 20px;
|
|
|
|
|
z-index: 9999;
|
|
|
|
|
display: flex;
|
|
|
|
|
flex-direction: column;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
`
|
|
|
|
|
document.body.appendChild(this.container)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Show toast notification
|
|
|
|
|
* @param {string} message
|
|
|
|
|
* @param {string} type - 'success', 'error', 'info', 'warning'
|
|
|
|
|
* @param {number} duration - Duration in ms
|
|
|
|
|
*/
|
|
|
|
|
static show(message, type = 'info', duration = 3000) {
|
|
|
|
|
this.init()
|
|
|
|
|
|
|
|
|
|
const toast = document.createElement('div')
|
|
|
|
|
toast.className = `toast toast-${type}`
|
|
|
|
|
toast.textContent = message
|
|
|
|
|
|
|
|
|
|
toast.style.cssText = `
|
|
|
|
|
padding: 12px 20px;
|
|
|
|
|
background: ${this.getBackgroundColor(type)};
|
|
|
|
|
color: white;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 500;
|
|
|
|
|
max-width: 300px;
|
|
|
|
|
animation: slideIn 0.3s ease-out;
|
|
|
|
|
`
|
|
|
|
|
|
|
|
|
|
this.container.appendChild(toast)
|
|
|
|
|
|
|
|
|
|
// Auto dismiss
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.style.animation = 'slideOut 0.3s ease-out'
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
toast.remove()
|
|
|
|
|
}, 300)
|
|
|
|
|
}, duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static getBackgroundColor(type) {
|
|
|
|
|
const colors = {
|
|
|
|
|
success: '#22c55e',
|
|
|
|
|
error: '#ef4444',
|
|
|
|
|
warning: '#f59e0b',
|
|
|
|
|
info: '#3b82f6'
|
|
|
|
|
}
|
|
|
|
|
return colors[type] || colors.info
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static success(message, duration) {
|
|
|
|
|
this.show(message, 'success', duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static error(message, duration) {
|
|
|
|
|
this.show(message, 'error', duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static warning(message, duration) {
|
|
|
|
|
this.show(message, 'warning', duration)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
static info(message, duration) {
|
|
|
|
|
this.show(message, 'info', duration)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add CSS animations
|
|
|
|
|
const style = document.createElement('style')
|
|
|
|
|
style.textContent = `
|
|
|
|
|
@keyframes slideIn {
|
|
|
|
|
from {
|
|
|
|
|
transform: translateX(400px);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
to {
|
|
|
|
|
transform: translateX(0);
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@keyframes slideOut {
|
|
|
|
|
from {
|
|
|
|
|
transform: translateX(0);
|
|
|
|
|
opacity: 1;
|
|
|
|
|
}
|
|
|
|
|
to {
|
|
|
|
|
transform: translateX(400px);
|
|
|
|
|
opacity: 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
`
|
|
|
|
|
document.head.appendChild(style)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 6.6 Update Map Controller
|
|
|
|
|
|
|
|
|
|
Add fog and scratch layers.
|
|
|
|
|
|
|
|
|
|
**File**: `app/javascript/maps_v2/controllers/map_controller.js` (add)
|
|
|
|
|
|
|
|
|
|
```javascript
|
|
|
|
|
// Add imports
|
|
|
|
|
import { FogLayer } from '../layers/fog_layer'
|
|
|
|
|
import { ScratchLayer } from '../layers/scratch_layer'
|
|
|
|
|
import { Toast } from '../components/toast'
|
|
|
|
|
|
|
|
|
|
// In loadMapData(), add:
|
|
|
|
|
|
|
|
|
|
// Add fog layer
|
|
|
|
|
if (!this.fogLayer) {
|
|
|
|
|
this.fogLayer = new FogLayer(this.map, {
|
|
|
|
|
clearRadius: 1000,
|
|
|
|
|
visible: false
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
this.fogLayer.add(pointsGeoJSON)
|
|
|
|
|
} else {
|
|
|
|
|
this.fogLayer.update(pointsGeoJSON)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Add scratch layer
|
|
|
|
|
if (!this.scratchLayer) {
|
|
|
|
|
this.scratchLayer = new ScratchLayer(this.map, { visible: false })
|
|
|
|
|
|
|
|
|
|
await this.scratchLayer.add(pointsGeoJSON)
|
|
|
|
|
} else {
|
|
|
|
|
await this.scratchLayer.update(pointsGeoJSON)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Show success toast
|
|
|
|
|
Toast.success(`Loaded ${points.length} points`)
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🧪 E2E Tests
|
|
|
|
|
|
2025-11-20 16:36:58 -05:00
|
|
|
**File**: `e2e/v2/phase-6-advanced.spec.js`
|
2025-11-15 15:07:40 -05:00
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
import { test, expect } from '@playwright/test'
|
|
|
|
|
import { login, waitForMap } from './helpers/setup'
|
|
|
|
|
|
|
|
|
|
test.describe('Phase 6: Advanced Features', () => {
|
|
|
|
|
test.beforeEach(async ({ page }) => {
|
|
|
|
|
await login(page)
|
|
|
|
|
await page.goto('/maps_v2')
|
|
|
|
|
await waitForMap(page)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test.describe('Keyboard Shortcuts', () => {
|
|
|
|
|
test('arrow keys pan map', async ({ page }) => {
|
|
|
|
|
const initialCenter = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getCenter()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('ArrowRight')
|
|
|
|
|
await page.waitForTimeout(500)
|
|
|
|
|
|
|
|
|
|
const newCenter = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getCenter()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(newCenter.lng).toBeGreaterThan(initialCenter.lng)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('+ key zooms in', async ({ page }) => {
|
|
|
|
|
const initialZoom = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getZoom()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('+')
|
|
|
|
|
await page.waitForTimeout(500)
|
|
|
|
|
|
|
|
|
|
const newZoom = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getZoom()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(newZoom).toBeGreaterThan(initialZoom)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('- key zooms out', async ({ page }) => {
|
|
|
|
|
const initialZoom = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getZoom()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
await page.keyboard.press('-')
|
|
|
|
|
await page.waitForTimeout(500)
|
|
|
|
|
|
|
|
|
|
const newZoom = await page.evaluate(() => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getZoom()
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
expect(newZoom).toBeLessThan(initialZoom)
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test('Escape closes dialogs', async ({ page }) => {
|
|
|
|
|
// Open settings
|
|
|
|
|
await page.click('.settings-toggle-btn')
|
|
|
|
|
|
|
|
|
|
const panel = page.locator('.settings-panel-content')
|
|
|
|
|
await expect(panel).toHaveClass(/open/)
|
|
|
|
|
|
|
|
|
|
// Press Escape
|
|
|
|
|
await page.keyboard.press('Escape')
|
|
|
|
|
|
|
|
|
|
await expect(panel).not.toHaveClass(/open/)
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test.describe('Toast Notifications', () => {
|
|
|
|
|
test('toast appears on data load', async ({ page }) => {
|
|
|
|
|
// Reload to trigger toast
|
|
|
|
|
await page.reload()
|
|
|
|
|
await waitForMap(page)
|
|
|
|
|
|
|
|
|
|
// Look for toast
|
|
|
|
|
const toast = page.locator('.toast')
|
|
|
|
|
// Toast may have already disappeared
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
test.describe('Regression Tests', () => {
|
|
|
|
|
test('all previous features still work', async ({ page }) => {
|
|
|
|
|
const layers = [
|
|
|
|
|
'points',
|
|
|
|
|
'routes',
|
|
|
|
|
'heatmap',
|
|
|
|
|
'visits',
|
|
|
|
|
'photos',
|
|
|
|
|
'areas-fill',
|
|
|
|
|
'tracks'
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
for (const layer of layers) {
|
|
|
|
|
const exists = await page.evaluate((l) => {
|
|
|
|
|
const map = window.mapInstance
|
|
|
|
|
return map?.getLayer(l) !== undefined
|
|
|
|
|
}, layer)
|
|
|
|
|
|
|
|
|
|
expect(exists).toBe(true)
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
})
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## ✅ Phase 6 Completion Checklist
|
|
|
|
|
|
|
|
|
|
### Implementation
|
|
|
|
|
- [ ] Created fog_layer.js
|
|
|
|
|
- [ ] Created scratch_layer.js
|
|
|
|
|
- [ ] Created keyboard_shortcuts_controller.js
|
|
|
|
|
- [ ] Created click_handler_controller.js
|
|
|
|
|
- [ ] Created toast.js
|
|
|
|
|
- [ ] Updated map_controller.js
|
|
|
|
|
|
|
|
|
|
### Functionality
|
|
|
|
|
- [ ] Fog of war renders
|
|
|
|
|
- [ ] Scratch map highlights countries
|
|
|
|
|
- [ ] All keyboard shortcuts work
|
|
|
|
|
- [ ] Click handler detects features
|
|
|
|
|
- [ ] Toast notifications appear
|
|
|
|
|
- [ ] 100% V1 feature parity achieved
|
|
|
|
|
|
|
|
|
|
### Testing
|
|
|
|
|
- [ ] All Phase 6 E2E tests pass
|
|
|
|
|
- [ ] Phase 1-5 tests still pass (regression)
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🚀 Deployment
|
|
|
|
|
|
|
|
|
|
```bash
|
|
|
|
|
git checkout -b maps-v2-phase-6
|
|
|
|
|
git add app/javascript/maps_v2/ e2e/v2/
|
|
|
|
|
git commit -m "feat: Maps V2 Phase 6 - Advanced features and 100% parity"
|
|
|
|
|
git push origin maps-v2-phase-6
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
## 🎉 Milestone: 100% Feature Parity!
|
|
|
|
|
|
|
|
|
|
Phase 6 achieves **100% feature parity** with V1. All visualization features are now complete.
|
|
|
|
|
|
|
|
|
|
**What's Next?**
|
|
|
|
|
|
|
|
|
|
**Phase 7**: Add real-time updates via ActionCable and family sharing features.
|