feat: add marker sizing, speed calc, and activity prediction
This commit is contained in:
parent
d0a71327cd
commit
b021d2b04b
10 changed files with 1254 additions and 51 deletions
21
README.md
21
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
|
- Profile pictures as map markers with colored borders based on zones
|
||||||
- Activity badges (walking, driving, etc.) from your phone's sensors
|
- Activity badges (walking, driving, etc.) from your phone's sensors
|
||||||
- Customizable colors and border styles
|
- Customizable colors and border styles
|
||||||
|
- Adjustable Marker Sizes (Small, Medium, Large)
|
||||||
|
- Speed Display in Popups
|
||||||
|
- Speed-based Activity Prediction
|
||||||
|
|
||||||
### Zone-based Markers
|
### Zone-based Markers
|
||||||
|
|
||||||
|
|
@ -95,6 +98,7 @@ google_api_key: YOUR_GOOGLE_API_KEY
|
||||||
map_type: hybrid # hybrid, satellite, roadmap, or terrain
|
map_type: hybrid # hybrid, satellite, roadmap, or terrain
|
||||||
default_zoom: 15
|
default_zoom: 15
|
||||||
update_interval: 10
|
update_interval: 10
|
||||||
|
marker_size: medium # small, medium, large
|
||||||
marker_border_radius: 50% # 50% for circles, or use px (e.g., 8px)
|
marker_border_radius: 50% # 50% for circles, or use px (e.g., 8px)
|
||||||
badge_border_radius: 50%
|
badge_border_radius: 50%
|
||||||
debug: false
|
debug: false
|
||||||
|
|
@ -116,6 +120,7 @@ activities:
|
||||||
color: '#2196f3'
|
color: '#2196f3'
|
||||||
in_vehicle:
|
in_vehicle:
|
||||||
color: '#9c27b0'
|
color: '#9c27b0'
|
||||||
|
activity_source: sensor # sensor or speed_predicted
|
||||||
```
|
```
|
||||||
|
|
||||||
## Configuration Options
|
## Configuration Options
|
||||||
|
|
@ -128,11 +133,13 @@ activities:
|
||||||
| `map_type` | string | `hybrid` | Google Maps type: `hybrid`, `satellite`, `roadmap`, or `terrain` |
|
| `map_type` | string | `hybrid` | Google Maps type: `hybrid`, `satellite`, `roadmap`, or `terrain` |
|
||||||
| `default_zoom` | number | `13` | Initial map zoom level (1-21) |
|
| `default_zoom` | number | `13` | Initial map zoom level (1-21) |
|
||||||
| `update_interval` | number | `10` | Update interval in seconds |
|
| `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 |
|
| `marker_border_radius` | string | `50%` | Border radius for profile pictures |
|
||||||
| `badge_border_radius` | string | `50%` | Border radius for activity badges |
|
| `badge_border_radius` | string | `50%` | Border radius for activity badges |
|
||||||
| `debug` | boolean | `false` | Enable debug mode for troubleshooting |
|
| `debug` | boolean | `false` | Enable debug mode for troubleshooting |
|
||||||
| `zones` | object | See below | Custom zone configurations |
|
| `zones` | object | See below | Custom zone configurations |
|
||||||
| `activities` | object | See below | Custom activity color configurations |
|
| `activities` | object | See below | Custom activity color configurations |
|
||||||
|
| `activity_source` | string | `sensor` | Activity source: `sensor` or `speed_predicted` |
|
||||||
|
|
||||||
### Supported Activities
|
### 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.
|
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.
|
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.
|
||||||
|
|
|
||||||
803
docs/specs/technical-spec.md
Normal file
803
docs/specs/technical-spec.md
Normal file
|
|
@ -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 `<ha-select id="marker_size">`
|
||||||
|
- Include three `<mwc-list-item>` 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 `<ha-select id="activity_source">`
|
||||||
|
- 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<string, MarkerSize> = {
|
||||||
|
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<string, PositionEntry[]>;
|
||||||
|
```
|
||||||
|
|
||||||
|
**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**
|
||||||
|
|
@ -29,6 +29,9 @@ export class ConfigManager {
|
||||||
update_interval: config.update_interval || DEFAULT_CONFIG.update_interval,
|
update_interval: config.update_interval || DEFAULT_CONFIG.update_interval,
|
||||||
marker_border_radius: config.marker_border_radius || DEFAULT_CONFIG.marker_border_radius,
|
marker_border_radius: config.marker_border_radius || DEFAULT_CONFIG.marker_border_radius,
|
||||||
badge_border_radius: config.badge_border_radius || DEFAULT_CONFIG.badge_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,
|
zones: config.zones || DEFAULT_CONFIG.zones,
|
||||||
activities: mergedActivities,
|
activities: mergedActivities,
|
||||||
debug: config.debug || DEFAULT_CONFIG.debug
|
debug: config.debug || DEFAULT_CONFIG.debug
|
||||||
|
|
@ -119,6 +122,7 @@ export class ConfigManager {
|
||||||
// Add border radius
|
// Add border radius
|
||||||
params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius));
|
params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius));
|
||||||
params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius));
|
params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius));
|
||||||
|
params.append('marker_size', encodeURIComponent(this._config.marker_size));
|
||||||
|
|
||||||
return params;
|
return params;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,37 @@ export const DEFAULT_ACTIVITIES = {
|
||||||
tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' }
|
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
|
* Default zone configurations
|
||||||
*/
|
*/
|
||||||
|
|
@ -39,6 +70,9 @@ export const DEFAULT_CONFIG = {
|
||||||
update_interval: 10, // in seconds
|
update_interval: 10, // in seconds
|
||||||
marker_border_radius: '50%',
|
marker_border_radius: '50%',
|
||||||
badge_border_radius: '50%',
|
badge_border_radius: '50%',
|
||||||
|
marker_size: 'medium',
|
||||||
|
use_predicted_activity: false,
|
||||||
|
activity_source: 'sensor',
|
||||||
debug: false,
|
debug: false,
|
||||||
zones: DEFAULT_ZONES,
|
zones: DEFAULT_ZONES,
|
||||||
activities: {}
|
activities: {}
|
||||||
|
|
|
||||||
|
|
@ -13,11 +13,39 @@ export class EditorHandlers {
|
||||||
this._attachMapProviderListeners(element, config, onChange);
|
this._attachMapProviderListeners(element, config, onChange);
|
||||||
this._attachBasicConfigListeners(element, config, onChange);
|
this._attachBasicConfigListeners(element, config, onChange);
|
||||||
this._attachBorderRadiusListeners(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._attachEntityListeners(element, config, onChange, onRender);
|
||||||
this._attachZoneListeners(element, config, onChange, onRender);
|
this._attachZoneListeners(element, config, onChange, onRender);
|
||||||
this._attachActivityListeners(element, config, onChange);
|
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
|
* Attaches map provider listeners
|
||||||
* @param {HTMLElement} element - Root element
|
* @param {HTMLElement} element - Root element
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,37 @@ export class EditorUI {
|
||||||
</div>
|
</div>
|
||||||
<div class="config-note">0% for square, 50% for circle</div>
|
<div class="config-note">0% for square, 50% for circle</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="config-row">
|
||||||
|
<ha-select
|
||||||
|
id="marker_size"
|
||||||
|
label="Marker Size"
|
||||||
|
value="${config.marker_size || 'medium'}">
|
||||||
|
<mwc-list-item value="small">Small (36px)</mwc-list-item>
|
||||||
|
<mwc-list-item value="medium">Medium (48px)</mwc-list-item>
|
||||||
|
<mwc-list-item value="large">Large (64px)</mwc-list-item>
|
||||||
|
</ha-select>
|
||||||
|
<div class="config-note">Adjust the visual size of the entity markers on the map.</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="config-row">
|
||||||
|
<ha-select
|
||||||
|
id="activity_source"
|
||||||
|
label="Activity Source"
|
||||||
|
value="${config.activity_source || 'sensor'}">
|
||||||
|
<mwc-list-item value="sensor">Activity Sensor</mwc-list-item>
|
||||||
|
<mwc-list-item value="speed_predicted">Speed-Based Prediction</mwc-list-item>
|
||||||
|
</ha-select>
|
||||||
|
<div class="config-note">
|
||||||
|
Choose how activity is determined.<br>
|
||||||
|
'Activity Sensor' uses a dedicated Home Assistant sensor (e.g., from a phone app).<br>
|
||||||
|
'Speed-Based Prediction' infers activity from movement speed:<br>
|
||||||
|
- Still: < 1 km/h<br>
|
||||||
|
- Walking: 1-7 km/h<br>
|
||||||
|
- Cycling: 7-25 km/h<br>
|
||||||
|
- In Vehicle: > 25 km/h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,15 @@
|
||||||
/**
|
/**
|
||||||
* Manages fetching and caching entity data from Home Assistant
|
* Manages fetching and caching entity data from Home Assistant
|
||||||
*/
|
*/
|
||||||
|
import { ACTIVITY_THRESHOLDS } from './constants.js';
|
||||||
|
|
||||||
export class EntityDataFetcher {
|
export class EntityDataFetcher {
|
||||||
constructor(debugMode = false) {
|
constructor(debugMode = false) {
|
||||||
this._entityCache = {};
|
this._entityCache = {};
|
||||||
this._debug = debugMode;
|
this._debug = debugMode;
|
||||||
this._hass = null;
|
this._hass = null;
|
||||||
this._entities = [];
|
this._entities = [];
|
||||||
|
this._positionHistory = new Map(); // entityId → PositionEntry[]
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -46,9 +49,10 @@ export class EntityDataFetcher {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches entity data from Home Assistant
|
* Fetches entity data from Home Assistant
|
||||||
|
* @param {Object} config - Card configuration
|
||||||
* @returns {Promise<Object|null>} Prepared entity data or null if no valid data
|
* @returns {Promise<Object|null>} Prepared entity data or null if no valid data
|
||||||
*/
|
*/
|
||||||
async fetchEntities() {
|
async fetchEntities(config) {
|
||||||
if (!this._hass || !this._entities) {
|
if (!this._hass || !this._entities) {
|
||||||
this._log('Cannot fetch entities: hass or entities not available');
|
this._log('Cannot fetch entities: hass or entities not available');
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -79,17 +83,30 @@ export class EntityDataFetcher {
|
||||||
activityState = this._hass.states[entityConfig.activity];
|
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
|
// Store in cache
|
||||||
this._entityCache[entityConfig.person] = {
|
this._entityCache[entityConfig.person] = {
|
||||||
person: personState,
|
person: personState,
|
||||||
activity: activityState,
|
activity: activityState,
|
||||||
|
speed: speedData,
|
||||||
|
predicted_activity: speedData ? this.predictActivity(speedData.speed_kmh) : null,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
hasValidData = true;
|
hasValidData = true;
|
||||||
|
|
||||||
this._log(`Cached entity ${entityConfig.person}:`, personState.state,
|
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) {
|
} catch (error) {
|
||||||
console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error);
|
console.error(`[EntityDataFetcher] Error fetching ${entityConfig.person}:`, error);
|
||||||
}
|
}
|
||||||
|
|
@ -100,14 +117,31 @@ export class EntityDataFetcher {
|
||||||
return null;
|
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
|
* Prepares entity data for sending to iframe
|
||||||
|
* @param {Object} config - Card configuration
|
||||||
* @returns {Object} Formatted entity data
|
* @returns {Object} Formatted entity data
|
||||||
*/
|
*/
|
||||||
prepareEntityData() {
|
prepareEntityData(config) {
|
||||||
const entityData = {};
|
const entityData = {};
|
||||||
|
|
||||||
for (const [entityId, data] of Object.entries(this._entityCache)) {
|
for (const [entityId, data] of Object.entries(this._entityCache)) {
|
||||||
|
|
@ -125,7 +159,9 @@ export class EntityDataFetcher {
|
||||||
friendly_name: data.person.attributes.friendly_name,
|
friendly_name: data.person.attributes.friendly_name,
|
||||||
entity_picture: pictureUrl
|
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;
|
if (!this.hasData()) return 0;
|
||||||
return Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -160,7 +160,7 @@ export class IframeMessenger {
|
||||||
* @param {string} badgeBorderRadius - Badge border radius
|
* @param {string} badgeBorderRadius - Badge border radius
|
||||||
* @returns {boolean} True if sent successfully
|
* @returns {boolean} True if sent successfully
|
||||||
*/
|
*/
|
||||||
sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius) {
|
sendConfigUpdate(zones, activities, markerBorderRadius, badgeBorderRadius, markerSize) {
|
||||||
if (!this._iframe || !this._iframe.contentWindow) {
|
if (!this._iframe || !this._iframe.contentWindow) {
|
||||||
this._log('Cannot send config update: iframe not available');
|
this._log('Cannot send config update: iframe not available');
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -173,6 +173,7 @@ export class IframeMessenger {
|
||||||
activities: activities,
|
activities: activities,
|
||||||
marker_border_radius: markerBorderRadius,
|
marker_border_radius: markerBorderRadius,
|
||||||
badge_border_radius: badgeBorderRadius,
|
badge_border_radius: badgeBorderRadius,
|
||||||
|
marker_size: markerSize,
|
||||||
timestamp: Date.now()
|
timestamp: Date.now()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ class MapBadgeCard extends HTMLElement {
|
||||||
'zones',
|
'zones',
|
||||||
'activities',
|
'activities',
|
||||||
'marker_border_radius',
|
'marker_border_radius',
|
||||||
'badge_border_radius'
|
'badge_border_radius',
|
||||||
|
'marker_size'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (visualPropsChanged && this._iframe && this._messenger.isReady()) {
|
if (visualPropsChanged && this._iframe && this._messenger.isReady()) {
|
||||||
|
|
@ -77,7 +78,8 @@ class MapBadgeCard extends HTMLElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
async _fetchEntities() {
|
async _fetchEntities() {
|
||||||
const data = await this._dataFetcher.fetchEntities();
|
const config = this._configManager.getConfig();
|
||||||
|
const data = await this._dataFetcher.fetchEntities(config);
|
||||||
|
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
|
|
||||||
|
|
@ -94,7 +96,8 @@ class MapBadgeCard extends HTMLElement {
|
||||||
config.zones,
|
config.zones,
|
||||||
config.activities,
|
config.activities,
|
||||||
config.marker_border_radius,
|
config.marker_border_radius,
|
||||||
config.badge_border_radius
|
config.badge_border_radius,
|
||||||
|
config.marker_size
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -161,6 +161,24 @@
|
||||||
display: inline-block;
|
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 customization */
|
||||||
.leaflet-popup-content-wrapper {
|
.leaflet-popup-content-wrapper {
|
||||||
padding: 0;
|
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 TILT_ZOOM_THRESHOLD = parseInt(urlParams.get('tiltzoom')) || 18; // Zoom level to enable tilt
|
||||||
const MARKER_BORDER_RADIUS = decodeURIComponent(urlParams.get('marker_radius') || '50%');
|
const MARKER_BORDER_RADIUS = decodeURIComponent(urlParams.get('marker_radius') || '50%');
|
||||||
const BADGE_BORDER_RADIUS = decodeURIComponent(urlParams.get('badge_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');
|
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) {
|
if (styleSheet && styleSheet.sheet) {
|
||||||
styleSheet.sheet.insertRule(`.custom-marker-image { border-radius: ${MARKER_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length);
|
// Add static sizing and radius rules that use CSS variables
|
||||||
styleSheet.sheet.insertRule(`.custom-marker-badge { border-radius: ${BADGE_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length);
|
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
|
// 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, ' ');
|
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 ? `
|
||||||
|
<div class="custom-popup-speed">
|
||||||
|
<i class="mdi mdi-speedometer" style="color: #666; margin-right: 4px;"></i>
|
||||||
|
${speedData.speed_kmh.toFixed(1)} km/h
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
// Create activity display HTML
|
||||||
|
const activityHtml = `
|
||||||
|
<div class="custom-popup-activity">
|
||||||
|
<i class="mdi ${activityConfig.icon}" style="color: ${activityConfig.color}; margin-right: 4px;"></i>
|
||||||
|
${activityConfig.name}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="custom-popup">
|
<div class="custom-popup">
|
||||||
|
|
@ -412,6 +492,8 @@ function createPopupHTML(friendlyName, personState, pictureUrl, zoneColor) {
|
||||||
<span class="custom-popup-state-icon" style="background: ${zoneColor}"></span>
|
<span class="custom-popup-state-icon" style="background: ${zoneColor}"></span>
|
||||||
${stateLabel}
|
${stateLabel}
|
||||||
</div>
|
</div>
|
||||||
|
${speedHtml}
|
||||||
|
${activityHtml}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -446,38 +528,50 @@ function updateMarker(entityId, data) {
|
||||||
function updateMarkerOSM(entityId, data, lat, lon, personState, activityState, pictureUrl) {
|
function updateMarkerOSM(entityId, data, lat, lon, personState, activityState, pictureUrl) {
|
||||||
const friendlyName = data.attributes.friendly_name || entityId;
|
const friendlyName = data.attributes.friendly_name || entityId;
|
||||||
const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' };
|
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
|
// Update existing marker
|
||||||
markers[entityId].setLatLng([lat, lon]);
|
marker.setLatLng([lat, lon]);
|
||||||
|
|
||||||
// Update popup content
|
// Update popup content
|
||||||
const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color);
|
const popupContent = createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState);
|
||||||
markers[entityId].setPopupContent(popupContent);
|
marker.setPopupContent(popupContent);
|
||||||
|
|
||||||
// Update icon HTML
|
// Update icon HTML
|
||||||
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
|
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
|
||||||
markers[entityId].setIcon(L.divIcon({
|
marker.setIcon(L.divIcon({
|
||||||
className: 'custom-leaflet-marker',
|
className: 'custom-leaflet-marker',
|
||||||
html: iconHtml,
|
html: iconHtml,
|
||||||
iconSize: [48, 62],
|
iconSize: [markerSize, markerHeight],
|
||||||
iconAnchor: [24, 62],
|
iconAnchor: [markerAnchor, markerHeight],
|
||||||
popupAnchor: [0, -68]
|
popupAnchor: [0, popupOffset]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// If popup was open, keep it open (setLatLng automatically updates popup position)
|
||||||
} else {
|
} else {
|
||||||
// Create new marker
|
// Create new marker
|
||||||
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
|
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: 'custom-leaflet-marker',
|
className: 'custom-leaflet-marker',
|
||||||
html: iconHtml,
|
html: iconHtml,
|
||||||
iconSize: [48, 62],
|
iconSize: [markerSize, markerHeight],
|
||||||
iconAnchor: [24, 62],
|
iconAnchor: [markerAnchor, markerHeight],
|
||||||
popupAnchor: [0, -68]
|
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);
|
marker.bindPopup(popupContent);
|
||||||
|
|
||||||
// Close other popups when this one opens
|
// 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 position = { lat: lat, lng: lon };
|
||||||
const friendlyName = data.attributes.friendly_name || entityId;
|
const friendlyName = data.attributes.friendly_name || entityId;
|
||||||
const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' };
|
const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' };
|
||||||
|
const speedData = data.speed || null;
|
||||||
|
|
||||||
if (markers[entityId]) {
|
if (markers[entityId]) {
|
||||||
// Update existing marker
|
// 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
|
// Update the custom overlay content
|
||||||
const overlayDiv = markers[entityId].overlayDiv;
|
const overlayDiv = marker.overlayDiv;
|
||||||
if (overlayDiv) {
|
if (overlayDiv) {
|
||||||
overlayDiv.innerHTML = createMarkerHTML(personState, activityState, pictureUrl);
|
overlayDiv.innerHTML = createMarkerHTML(personState, activityState, pictureUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update info window content
|
// Update info window content and position
|
||||||
if (markers[entityId].infoWindow) {
|
if (marker.infoWindow) {
|
||||||
markers[entityId].infoWindow.setContent(createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color));
|
marker.infoWindow.setContent(createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState));
|
||||||
|
marker.infoWindow.setPosition(position);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Create custom HTML overlay
|
// Create custom HTML overlay
|
||||||
|
|
@ -557,8 +656,12 @@ function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState
|
||||||
);
|
);
|
||||||
|
|
||||||
const div = this.div;
|
const div = this.div;
|
||||||
div.style.left = (pos.x - 24) + 'px'; // Center horizontally (48px / 2)
|
const markerSize = selectedMarkerSize.marker;
|
||||||
div.style.top = (pos.y - 62) + 'px'; // Position above point (62px total height)
|
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() {
|
onRemove() {
|
||||||
|
|
@ -590,9 +693,9 @@ function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState
|
||||||
|
|
||||||
// Create info window with modern popup
|
// Create info window with modern popup
|
||||||
const infoWindow = new google.maps.InfoWindow({
|
const infoWindow = new google.maps.InfoWindow({
|
||||||
content: createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color),
|
content: createPopupHTML(friendlyName, personState, pictureUrl, zoneConfig.color, speedData, activityState),
|
||||||
position: position,
|
position: position,
|
||||||
pixelOffset: new google.maps.Size(0, -68)
|
pixelOffset: new google.maps.Size(0, selectedMarkerSize.popupOffset)
|
||||||
});
|
});
|
||||||
|
|
||||||
marker.infoWindow = infoWindow;
|
marker.infoWindow = infoWindow;
|
||||||
|
|
@ -726,23 +829,33 @@ window.addEventListener('message', (event) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (event.data.marker_border_radius || event.data.badge_border_radius) {
|
if (event.data.marker_border_radius || event.data.badge_border_radius) {
|
||||||
// Update border radius styles
|
// Update border radius CSS variables
|
||||||
const styleSheet = document.getElementById('dynamic-styles');
|
if (event.data.marker_border_radius) {
|
||||||
if (styleSheet && styleSheet.sheet) {
|
document.documentElement.style.setProperty('--marker-radius', event.data.marker_border_radius);
|
||||||
// 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);
|
|
||||||
}
|
}
|
||||||
|
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
|
// Re-render all markers with new config
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue