commit a4a7768cd589e2a20c098b86136f173567811775 Author: Nicole Date: Sat Nov 15 11:25:59 2025 +0100 First Commit diff --git a/README.md b/README.md new file mode 100644 index 0000000..d051d81 --- /dev/null +++ b/README.md @@ -0,0 +1,138 @@ +# Map Badge Card for Home Assistant + +A custom card that shows person locations on a map with profile pictures and activity badges. + +![Map Badge Card](https://img.shields.io/badge/Home%20Assistant-Custom%20Card-blue) +![License](https://img.shields.io/badge/license-MIT-green) + +![Map Overview](assets/map.png) + +## Features + +- OpenStreetMap (free) or Google Maps +- 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 + +### Zone-based Markers + +![Home Zone](assets/home.png) + +Markers show colored borders based on zones (home = green, away = gray, etc.) + +### Google Maps Support + + + + + + + + + + +
Google MapsGoogle Maps 45°
Standard view3D tilt at high zoom
+ +## Installation + +### HACS (Recommended) + +_Coming soon_ + +### Manual Installation + +1. Download `map-badge-card.js` and `map-badge-v2.html` +2. Create a folder `/config/www/map-badge-card/` +3. Copy both files into that folder +4. Add the card resource: + - Go to Settings → Dashboards → Resources + - Click "Add Resource" + - URL: `/local/map-badge-card/map-badge-card.js` + - Resource type: `JavaScript Module` +5. Refresh your browser + +## Configuration + +### Basic Configuration + +```yaml +type: custom:map-badge-card +entities: + - person: person.john + activity: sensor.john_phone_activity + - person: person.jane + activity: sensor.jane_phone_activity +map_provider: osm # or 'google' +default_zoom: 13 +update_interval: 10 # seconds +``` + +### Full Configuration Example + +```yaml +type: custom:map-badge-card +entities: + - person: person.john + activity: sensor.john_phone_activity + - person: person.jane + activity: sensor.jane_phone_activity +map_provider: google +google_api_key: YOUR_GOOGLE_API_KEY +map_type: hybrid # hybrid, satellite, roadmap, or terrain +default_zoom: 15 +update_interval: 10 +marker_border_radius: 50% # 50% for circles, or use px (e.g., 8px) +badge_border_radius: 50% +debug: false +zones: + home: + color: '#cef595' + not_home: + color: '#757575' + work: + color: '#4285f4' +activities: + still: + color: '#000000' + walking: + color: '#4caf50' + running: + color: '#ff5722' + on_bicycle: + color: '#2196f3' + in_vehicle: + color: '#9c27b0' +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `entities` | list | **Required** | List of person entities with optional activity sensors | +| `map_provider` | string | `osm` | Map provider: `osm` (OpenStreetMap) or `google` | +| `google_api_key` | string | - | Google Maps API key (required only for Google Maps) | +| `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_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 | + +### Supported Activities + +Based on [Google](https://developers.google.com/android/reference/com/google/android/gms/location/DetectedActivity) and iOS activity detection APIs. Icons are fixed, you can only change colors. + +- Still, On Foot, Walking, Running, Cycling +- In Vehicle (covers car, automotive, etc.) +- On Train, On Bus +- Tilting + +All default to black background with white icons. + +## Requirements + +Your person entities need GPS coordinates (latitude/longitude) and profile pictures. 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. diff --git a/assets/gmaps.png b/assets/gmaps.png new file mode 100644 index 0000000..15ea124 Binary files /dev/null and b/assets/gmaps.png differ diff --git a/assets/gmaps_45.png b/assets/gmaps_45.png new file mode 100644 index 0000000..0e0f87e Binary files /dev/null and b/assets/gmaps_45.png differ diff --git a/assets/home.png b/assets/home.png new file mode 100644 index 0000000..66d4f79 Binary files /dev/null and b/assets/home.png differ diff --git a/assets/map.png b/assets/map.png new file mode 100644 index 0000000..ee75da5 Binary files /dev/null and b/assets/map.png differ diff --git a/map-badge-card.js b/map-badge-card.js new file mode 100644 index 0000000..de99a76 --- /dev/null +++ b/map-badge-card.js @@ -0,0 +1,1112 @@ +class MapBadgeCard extends HTMLElement { + constructor() { + super(); + this._entityCache = {}; + this._updateInterval = null; + this._iframeReady = false; + this._pendingData = null; + this._retryCount = 0; + } + + setConfig(config) { + console.log('[Card] setConfig called with:', JSON.stringify(config, null, 2)); + + if (!config.entities || !Array.isArray(config.entities)) { + throw new Error('You need to define entities'); + } + + const oldConfig = this._config; + // Hardcoded activity icons based on Google/iOS activity detection + // Map similar activities to single entities with friendly names + const defaultActivities = { + unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }, + still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' }, + on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' }, + walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' }, + running: { icon: 'mdi-run', color: '#000000', name: 'Running' }, + on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' }, + in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' }, + in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' }, + tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } + }; + + // Merge user colors with default icons and names + const activities = {}; + Object.keys(defaultActivities).forEach(key => { + activities[key] = { + icon: defaultActivities[key].icon, + name: defaultActivities[key].name, + color: (config.activities && config.activities[key] && config.activities[key].color) ? config.activities[key].color : defaultActivities[key].color + }; + }); + + this._config = { + entities: config.entities, + map_provider: config.map_provider || 'osm', // 'osm' or 'google' + google_api_key: config.google_api_key || '', + map_type: config.map_type || 'hybrid', + default_zoom: config.default_zoom || 13, + update_interval: config.update_interval || 10, // Now in seconds + marker_border_radius: config.marker_border_radius || '50%', + badge_border_radius: config.badge_border_radius || '50%', + zones: config.zones || { + home: { color: '#cef595' }, + not_home: { color: '#757575' } + }, + activities: activities, + debug: config.debug || false // Add debug mode option + }; + + // Check if zones or activities changed (during config editing) + const zonesChanged = oldConfig && JSON.stringify(oldConfig.zones) !== JSON.stringify(this._config.zones); + const activitiesChanged = oldConfig && JSON.stringify(oldConfig.activities) !== JSON.stringify(this._config.activities); + const borderRadiusChanged = oldConfig && ( + oldConfig.marker_border_radius !== this._config.marker_border_radius || + oldConfig.badge_border_radius !== this._config.badge_border_radius + ); + + // If only zones/activities/border radius changed, send update to iframe instead of full re-render + if ((zonesChanged || activitiesChanged || borderRadiusChanged) && this._iframe && this._iframeReady) { + this._sendConfigUpdateToIframe(); + } else { + this._render(); + } + } + + set hass(hass) { + this._hass = hass; + + // Start fetching entity data when hass is available + if (hass && !this._updateInterval) { + this._startEntityUpdates(); + } + + // If we have pending data and iframe is ready, send it + if (hass && this._iframeReady && this._pendingData) { + this._sendDataToIframe(this._pendingData); + this._pendingData = null; + } + } + + _log(message, ...args) { + if (this._config && this._config.debug) { + console.log(`[MapBadgeCard ${new Date().toISOString()}] ${message}`, ...args); + } + } + + _startEntityUpdates() { + this._log('Starting entity updates'); + + // Fetch entities immediately + this._fetchEntities(); + + // Set up interval for updates + if (this._updateInterval) { + clearInterval(this._updateInterval); + } + + this._updateInterval = setInterval(() => { + this._fetchEntities(); + }, this._config.update_interval * 1000); // Convert seconds to milliseconds + } + + async _fetchEntities() { + if (!this._hass || !this._config.entities) { + this._log('Cannot fetch entities: hass or entities not available'); + return; + } + + this._log('Fetching entities from hass...'); + + let hasValidData = false; + + for (const entityConfig of this._config.entities) { + try { + // Fetch person entity directly from hass states + const personState = this._hass.states[entityConfig.person]; + if (!personState) { + console.error(`[MapBadgeCard] Entity not found: ${entityConfig.person}`); + continue; + } + + // Check if we have valid GPS data + if (!personState.attributes.latitude || !personState.attributes.longitude) { + this._log(`No GPS data for ${entityConfig.person}`); + continue; + } + + // Fetch activity entity if specified + let activityState = null; + if (entityConfig.activity) { + activityState = this._hass.states[entityConfig.activity]; + } + + // Store in cache + this._entityCache[entityConfig.person] = { + person: personState, + activity: activityState, + timestamp: Date.now() + }; + + hasValidData = true; + + this._log(`Cached entity ${entityConfig.person}:`, personState.state, + 'Location:', personState.attributes.latitude, personState.attributes.longitude); + } catch (error) { + console.error(`[MapBadgeCard] Error fetching ${entityConfig.person}:`, error); + } + } + + // Only send update if we have valid data + if (hasValidData) { + const data = this._prepareEntityData(); + if (this._iframeReady) { + this._sendDataToIframe(data); + } else { + this._log('Iframe not ready, storing data as pending'); + this._pendingData = data; + } + } else { + this._log('No valid entity data to send'); + } + } + + _prepareEntityData() { + const entityData = {}; + + for (const [entityId, data] of Object.entries(this._entityCache)) { + // Build picture URL + let pictureUrl = data.person.attributes.entity_picture || ''; + if (pictureUrl && pictureUrl.startsWith('/')) { + pictureUrl = window.location.origin + pictureUrl; + } + + entityData[entityId] = { + state: data.person.state, + attributes: { + latitude: data.person.attributes.latitude, + longitude: data.person.attributes.longitude, + friendly_name: data.person.attributes.friendly_name, + entity_picture: pictureUrl + }, + activity: data.activity ? data.activity.state : 'unknown' + }; + } + + return entityData; + } + + _sendDataToIframe(data = null) { + if (!this._iframe || !this._iframe.contentWindow) { + this._log('Cannot send data: iframe not available'); + return; + } + + if (!data) { + data = this._prepareEntityData(); + } + + if (Object.keys(data).length === 0) { + this._log('No data to send to iframe'); + return; + } + + try { + const message = { + type: 'entity-update', + data: data, + timestamp: Date.now(), + debug: this._config.debug + }; + + this._log('Sending data to iframe:', message); + this._iframe.contentWindow.postMessage(message, '*'); + this._retryCount = 0; // Reset retry count on successful send + } catch (error) { + console.error('[MapBadgeCard] Error sending data to iframe:', error); + } + } + + _sendConfigUpdateToIframe() { + if (!this._iframe || !this._iframe.contentWindow) { + this._log('Cannot send config update: iframe not available'); + return; + } + + try { + const message = { + type: 'config-update', + zones: this._config.zones, + activities: this._config.activities, + marker_border_radius: this._config.marker_border_radius, + badge_border_radius: this._config.badge_border_radius, + timestamp: Date.now() + }; + + this._log('Sending config update to iframe:', message); + this._iframe.contentWindow.postMessage(message, '*'); + } catch (error) { + console.error('[MapBadgeCard] Error sending config update to iframe:', error); + } + } + + _render() { + if (!this._config) return; + + // Create a simpler iframe URL without auth complexity + const params = new URLSearchParams({ + provider: this._config.map_provider, + apikey: this._config.google_api_key || '', + maptype: this._config.map_type, + zoom: this._config.default_zoom, + mode: 'proxy', // Tell iframe to expect data via postMessage + debug: this._config.debug ? '1' : '0' + }); + + // Add entities for initial configuration + const entitiesParam = this._config.entities + .map(e => `${e.person}${e.activity ? ':' + e.activity : ''}`) + .join(','); + params.append('entities', entitiesParam); + + // Add zones (zones only have colors now, no icons) + const zonesParam = Object.entries(this._config.zones) + .map(([state, config]) => `${state}:${encodeURIComponent(config.color)}`) + .join(','); + params.append('zones', zonesParam); + + // Add activities + const activitiesParam = Object.entries(this._config.activities) + .map(([state, config]) => `${state}:${config.icon}:${encodeURIComponent(config.color)}`) + .join(','); + params.append('activities', activitiesParam); + + // Add border radius configuration + params.append('marker_radius', encodeURIComponent(this._config.marker_border_radius)); + params.append('badge_radius', encodeURIComponent(this._config.badge_border_radius)); + + const iframeUrl = `/local/map-badge-card/map-badge-v2.html?${params.toString()}`; + + this.innerHTML = ` + +
+ + ${this._config.debug ? ` +
+
Debug Mode ON
+
Entities: ${this._config.entities.length}
+
Cache: ${Object.keys(this._entityCache).length}
+
Ready: ${this._iframeReady}
+
+ ` : ''} +
+
+ `; + + // Reset iframe ready state + this._iframeReady = false; + + // Get iframe reference + this._iframe = this.querySelector('#map-badge-iframe'); + + // Set up multiple attempts to establish communication + if (this._iframe) { + // Method 1: Wait for iframe load event + this._iframe.onload = () => { + this._log('Iframe loaded (onload event)'); + + // Try sending data after a short delay + setTimeout(() => { + this._iframeReady = true; + if (this._pendingData) { + this._log('Sending pending data after iframe load'); + this._sendDataToIframe(this._pendingData); + this._pendingData = null; + } else if (Object.keys(this._entityCache).length > 0) { + this._log('Sending cached data after iframe load'); + this._sendDataToIframe(); + } else { + this._log('No data to send after iframe load, fetching...'); + this._fetchEntities(); + } + }, 500); + + // Also try again after a longer delay as backup + setTimeout(() => { + if (Object.keys(this._entityCache).length > 0) { + this._log('Backup data send after iframe load'); + this._sendDataToIframe(); + } + }, 2000); + }; + } + + // Listen for messages from iframe + if (!this._messageListener) { + this._messageListener = (event) => { + if (event.data) { + if (event.data.type === 'iframe-ready') { + this._log('Iframe reports ready'); + this._iframeReady = true; + + // Send data immediately + if (this._pendingData) { + this._log('Sending pending data on iframe-ready'); + this._sendDataToIframe(this._pendingData); + this._pendingData = null; + } else if (Object.keys(this._entityCache).length > 0) { + this._log('Sending cached data on iframe-ready'); + this._sendDataToIframe(); + } else { + this._log('No data available on iframe-ready, fetching...'); + this._fetchEntities(); + } + } else if (event.data.type === 'request-data') { + this._log('Iframe requesting data'); + this._fetchEntities(); + } else if (event.data.type === 'data-received') { + this._log('Iframe confirmed data received'); + this._retryCount = 0; + } + } + }; + window.addEventListener('message', this._messageListener); + } + + // Set up periodic retry mechanism + if (!this._retryInterval) { + this._retryInterval = setInterval(() => { + if (this._iframeReady && this._retryCount < 3 && Object.keys(this._entityCache).length > 0) { + const now = Date.now(); + const lastUpdate = Math.max(...Object.values(this._entityCache).map(e => e.timestamp || 0)); + + // If we haven't sent data successfully in the last 5 seconds, retry + if (now - lastUpdate < 30000) { // Data is less than 30 seconds old + this._log('Retrying data send, attempt:', this._retryCount + 1); + this._sendDataToIframe(); + this._retryCount++; + } + } + }, 5000); + } + } + + disconnectedCallback() { + if (this._updateInterval) { + clearInterval(this._updateInterval); + this._updateInterval = null; + } + + if (this._retryInterval) { + clearInterval(this._retryInterval); + this._retryInterval = null; + } + + if (this._messageListener) { + window.removeEventListener('message', this._messageListener); + this._messageListener = null; + } + } + + getCardSize() { + return 5; + } + + static getConfigElement() { + return document.createElement('map-badge-card-editor'); + } + + static getStubConfig() { + return { + entities: [], + map_provider: 'osm', + google_api_key: '', + map_type: 'hybrid', + default_zoom: 13, + update_interval: 10, + marker_border_radius: '50%', + badge_border_radius: '50%', + debug: false, + zones: { + home: { color: '#cef595' }, + not_home: { color: '#757575' } + }, + activities: {} + }; + } +} + +// Keep the same configuration editor +class MapBadgeCardEditor extends HTMLElement { + constructor() { + super(); + this._debounceTimeout = null; + this._hass = null; + } + + set hass(hass) { + this._hass = hass; + this._setEntityPickerHass(); + } + + setConfig(config) { + this._config = JSON.parse(JSON.stringify(config)); + + if (!this._config.entities) { + this._config.entities = []; + } + + if (!this._config.zones) { + this._config.zones = { + home: { color: '#cef595' }, + not_home: { color: '#757575' } + }; + } + + // Hardcoded activity icons based on Google/iOS activity detection + // Map similar activities to single entities with friendly names + const defaultActivities = { + unknown: { icon: 'mdi-human-male', color: '#000000', name: 'Unknown' }, + still: { icon: 'mdi-human-male', color: '#000000', name: 'Still' }, + on_foot: { icon: 'mdi-walk', color: '#000000', name: 'On Foot' }, + walking: { icon: 'mdi-walk', color: '#000000', name: 'Walking' }, + running: { icon: 'mdi-run', color: '#000000', name: 'Running' }, + on_bicycle: { icon: 'mdi-bike', color: '#000000', name: 'Cycling' }, + in_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_road_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_four_wheeler_vehicle: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_car: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + Automotive: { icon: 'mdi-car', color: '#000000', name: 'In Vehicle' }, + in_rail_vehicle: { icon: 'mdi-train', color: '#000000', name: 'On Train' }, + in_bus: { icon: 'mdi-bus', color: '#000000', name: 'On Bus' }, + tilting: { icon: 'mdi-phone-rotate-landscape', color: '#000000', name: 'Tilting' } + }; + + // Merge user colors with default icons and names + const activities = {}; + Object.keys(defaultActivities).forEach(key => { + activities[key] = { + icon: defaultActivities[key].icon, + name: defaultActivities[key].name, + color: (this._config.activities && this._config.activities[key] && this._config.activities[key].color) ? this._config.activities[key].color : defaultActivities[key].color + }; + }); + + this._config.activities = activities; + + console.log('[Editor] setConfig - activities after merge:', JSON.stringify(this._config.activities, null, 2)); + + if (!this._config.map_provider) { + this._config.map_provider = 'osm'; + } + + if (!this._config.marker_border_radius) { + this._config.marker_border_radius = '50%'; + } + + if (!this._config.badge_border_radius) { + this._config.badge_border_radius = '50%'; + } + + this._render(); + } + + _render() { + if (!this._config) return; + + const zonesHtml = Object.entries(this._config.zones) + .map(([state, config], idx) => ` +
+
+ + +
+
+ + +
+ + + +
+ `).join(''); + + console.log('[Editor] _render - activities object:', this._config.activities); + console.log('[Editor] _render - activities entries:', Object.entries(this._config.activities)); + + // Group activities by unique name to avoid duplicates in UI + // Filter out "Unknown" - it uses default black and shouldn't be configurable + const uniqueActivities = new Map(); + const activityGroups = {}; // Map from display name to array of state keys + const hiddenActivities = ['Unknown']; // Activities to hide from UI + + Object.entries(this._config.activities).forEach(([state, config]) => { + const displayName = config.name || state.replace(/_/g, ' '); + + // Skip hidden activities + if (hiddenActivities.includes(displayName)) { + return; + } + + if (!uniqueActivities.has(displayName)) { + uniqueActivities.set(displayName, { ...config, states: [state] }); + activityGroups[displayName] = [state]; + } else { + // Add this state to the existing group + activityGroups[displayName].push(state); + } + }); + + console.log('[Editor] Activity groups:', activityGroups); + console.log('[Editor] Unique activities count:', uniqueActivities.size); + + const activitiesHtml = Array.from(uniqueActivities.entries()) + .map(([displayName, config]) => { + // Convert mdi-icon-name to mdi:icon-name for ha-icon + const haIconFormat = config.icon.replace('mdi-', 'mdi:'); + const states = activityGroups[displayName].join(','); + return ` +
+ +
+ +
+
+ + +
+
+ `; + }).join(''); + + console.log('[Editor] _render - activitiesHtml length:', activitiesHtml.length); + + // Get entity lists for autocomplete + let personEntities = []; + let sensorEntities = []; + let personDatalist = ''; + let sensorDatalist = ''; + + if (this._hass && this._hass.states) { + personEntities = Object.keys(this._hass.states) + .filter(e => e.startsWith('person.')) + .sort(); + sensorEntities = Object.keys(this._hass.states) + .filter(e => e.startsWith('sensor.')) + .sort(); + + // Create datalist elements for autocomplete + personDatalist = ` + + ${personEntities.map(e => { + const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e; + return ``; + }).join('')} + + `; + + sensorDatalist = ` + + ${sensorEntities.map(e => { + const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e; + return ``; + }).join('')} + + `; + } + + const entitiesHtml = this._config.entities + .map((entity, idx) => ` +
+
+ + +
+
+ + +
+ + + +
+ `).join(''); + + this.innerHTML = ` + +
+
Map Badge Card Configuration
+ +
+ + OpenStreetMap (No API Key Required) + Google Maps + +
OpenStreetMap is free and requires no authentication
+
+ +
+ + +
Required only for Google Maps - Get your API key from Google Cloud Console
+
+ +
+ + Hybrid + Satellite + Roadmap + Terrain + +
Map type (Google Maps only)
+
+ +
+ + +
1 = World view, 21 = Maximum zoom
+
+ +
+ + +
How often to refresh location data
+
+ +
+ +
+ + ${this._config.marker_border_radius || '50%'} +
+
0% for square, 50% for circle
+
+ +
+ +
+ + ${this._config.badge_border_radius || '50%'} +
+
0% for square, 50% for circle
+
+ +
+
+ + Entities +
+
${entitiesHtml}
+ ${personDatalist} + ${sensorDatalist} + + + Add Entity + +
+ +
+
+ + Zone Configuration +
+
${zonesHtml}
+ + + Add Zone + +
+ +
+
+ + Activity Configuration +
+
${activitiesHtml}
+
+
+ `; + + // Use requestAnimationFrame to ensure DOM is ready before attaching listeners + requestAnimationFrame(() => { + this._attachListeners(); + this._setEntityPickerHass(); + }); + } + + _setEntityPickerHass() { + // Not needed anymore since we removed icon pickers + } + + _attachListeners() { + // Map provider selector + this.querySelector('#map_provider')?.addEventListener('selected', (e) => { + this._config.map_provider = e.target.value; + // Show/hide Google Maps fields based on selection + const googleApiKeyRow = this.querySelector('#google-api-key-row'); + const mapTypeRow = this.querySelector('#map-type-row'); + if (googleApiKeyRow) { + googleApiKeyRow.style.display = e.target.value === 'google' ? 'block' : 'none'; + } + if (mapTypeRow) { + mapTypeRow.style.display = e.target.value === 'google' ? 'block' : 'none'; + } + this._fireChanged(); + }); + + // Basic config - using 'change' event for ha-textfield components + this.querySelector('#google_api_key')?.addEventListener('change', (e) => { + this._config.google_api_key = e.target.value; + this._fireChanged(); + }); + this.querySelector('#map_type')?.addEventListener('selected', (e) => { + this._config.map_type = e.target.value; + this._fireChanged(); + }); + this.querySelector('#default_zoom')?.addEventListener('change', (e) => { + this._config.default_zoom = parseInt(e.target.value); + this._fireChanged(); + }); + this.querySelector('#update_interval')?.addEventListener('change', (e) => { + this._config.update_interval = parseInt(e.target.value); + this._fireChanged(); + }); + + // Marker border radius slider + const markerSlider = this.querySelector('#marker_border_radius'); + const markerValueDisplay = this.querySelector('#marker-radius-value'); + markerSlider?.addEventListener('input', (e) => { + const value = e.target.value + '%'; + markerValueDisplay.textContent = value; + this._config.marker_border_radius = value; + this._fireChanged(); + }); + + // Badge border radius slider + const badgeSlider = this.querySelector('#badge_border_radius'); + const badgeValueDisplay = this.querySelector('#badge-radius-value'); + badgeSlider?.addEventListener('input', (e) => { + const value = e.target.value + '%'; + badgeValueDisplay.textContent = value; + this._config.badge_border_radius = value; + this._fireChanged(); + }); + + // Entities - using 'change' event for input elements + this.querySelectorAll('input.entity-input[data-entity-idx]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.entityIdx); + const field = e.target.dataset.entityField; + if (idx < this._config.entities.length) { + this._config.entities[idx][field] = e.target.value || ''; + this._fireChanged(); + } + }); + }); + this.querySelectorAll('[data-entity-delete]').forEach(el => { + el.addEventListener('click', (e) => { + const button = e.target.closest('ha-icon-button'); + if (button) { + const idx = parseInt(button.dataset.entityDelete); + this._config.entities.splice(idx, 1); + this._render(); + this._fireChanged(); + } + }); + }); + this.querySelector('#add-entity')?.addEventListener('click', () => { + this._config.entities.push({ person: '', activity: '' }); + this._render(); + this._fireChanged(); + }); + + // Zones - using 'change' event for input elements + this.querySelectorAll('input[data-zone-idx][data-zone-field="state"]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.zoneIdx); + const zones = Object.entries(this._config.zones); + if (idx >= zones.length) return; + + const [oldState, oldConfig] = zones[idx]; + const newValue = e.target.value; + + if (newValue && oldState !== newValue) { + delete this._config.zones[oldState]; + this._config.zones[newValue] = oldConfig; + } + this._fireChanged(); + }); + }); + this.querySelectorAll('input[data-zone-idx][data-zone-field="color"]').forEach(el => { + el.addEventListener('change', (e) => { + const idx = parseInt(e.target.dataset.zoneIdx); + const zones = Object.entries(this._config.zones); + if (idx >= zones.length) return; + + const [state, config] = zones[idx]; + config.color = e.target.value; + this._fireChanged(); + }); + }); + this.querySelectorAll('[data-zone-delete]').forEach(el => { + el.addEventListener('click', (e) => { + const button = e.target.closest('ha-icon-button'); + if (button) { + const idx = parseInt(button.dataset.zoneDelete); + const zones = Object.entries(this._config.zones); + const [state] = zones[idx]; + delete this._config.zones[state]; + this._render(); + this._fireChanged(); + } + }); + }); + this.querySelector('#add-zone')?.addEventListener('click', () => { + this._config.zones[''] = { color: '#757575' }; + this._render(); + this._fireChanged(); + }); + + // Activities - only handle color changes (icons are hardcoded) + // Note: data-activity-states contains comma-separated list of states + this.querySelectorAll('input[data-activity-states]').forEach(el => { + el.addEventListener('change', (e) => { + const states = e.target.dataset.activityStates.split(','); + const newColor = e.target.value; + + // Update color for all states in this group + states.forEach(state => { + if (this._config.activities[state]) { + this._config.activities[state].color = newColor; + } + }); + + this._fireChanged(); + }); + }); + } + + _fireChanged() { + console.log('[Editor] Firing config-changed event with config:', JSON.stringify(this._config, null, 2)); + const event = new CustomEvent('config-changed', { + detail: { config: this._config }, + bubbles: true, + composed: true + }); + this.dispatchEvent(event); + } +} + +customElements.define('map-badge-card', MapBadgeCard); +customElements.define('map-badge-card-editor', MapBadgeCardEditor); + +window.customCards = window.customCards || []; +window.customCards.push({ + type: 'map-badge-card', + name: 'Map Badge Card', + description: 'Map with person markers, zone borders, and activity badges', + preview: false +}); + diff --git a/map-badge-v2.html b/map-badge-v2.html new file mode 100644 index 0000000..946c9e8 --- /dev/null +++ b/map-badge-v2.html @@ -0,0 +1,616 @@ + + + + + + + + + + + +
+ + + + +