diff --git a/README.md b/README.md index 3d5cbca..ff28a6b 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ A custom card that shows person locations on a map with profile pictures and act - Profile pictures as map markers with colored borders based on zones - Activity badges (walking, driving, etc.) from your phone's sensors - Customizable colors and border styles +- Adjustable Marker Sizes (Small, Medium, Large) +- Speed Display in Popups +- Speed-based Activity Prediction ### Zone-based Markers @@ -95,6 +98,7 @@ google_api_key: YOUR_GOOGLE_API_KEY map_type: hybrid # hybrid, satellite, roadmap, or terrain default_zoom: 15 update_interval: 10 +marker_size: medium # small, medium, large marker_border_radius: 50% # 50% for circles, or use px (e.g., 8px) badge_border_radius: 50% debug: false @@ -116,6 +120,7 @@ activities: color: '#2196f3' in_vehicle: color: '#9c27b0' +activity_source: sensor # sensor or speed_predicted ``` ## Configuration Options @@ -128,11 +133,13 @@ activities: | `map_type` | string | `hybrid` | Google Maps type: `hybrid`, `satellite`, `roadmap`, or `terrain` | | `default_zoom` | number | `13` | Initial map zoom level (1-21) | | `update_interval` | number | `10` | Update interval in seconds | +| `marker_size` | string | `medium` | Marker size: `small`, `medium`, or `large` | | `marker_border_radius` | string | `50%` | Border radius for profile pictures | | `badge_border_radius` | string | `50%` | Border radius for activity badges | | `debug` | boolean | `false` | Enable debug mode for troubleshooting | | `zones` | object | See below | Custom zone configurations | | `activities` | object | See below | Custom activity color configurations | +| `activity_source` | string | `sensor` | Activity source: `sensor` or `speed_predicted` | ### Supported Activities @@ -150,3 +157,17 @@ All default to black background with white icons. Your person entities need GPS coordinates (latitude/longitude). Activity sensors come from the [Home Assistant Companion App](https://companion.home-assistant.io/) or similar integrations. For Google Maps, you'll need an API key from [Google Cloud Console](https://console.cloud.google.com/) with billing enabled. OpenStreetMap works out of the box. + +## Notes + +### Speed Prediction Feature +The speed-based activity prediction feature requires GPS position history and works best with frequent updates. The card calculates speed based on position changes over time, so: +- Speed display will appear after at least 2 position updates +- Prediction accuracy improves with more frequent GPS updates (e.g., every 10-30 seconds) +- Activity prediction uses the following thresholds: + - **Still**: < 1 km/h + - **Walking**: 1-7 km/h + - **On Bicycle**: 7-25 km/h + - **In Vehicle**: > 25 km/h + +The speed calculation is performed locally in your browser and uses a Haversine formula for distance calculation between GPS coordinates. diff --git a/docs/specs/technical-spec.md b/docs/specs/technical-spec.md new file mode 100644 index 0000000..f732c39 --- /dev/null +++ b/docs/specs/technical-spec.md @@ -0,0 +1,803 @@ +# Technical Specification: Map Badge Card Enhanced Features + +**Created**: 2025-11-19 +**Planner**: @planner +**Status**: Draft → Ready for Implementation + +## Overview + +This technical specification details the implementation of four enhanced features for the Home Assistant Map Badge Card: + +1. **Adjustable Marker Size** - Configurable marker presets (small/medium/large) via UI +2. **Speed Calculation** - Real-time speed tracking using position history with Haversine formula +3. **Popup Follow Fix** - Ensure info popups remain anchored to moving markers +4. **Predicted Activity** - Speed-based activity inference as alternative to sensor data + +### Objectives + +- [ ] Enable users to customize marker appearance with three size presets +- [ ] Calculate and display accurate travel speed in entity popups +- [ ] Fix popup positioning bugs for moving entities across both map providers +- [ ] Provide fallback activity detection based on movement patterns +- [ ] Maintain 100% backward compatibility with existing configurations +- [ ] Ensure consistent behavior across Leaflet and Google Maps providers + +### Success Criteria + +- [ ] Marker sizes render correctly at 36px, 48px, and 64px dimensions +- [ ] Speed calculations show < 10% error compared to GPS accuracy +- [ ] Popups track marker movement without reposition lag +- [ ] Activity prediction matches documented thresholds (1, 7, 25 km/h) +- [ ] All new configuration options appear in UI editor +- [ ] Existing cards continue functioning without configuration changes +- [ ] Unit test coverage ≥ 80% for new calculation logic +- [ ] Manual testing confirms functionality on both map providers + +## Architecture + +### Component Structure + +``` +Map Badge Card System +├── Main Component (map-badge-card.js) +│ └── ConfigManager (config-manager.js) +│ ├── EntityDataFetcher (entity-data-fetcher.js) ← ADD: Speed calc & history +│ ├── IframeMessenger (iframe-messenger.js) ← UPDATE: Pass marker_size +│ └── Editor UI Layer (editor-ui.js, editor-handlers.js) ← UPDATE: New controls +└── Map Renderer (map-badge-v2.html) ← UPDATE: Dynamic sizes, popup tracking + ├── Leaflet Implementation + └── Google Maps Implementation +``` + +### Technology Stack + +- **Language**: JavaScript ES6 Modules +- **Framework**: Home Assistant Custom Card (Legacy API) +- **Map Libraries**: Leaflet 1.x, Google Maps JavaScript API +- **Key Algorithms**: Haversine formula for distance calculation +- **Browser APIs**: PostMessage for iframe communication + +### Design Patterns + +1. **Configuration Cascade**: Default → User Config → Runtime Override + - Prevents cascading re-renders by batching config updates + - Maintains backward compatibility with optional fields + +2. **Position History Ring Buffer**: Fixed-size array (5 entries) per entity + - Prevents memory leaks from infinite history growth + - Provides sufficient data points for accurate speed averaging + +3. **Data Transformation Pipeline**: Raw HA State → Cached Entity Data → Map Parameters + - Centralizes calculation logic in EntityDataFetcher + - Ensures consistent data format across consumers + +4. **Provider Abstraction**: Unified interface for Leaflet/Google Maps + - Minimizes provider-specific code duplication + - Enables feature parity across implementations + +## Implementation Phases + +### Phase 1: Foundation (Data Structures & Configuration) + +**Objective**: Establish constants, config validation, and parameter passing infrastructure + +**Assigned to**: @coder-mid + +**Tasks**: + +1. **Update Constants** (`src/constants.js`) + - Add `MARKER_SIZES` object with small/medium/large presets (marker width, badge size, popup offset) + - Add `ACTIVITY_THRESHOLDS` object defining speed boundaries (still, walking, cycling, vehicle) + - Extend `DEFAULT_CONFIG` with `marker_size`, `use_predicted_activity`, `activity_source` + - **Complexity**: Simple (data definition only) + - **Dependencies**: None + - **Deliverables**: Updated constants file with 3 new exports + +2. **Update ConfigManager** (`src/config-manager.js`) + - Extend `setConfig()` to validate and store new marker/activity configuration + - Add marker_size to `buildIframeParams()` return value + - Add boolean flag tracking for config change detection + - **Complexity**: Simple (config pass-through, no logic changes) + - **Dependencies**: Phase 1.1 + - **Deliverables**: Modified config manager with new parameter handling + +### Phase 2: Core Features (Calculation & Rendering) + +**Objective**: Implement speed calculation engine and dynamic marker sizing + +**Assigned to**: @coder-senior + +**Tasks**: + +3. **Enhance EntityDataFetcher** (`src/entity-data-fetcher.js`) + - Add `_positionHistory` Map (entityId → PositionEntry[]) + - Implement `_calculateHaversineDistance()` (returns meters) + - Implement `calculateSpeed()` (returns {speed_kmh, speed_mph, time_diff}) + - Implement `_updatePositionHistory()` with ring buffer logic (max 5 entries) + - Implement `predictActivity()` (returns string: 'still', 'walking', 'on_bicycle', 'in_vehicle') + - Modify `fetchEntities()` to calculate/store speed, update history + - Modify `prepareEntityData()` to include speed and predicted_activity fields + - **Complexity**: Medium (requires mathematical accuracy + caching) + - **Dependencies**: Phase 1.1 + - **Deliverables**: Enhanced data fetcher with calculation methods + +4. **Update Map HTML Rendering** (`src/map-badge-v2.html`) + - Parse `marker_size` URL parameter on load + - Create CSS variables: `--marker-size`, `--badge-size`, `--popup-offset` + - Update `.custom-marker-wrapper` CSS to use variables + - Modify Leaflet icon creation to use dynamic `iconSize`, `iconAnchor`, `popupAnchor` + - Modify Google Maps `CustomMarker.draw()` to use dynamic dimensions + - Ensure marker CSS class names remain consistent + - **Complexity**: Medium (requires CSS/JS synchronization) + - **Dependencies**: Phase 2.1, Phase 1.2 + - **Deliverables**: Map renderer supporting 3 size presets + +### Phase 3: UI Integration (Editor Controls) + +**Objective**: Add configuration controls to visual editor + +**Assigned to**: @coder-junior + +**Tasks**: + +5. **Add Marker Size Control** (`src/editor-ui.js`) + - Extend `_generateAppearanceSection()` with `` + - Include three `` entries: small (36px), medium (48px), large (64px) + - Add descriptive helper text below dropdown + - **Complexity**: Simple (HTML template modification) + - **Dependencies**: Phase 2.2 + - **Deliverables**: Editor UI with marker size dropdown + +6. **Add Activity Source Control** (`src/editor-ui.js`) + - Extend appearance section with `` + - Include two options: 'Activity Sensor' and 'Speed-Based Prediction' + - Add multi-line helper text explaining speed thresholds + - **Complexity**: Simple (HTML template modification) + - **Dependencies**: Phase 3.1 + - **Deliverables**: Editor UI with activity source selector + +7. **Attach Editor Event Handlers** (`src/editor-handlers.js`) + - Add `_attachMarkerSizeListener()` method + - Add `_attachActivitySourceListener()` method + - Register both in `attachListeners()` call + - Ensure handlers use `onChange()` callback properly + - **Complexity**: Simple (event listener pattern) + - **Dependencies**: Phase 3.2 + - **Deliverables**: Functional UI controls with state management + +### Phase 4: Popup & Display Logic + +**Objective**: Fix popup anchoring and enhance content with speed data + +**Assigned to**: @coder-mid + +**Tasks**: + +8. **Fix Popup Following Behavior** (`src/map-badge-v2.html`) + - **Leaflet**: Ensure `marker.update()` called when popup open during position change + - **Google Maps**: Store InfoWindow reference per marker, call `setPosition()` on update + - Add position change detection to trigger popup reposition + - Prevent popup flicker during rapid updates + - **Complexity**: Medium (requires timing/animation testing) + - **Dependencies**: Phase 2.2 + - **Deliverables**: Anchored popups that track marker movement + +9. **Enhance Popup HTML** (`src/map-badge-v2.html`) + - Modify `createPopupHTML()` to accept `speedData` parameter + - Add speed display row with icon when `speedData.speed_kmh !== null` + - Format speed as "X.X km/h" (one decimal place) + - Position speed below state, above activity in visual hierarchy + - **Complexity**: Medium (requires styling + conditional rendering) + - **Dependencies**: Phase 4.1 + - **Deliverables**: Richer popup content with speed information + +10. **Implement Activity Selection Logic** (`src/entity-data-fetcher.js`) + - Add `_getActivityToUse(data, config)` helper method + - Respect `config.activity_source` when determining displayed activity + - Return predicted activity when `speed_predicted` selected and speed exists + - Fallback to sensor activity otherwise + - Modify `prepareEntityData()` to use activity selection logic + - **Complexity**: Simple (conditional logic) + - **Dependencies**: Phase 4.2 + - **Deliverables**: Configurable activity source behavior + +### Phase 5: Integration & Dynamic Updates + +**Objective**: Enable runtime config changes and ensure system-wide consistency + +**Assigned to**: @coder-mid + +**Tasks**: + +11. **Extend IframeMessenger** (`src/iframe-messenger.js`) + - Add `markerSize` parameter to `sendConfigUpdate()` + - Include marker_size in PostMessage payload + - Maintain backward compatibility (default to 'medium') + - **Complexity**: Simple (parameter pass-through) + - **Dependencies**: Phase 4.3 + - **Deliverables**: Messenger supporting marker size propagation + +12. **Update Config Change Detection** (`src/map-badge-card.js`) + - Add `'marker_size'` to `visualPropsChanged` check array + - Pass `config.marker_size` to `_messenger.sendConfigUpdate()` + - Ensure config reload triggers re-render with new marker size + - **Complexity**: Simple (property tracking) + - **Dependencies**: Phase 5.1 + - **Deliverables**: Main card component integrated with new features + +13. **Handle Runtime Marker Size Changes** (`src/map-badge-v2.html`) + - Listen for `marker_size` in config-update PostMessage + - Update CSS variables when new size received + - Trigger `updateAllMarkers()` to re-render with new dimensions + - Preserve popup open/close state during re-render toggle + - **Complexity**: Simple (event handler + batch update) + - **Dependencies**: Phase 5.2 + - **Deliverables**: Live marker size adjustment without reload + +## API Contracts + +### Configuration Schema + +**Location**: User config passed to `setConfig()` + +**New Properties**: + +| Name | Type | Required | Default | Description | +|------|------|----------|---------|-------------| +| `marker_size` | string | No | `'medium'` | Marker preset: 'small', 'medium', 'large' | +| `activity_source` | string | No | `'sensor'` | Activity mode: 'sensor' or 'speed_predicted' | +| `use_predicted_activity` | boolean | No | `false` | (Deprecated) Backward compatibility flag | + +**Example Valid Configurations**: + +```yaml +# Minimal config (uses defaults) +type: custom:map-badge-card +entities: + - person: person.john + +# Marker size only +type: custom:map-badge-card +marker_size: large +google_api_key: YOUR_KEY + +# Full feature set +type: custom:map-badge-card +marker_size: medium +activity_source: speed_predicted +entities: + - person: person.john + activity: sensor.john_phone_activity +``` + +### Iframe Parameters + +**Location**: URL query string passed to `map-badge-v2.html` + +**New Parameters**: + +| Parameter | Type | Example | Description | +|-----------|------|---------|-------------| +| `marker_size` | string | `marker_size=large` | Selected size preset | + +**Complete URL Example**: +``` +map-badge-v2.html?marker_size=medium&dark_mode=false&theme_color=%23ff9800&... +``` + +### PostMessage API (Config Updates) + +**Location**: Communication from main card to iframe + +**Enhanced Message Structure**: + +```typescript +interface ConfigUpdateMessage { + type: 'config-update'; + zones: ZoneConfig[]; + activities: ActivityConfig[]; + marker_border_radius: number; + badge_border_radius: number; + marker_size: string; // NEW: Size preset + timestamp: number; +} +``` + +**Sending Example**: +```javascript +// From map-badge-card.js +_messenger.sendConfigUpdate( + config.zones, + config.activities, + config.marker_border_radius, + config.badge_border_radius, + config.marker_size // New parameter +); +``` + +**Receiving Example**: +```javascript +// In map-badge-v2.html +if (event.data.type === 'config-update') { + if (event.data.marker_size) { + applyMarkerSize(event.data.marker_size); + } +} +``` + +## Data Models + +### Marker Size Presets + +**Location**: `src/constants.js` + +```javascript +interface MarkerSize { + marker: number; // Base marker diameter (pixels) + badge: number; // Badge diameter (pixels) + popupOffset: number; // Popup anchor offset (pixels, negative) +} + +export const MARKER_SIZES: Record = { + small: { + marker: 36, + badge: 15, + popupOffset: -52 + }, + medium: { + marker: 48, + badge: 20, + popupOffset: -68 + }, + large: { + marker: 64, + badge: 24, + popupOffset: -88 + } +}; +``` + +**Usage**: +- Leaflet: `iconSize = [marker, marker + 14]` +- Google Maps: `div.style.width = marker + 'px'` +- CSS: `--marker-size: ${marker}px` + +### Activity Thresholds + +**Location**: `src/constants.js` + +```javascript +interface ActivityThresholds { + still: number; // km/h threshold for "still" (upper bound) + walking: number; // km/h threshold for "on_bicycle" (upper bound) + cycling: number; // km/h threshold for "in_vehicle" (upper bound) + vehicle: number; // km/h threshold for "vehicle" (unused, for parity) +} + +export const ACTIVITY_THRESHOLDS: ActivityThresholds = { + still: 1, // < 1 km/h → 'still' + walking: 7, // 1-7 km/h → 'walking' + cycling: 25, // 7-25 km/h → 'on_bicycle' + vehicle: 25 // > 25 km/h → 'in_vehicle' +}; +``` + +**Activity Mapping**: +```javascript +function predictActivity(speedKmh: number): string { + if (speedKmh < ACTIVITY_THRESHOLDS.still) return 'still'; + if (speedKmh < ACTIVITY_THRESHOLDS.walking) return 'walking'; + if (speedKmh < ACTIVITY_THRESHOLDS.cycling) return 'on_bicycle'; + return 'in_vehicle'; +} +``` + +### Position History Entry + +**Location**: Internal to `EntityDataFetcher` + +```typescript +interface PositionEntry { + latitude: number; + longitude: number; + timestamp: number; // Unix timestamp in milliseconds +} + +// Storage structure +private _positionHistory: Map; +``` + +**History Management**: +- Maximum 5 entries per entity (ring buffer) +- Oldest entries removed automatically +- New entries appended with current timestamp +- Speed calculation requires ≥ 2 entries + +### Speed Calculation Result + +**Location**: Calculated in `EntityDataFetcher`, passed to map + +```typescript +interface SpeedData { + speed_kmh: number; // Speed in kilometers per hour + speed_mph: number; // Speed in miles per hour + time_diff: number; // Time difference in seconds + distance_m?: number; // Optional: distance in meters + accuracy?: number; // Optional: GPS accuracy if available +} +``` + +**Calculation Formula**: +```javascript +const distance = haversine(prevLat, prevLon, currLat, currLon); // meters +const timeDiff = (currTime - prevTime) / 1000; // seconds +const speedKmh = (distance / 1000) / (timeDiff / 3600); // km/h +``` + +### Entity Data Output + +**Location**: `EntityDataFetcher.prepareEntityData()` return value + +```typescript +interface EntityDataMap { + [entityId: string]: { + state: string; + attributes: { + friendly_name: string; + latitude: number; + longitude: number; + entity_picture?: string; + // ... other HA attributes + }; + activity: string; // Selected activity ('sensor' or 'speed_predicted') + speed: SpeedData | null; // Calculated speed (null if insufficient history) + predicted_activity: string | null; // Calculated activity (null if no speed) + zone: string | null; + zone_color: string | null; + }; +} +``` + +**Activity Selection Logic**: +```javascript +const activity = config.activity_source === 'speed_predicted' && speedData + ? predictActivity(speedData.speed_kmh) + : sensorActivity; +``` + +## Testing Requirements + +### Unit Tests (Target: 80% coverage) + +**Location**: New test files or existing test structure + +1. **Haversine Distance Calculation** + ```javascript + // Test: Known locations with expected distances + test('haversine: New York to London', () => { + const distance = haversine(40.7128, -74.0060, 51.5074, -0.1278); + expect(distance).toBeCloseTo(5570000, -4); // ~5570km + }); + + // Edge: Same location → distance = 0 + // Edge: Antipodal points → half circumference + ``` + +2. **Speed Calculation** + ```javascript + // Test: Simulated position history + test('speed: 100m in 10 seconds', () => { + const history = [ + { lat: 0, lon: 0, timestamp: 0 }, + { lat: 0.0009, lon: 0, timestamp: 10000 } // ~100m north + ]; + const speed = calculateSpeed('person.test', history[1]); + expect(speed.speed_kmh).toBeCloseTo(36, 1); // 100m/10s = 36 km/h + }); + + // Edge: Single history entry → returns null + // Edge: Zero time difference → returns null + // Edge: Negative coordinates → still calculates correctly + ``` + +3. **Activity Prediction** + ```javascript + // Test: All threshold boundaries + test('predictActivity: walking threshold', () => { + expect(predictActivity(0.5)).toBe('still'); + expect(predictActivity(1)).toBe('still'); // Edge case + expect(predictActivity(1.1)).toBe('walking'); + expect(predictActivity(6.9)).toBe('walking'); + expect(predictActivity(7)).toBe('walking'); // Edge case + expect(predictActivity(7.1)).toBe('on_bicycle'); + expect(predictActivity(24.9)).toBe('on_bicycle'); + expect(predictActivity(25)).toBe('on_bicycle'); // Edge case + expect(predictActivity(25.1)).toBe('in_vehicle'); + }); + + // Edge: Negative speed → should not occur, but handle gracefully + // Edge: Null/undefined speed → returns null + ``` + +4. **Marker Size CSS Application** + ```javascript + // Test: CSS variable setting + test('applyMarkerSize: small preset', () => { + applyMarkerSize('small'); + expect(document.documentElement.style.getPropertyValue('--marker-size')).toBe('36px'); + }); + + // Edge: Invalid preset → defaults to medium + // Edge: Empty string → defaults to medium + ``` + +### Integration Tests + +**Location**: Test card loading with configuration + +1. **Config Propagation Flow** + ```javascript + // Test: marker_size flows through entire system + test('config propagation: marker_size', async () => { + const config = { marker_size: 'large', /* ... */ }; + card.setConfig(config); + await card.updateComplete; + + // Verify iframe URL contains marker_size=large + expect(iframe.src).toContain('marker_size=large'); + + // Verify CSS variable set in iframe + expect(iframe.contentDocument.documentElement.style.getPropertyValue('--marker-size')).toBe('64px'); + }); + ``` + +2. **Position History Persistence** + ```javascript + // Test: History maintained across multiple updates + test('position history: 5 updates', async () => { + const fetcher = new EntityDataFetcher(); + + // Simulate 5 position updates + for (let i = 0; i < 5; i++) { + await fetcher.fetchEntities(); + await sleep(1000); + } + + // Verify history has 5 entries + const history = fetcher._positionHistory['person.test']; + expect(history.length).toBe(5); + + // 6th update should still have 5 (ring buffer) + await fetcher.fetchEntities(); + expect(history.length).toBe(5); + }); + ``` + +3. **Popup Following Behavior** + ```javascript + // Test: Markers + markers move → popup stays anchored + test('popup following: Leaflet', async () => { + const marker = createMarkerOSM(entityData); + marker.bindPopup('Test').openPopup(); + + const initialPopupPos = marker.getPopup().getLatLng(); + + // Move marker + marker.setLatLng([newLat, newLon]); + marker.update(); + + const newPopupPos = marker.getPopup().getLatLng(); + expect(newPopupPos.lat).toBeCloseTo(newLat, 6); + expect(newPopupPos.lng).toBeCloseTo(newLon, 6); + }); + + // Repeat for Google Maps with InfoWindow.setPosition() + ``` + +4. **Activity Override Logic** + ```javascript + // Test: speed_predicted overrides sensor + test('activity source: speed_predicted', () => { + const config = { activity_source: 'speed_predicted' }; + const data = { + activity: { state: 'walking' }, + speed: { speed_kmh: 50 } + }; + + const result = entityDataFetcher.prepareEntityData(config); + expect(result['person.test'].activity).toBe('in_vehicle'); // From speed, not sensor + }); + ``` + +### Manual Testing Scenarios + +**Environment**: Real Home Assistant instance + mobile device + +1. **Marker Size Visualization** + - [ ] Configure card with `marker_size: small` → verify 36px markers + - [ ] Configure card with `marker_size: medium` → verify 48px markers (default) + - [ ] Configure card with `marker_size: large` → verify 64px markers + - [ ] Test both Leaflet and Google Maps providers + - [ ] Verify badges scale proportionally within markers + - [ ] Check popup positioning aligns correctly at all sizes + +2. **Speed Calculation Accuracy** + - [ ] Walk 100m at constant speed, verify calculated speed matches expected + - [ ] Drive at 50 km/h, verify speed display within ±2 km/h + - [ ] First card load shows no speed (waiting for history) + - [ ] After 2+ position updates, speed appears in popup + - [ ] Check speed updates smoothly (not jumping erratically) + +3. **Popup Tracking Behavior** + - [ ] Open popup, move ~10 meters → popup should move with marker + - [ ] Rapid movement (driving) → popup stays anchored without lag + - [ ] Switch between map providers → behavior consistent + - [ ] Popup close/reopen during movement → re-anchors correctly + - [ ] No visual flicker or reposition artifacts + +4. **Activity Prediction Validation** + - [ ] Stand still (< 1 km/h) → shows 'still' + - [ ] Walk slowly (3 km/h) → shows 'walking' + - [ ] Cycle (15 km/h) → shows 'on_bicycle' + - [ ] Drive (60 km/h) → shows 'in_vehicle' + - [ ] Switch between sensor and speed_predicted → activity updates + - [ ] Speed-based mode works without activity sensor configured + +5. **Configuration Editor UX** + - [ ] Marker size dropdown shows preview text with pixel dimensions + - [ ] Activity source selector includes helpful threshold explanation + - [ ] Changes reflect immediately in preview (if available) + - [ ] Configuration YAML updates correctly when UI changed + - [ ] Form validation prevents invalid values + +6. **Backward Compatibility** + - [ ] Existing cards without new config options → work unchanged + - [ ] Default values match previous hardcoded behavior + - [ ] Migration from old to new config → seamless transition + - [ ] No console errors or warnings with legacy configurations + +### Performance Requirements + +- **Speed Calculation**: < 5ms per entity (single haversine calculation) +- **Position History**: Memory < 1KB per entity (5 entries × ~24 bytes) +- **Popup Updates**: < 16ms frame time (60 FPS target) +- **Config Updates**: < 50ms full re-render with new marker size +- **Concurrent Entities**: Tested with 10+ entities without degradation + +## Dependencies & Risks + +### External Dependencies + +- **Home Assistant Frontend**: Must support existing card API +- **Browser Support**: ES6 modules, CSS variables (Chrome 49+, Firefox 31+, Safari 9.1+) +- **Map APIs**: Leaflet (bundled), Google Maps (external API key required) +- **No new runtime dependencies** (Haversine formula self-implemented) + +### Internal Dependencies + +1. **ConfigManager must be updated before EntityDataFetcher** + - Reason: Default values required for speed calculation logic + - Mitigation: Phased implementation with clear ordering + +2. **EntityDataFetcher must be complete before Map HTML** + - Reason: Map renderer consumes speed/speed/predicted_activity fields + - Mitigation: Phase 2 fully completes before Phase 4 begins + +3. **Editor UI must be complete before integration testing** + - Reason: Manual testing requires UI configuration controls + - Mitigation: Phase 3 prioritized early in development cycle + +### Risk Assessment + +#### Risk 1: GPS Jitter Causes Erratic Speed Readings +**Likelihood**: Medium +**Impact**: Medium (poor user experience) +**Mitigation**: +- Implement minimum time threshold (5 seconds) between speed calculations +- Average across multiple history points (not just last 2) +- Add GPS accuracy filtering if available in HA attributes +- Display "-- km/h" when accuracy below threshold + +#### Risk 2: Google Maps InfoWindow Performance Issues +**Likelihood**: Low +**Impact**: High (UI freezes during movement) +**Mitigation**: +- Throttle setPosition() calls to max 4x per second +- Test with 10+ moving markers simultaneously +- Provide fallback to disable auto-follow for power users +- Document performance considerations in README + +#### Risk 3: Position History Lost on Iframe Reload +**Likelihood**: High +**Impact**: Low-Medium (speed unavailable temporarily) +**Mitigation**: +- Document that history is main-card-scoped, not persisted across reloads +- Reduce iframe reload frequency (already batched in existing code) +- Accept temporary speed unavailability as acceptable UX +- Consider future enhancement: persist history in localStorage + +#### Risk 4: Mobile Browser CSS Variable Support +**Likelihood**: Low +**Impact**: Medium (marker sizing fails on old browsers) +**Mitigation**: +- Provide JavaScript fallback: direct style manipulation if CSS variables unsupported +- Test on iOS Safari 10+, Android Chrome 50+ +- Document minimum browser requirements in README +- Graceful degradation: stick to medium size if feature detection fails + +#### Risk 5: Activity Sensor vs Speed Mismatch Confuses Users +**Likelihood**: Medium +**Impact**: Low (user education issue) +**Mitigation**: +- Add explicit UI label: "Activity (from speed)" when in predictive mode +- Include configuration helper text explaining thresholds +- Document in README when to use each mode +- Consider debug logging to help users understand selection logic + +## Open Questions + +1. **Q**: Should speed calculation continue when device is stationary? + **A**: Yes, but will return 0 km/h. Helps indicate "still" vs "no data" + +2. **Q**: How to handle time sync issues if HA server clock drifts? + **A**: Use `state.last_changed` timestamp (UTC from HA), not client time + +3. **Q**: Should popup follow be configurable (enable/disable)? + **A**: Defer to future enhancement if requested. Current plan: always follow. + +4. **Q**: Add imperial (mph) display option? + **A**: Calculate both but display km/h only for now. Add config option if users request. + +5. **Q**: Persist position history across browser sessions? + **A**: Not in scope. History is ephemeral by design (security/privacy consideration). + +## Implementation Notes + +### Code Quality Standards + +- **ESLint**: Maintain existing rules +- **Formatting**: Match existing 2-space indentation, semicolons +- **Comments**: Add JSDoc for new public methods, inline comments for complex calculations +- **Naming**: `camelCase` for variables/functions, `PascalCase` for classes, `UPPER_CASE` for constants + +### Performance Optimizations + +- **Memoization**: Cache marker size CSS variable values to avoid repeated calculations +- **Batch Updates**: Use requestAnimationFrame for popup position updates if needed +- **Early Returns**: Return early in speed calc if insufficient history (avoid math operations) +- **Object Pooling**: Reuse PositionEntry objects to reduce GC pressure (if many entities) + +### Backward Compatibility + +- All new config properties optional with sensible defaults +- Default `marker_size: 'medium'` matches current 48px hardcoded size +- Default `activity_source: 'sensor'` preserves existing behavior +- Graceful fallbacks in map renderer if parameters missing +- Existing card configs continue working without changes + +### Debugging Support + +Add debug logging (respect existing `debugMode` flag): +```javascript +if (this._debugMode) { + console.log(`[MapBadge] Speed: ${speedKmh.toFixed(1)} km/h for ${entityId}`); + console.log(`[MapBadge] Activity: ${activity} (source: ${config.activity_source})`); +} +``` + +### Future Extensibility + +- **Marker Size**: Easy to add 'xlarge' preset (just add to MARKER_SIZES) +- **Activity Thresholds**: Could be user-configurable (expose in UI) +- **Speed Calculation**: Could add averaging window configuration +- **Popup Content**: Speed display could be toggled via config + +--- + +## Status Updates + +- **2025-11-19**: Technical specification created by @documentator +- **2025-11-19**: Ready for implementation phase (pending coder assignment) +- **Next**: Phase 1 development to begin upon Vexa delegation + +**Specification Status**: ✅ **READY FOR IMPLEMENTATION** \ No newline at end of file diff --git a/src/config-manager.js b/src/config-manager.js index d48ec4c..ea3a083 100644 --- a/src/config-manager.js +++ b/src/config-manager.js @@ -29,6 +29,9 @@ export class ConfigManager { update_interval: config.update_interval || DEFAULT_CONFIG.update_interval, marker_border_radius: config.marker_border_radius || DEFAULT_CONFIG.marker_border_radius, badge_border_radius: config.badge_border_radius || DEFAULT_CONFIG.badge_border_radius, + marker_size: config.marker_size || DEFAULT_CONFIG.marker_size, + use_predicted_activity: config.use_predicted_activity || DEFAULT_CONFIG.use_predicted_activity, + activity_source: config.activity_source || DEFAULT_CONFIG.activity_source, zones: config.zones || DEFAULT_CONFIG.zones, activities: mergedActivities, debug: config.debug || DEFAULT_CONFIG.debug @@ -119,6 +122,7 @@ export class ConfigManager { // Add border radius params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius)); params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius)); + params.append('marker_size', encodeURIComponent(this._config.marker_size)); return params; } diff --git a/src/constants.js b/src/constants.js index 7f30105..a3da8f7 100644 --- a/src/constants.js +++ b/src/constants.js @@ -19,6 +19,37 @@ export const DEFAULT_ACTIVITIES = { tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } }; +/** + * Marker size presets + */ +export const MARKER_SIZES = { + small: { + marker: 36, + badge: 15, + popupOffset: -52 + }, + medium: { + marker: 48, + badge: 20, + popupOffset: -68 + }, + large: { + marker: 64, + badge: 24, + popupOffset: -88 + } +}; + +/** + * Activity speed thresholds in km/h + */ +export const ACTIVITY_THRESHOLDS = { + still: 1, + walking: 7, + cycling: 25, + vehicle: 25 +}; + /** * Default zone configurations */ @@ -39,6 +70,9 @@ export const DEFAULT_CONFIG = { update_interval: 10, // in seconds marker_border_radius: '50%', badge_border_radius: '50%', + marker_size: 'medium', + use_predicted_activity: false, + activity_source: 'sensor', debug: false, zones: DEFAULT_ZONES, activities: {} diff --git a/src/editor-handlers.js b/src/editor-handlers.js index 9a92db1..01d1d36 100644 --- a/src/editor-handlers.js +++ b/src/editor-handlers.js @@ -13,11 +13,39 @@ export class EditorHandlers { this._attachMapProviderListeners(element, config, onChange); this._attachBasicConfigListeners(element, config, onChange); this._attachBorderRadiusListeners(element, config, onChange); + this._attachMarkerSizeListener(element, config, onChange); + this._attachActivitySourceListener(element, config, onChange); this._attachEntityListeners(element, config, onChange, onRender); this._attachZoneListeners(element, config, onChange, onRender); this._attachActivityListeners(element, config, onChange); } + /** + * Attaches marker size listener + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachMarkerSizeListener(element, config, onChange) { + element.querySelector('#marker_size')?.addEventListener('selected', (e) => { + config.marker_size = e.target.value; + onChange(); + }); + } + + /** + * Attaches activity source listener + * @param {HTMLElement} element - Root element + * @param {Object} config - Configuration object + * @param {Function} onChange - Callback when config changes + */ + static _attachActivitySourceListener(element, config, onChange) { + element.querySelector('#activity_source')?.addEventListener('selected', (e) => { + config.activity_source = e.target.value; + onChange(); + }); + } + /** * Attaches map provider listeners * @param {HTMLElement} element - Root element diff --git a/src/editor-ui.js b/src/editor-ui.js index ef2dc2e..fe6624c 100644 --- a/src/editor-ui.js +++ b/src/editor-ui.js @@ -348,6 +348,37 @@ export class EditorUI {
0% for square, 50% for circle
+ +
+ + Small (36px) + Medium (48px) + Large (64px) + +
Adjust the visual size of the entity markers on the map.
+
+ +
+ + Activity Sensor + Speed-Based Prediction + +
+ Choose how activity is determined.
+ 'Activity Sensor' uses a dedicated Home Assistant sensor (e.g., from a phone app).
+ 'Speed-Based Prediction' infers activity from movement speed:
+ - Still: < 1 km/h
+ - Walking: 1-7 km/h
+ - Cycling: 7-25 km/h
+ - In Vehicle: > 25 km/h +
+
`; } diff --git a/src/entity-data-fetcher.js b/src/entity-data-fetcher.js index 159c64a..78d8eb4 100644 --- a/src/entity-data-fetcher.js +++ b/src/entity-data-fetcher.js @@ -1,12 +1,15 @@ /** * Manages fetching and caching entity data from Home Assistant */ +import { ACTIVITY_THRESHOLDS } from './constants.js'; + export class EntityDataFetcher { constructor(debugMode = false) { this._entityCache = {}; this._debug = debugMode; this._hass = null; this._entities = []; + this._positionHistory = new Map(); // entityId → PositionEntry[] } /** @@ -46,9 +49,10 @@ export class EntityDataFetcher { /** * Fetches entity data from Home Assistant + * @param {Object} config - Card configuration * @returns {Promise} Prepared entity data or null if no valid data */ - async fetchEntities() { + async fetchEntities(config) { if (!this._hass || !this._entities) { this._log('Cannot fetch entities: hass or entities not available'); return null; @@ -79,17 +83,30 @@ export class EntityDataFetcher { activityState = this._hass.states[entityConfig.activity]; } + // Calculate speed and update position history + const currentPosition = { + latitude: personState.attributes.latitude, + longitude: personState.attributes.longitude, + timestamp: Date.now() + }; + + const speedData = this.calculateSpeed(entityConfig.person, currentPosition); + this._updatePositionHistory(entityConfig.person, currentPosition); + // Store in cache this._entityCache[entityConfig.person] = { person: personState, activity: activityState, + speed: speedData, + predicted_activity: speedData ? this.predictActivity(speedData.speed_kmh) : null, timestamp: Date.now() }; hasValidData = true; this._log(`Cached entity ${entityConfig.person}:`, personState.state, - 'Location:', personState.attributes.latitude, personState.attributes.longitude); + 'Location:', personState.attributes.latitude, personState.attributes.longitude, + 'Speed:', speedData ? `${speedData.speed_kmh.toFixed(1)} km/h` : 'N/A'); } catch (error) { console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error); } @@ -100,14 +117,31 @@ export class EntityDataFetcher { return null; } - return this.prepareEntityData(); + return this.prepareEntityData(config); + } + + /** + * Determines which activity to use based on configuration + * @param {Object} data - Entity cache data + * @param {Object} config - Card configuration + * @returns {string} Selected activity state + */ + _getActivityToUse(data, config) { + // Check if speed-based prediction is enabled and speed data is available + if (config?.activity_source === 'speed_predicted' && data.speed) { + return data.predicted_activity || 'unknown'; + } + + // Default to sensor activity if available + return data.activity ? data.activity.state : 'unknown'; } /** * Prepares entity data for sending to iframe + * @param {Object} config - Card configuration * @returns {Object} Formatted entity data */ - prepareEntityData() { + prepareEntityData(config) { const entityData = {}; for (const [entityId, data] of Object.entries(this._entityCache)) { @@ -125,7 +159,9 @@ export class EntityDataFetcher { friendly_name: data.person.attributes.friendly_name, entity_picture: pictureUrl }, - activity: data.activity ? data.activity.state : 'unknown' + activity: this._getActivityToUse(data, config), + speed: data.speed || null, + predicted_activity: data.predicted_activity || null }; } @@ -156,4 +192,133 @@ export class EntityDataFetcher { if (!this.hasData()) return 0; return Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 0)); } + + /** + * Calculates distance between two coordinates using Haversine formula + * @param {number} lat1 - First latitude + * @param {number} lon1 - First longitude + * @param {number} lat2 - Second latitude + * @param {number} lon2 - Second longitude + * @returns {number} Distance in meters + */ + _calculateHaversineDistance(lat1, lon1, lat2, lon2) { + const R = 6371000; // Earth's radius in meters + const φ1 = (lat1 * Math.PI) / 180; + const φ2 = (lat2 * Math.PI) / 180; + const Δφ = ((lat2 - lat1) * Math.PI) / 180; + const Δλ = ((lon2 - lon1) * Math.PI) / 180; + + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; + } + + /** + * Calculates average speed based on position history over available points + * @param {string} entityId - Entity identifier + * @param {Object} currentPosition - Current position data + * @returns {Object|null} Speed data or null if insufficient history + */ + calculateSpeed(entityId, currentPosition) { + const history = this._positionHistory.get(entityId) || []; + + // Need at least 2 points to calculate speed + if (history.length < 1) { + return null; + } + + let totalDistance = 0; + let totalTime = 0; + let pointsUsed = 0; + + // Use all available history points to average out GPS jitter + // Start from the oldest point and work forward to current position + const startIndex = 0; + + // Calculate cumulative distance and time across all history points + for (let i = startIndex; i < history.length; i++) { + const point1 = history[i]; + const point2 = i + 1 < history.length ? history[i + 1] : currentPosition; + + const distance = this._calculateHaversineDistance( + point1.latitude, + point1.longitude, + point2.latitude, + point2.longitude + ); + + const timeDiff = (point2.timestamp - point1.timestamp) / 1000; // seconds + + // Only include valid time intervals to avoid skewing the average + if (timeDiff > 0) { + totalDistance += distance; + totalTime += timeDiff; + pointsUsed++; + } + } + + // We need at least some valid time intervals + if (totalTime < 5) { // Increased from 1 to 5 seconds to reduce GPS jitter + return null; + } + + const speedKmh = (totalDistance / 1000) / (totalTime / 3600); // km/h + const speedMph = speedKmh * 0.621371; // mph + + return { + speed_kmh: speedKmh, + speed_mph: speedMph, + time_diff: totalTime, + distance_m: totalDistance + }; + } + + /** + * Predicts activity based on speed + * @param {number} speedKmh - Speed in km/h + * @returns {string|null} Predicted activity or null + */ + predictActivity(speedKmh) { + if (speedKmh === null || speedKmh === undefined) { + return null; + } + + if (speedKmh < ACTIVITY_THRESHOLDS.still) { + return 'still'; + } else if (speedKmh < ACTIVITY_THRESHOLDS.walking) { + return 'walking'; + } else if (speedKmh < ACTIVITY_THRESHOLDS.cycling) { + return 'on_bicycle'; + } else { + return 'in_vehicle'; + } + } + + /** + * Updates position history for an entity (ring buffer of max 5 entries) + * @param {string} entityId - Entity identifier + * @param {Object} position - Current position data + */ + _updatePositionHistory(entityId, position) { + if (!this._positionHistory.has(entityId)) { + this._positionHistory.set(entityId, []); + } + + const history = this._positionHistory.get(entityId); + history.push({ + latitude: position.latitude, + longitude: position.longitude, + timestamp: position.timestamp + }); + + // Keep only the last 5 entries (ring buffer) + if (history.length > 5) { + history.shift(); + } + + this._positionHistory.set(entityId, history); + } } diff --git a/src/iframe-messenger.js b/src/iframe-messenger.js index b072860..14ebb0b 100644 --- a/src/iframe-messenger.js +++ b/src/iframe-messenger.js @@ -160,7 +160,7 @@ export class IframeMessenger { * @param {string} badgeBorderRadius - Badge border radius * @returns {boolean} True if sent successfully */ - sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius) { + sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius, markerSize) { if (!this._iframe || !this._iframe.contentWindow) { this._log('Cannot send config update: iframe not available'); return false; @@ -173,6 +173,7 @@ export class IframeMessenger { activities: activities, marker_border_radius: markerBorderRadius, badge_border_radius: badgeBorderRadius, + marker_size: markerSize, timestamp: Date.now() }; diff --git a/src/map-badge-card.js b/src/map-badge-card.js index 556b66d..b0b2570 100644 --- a/src/map-badge-card.js +++ b/src/map-badge-card.js @@ -35,7 +35,8 @@ class MapBadgeCard extends HTMLElement { 'zones', 'activities', 'marker_border_radius', - 'badge_border_radius' + 'badge_border_radius', + 'marker_size' ]); if (visualPropsChanged && this._iframe && this._messenger.isReady()) { @@ -77,7 +78,8 @@ class MapBadgeCard extends HTMLElement { } async _fetchEntities() { - const data = await this._dataFetcher.fetchEntities(); + const config = this._configManager.getConfig(); + const data = await this._dataFetcher.fetchEntities(config); if (!data) return; @@ -94,7 +96,8 @@ class MapBadgeCard extends HTMLElement { config.zones, config.activities, config.marker_border_radius, - config.badge_border_radius + config.badge_border_radius, + config.marker_size ); } diff --git a/src/map-badge-v2.html b/src/map-badge-v2.html index dae9a1e..1efd597 100644 --- a/src/map-badge-v2.html +++ b/src/map-badge-v2.html @@ -161,6 +161,24 @@ display: inline-block; } + .custom-popup-speed { + font-size: 13px; + color: #666; + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + } + + .custom-popup-activity { + font-size: 13px; + color: #666; + display: flex; + align-items: center; + gap: 4px; + margin-top: 4px; + } + /* Leaflet popup customization */ .leaflet-popup-content-wrapper { padding: 0; @@ -207,12 +225,55 @@ const DEBUG = urlParams.get('debug') === '1'; const TILT_ZOOM_THRESHOLD = parseInt(urlParams.get('tiltzoom')) || 18; // Zoom level to enable tilt const MARKER_BORDER_RADIUS = decodeURIComponent(urlParams.get('marker_radius') || '50%'); const BADGE_BORDER_RADIUS = decodeURIComponent(urlParams.get('badge_radius') || '50%'); +const MARKER_SIZE_PARAM = urlParams.get('marker_size') || 'medium'; -// Apply dynamic border-radius styles +// Marker size presets +const MARKER_SIZES = { + small: { + marker: 36, + badge: 15, + popupOffset: -52 + }, + medium: { + marker: 48, + badge: 20, + popupOffset: -68 + }, + large: { + marker: 64, + badge: 24, + popupOffset: -88 + } +}; + +// Get selected marker size with fallback to medium +let selectedMarkerSize = MARKER_SIZES[MARKER_SIZE_PARAM] || MARKER_SIZES.medium; + +// Apply CSS custom properties for dynamic sizing +document.documentElement.style.setProperty('--marker-size', `${selectedMarkerSize.marker}px`); +document.documentElement.style.setProperty('--badge-size', `${selectedMarkerSize.badge}px`); +document.documentElement.style.setProperty('--popup-offset', `${selectedMarkerSize.popupOffset}px`); +document.documentElement.style.setProperty('--marker-height', `${selectedMarkerSize.marker + 14}px`); // +14 for pointer + +// Apply CSS variables for border radius and sizing const styleSheet = document.getElementById('dynamic-styles'); + +// Set CSS variables on document root +document.documentElement.style.setProperty('--marker-radius', MARKER_BORDER_RADIUS); +document.documentElement.style.setProperty('--badge-radius', BADGE_BORDER_RADIUS); +document.documentElement.style.setProperty('--marker-size', `${selectedMarkerSize.marker}px`); +document.documentElement.style.setProperty('--badge-size', `${selectedMarkerSize.badge}px`); +document.documentElement.style.setProperty('--popup-offset', `${selectedMarkerSize.popupOffset}px`); +document.documentElement.style.setProperty('--marker-height', `${selectedMarkerSize.marker + 14}px`); + +// Add static CSS rules that use CSS variables (only if stylesheet is available) if (styleSheet && styleSheet.sheet) { - styleSheet.sheet.insertRule(`.custom-marker-image { border-radius: ${MARKER_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length); - styleSheet.sheet.insertRule(`.custom-marker-badge { border-radius: ${BADGE_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length); + // Add static sizing and radius rules that use CSS variables + styleSheet.sheet.insertRule(`.custom-marker-wrapper { width: var(--marker-size); height: calc(var(--marker-size) + 14px); }`, styleSheet.sheet.cssRules.length); + styleSheet.sheet.insertRule(`.custom-marker-profile-wrapper { width: var(--marker-size); height: var(--marker-height); }`, styleSheet.sheet.cssRules.length); + styleSheet.sheet.insertRule(`.custom-marker-image-container { width: var(--marker-size); height: var(--marker-height); }`, styleSheet.sheet.cssRules.length); + styleSheet.sheet.insertRule(`.custom-marker-image { width: var(--marker-size); height: var(--marker-size); border-radius: var(--marker-radius); }`, styleSheet.sheet.cssRules.length); + styleSheet.sheet.insertRule(`.custom-marker-badge { width: var(--badge-size); height: var(--badge-size); border-radius: var(--badge-radius); font-size: calc(var(--badge-size) * 0.6); }`, styleSheet.sheet.cssRules.length); } // Parse entities configuration @@ -396,8 +457,27 @@ function createMarkerHTML(personState, activityState, pictureUrl) { `; } -function createPopupHTML(friendlyName, personState, pictureUrl, zoneColor) { +function createPopupHTML(friendlyName, personState, pictureUrl, zoneColor, speedData, activityState) { const stateLabel = personState.charAt(0).toUpperCase() + personState.slice(1).replace(/_/g, ' '); + + // Get activity display info from ACTIVITIES config + const activityConfig = ACTIVITIES[activityState] || ACTIVITIES.unknown || { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }; + + // Create speed display HTML if speed data is available + const speedHtml = speedData && speedData.speed_kmh !== null ? ` +
+ + ${speedData.speed_kmh.toFixed(1)} km/h +
+ ` : ''; + + // Create activity display HTML + const activityHtml = ` +
+ + ${activityConfig.name} +
+ `; return `
@@ -412,6 +492,8 @@ function createPopupHTML(friendlyName, personState, pictureUrl, zoneColor) { ${stateLabel}
+ ${speedHtml} + ${activityHtml} @@ -446,38 +528,50 @@ function updateMarker(entityId, data) { function updateMarkerOSM(entityId, data, lat, lon, personState, activityState, pictureUrl) { const friendlyName = data.attributes.friendly_name || entityId; const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' }; + const speedData = data.speed || null; + + // Get dynamic marker dimensions + const markerSize = selectedMarkerSize.marker; + const markerHeight = markerSize + 14; + const markerAnchor = Math.floor(markerSize / 2); + const popupOffset = selectedMarkerSize.popupOffset; - if (markers[entityId]) { + let marker = markers[entityId]; + const wasPopupOpen = marker && marker.isPopupOpen(); + + if (marker) { // Update existing marker - markers[entityId].setLatLng([lat, lon]); + marker.setLatLng([lat, lon]); // Update popup content - const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color); - markers[entityId].setPopupContent(popupContent); + const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState); + marker.setPopupContent(popupContent); // Update icon HTML const iconHtml = createMarkerHTML(personState, activityState, pictureUrl); - markers[entityId].setIcon(L.divIcon({ + marker.setIcon(L.divIcon({ className: 'custom-leaflet-marker', html: iconHtml, - iconSize: [48, 62], - iconAnchor: [24, 62], - popupAnchor: [0, -68] + iconSize: [markerSize, markerHeight], + iconAnchor: [markerAnchor, markerHeight], + popupAnchor: [0, popupOffset] })); + + // If popup was open, keep it open (setLatLng automatically updates popup position) } else { // Create new marker const iconHtml = createMarkerHTML(personState, activityState, pictureUrl); const icon = L.divIcon({ className: 'custom-leaflet-marker', html: iconHtml, - iconSize: [48, 62], - iconAnchor: [24, 62], - popupAnchor: [0, -68] + iconSize: [markerSize, markerHeight], + iconAnchor: [markerAnchor, markerHeight], + popupAnchor: [0, popupOffset] }); - const marker = L.marker([lat, lon], { icon: icon }).addTo(map); + marker = L.marker([lat, lon], { icon: icon }).addTo(map); - const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color); + const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState); marker.bindPopup(popupContent); // Close other popups when this one opens @@ -499,20 +593,25 @@ function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState const position = { lat: lat, lng: lon }; const friendlyName = data.attributes.friendly_name || entityId; const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' }; + const speedData = data.speed || null; if (markers[entityId]) { // Update existing marker - markers[entityId].setPosition(position); + const marker = markers[entityId]; + const wasPopupOpen = marker.infoWindow && marker.infoWindow.getMap(); + + marker.setPosition(position); // Update the custom overlay content - const overlayDiv = markers[entityId].overlayDiv; + const overlayDiv = marker.overlayDiv; if (overlayDiv) { overlayDiv.innerHTML = createMarkerHTML(personState, activityState, pictureUrl); } - // Update info window content - if (markers[entityId].infoWindow) { - markers[entityId].infoWindow.setContent(createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color)); + // Update info window content and position + if (marker.infoWindow) { + marker.infoWindow.setContent(createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState)); + marker.infoWindow.setPosition(position); } } else { // Create custom HTML overlay @@ -557,8 +656,12 @@ function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState ); const div = this.div; - div.style.left = (pos.x - 24) + 'px'; // Center horizontally (48px / 2) - div.style.top = (pos.y - 62) + 'px'; // Position above point (62px total height) + const markerSize = selectedMarkerSize.marker; + const markerHeight = markerSize + 14; + const markerAnchor = Math.floor(markerSize / 2); + + div.style.left = (pos.x - markerAnchor) + 'px'; // Center horizontally + div.style.top = (pos.y - markerHeight) + 'px'; // Position above point } onRemove() { @@ -590,9 +693,9 @@ function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState // Create info window with modern popup const infoWindow = new google.maps.InfoWindow({ - content: createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color), + content: createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState), position: position, - pixelOffset: new google.maps.Size(0, -68) + pixelOffset: new google.maps.Size(0, selectedMarkerSize.popupOffset) }); marker.infoWindow = infoWindow; @@ -726,23 +829,33 @@ window.addEventListener('message', (event) => { } if (event.data.marker_border_radius || event.data.badge_border_radius) { - // Update border radius styles - const styleSheet = document.getElementById('dynamic-styles'); - if (styleSheet && styleSheet.sheet) { - // Remove old border-radius rules (they're the last two rules) - const rulesCount = styleSheet.sheet.cssRules.length; - if (rulesCount >= 2) { - styleSheet.sheet.deleteRule(rulesCount - 1); - styleSheet.sheet.deleteRule(rulesCount - 2); - } - - // Add new border-radius rules - const markerRadius = event.data.marker_border_radius || '50%'; - const badgeRadius = event.data.badge_border_radius || '50%'; - styleSheet.sheet.insertRule(`.custom-marker-image { border-radius: ${markerRadius}; }`, styleSheet.sheet.cssRules.length); - styleSheet.sheet.insertRule(`.custom-marker-badge { border-radius: ${badgeRadius}; }`, styleSheet.sheet.cssRules.length); - console.log('Border radius updated:', markerRadius, badgeRadius); + // Update border radius CSS variables + if (event.data.marker_border_radius) { + document.documentElement.style.setProperty('--marker-radius', event.data.marker_border_radius); } + if (event.data.badge_border_radius) { + document.documentElement.style.setProperty('--badge-radius', event.data.badge_border_radius); + } + + const markerRadius = event.data.marker_border_radius || getComputedStyle(document.documentElement).getPropertyValue('--marker-radius'); + const badgeRadius = event.data.badge_border_radius || getComputedStyle(document.documentElement).getPropertyValue('--badge-radius'); + console.log('Border radius updated:', markerRadius, badgeRadius); + } + + if (event.data.marker_size && MARKER_SIZES[event.data.marker_size]) { + // Update marker size dynamically + const newSize = MARKER_SIZES[event.data.marker_size]; + selectedMarkerSize.marker = newSize.marker; + selectedMarkerSize.badge = newSize.badge; + selectedMarkerSize.popupOffset = newSize.popupOffset; + + // Update CSS custom properties + document.documentElement.style.setProperty('--marker-size', `${newSize.marker}px`); + document.documentElement.style.setProperty('--badge-size', `${newSize.badge}px`); + document.documentElement.style.setProperty('--popup-offset', `${newSize.popupOffset}px`); + document.documentElement.style.setProperty('--marker-height', `${newSize.marker + 14}px`); + + console.log('Marker size updated to:', event.data.marker_size); } // Re-render all markers with new config