dawarich/app/javascript/maps_v2/PHASE_3_MOBILE.md

40 KiB

Phase 3: Heatmap + Mobile UI

Timeline: Week 3 Goal: Add heatmap visualization and mobile-first UI Dependencies: Phase 1 & 2 complete Status: Ready for implementation

🎯 Phase Objectives

Build on Phases 1 & 2 by adding:

  • Heatmap layer for density visualization
  • Mobile-first bottom sheet UI
  • Touch gesture support (swipe, pinch)
  • Settings panel with preferences
  • Responsive breakpoints
  • E2E tests

Deploy Decision: Users get a mobile-optimized map with density visualization.


📋 Features Checklist

  • Heatmap layer showing point density
  • Bottom sheet UI (collapsed/half/full states)
  • Swipe gestures for bottom sheet
  • Settings panel (map style, clustering options)
  • Responsive layout (mobile vs desktop)
  • Pinch-to-zoom gesture support
  • Touch-optimized controls
  • E2E tests passing

🏗️ New Files (Phase 3)

app/javascript/maps_v2/
├── layers/
│   └── heatmap_layer.js               # NEW: Density heatmap
├── controllers/
│   ├── bottom_sheet_controller.js     # NEW: Mobile bottom sheet
│   └── settings_panel_controller.js   # NEW: Settings UI
└── utils/
    ├── gestures.js                    # NEW: Touch gestures
    └── responsive.js                  # NEW: Breakpoint utilities

app/views/maps_v2/
└── _bottom_sheet.html.erb             # NEW: Bottom sheet partial
└── _settings_panel.html.erb           # NEW: Settings partial

e2e/v2/
└── phase-3-mobile.spec.ts             # NEW: E2E tests

3.1 Heatmap Layer

Density-based visualization using MapLibre heatmap.

File: app/javascript/maps_v2/layers/heatmap_layer.js

import { BaseLayer } from './base_layer'

/**
 * Heatmap layer showing point density
 * Uses MapLibre's native heatmap for performance
 */
export class HeatmapLayer extends BaseLayer {
  constructor(map, options = {}) {
    super(map, { id: 'heatmap', ...options })
    this.radius = options.radius || 20
    this.weight = options.weight || 1
    this.intensity = options.intensity || 1
    this.opacity = options.opacity || 0.6
  }

  getSourceConfig() {
    return {
      type: 'geojson',
      data: this.data || {
        type: 'FeatureCollection',
        features: []
      }
    }
  }

  getLayerConfigs() {
    return [
      {
        id: this.id,
        type: 'heatmap',
        source: this.sourceId,
        paint: {
          // Increase weight as diameter increases
          'heatmap-weight': [
            'interpolate',
            ['linear'],
            ['get', 'weight'],
            0, 0,
            6, 1
          ],

          // Increase intensity as zoom increases
          'heatmap-intensity': [
            'interpolate',
            ['linear'],
            ['zoom'],
            0, this.intensity,
            9, this.intensity * 3
          ],

          // Color ramp from blue to red
          'heatmap-color': [
            'interpolate',
            ['linear'],
            ['heatmap-density'],
            0, 'rgba(33,102,172,0)',
            0.2, 'rgb(103,169,207)',
            0.4, 'rgb(209,229,240)',
            0.6, 'rgb(253,219,199)',
            0.8, 'rgb(239,138,98)',
            1, 'rgb(178,24,43)'
          ],

          // Adjust radius by zoom level
          'heatmap-radius': [
            'interpolate',
            ['linear'],
            ['zoom'],
            0, this.radius,
            9, this.radius * 3
          ],

          // Transition from heatmap to circle layer by zoom level
          'heatmap-opacity': [
            'interpolate',
            ['linear'],
            ['zoom'],
            7, this.opacity,
            9, 0
          ]
        }
      }
    ]
  }

  /**
   * Update intensity
   * @param {number} intensity - 0-2
   */
  setIntensity(intensity) {
    this.intensity = intensity
    this.map.setPaintProperty(this.id, 'heatmap-intensity', [
      'interpolate',
      ['linear'],
      ['zoom'],
      0, intensity,
      9, intensity * 3
    ])
  }

  /**
   * Update radius
   * @param {number} radius - Pixel radius
   */
  setRadius(radius) {
    this.radius = radius
    this.map.setPaintProperty(this.id, 'heatmap-radius', [
      'interpolate',
      ['linear'],
      ['zoom'],
      0, radius,
      9, radius * 3
    ])
  }

  /**
   * Update opacity
   * @param {number} opacity - 0-1
   */
  setOpacity(opacity) {
    this.opacity = opacity
    this.map.setPaintProperty(this.id, 'heatmap-opacity', [
      'interpolate',
      ['linear'],
      ['zoom'],
      7, opacity,
      9, 0
    ])
  }
}

3.2 Touch Gestures Utilities

File: app/javascript/maps_v2/utils/gestures.js

/**
 * Touch gesture utilities
 * Handles swipe, pinch, long-press detection
 */

