ha-map-badge-card/map-badge-card.js
2025-11-15 11:25:59 +01:00

1112 lines
39 KiB
JavaScript

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 = `
<ha-card style="height: 100%; display: flex; flex-direction: column;">
<div style="padding: 0; margin: 0; overflow: hidden; position: relative; flex: 1;">
<iframe
id="map-badge-iframe"
src="${iframeUrl}"
style="width: 100%; height: 100%; border: none; display: block; margin: 0; padding: 0;"
allowfullscreen
></iframe>
${this._config.debug ? `
<div style="position: absolute; top: 5px; right: 5px; background: rgba(255,255,255,0.9); padding: 5px; font-size: 10px; z-index: 1000;">
<div>Debug Mode ON</div>
<div>Entities: ${this._config.entities.length}</div>
<div>Cache: ${Object.keys(this._entityCache).length}</div>
<div>Ready: ${this._iframeReady}</div>
</div>
` : ''}
</div>
</ha-card>
`;
// 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) => `
<div class="config-item">
<div class="input-wrapper" style="flex: 1;">
<label>State Name</label>
<input
type="text"
id="zone-state-${idx}"
class="entity-input"
value="${state}"
data-zone-idx="${idx}"
data-zone-field="state"
placeholder="home">
</div>
<div class="input-wrapper" style="width: 80px;">
<label>Color</label>
<input type="color" value="${config.color}" data-zone-idx="${idx}" data-zone-field="color" style="width: 100%; height: 40px; border-radius: 4px; border: 1px solid var(--divider-color); cursor: pointer;">
</div>
<ha-icon-button
data-zone-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon>
</ha-icon-button>
</div>
`).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 `
<div class="config-item" style="align-items: center; gap: 16px;">
<ha-icon icon="${haIconFormat}" style="--mdc-icon-size: 24px; color: var(--primary-text-color); flex-shrink: 0;"></ha-icon>
<div class="input-wrapper" style="flex: 1;">
<label>${displayName}</label>
</div>
<div class="input-wrapper" style="width: 100px;">
<label>Color</label>
<input type="color" value="${config.color || '#000000'}" data-activity-states="${states}" style="width: 100%; height: 40px; border-radius: 4px; border: 1px solid var(--divider-color); cursor: pointer;">
</div>
</div>
`;
}).join('');
console.log('[Editor] _render - activitiesHtml length:', activitiesHtml.length);
// Get entity lists for autocomplete
let personEntities = [];
let sensorEntities = [];
let personDatalist = '<datalist id="person-entities-list"></datalist>';
let sensorDatalist = '<datalist id="sensor-entities-list"></datalist>';
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 = `
<datalist id="person-entities-list">
${personEntities.map(e => {
const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e;
return `<option value="${e}">${friendlyName}</option>`;
}).join('')}
</datalist>
`;
sensorDatalist = `
<datalist id="sensor-entities-list">
${sensorEntities.map(e => {
const friendlyName = this._hass.states[e]?.attributes?.friendly_name || e;
return `<option value="${e}">${friendlyName}</option>`;
}).join('')}
</datalist>
`;
}
const entitiesHtml = this._config.entities
.map((entity, idx) => `
<div class="config-item">
<div class="input-wrapper" style="flex: 1;">
<label>Person Entity</label>
<input
type="text"
id="entity-person-${idx}"
class="entity-input"
value="${entity.person || ''}"
data-entity-idx="${idx}"
data-entity-field="person"
placeholder="person.example"
list="person-entities-list">
</div>
<div class="input-wrapper" style="flex: 1;">
<label>Activity Sensor</label>
<input
type="text"
id="entity-activity-${idx}"
class="entity-input"
value="${entity.activity || ''}"
data-entity-idx="${idx}"
data-entity-field="activity"
placeholder="sensor.phone_activity"
list="sensor-entities-list">
</div>
<ha-icon-button
data-entity-delete="${idx}">
<ha-icon icon="mdi:delete"></ha-icon>
</ha-icon-button>
</div>
`).join('');
this.innerHTML = `
<style>
.config-container {
padding: 20px;
max-width: 800px;
}
.config-header {
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
color: var(--primary-text-color);
padding-bottom: 12px;
border-bottom: 2px solid var(--divider-color);
}
.config-row {
margin: 20px 0;
}
.config-row ha-textfield,
.config-row ha-select {
width: 100%;
}
.config-note {
font-size: 12px;
color: var(--secondary-text-color);
margin-top: 6px;
font-style: italic;
}
.config-section {
margin: 32px 0;
padding: 20px;
background: var(--card-background-color);
border-radius: 12px;
border: 1px solid var(--divider-color);
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.config-section-header {
font-size: 16px;
font-weight: 600;
margin-bottom: 20px;
color: var(--primary-text-color);
display: flex;
align-items: center;
gap: 10px;
}
.config-section-header ha-icon {
flex-shrink: 0;
}
.config-item {
display: flex;
gap: 12px;
margin-bottom: 16px;
align-items: flex-end;
}
.add-button {
margin-top: 12px;
}
ha-icon-button[data-entity-delete],
ha-icon-button[data-zone-delete],
ha-icon-button[data-activity-delete] {
--mdc-icon-button-size: 40px;
color: var(--error-color);
flex-shrink: 0;
}
.input-wrapper {
display: flex;
flex-direction: column;
}
.input-wrapper label {
font-size: 12px;
color: var(--secondary-text-color);
margin-bottom: 4px;
font-weight: 500;
}
.entity-input {
width: 100%;
padding: 8px 12px;
border: 1px solid var(--divider-color);
border-radius: 4px;
background: var(--card-background-color);
color: var(--primary-text-color);
font-family: inherit;
font-size: 14px;
transition: border-color 0.2s;
}
.entity-input:focus {
outline: none;
border-color: var(--primary-color);
}
.entity-input::placeholder {
color: var(--secondary-text-color);
opacity: 0.5;
}
.radius-slider {
-webkit-appearance: none;
appearance: none;
height: 6px;
border-radius: 3px;
background: var(--divider-color);
outline: none;
transition: background 0.2s;
}
.radius-slider:hover {
background: var(--secondary-text-color);
}
.radius-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
transition: transform 0.2s;
}
.radius-slider::-webkit-slider-thumb:hover {
transform: scale(1.2);
}
.radius-slider::-moz-range-thumb {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--primary-color);
cursor: pointer;
border: none;
transition: transform 0.2s;
}
.radius-slider::-moz-range-thumb:hover {
transform: scale(1.2);
}
</style>
<div class="config-container">
<div class="config-header">Map Badge Card Configuration</div>
<div class="config-row">
<ha-select
id="map_provider"
label="Map Provider"
value="${this._config.map_provider || 'osm'}">
<mwc-list-item value="osm">OpenStreetMap (No API Key Required)</mwc-list-item>
<mwc-list-item value="google">Google Maps</mwc-list-item>
</ha-select>
<div class="config-note">OpenStreetMap is free and requires no authentication</div>
</div>
<div class="config-row" id="google-api-key-row" style="display: ${this._config.map_provider === 'google' ? 'block' : 'none'}">
<ha-textfield
id="google_api_key"
label="Google API Key"
value="${this._config.google_api_key || ''}"
placeholder="AIzaSy...">
</ha-textfield>
<div class="config-note">Required only for Google Maps - Get your API key from Google Cloud Console</div>
</div>
<div class="config-row" id="map-type-row" style="display: ${this._config.map_provider === 'google' ? 'block' : 'none'}">
<ha-select
id="map_type"
label="Map Type"
value="${this._config.map_type || 'hybrid'}">
<mwc-list-item value="hybrid">Hybrid</mwc-list-item>
<mwc-list-item value="satellite">Satellite</mwc-list-item>
<mwc-list-item value="roadmap">Roadmap</mwc-list-item>
<mwc-list-item value="terrain">Terrain</mwc-list-item>
</ha-select>
<div class="config-note">Map type (Google Maps only)</div>
</div>
<div class="config-row">
<ha-textfield
id="default_zoom"
label="Default Zoom"
value="${this._config.default_zoom || 13}"
type="number"
min="1"
max="21">
</ha-textfield>
<div class="config-note">1 = World view, 21 = Maximum zoom</div>
</div>
<div class="config-row">
<ha-textfield
id="update_interval"
label="Update Interval (seconds)"
value="${this._config.update_interval || 10}"
type="number"
min="1">
</ha-textfield>
<div class="config-note">How often to refresh location data</div>
</div>
<div class="config-row">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--primary-text-color);">
Marker Border Radius
</label>
<div style="display: flex; align-items: center; gap: 12px;">
<input
type="range"
id="marker_border_radius"
min="0"
max="50"
value="${parseInt(this._config.marker_border_radius) || 50}"
class="radius-slider"
style="flex: 1;">
<span id="marker-radius-value" style="min-width: 40px; text-align: right; font-weight: 600;">${this._config.marker_border_radius || '50%'}</span>
</div>
<div class="config-note">0% for square, 50% for circle</div>
</div>
<div class="config-row">
<label style="display: block; margin-bottom: 8px; font-weight: 500; color: var(--primary-text-color);">
Badge Border Radius
</label>
<div style="display: flex; align-items: center; gap: 12px;">
<input
type="range"
id="badge_border_radius"
min="0"
max="50"
value="${parseInt(this._config.badge_border_radius) || 50}"
class="radius-slider"
style="flex: 1;">
<span id="badge-radius-value" style="min-width: 40px; text-align: right; font-weight: 600;">${this._config.badge_border_radius || '50%'}</span>
</div>
<div class="config-note">0% for square, 50% for circle</div>
</div>
<div class="config-section">
<div class="config-section-header">
<ha-icon icon="mdi:account-multiple"></ha-icon>
Entities
</div>
<div id="entities-container">${entitiesHtml}</div>
${personDatalist}
${sensorDatalist}
<ha-button class="add-button" id="add-entity">
<ha-icon icon="mdi:plus" slot="icon"></ha-icon>
Add Entity
</ha-button>
</div>
<div class="config-section">
<div class="config-section-header">
<ha-icon icon="mdi:map-marker"></ha-icon>
Zone Configuration
</div>
<div id="zones-container">${zonesHtml}</div>
<ha-button class="add-button" id="add-zone">
<ha-icon icon="mdi:plus" slot="icon"></ha-icon>
Add Zone
</ha-button>
</div>
<div class="config-section">
<div class="config-section-header">
<ha-icon icon="mdi:walk"></ha-icon>
Activity Configuration
</div>
<div id="activities-container">${activitiesHtml}</div>
</div>
</div>
`;
// 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
});