export class GestureDetector {
  constructor(element, options = {}) {
    this.element = element
    this.threshold = options.threshold || 50
    this.longPressDelay = options.longPressDelay || 500

    this.touchStartX = 0
    this.touchStartY = 0
    this.touchEndX = 0
    this.touchEndY = 0
    this.touchStartTime = 0
    this.longPressTimer = null

    this.onSwipeUp = options.onSwipeUp || null
    this.onSwipeDown = options.onSwipeDown || null
    this.onSwipeLeft = options.onSwipeLeft || null
    this.onSwipeRight = options.onSwipeRight || null
    this.onLongPress = options.onLongPress || null

    this.bind()
  }

  bind() {
    this.element.addEventListener('touchstart', this.handleTouchStart.bind(this), { passive: true })
    this.element.addEventListener('touchend', this.handleTouchEnd.bind(this), { passive: true })
    this.element.addEventListener('touchmove', this.handleTouchMove.bind(this), { passive: true })
  }

  handleTouchStart(e) {
    const touch = e.touches[0]
    this.touchStartX = touch.clientX
    this.touchStartY = touch.clientY
    this.touchStartTime = Date.now()

    // Start long press timer
    if (this.onLongPress) {
      this.longPressTimer = setTimeout(() => {
        this.onLongPress({ x: this.touchStartX, y: this.touchStartY })
      }, this.longPressDelay)
    }
  }

  handleTouchMove(e) {
    // Cancel long press if user moves
    if (this.longPressTimer) {
      clearTimeout(this.longPressTimer)
      this.longPressTimer = null
    }
  }

  handleTouchEnd(e) {
    // Cancel long press
    if (this.longPressTimer) {
      clearTimeout(this.longPressTimer)
      this.longPressTimer = null
    }

    const touch = e.changedTouches[0]
    this.touchEndX = touch.clientX
    this.touchEndY = touch.clientY

    this.detectSwipe()
  }

  detectSwipe() {
    const deltaX = this.touchEndX - this.touchStartX
    const deltaY = this.touchEndY - this.touchStartY
    const absDeltaX = Math.abs(deltaX)
    const absDeltaY = Math.abs(deltaY)

    // Horizontal swipe
    if (absDeltaX > this.threshold && absDeltaX > absDeltaY) {
      if (deltaX > 0) {
        this.onSwipeRight?.({ deltaX, deltaY })
      } else {
        this.onSwipeLeft?.({ deltaX, deltaY })
      }
    }

    // Vertical swipe
    if (absDeltaY > this.threshold && absDeltaY > absDeltaX) {
      if (deltaY > 0) {
        this.onSwipeDown?.({ deltaX, deltaY })
      } else {
        this.onSwipeUp?.({ deltaX, deltaY })
      }
    }
  }

  destroy() {
    if (this.longPressTimer) {
      clearTimeout(this.longPressTimer)
    }
  }
}

3.3 Responsive Utilities

File: app/javascript/maps_v2/utils/responsive.js

/**
 * Responsive breakpoint utilities
 */

export const BREAKPOINTS = {
  mobile: 768,
  tablet: 1024,
  desktop: 1280
}

/**
 * Check if viewport is mobile
 * @returns {boolean}
 */
export function isMobile() {
  return window.innerWidth < BREAKPOINTS.mobile
}

/**
 * Check if viewport is tablet
 * @returns {boolean}
 */
export function isTablet() {
  return window.innerWidth >= BREAKPOINTS.mobile && window.innerWidth < BREAKPOINTS.tablet
}

/**
 * Check if viewport is desktop
 * @returns {boolean}
 */
export function isDesktop() {
  return window.innerWidth >= BREAKPOINTS.desktop
}

/**
 * Get current breakpoint name
 * @returns {'mobile'|'tablet'|'desktop'}
 */
export function getCurrentBreakpoint() {
  if (isMobile()) return 'mobile'
  if (isTablet()) return 'tablet'
  return 'desktop'
}

/**
 * Watch for breakpoint changes
 * @param {Function} callback - Called with breakpoint name
 * @returns {Function} Cleanup function
 */
export function watchBreakpoint(callback) {
  let currentBreakpoint = getCurrentBreakpoint()

  const handler = () => {
    const newBreakpoint = getCurrentBreakpoint()
    if (newBreakpoint !== currentBreakpoint) {
      currentBreakpoint = newBreakpoint
      callback(newBreakpoint)
    }
  }

  window.addEventListener('resize', handler)

  // Cleanup
  return () => window.removeEventListener('resize', handler)
}

3.4 Bottom Sheet Controller

Mobile-first sliding panel with snap points.

File: app/javascript/maps_v2/controllers/bottom_sheet_controller.js

import { Controller } from '@hotwired/stimulus'
import { GestureDetector } from '../utils/gestures'
import { isMobile } from '../utils/responsive'

/**
 * Bottom sheet controller for mobile UI
 * Supports swipe gestures and snap points
 */
export default class extends Controller {
  static targets = ['sheet', 'handle']

  static values = {
    snapPoints: { type: Array, default: [0.15, 0.5, 0.9] }, // Percentages of viewport height
    currentSnap: { type: Number, default: 1 } // Index of current snap point
  }

  connect() {
    // Only enable on mobile
    if (!isMobile()) {
      this.element.style.display = 'none'
      return
    }

    this.isDragging = false
    this.startY = 0
    this.currentY = 0
    this.sheetHeight = 0

    this.setupGestures()
    this.snapToPoint(this.currentSnapValue)
  }

  disconnect() {
    this.gestureDetector?.destroy()
  }

  /**
   * Setup touch gestures
   */
  setupGestures() {
    this.gestureDetector = new GestureDetector(this.sheetTarget, {
      onSwipeUp: () => this.snapToNext(),
      onSwipeDown: () => this.snapToPrevious()
    })

    // Add drag handler for more control
    this.handleTarget.addEventListener('touchstart', this.onTouchStart.bind(this))
    this.handleTarget.addEventListener('touchmove', this.onTouchMove.bind(this))
    this.handleTarget.addEventListener('touchend', this.onTouchEnd.bind(this))
  }

  /**
   * Touch start handler
   */
  onTouchStart(e) {
    this.isDragging = true
    this.startY = e.touches[0].clientY
    this.sheetHeight = this.sheetTarget.offsetHeight

    this.sheetTarget.style.transition = 'none'
  }

  /**
   * Touch move handler
   */
  onTouchMove(e) {
    if (!this.isDragging) return

    this.currentY = e.touches[0].clientY
    const deltaY = this.currentY - this.startY

    // Calculate new height
    const newHeight = this.sheetHeight - deltaY
    const viewportHeight = window.innerHeight
    const percentage = newHeight / viewportHeight

    // Clamp between min and max snap points
    const minSnap = this.snapPointsValue[0]
    const maxSnap = this.snapPointsValue[this.snapPointsValue.length - 1]

    if (percentage >= minSnap && percentage <= maxSnap) {
      this.sheetTarget.style.height = `${percentage * 100}vh`
    }
  }

  /**
   * Touch end handler
   */
  onTouchEnd() {
    if (!this.isDragging) return

    this.isDragging = false
    this.sheetTarget.style.transition = ''

    // Find nearest snap point
    const viewportHeight = window.innerHeight
    const currentHeight = this.sheetTarget.offsetHeight
    const currentPercentage = currentHeight / viewportHeight

    const nearestSnapIndex = this.findNearestSnapPoint(currentPercentage)
    this.snapToPoint(nearestSnapIndex)
  }

  /**
   * Find nearest snap point
   * @param {number} percentage - Current height percentage
   * @returns {number} Snap point index
   */
  findNearestSnapPoint(percentage) {
    let nearestIndex = 0
    let minDiff = Math.abs(this.snapPointsValue[0] - percentage)

    this.snapPointsValue.forEach((snap, index) => {
      const diff = Math.abs(snap - percentage)
      if (diff < minDiff) {
        minDiff = diff
        nearestIndex = index
      }
    })

    return nearestIndex
  }

  /**
   * Snap to specific point
   * @param {number} index - Snap point index
   */
  snapToPoint(index) {
    if (index < 0 || index >= this.snapPointsValue.length) return

    this.currentSnapValue = index
    const percentage = this.snapPointsValue[index]

    this.sheetTarget.style.height = `${percentage * 100}vh`

    // Dispatch event
    this.dispatch('snapped', {
      detail: { index, percentage }
    })
  }

  /**
   * Snap to next point (expand)
   */
  snapToNext() {
    const nextIndex = Math.min(
      this.currentSnapValue + 1,
      this.snapPointsValue.length - 1
    )
    this.snapToPoint(nextIndex)
  }

  /**
   * Snap to previous point (collapse)
   */
  snapToPrevious() {
    const prevIndex = Math.max(this.currentSnapValue - 1, 0)
    this.snapToPoint(prevIndex)
  }

  /**
   * Expand to full height
   */
  expand() {
    this.snapToPoint(this.snapPointsValue.length - 1)
  }

  /**
   * Collapse to minimum
   */
  collapse() {
    this.snapToPoint(0)
  }

  /**
   * Toggle between collapsed and half
   */
  toggle() {
    if (this.currentSnapValue === 0) {
      this.snapToPoint(1) // Half
    } else {
      this.collapse()
    }
  }
}

3.5 Settings Panel Controller

Map configuration and preferences.

File: app/javascript/maps_v2/controllers/settings_panel_controller.js

import { Controller } from '@hotwired/stimulus'

/**
 * Settings panel controller
 * Manages map preferences and configuration
 */
export default class extends Controller {
  static targets = [
    'panel',
    'clusteringToggle',
    'clusterRadiusInput',
    'heatmapIntensityInput',
    'heatmapRadiusInput',
    'mapStyleSelect'
  ]

  static outlets = ['map']

  static values = {
    open: { type: Boolean, default: false }
  }

  connect() {
    this.loadSettings()
  }

  /**
   * Toggle settings panel
   */
  toggle() {
    this.openValue = !this.openValue
    this.panelTarget.classList.toggle('open', this.openValue)
  }

  /**
   * Open settings panel
   */
  open() {
    this.openValue = true
    this.panelTarget.classList.add('open')
  }

  /**
   * Close settings panel
   */
  close() {
    this.openValue = false
    this.panelTarget.classList.remove('open')
  }

  /**
   * Load settings from localStorage
   */
  loadSettings() {
    const settings = this.getStoredSettings()

    if (this.hasClusteringToggleTarget) {
      this.clusteringToggleTarget.checked = settings.clustering !== false
    }

    if (this.hasClusterRadiusInputTarget) {
      this.clusterRadiusInputTarget.value = settings.clusterRadius || 50
    }

    if (this.hasHeatmapIntensityInputTarget) {
      this.heatmapIntensityInputTarget.value = settings.heatmapIntensity || 1
    }

    if (this.hasHeatmapRadiusInputTarget) {
      this.heatmapRadiusInputTarget.value = settings.heatmapRadius || 20
    }

    if (this.hasMapStyleSelectTarget) {
      this.mapStyleSelectTarget.value = settings.mapStyle || 'positron'
    }
  }

  /**
   * Get stored settings
   * @returns {Object}
   */
  getStoredSettings() {
    const stored = localStorage.getItem('maps-v2-settings')
    return stored ? JSON.parse(stored) : {}
  }

  /**
   * Save settings to localStorage
   */
  saveSettings() {
    const settings = {
      clustering: this.hasClusteringToggleTarget ? this.clusteringToggleTarget.checked : true,
      clusterRadius: this.hasClusterRadiusInputTarget ? parseInt(this.clusterRadiusInputTarget.value) : 50,
      heatmapIntensity: this.hasHeatmapIntensityInputTarget ? parseFloat(this.heatmapIntensityInputTarget.value) : 1,
      heatmapRadius: this.hasHeatmapRadiusInputTarget ? parseInt(this.heatmapRadiusInputTarget.value) : 20,
      mapStyle: this.hasMapStyleSelectTarget ? this.mapStyleSelectTarget.value : 'positron'
    }

    localStorage.setItem('maps-v2-settings', JSON.stringify(settings))

    return settings
  }

  /**
   * Handle clustering toggle
   */
  toggleClustering() {
    const settings = this.saveSettings()

    if (this.hasMapOutlet) {
      // Recreate points layer with new clustering setting
      this.mapOutlet.loadMapData()
    }
  }

  /**
   * Handle cluster radius change
   */
  updateClusterRadius() {
    const settings = this.saveSettings()

    if (this.hasMapOutlet) {
      this.mapOutlet.loadMapData()
    }
  }

  /**
   * Handle heatmap intensity change
   */
  updateHeatmapIntensity() {
    const settings = this.saveSettings()

    if (this.hasMapOutlet && this.mapOutlet.heatmapLayer) {
      this.mapOutlet.heatmapLayer.setIntensity(settings.heatmapIntensity)
    }
  }

  /**
   * Handle heatmap radius change
   */
  updateHeatmapRadius() {
    const settings = this.saveSettings()

    if (this.hasMapOutlet && this.mapOutlet.heatmapLayer) {
      this.mapOutlet.heatmapLayer.setRadius(settings.heatmapRadius)
    }
  }

  /**
   * Handle map style change
   */
  changeMapStyle() {
    const settings = this.saveSettings()

    if (this.hasMapOutlet) {
      const styleUrls = {
        positron: 'https://basemaps.cartocdn.com/gl/positron-gl-style/style.json',
        'dark-matter': 'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json',
        voyager: 'https://basemaps.cartocdn.com/gl/voyager-gl-style/style.json'
      }

      const styleUrl = styleUrls[settings.mapStyle] || styleUrls.positron
      this.mapOutlet.map.setStyle(styleUrl)

      // Reload layers after style change
      this.mapOutlet.map.once('styledata', () => {
        this.mapOutlet.loadMapData()
      })
    }
  }

  /**
   * Reset to defaults
   */
  resetToDefaults() {
    localStorage.removeItem('maps-v2-settings')
    this.loadSettings()

    if (this.hasMapOutlet) {
      this.mapOutlet.loadMapData()
    }
  }
}

3.6 Update Map Controller

Add heatmap layer and settings integration.

File: app/javascript/maps_v2/controllers/map_controller.js (update)

// Add at top
import { HeatmapLayer } from '../layers/heatmap_layer'

// In connect() method, add:
connect() {
  this.initializeMap()
  this.initializeAPI()
  this.loadSettings() // NEW
  this.loadMapData()
}

// Add new method:
/**
 * Load settings from localStorage
 * NEW in Phase 3
 */
loadSettings() {
  const stored = localStorage.getItem('maps-v2-settings')
  this.settings = stored ? JSON.parse(stored) : {
    clustering: true,
    clusterRadius: 50,
    heatmapIntensity: 1,
    heatmapRadius: 20,
    mapStyle: 'positron'
  }
}

// Update loadMapData() to add heatmap:
async loadMapData() {
  this.showLoading()

  try {
    const points = await this.api.fetchAllPoints({
      start_at: this.startDateValue,
      end_at: this.endDateValue,
      onProgress: this.updateLoadingProgress.bind(this)
    })

    const pointsGeoJSON = pointsToGeoJSON(points)

    // Update points layer
    if (!this.pointsLayer) {
      this.pointsLayer = new PointsLayer(this.map, {
        clustering: this.settings.clustering,
        clusterRadius: this.settings.clusterRadius
      })

      if (this.map.loaded()) {
        this.pointsLayer.add(pointsGeoJSON)
      } else {
        this.map.on('load', () => {
          this.pointsLayer.add(pointsGeoJSON)
        })
      }
    } else {
      this.pointsLayer.update(pointsGeoJSON)
    }

    // Update routes layer
    const routesGeoJSON = this.pointsToRoutes(points)

    if (!this.routesLayer) {
      this.routesLayer = new RoutesLayer(this.map)

      if (this.map.loaded()) {
        this.routesLayer.add(routesGeoJSON)
      } else {
        this.map.on('load', () => {
          this.routesLayer.add(routesGeoJSON)
        })
      }
    } else {
      this.routesLayer.update(routesGeoJSON)
    }

    // NEW: Add heatmap layer
    if (!this.heatmapLayer) {
      this.heatmapLayer = new HeatmapLayer(this.map, {
        radius: this.settings.heatmapRadius,
        intensity: this.settings.heatmapIntensity,
        visible: false // Hidden by default
      })

      if (this.map.loaded()) {
        this.heatmapLayer.add(pointsGeoJSON)
      } else {
        this.map.on('load', () => {
          this.heatmapLayer.add(pointsGeoJSON)
        })
      }
    } else {
      this.heatmapLayer.update(pointsGeoJSON)
    }

    if (points.length > 0) {
      this.fitMapToBounds(pointsGeoJSON)
    }

  } catch (error) {
    console.error('Failed to load map data:', error)
    alert('Failed to load location data. Please try again.')
  } finally {
    this.hideLoading()
  }
}

3.7 Bottom Sheet Partial

File: app/views/maps_v2/_bottom_sheet.html.erb

<div data-controller="bottom-sheet"
     data-bottom-sheet-snap-points-value="[0.15, 0.5, 0.9]"
     data-bottom-sheet-current-snap-value="1"
     class="bottom-sheet">

  <!-- Handle (drag area) -->
  <div data-bottom-sheet-target="handle" class="bottom-sheet-handle">
    <div class="handle-bar"></div>
  </div>

  <!-- Content -->
  <div data-bottom-sheet-target="sheet" class="bottom-sheet-content">
    <div class="bottom-sheet-header">
      <h3>Map Layers</h3>
    </div>

    <div class="bottom-sheet-body">
      <!-- Layer controls -->
      <div class="layer-list">
        <button data-layer-controls-target="button"
                data-layer="points"
                data-action="click->layer-controls#toggleLayer"
                class="layer-item active">
          <span class="layer-icon">📍</span>
          <span class="layer-name">Points</span>
          <span class="layer-toggle"></span>
        </button>

        <button data-layer-controls-target="button"
                data-layer="routes"
                data-action="click->layer-controls#toggleLayer"
                class="layer-item active">
          <span class="layer-icon">🛣️</span>
          <span class="layer-name">Routes</span>
          <span class="layer-toggle"></span>
        </button>

        <button data-layer-controls-target="button"
                data-layer="heatmap"
                data-action="click->layer-controls#toggleLayer"
                class="layer-item">
          <span class="layer-icon">🔥</span>
          <span class="layer-name">Heatmap</span>
          <span class="layer-toggle"></span>
        </button>
      </div>
    </div>
  </div>
</div>

<style>
  .bottom-sheet {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    background: white;
    border-radius: 16px 16px 0 0;
    box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.1);
    z-index: 100;
    height: 50vh;
    transition: height 0.3s cubic-bezier(0.4, 0, 0.2, 1);
  }

  .bottom-sheet-handle {
    padding: 12px 0;
    cursor: grab;
    display: flex;
    justify-content: center;
  }

  .bottom-sheet-handle:active {
    cursor: grabbing;
  }

  .handle-bar {
    width: 40px;
    height: 4px;
    background: #d1d5db;
    border-radius: 2px;
  }

  .bottom-sheet-content {
    height: calc(100% - 40px);
    overflow-y: auto;
    overflow-x: hidden;
  }

  .bottom-sheet-header {
    padding: 0 20px 16px;
    border-bottom: 1px solid #e5e7eb;
  }

  .bottom-sheet-header h3 {
    margin: 0;
    font-size: 18px;
    font-weight: 600;
    color: #111827;
  }

  .bottom-sheet-body {
    padding: 16px 20px;
  }

  .layer-list {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }

  .layer-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 12px 16px;
    background: #f9fafb;
    border: 2px solid transparent;
    border-radius: 8px;
    font-size: 16px;
    cursor: pointer;
    transition: all 0.2s;
    width: 100%;
    text-align: left;
  }

  .layer-item:hover {
    background: #f3f4f6;
  }

  .layer-item.active {
    background: #eff6ff;
    border-color: #3b82f6;
  }

  .layer-icon {
    font-size: 20px;
  }

  .layer-name {
    flex: 1;
    font-weight: 500;
    color: #374151;
  }

  .layer-item.active .layer-name {
    color: #1e40af;
  }

  .layer-toggle {
    width: 44px;
    height: 24px;
    background: #d1d5db;
    border-radius: 12px;
    position: relative;
    transition: background 0.2s;
  }

  .layer-toggle::after {
    content: '';
    position: absolute;
    width: 20px;
    height: 20px;
    background: white;
    border-radius: 50%;
    top: 2px;
    left: 2px;
    transition: transform 0.2s;
  }

  .layer-item.active .layer-toggle {
    background: #3b82f6;
  }

  .layer-item.active .layer-toggle::after {
    transform: translateX(20px);
  }

  /* Desktop - hide bottom sheet */
  @media (min-width: 768px) {
    .bottom-sheet {
      display: none;
    }
  }
</style>

3.8 Settings Panel Partial

File: app/views/maps_v2/_settings_panel.html.erb

<div data-controller="settings-panel"
     data-settings-panel-map-outlet=".map-wrapper"
     class="settings-panel">

  <!-- Toggle button -->
  <button data-action="click->settings-panel#toggle"
          class="settings-toggle-btn"
          title="Settings">
    ⚙️
  </button>

  <!-- Panel -->
  <div data-settings-panel-target="panel" class="settings-panel-content">
    <div class="settings-header">
      <h3>Map Settings</h3>
      <button data-action="click->settings-panel#close"
              class="close-btn">
        ✕
      </button>
    </div>

    <div class="settings-body">
      <!-- Map Style -->
      <div class="setting-group">
        <label>Map Style</label>
        <select data-settings-panel-target="mapStyleSelect"
                data-action="change->settings-panel#changeMapStyle"
                class="setting-select">
          <option value="positron">Light</option>
          <option value="dark-matter">Dark</option>
          <option value="voyager">Voyager</option>
        </select>
      </div>

      <!-- Clustering -->
      <div class="setting-group">
        <label class="setting-checkbox">
          <input type="checkbox"
                 data-settings-panel-target="clusteringToggle"
                 data-action="change->settings-panel#toggleClustering"
                 checked>
          <span>Enable Point Clustering</span>
        </label>
      </div>

      <!-- Cluster Radius -->
      <div class="setting-group">
        <label>Cluster Radius</label>
        <input type="range"
               data-settings-panel-target="clusterRadiusInput"
               data-action="change->settings-panel#updateClusterRadius"
               min="20"
               max="100"
               value="50"
               class="setting-range">
        <span class="setting-value" data-settings-panel-target="clusterRadiusValue">50</span>
      </div>

      <!-- Heatmap Intensity -->
      <div class="setting-group">
        <label>Heatmap Intensity</label>
        <input type="range"
               data-settings-panel-target="heatmapIntensityInput"
               data-action="change->settings-panel#updateHeatmapIntensity"
               min="0.1"
               max="2"
               step="0.1"
               value="1"
               class="setting-range">
        <span class="setting-value" data-settings-panel-target="heatmapIntensityValue">1.0</span>
      </div>

      <!-- Heatmap Radius -->
      <div class="setting-group">
        <label>Heatmap Radius</label>
        <input type="range"
               data-settings-panel-target="heatmapRadiusInput"
               data-action="change->settings-panel#updateHeatmapRadius"
               min="10"
               max="50"
               value="20"
               class="setting-range">
        <span class="setting-value" data-settings-panel-target="heatmapRadiusValue">20</span>
      </div>

      <!-- Reset Button -->
      <button data-action="click->settings-panel#resetToDefaults"
              class="reset-btn">
        Reset to Defaults
      </button>
    </div>
  </div>
</div>

<style>
  .settings-toggle-btn {
    position: fixed;
    top: 16px;
    right: 16px;
    width: 44px;
    height: 44px;
    background: white;
    border: 2px solid #e5e7eb;
    border-radius: 8px;
    font-size: 20px;
    cursor: pointer;
    z-index: 50;
    transition: all 0.2s;
  }

  .settings-toggle-btn:hover {
    border-color: #3b82f6;
    box-shadow: 0 2px 8px rgba(59, 130, 246, 0.2);
  }

  .settings-panel-content {
    position: fixed;
    top: 0;
    right: -320px;
    width: 320px;
    height: 100vh;
    background: white;
    box-shadow: -4px 0 12px rgba(0, 0, 0, 0.1);
    z-index: 60;
    transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
    overflow-y: auto;
  }

  .settings-panel-content.open {
    right: 0;
  }

  .settings-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 20px;
    border-bottom: 1px solid #e5e7eb;
  }

  .settings-header h3 {
    margin: 0;
    font-size: 18px;
    font-weight: 600;
    color: #111827;
  }

  .close-btn {
    width: 32px;
    height: 32px;
    background: transparent;
    border: none;
    font-size: 20px;
    cursor: pointer;
    color: #6b7280;
    transition: color 0.2s;
  }

  .close-btn:hover {
    color: #111827;
  }

  .settings-body {
    padding: 20px;
  }

  .setting-group {
    margin-bottom: 24px;
  }

  .setting-group label {
    display: block;
    margin-bottom: 8px;
    font-size: 14px;
    font-weight: 500;
    color: #374151;
  }

  .setting-select {
    width: 100%;
    padding: 8px 12px;
    border: 1px solid #d1d5db;
    border-radius: 6px;
    font-size: 14px;
  }

  .setting-checkbox {
    display: flex;
    align-items: center;
    gap: 8px;
    cursor: pointer;
  }

  .setting-checkbox input[type="checkbox"] {
    width: 20px;
    height: 20px;
    cursor: pointer;
  }

  .setting-range {
    width: calc(100% - 60px);
    margin-right: 12px;
  }

  .setting-value {
    display: inline-block;
    width: 48px;
    text-align: right;
    font-size: 14px;
    color: #6b7280;
  }

  .reset-btn {
    width: 100%;
    padding: 10px;
    background: #f3f4f6;
    border: 1px solid #d1d5db;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 500;
    color: #374151;
    cursor: pointer;
    transition: all 0.2s;
  }

  .reset-btn:hover {
    background: #e5e7eb;
  }

  /* Mobile adjustments */
  @media (max-width: 768px) {
    .settings-panel-content {
      width: 100%;
      right: -100%;
    }

    .settings-panel-content.open {
      right: 0;
    }
  }
</style>

3.9 Updated View Template

File: app/views/maps_v2/index.html.erb (update - add bottom sheet and settings)

<div class="maps-v2-container">
  <!-- Map -->
  <div class="map-wrapper"
       data-controller="map date-picker layer-controls"
       data-map-api-key-value="<%= current_api_user.api_key %>"
       data-map-start-date-value="<%= @start_date.iso8601 %>"
       data-map-end-date-value="<%= @end_date.iso8601 %>"
       data-date-picker-start-date-value="<%= @start_date.iso8601 %>"
       data-date-picker-end-date-value="<%= @end_date.iso8601 %>"
       data-date-picker-map-outlet=".map-wrapper"
       data-layer-controls-map-outlet=".map-wrapper">

    <div data-map-target="container" class="map-container"></div>

    <div data-map-target="loading" class="loading-overlay hidden">
      <div class="loading-spinner"></div>
      <div class="loading-text">Loading points...</div>
    </div>

    <!-- Layer Controls (desktop only) -->
    <div class="layer-controls desktop-only">
      <button data-layer-controls-target="button"
              data-layer="points"
              data-action="click->layer-controls#toggleLayer"
              class="layer-button active"
              aria-pressed="true">
        Points
      </button>

      <button data-layer-controls-target="button"
              data-layer="routes"
              data-action="click->layer-controls#toggleLayer"
              class="layer-button active"
              aria-pressed="true">
        Routes
      </button>

      <button data-layer-controls-target="button"
              data-layer="heatmap"
              data-action="click->layer-controls#toggleLayer"
              class="layer-button"
              aria-pressed="false">
        Heatmap
      </button>
    </div>
  </div>

  <!-- Date Navigation Panel (desktop) -->
  <div class="controls-panel desktop-only">
    <!-- [Same as Phase 2] -->
  </div>

  <!-- NEW: Bottom Sheet (mobile only) -->
  <%= render 'maps_v2/bottom_sheet' %>

  <!-- NEW: Settings Panel -->
  <%= render 'maps_v2/settings_panel' %>
</div>

<style>
  /* Add responsive utilities */
  .desktop-only {
    display: block;
  }

  @media (max-width: 768px) {
    .desktop-only {
      display: none;
    }

    .controls-panel {
      display: none;
    }
  }
</style>

🧪 E2E Tests

File: e2e/v2/phase-3-mobile.spec.ts

import { test, expect, devices } from '@playwright/test'
import { login, waitForMap } from './helpers/setup'

test.describe('Phase 3: Heatmap + Mobile UI', () => {
  test.beforeEach(async ({ page }) => {
    await login(page)
    await page.goto('/maps_v2')
    await waitForMap(page)
  })

  test.describe('Heatmap Layer', () => {
    test('heatmap layer exists', async ({ page }) => {
      const hasHeatmap = await page.evaluate(() => {
        const map = window.mapInstance
        return map?.getLayer('heatmap') !== undefined
      })

      expect(hasHeatmap).toBe(true)
    })

    test('heatmap toggle works', async ({ page }) => {
      // Click heatmap button (desktop)
      const heatmapButton = page.locator('button[data-layer="heatmap"]')

      if (await heatmapButton.isVisible()) {
        await heatmapButton.click()

        const isVisible = await page.evaluate(() => {
          const map = window.mapInstance
          return map?.getLayoutProperty('heatmap', 'visibility') === 'visible'
        })

        expect(isVisible).toBe(true)
      }
    })
  })

  test.describe('Settings Panel', () => {
    test('settings panel opens and closes', async ({ page }) => {
      const settingsBtn = page.locator('.settings-toggle-btn')
      await settingsBtn.click()

      const panel = page.locator('.settings-panel-content')
      await expect(panel).toHaveClass(/open/)

      const closeBtn = page.locator('.close-btn')
      await closeBtn.click()

      await expect(panel).not.toHaveClass(/open/)
    })

    test('map style can be changed', async ({ page }) => {
      await page.click('.settings-toggle-btn')

      const styleSelect = page.locator('[data-settings-panel-target="mapStyleSelect"]')
      await styleSelect.selectOption('dark-matter')

      // Wait for style to load
      await page.waitForTimeout(1000)

      // Check localStorage
      const savedStyle = await page.evaluate(() => {
        const settings = JSON.parse(localStorage.getItem('maps-v2-settings') || '{}')
        return settings.mapStyle
      })

      expect(savedStyle).toBe('dark-matter')
    })

    test('clustering can be toggled', async ({ page }) => {
      await page.click('.settings-toggle-btn')

      const clusterToggle = page.locator('[data-settings-panel-target="clusteringToggle"]')
      await clusterToggle.click()

      // Wait for reload
      await waitForMap(page)

      // Check localStorage
      const clustering = await page.evaluate(() => {
        const settings = JSON.parse(localStorage.getItem('maps-v2-settings') || '{}')
        return settings.clustering
      })

      expect(clustering).toBe(false)
    })

    test('heatmap intensity slider works', async ({ page }) => {
      await page.click('.settings-toggle-btn')

      const intensitySlider = page.locator('[data-settings-panel-target="heatmapIntensityInput"]')
      await intensitySlider.fill('1.5')

      const savedIntensity = await page.evaluate(() => {
        const settings = JSON.parse(localStorage.getItem('maps-v2-settings') || '{}')
        return settings.heatmapIntensity
      })

      expect(savedIntensity).toBe(1.5)
    })
  })

  test.describe('Mobile UI', () => {
    test.use({ ...devices['iPhone 12'] })

    test('bottom sheet is visible on mobile', async ({ page }) => {
      await page.goto('/maps_v2')
      await waitForMap(page)

      const bottomSheet = page.locator('.bottom-sheet')
      await expect(bottomSheet).toBeVisible()
    })

    test('bottom sheet can be swiped', async ({ page }) => {
      await page.goto('/maps_v2')
      await waitForMap(page)

      const bottomSheet = page.locator('.bottom-sheet')
      const initialHeight = await bottomSheet.evaluate(el =>
        window.getComputedStyle(el).height
      )

      // Swipe up on handle
      const handle = page.locator('.bottom-sheet-handle')
      await handle.hover()

      // Simulate swipe up
      await page.touchscreen.tap(200, 500)
      await page.touchscreen.tap(200, 200)

      await page.waitForTimeout(500)

      const newHeight = await bottomSheet.evaluate(el =>
        window.getComputedStyle(el).height
      )

      // Height should have changed
      expect(newHeight).not.toBe(initialHeight)
    })

    test('layer controls in bottom sheet work', async ({ page }) => {
      await page.goto('/maps_v2')
      await waitForMap(page)

      // Find points button in bottom sheet
      const pointsButton = page.locator('.bottom-sheet .layer-item[data-layer="points"]')

      if (await pointsButton.isVisible()) {
        await pointsButton.click()

        await expect(pointsButton).not.toHaveClass(/active/)
      }
    })
  })

  test.describe('Responsive Design', () => {
    test('desktop shows layer controls', async ({ page }) => {
      await page.setViewportSize({ width: 1280, height: 720 })
      await page.goto('/maps_v2')
      await waitForMap(page)

      const layerControls = page.locator('.layer-controls.desktop-only')
      await expect(layerControls).toBeVisible()

      const bottomSheet = page.locator('.bottom-sheet')
      // Bottom sheet should be hidden on desktop
      await expect(bottomSheet).toHaveCSS('display', 'none')
    })

    test('mobile hides desktop controls', async ({ page }) => {
      await page.setViewportSize({ width: 375, height: 667 })
      await page.goto('/maps_v2')
      await waitForMap(page)

      const desktopControls = page.locator('.layer-controls.desktop-only')
      await expect(desktopControls).toHaveCSS('display', 'none')

      const bottomSheet = page.locator('.bottom-sheet')
      await expect(bottomSheet).toBeVisible()
    })
  })

  test.describe('Regression Tests', () => {
    test('points layer still works', async ({ page }) => {
      const hasPoints = await page.evaluate(() => {
        const map = window.mapInstance
        const source = map?.getSource('points-source')
        return source && source._data?.features?.length > 0
      })

      expect(hasPoints).toBe(true)
    })

    test('routes layer still works', async ({ page }) => {
      const hasRoutes = await page.evaluate(() => {
        const map = window.mapInstance
        const source = map?.getSource('routes-source')
        return source && source._data?.features?.length > 0
      })

      expect(hasRoutes).toBe(true)
    })

    test('date navigation still works', async ({ page }) => {
      const nextDayBtn = page.locator('button[title="Next Day"]')

      if (await nextDayBtn.isVisible()) {
        await nextDayBtn.click()
        await waitForMap(page)
      }
    })
  })
})

Phase 3 Completion Checklist

Implementation

  • Created heatmap_layer.js
  • Created bottom_sheet_controller.js
  • Created settings_panel_controller.js
  • Created gestures.js
  • Created responsive.js
  • Updated map_controller.js
  • Created bottom sheet partial
  • Created settings panel partial
  • Updated main view template

Functionality

  • Heatmap renders correctly
  • Bottom sheet works on mobile
  • Swipe gestures functional
  • Settings panel opens/closes
  • Settings persist to localStorage
  • Map style changes work
  • Clustering toggle works
  • Responsive breakpoints work

Testing

  • All Phase 3 E2E tests pass
  • Phase 1 tests still pass (regression)
  • Phase 2 tests still pass (regression)
  • Manual mobile testing complete
  • Manual desktop testing complete

Performance

  • Heatmap performs well with large datasets
  • Bottom sheet animations smooth (60fps)
  • Settings changes apply instantly
  • No performance regression

🚀 Deployment

git checkout -b maps-v2-phase-3
git add app/javascript/maps_v2/ app/views/maps_v2/ e2e/v2/
git commit -m "feat: Maps V2 Phase 3 - Heatmap and mobile UI"

# Run all tests (regression)
npx playwright test e2e/v2/phase-1-mvp.spec.ts
npx playwright test e2e/v2/phase-2-routes.spec.ts
npx playwright test e2e/v2/phase-3-mobile.spec.ts

# Deploy to staging
git push origin maps-v2-phase-3

🎉 What's Next?

Phase 4: Add visits and photos layers with search/filter functionality.

User Feedback: Get mobile users to test the bottom sheet and gestures!