First Commit

This commit is contained in:
Nicole 2025-11-15 11:25:59 +01:00
commit a4a7768cd5
7 changed files with 1866 additions and 0 deletions

138
README.md Normal file
View file

@ -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
<table>
<tr>
<td><img src="assets/gmaps.png" alt="Google Maps" /></td>
<td><img src="assets/gmaps_45.png" alt="Google Maps 45°" /></td>
</tr>
<tr>
<td align="center">Standard view</td>
<td align="center">3D tilt at high zoom</td>
</tr>
</table>
## 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.

BIN
assets/gmaps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

BIN
assets/gmaps_45.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
assets/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

BIN
assets/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

1112
map-badge-card.js Normal file

File diff suppressed because it is too large Load diff

616
map-badge-v2.html Normal file
View file

@ -0,0 +1,616 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/MaterialDesign-Webfont/7.2.96/css/materialdesignicons.min.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style id="dynamic-styles">
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; overflow: hidden; }
#map { width: 100%; height: 100vh; }
.custom-marker-wrapper {
position: relative;
width: 48px;
height: 48px;
}
.custom-marker-image {
width: 48px;
height: 48px;
object-fit: cover;
display: block;
}
.custom-marker-badge {
position: absolute;
right: -2px;
bottom: -2px;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
color: white;
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
}
#refresh-button {
position: absolute;
top: 10px;
right: 10px;
background: rgba(255,255,255,0.95);
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 14px;
cursor: pointer;
z-index: 1000;
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
display: flex;
align-items: center;
gap: 4px;
}
#refresh-button:hover {
background: rgba(255,255,255,1);
}
/* Leaflet custom marker styling */
.custom-leaflet-marker {
background: transparent !important;
border: none !important;
}
</style>
</head>
<body>
<div id="map"></div>
<button id="refresh-button" title="Reset map to initial view">
<i class="mdi mdi-fit-to-screen"></i> Recenter
</button>
<script>
// Parse URL parameters for configuration
const urlParams = new URLSearchParams(window.location.search);
const MAP_PROVIDER = urlParams.get('provider') || 'osm'; // 'osm' or 'google'
const GOOGLE_API_KEY = urlParams.get('apikey') || 'YOUR_API_KEY_HERE';
const DEFAULT_ZOOM = parseInt(urlParams.get('zoom')) || 13;
const MAP_TYPE = urlParams.get('maptype') || 'hybrid';
const MODE = urlParams.get('mode') || 'proxy';
const DEBUG = urlParams.get('debug') === '1';
const TILT_ZOOM_THRESHOLD = parseInt(urlParams.get('tiltzoom')) || 18; // Zoom level to enable tilt
const MARKER_BORDER_RADIUS = decodeURIComponent(urlParams.get('marker_radius') || '50%');
const BADGE_BORDER_RADIUS = decodeURIComponent(urlParams.get('badge_radius') || '50%');
// Apply dynamic border-radius styles
const styleSheet = document.getElementById('dynamic-styles');
if (styleSheet && styleSheet.sheet) {
styleSheet.sheet.insertRule(`.custom-marker-image { border-radius: ${MARKER_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length);
styleSheet.sheet.insertRule(`.custom-marker-badge { border-radius: ${BADGE_BORDER_RADIUS}; }`, styleSheet.sheet.cssRules.length);
}
// Parse entities configuration
const entitiesParam = urlParams.get('entities') || '';
const ENTITIES = entitiesParam.split(',').map(e => {
const parts = e.trim().split(':');
return {
person: parts[0],
activity: parts[1] || null
};
}).filter(e => e.person);
// Parse zones configuration (zones only have colors now, no icons)
const zonesParam = urlParams.get('zones') || '';
const ZONES = {};
if (zonesParam) {
zonesParam.split(',').forEach(zone => {
const [state, color] = zone.split(':');
if (state && color) {
ZONES[state] = { color: decodeURIComponent(color) };
}
});
}
// Default zones if not configured
if (Object.keys(ZONES).length === 0) {
ZONES.home = { color: '#cef595' };
ZONES.not_home = { color: '#757575' };
}
// Parse activities configuration
const activitiesParam = urlParams.get('activities') || '';
const ACTIVITIES = {};
if (activitiesParam) {
activitiesParam.split(',').forEach(activity => {
const [state, icon, color] = activity.split(':');
if (state && icon && color) {
// Convert icon from 'mdi:icon-name' to 'mdi-icon-name' format
const iconClass = icon.replace(':', '-');
ACTIVITIES[state] = { icon: iconClass, color: decodeURIComponent(color) };
}
});
}
// Default activities if not configured (activities always have white icons on configurable backgrounds)
// By default, use black background if no activities are configured
if (Object.keys(ACTIVITIES).length === 0) {
ACTIVITIES.unknown = { icon: 'mdi-human-male', color: '#000000' };
}
let map;
let markers = {};
let entityData = {};
let lastUpdate = null;
let updateCount = 0;
let initialViewSet = false;
let isOSM = MAP_PROVIDER === 'osm';
// OpenStreetMap initialization
function initOSM() {
try {
map = L.map('map').setView([0, 0], DEFAULT_ZOOM);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 19
}).addTo(map);
console.log('OpenStreetMap initialized');
// Signal that map is ready
if (window.parent !== window) {
window.parent.postMessage({ type: 'iframe-ready' }, '*');
}
} catch (error) {
console.error('Error initializing OpenStreetMap:', error);
}
}
// Google Maps initialization
function initGoogleMaps() {
try {
// Determine map type ID
let mapTypeId;
switch(MAP_TYPE.toLowerCase()) {
case 'satellite':
mapTypeId = google.maps.MapTypeId.SATELLITE;
break;
case 'hybrid':
mapTypeId = google.maps.MapTypeId.HYBRID;
break;
case 'terrain':
mapTypeId = google.maps.MapTypeId.TERRAIN;
break;
case 'roadmap':
default:
mapTypeId = google.maps.MapTypeId.ROADMAP;
break;
}
map = new google.maps.Map(document.getElementById('map'), {
center: { lat: 0, lng: 0 },
zoom: DEFAULT_ZOOM,
mapTypeId: mapTypeId,
mapTypeControl: false,
mapTypeControlOptions: {
style: google.maps.MapTypeControlStyle.HORIZONTAL_BAR,
position: google.maps.ControlPosition.TOP_RIGHT
},
zoomControl: false,
zoomControlOptions: {
position: google.maps.ControlPosition.RIGHT_CENTER
},
streetViewControl: true,
fullscreenControl: false,
// Enable rotation controls
rotateControl: false,
rotateControlOptions: {
position: google.maps.ControlPosition.RIGHT_CENTER
},
// Smooth animations
gestureHandling: 'greedy',
// Allow tilt
tilt: 0 // Start flat, will tilt when zoomed in
});
// Listen for zoom changes to enable/disable tilt
map.addListener('zoom_changed', () => {
const currentZoom = map.getZoom();
const currentTilt = map.getTilt();
// Enable 45-degree tilt when zoomed in beyond threshold
if (currentZoom >= TILT_ZOOM_THRESHOLD && currentTilt === 0) {
map.setTilt(45);
console.log(`Tilt enabled at zoom level ${currentZoom}`);
}
// Disable tilt when zooming out
else if (currentZoom < TILT_ZOOM_THRESHOLD && currentTilt === 45) {
map.setTilt(0);
console.log(`Tilt disabled at zoom level ${currentZoom}`);
}
});
console.log('Google Maps initialized with tilt support');
} catch (error) {
console.error('Error initializing map:', error);
}
}
// Unified init function
function initMap() {
if (isOSM) {
initOSM();
} else {
initGoogleMaps();
}
}
function createMarkerHTML(personState, activityState, pictureUrl) {
const zoneConfig = ZONES[personState] || ZONES.not_home || { color: '#757575' };
const activityConfig = ACTIVITIES[activityState] || ACTIVITIES.unknown || { icon: 'mdi-human-male', color: '#000000' };
return `
<div class="custom-marker-wrapper">
<img
src="${pictureUrl}"
class="custom-marker-image"
style="border: 3px solid ${zoneConfig.color}"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 width=%2248%22 height=%2248%22><circle cx=%2224%22 cy=%2224%22 r=%2220%22 fill=%22%23cccccc%22/></svg>'">
<div class="custom-marker-badge" style="background: ${activityConfig.color}; color: white;">
<i class="mdi ${activityConfig.icon}"></i>
</div>
</div>
`;
}
function updateMarker(entityId, data) {
const lat = data.attributes.latitude;
const lon = data.attributes.longitude;
const personState = data.state;
const activityState = data.activity || 'unknown';
const pictureUrl = data.attributes.entity_picture || '';
if (!lat || !lon || isNaN(lat) || isNaN(lon)) {
console.warn(`Invalid GPS coordinates for ${entityId}:`, lat, lon);
return false;
}
try {
if (isOSM) {
return updateMarkerOSM(entityId, data, lat, lon, personState, activityState, pictureUrl);
} else {
return updateMarkerGoogle(entityId, data, lat, lon, personState, activityState, pictureUrl);
}
} catch (error) {
console.error(`Error updating marker for ${entityId}:`, error);
return false;
}
}
// OpenStreetMap marker update
function updateMarkerOSM(entityId, data, lat, lon, personState, activityState, pictureUrl) {
const friendlyName = data.attributes.friendly_name || entityId;
const activityLabel = activityState.replace(/_/g, ' ');
if (markers[entityId]) {
// Update existing marker
markers[entityId].setLatLng([lat, lon]);
// Update popup content
const popupContent = `<div style="padding: 8px;"><b>${friendlyName}</b><br>📍 ${personState}<br>🏃 ${activityLabel}</div>`;
markers[entityId].setPopupContent(popupContent);
// Update icon HTML
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
markers[entityId].setIcon(L.divIcon({
className: 'custom-leaflet-marker',
html: iconHtml,
iconSize: [48, 48],
iconAnchor: [24, 48],
popupAnchor: [0, -48]
}));
} else {
// Create new marker
const iconHtml = createMarkerHTML(personState, activityState, pictureUrl);
const icon = L.divIcon({
className: 'custom-leaflet-marker',
html: iconHtml,
iconSize: [48, 48],
iconAnchor: [24, 48],
popupAnchor: [0, -48]
});
const marker = L.marker([lat, lon], { icon: icon }).addTo(map);
const popupContent = `<div style="padding: 8px;"><b>${friendlyName}</b><br>📍 ${personState}<br>🏃 ${activityLabel}</div>`;
marker.bindPopup(popupContent);
markers[entityId] = marker;
}
return true;
}
// Google Maps marker update
function updateMarkerGoogle(entityId, data, lat, lon, personState, activityState, pictureUrl) {
const position = { lat: lat, lng: lon };
if (markers[entityId]) {
// Update existing marker
markers[entityId].setPosition(position);
// Update the custom overlay content
const overlayDiv = markers[entityId].overlayDiv;
if (overlayDiv) {
overlayDiv.innerHTML = createMarkerHTML(personState, activityState, pictureUrl);
}
} else {
// Create custom HTML overlay
class CustomMarker extends google.maps.OverlayView {
constructor(position, html, title) {
super();
this.position = position;
this.html = html;
this.title = title;
this.div = null;
}
onAdd() {
const div = document.createElement('div');
div.style.position = 'absolute';
div.style.cursor = 'pointer';
div.innerHTML = this.html;
div.title = this.title;
// Add click event for info window
div.addEventListener('click', () => {
if (this.infoWindow) {
this.infoWindow.open(this.getMap());
}
});
this.div = div;
const panes = this.getPanes();
panes.overlayMouseTarget.appendChild(div);
}
draw() {
const overlayProjection = this.getProjection();
const pos = overlayProjection.fromLatLngToDivPixel(
new google.maps.LatLng(this.position.lat, this.position.lng)
);
const div = this.div;
div.style.left = (pos.x - 24) + 'px'; // Center horizontally (48px / 2)
div.style.top = (pos.y - 48) + 'px'; // Position above point
}
onRemove() {
if (this.div) {
this.div.parentNode.removeChild(this.div);
this.div = null;
}
}
setPosition(newPosition) {
this.position = newPosition;
this.draw();
}
updateContent(html) {
if (this.div) {
this.div.innerHTML = html;
}
}
}
const friendlyName = data.attributes.friendly_name || entityId;
const activityLabel = activityState.replace(/_/g, ' ');
const marker = new CustomMarker(
position,
createMarkerHTML(personState, activityState, pictureUrl),
friendlyName
);
marker.setMap(map);
// Create info window
const infoWindow = new google.maps.InfoWindow({
content: `<div style="padding: 8px;"><b>${friendlyName}</b><br>📍 ${personState}<br>🏃 ${activityLabel}</div>`,
position: position
});
marker.infoWindow = infoWindow;
marker.overlayDiv = marker.div;
markers[entityId] = marker;
}
return true;
}
function fitMapToMarkers() {
if (Object.keys(markers).length === 0) {
return;
}
try {
if (isOSM) {
// OpenStreetMap (Leaflet)
if (Object.keys(markers).length === 1) {
const marker = Object.values(markers)[0];
map.setView(marker.getLatLng(), DEFAULT_ZOOM);
} else {
const group = L.featureGroup(Object.values(markers));
map.fitBounds(group.getBounds(), { padding: [50, 50] });
}
} else {
// Google Maps
if (Object.keys(markers).length === 1) {
const marker = Object.values(markers)[0];
map.setCenter(marker.position);
map.setZoom(DEFAULT_ZOOM);
} else {
const bounds = new google.maps.LatLngBounds();
Object.values(markers).forEach(marker => {
bounds.extend(marker.position);
});
map.fitBounds(bounds, { padding: 50 });
}
}
} catch (error) {
console.error('Error fitting map to markers:', error);
}
}
function updateAllMarkers() {
let successCount = 0;
let errorCount = 0;
for (const [entityId, data] of Object.entries(entityData)) {
if (updateMarker(entityId, data)) {
successCount++;
} else {
errorCount++;
}
}
if (!initialViewSet && Object.keys(markers).length > 0) {
fitMapToMarkers();
initialViewSet = true;
}
}
// Load Google Maps API only if needed
function loadGoogleMapsAPI() {
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${GOOGLE_API_KEY}&loading=async&callback=initMap`;
script.async = true;
script.defer = true;
script.onerror = () => {
console.error('Failed to load Google Maps API');
};
document.head.appendChild(script);
}
// Make initMap global for callback
window.initMap = initMap;
// Load appropriate map provider
if (isOSM) {
// OpenStreetMap - Leaflet is already loaded, just initialize
initMap();
} else {
// Google Maps - need to load API first
loadGoogleMapsAPI();
}
// Set up refresh button
document.getElementById('refresh-button').addEventListener('click', fitMapToMarkers);
// Listen for entity data from parent
window.addEventListener('message', (event) => {
if (!event.data || !event.data.type) {
return;
}
if (event.data.type === 'entity-update') {
const newData = event.data.data || {};
const hasData = Object.keys(newData).length > 0;
if (hasData) {
entityData = newData;
lastUpdate = event.data.timestamp;
updateCount++;
updateAllMarkers();
if (window.parent !== window) {
window.parent.postMessage({ type: 'data-received' }, '*');
}
}
} else if (event.data.type === 'config-update') {
// Handle dynamic config updates (zones, activities, border radius)
if (event.data.zones) {
Object.keys(ZONES).forEach(key => delete ZONES[key]);
Object.assign(ZONES, event.data.zones);
console.log('Zones updated:', ZONES);
}
if (event.data.activities) {
Object.keys(ACTIVITIES).forEach(key => delete ACTIVITIES[key]);
// Convert icon format from 'mdi:icon-name' to 'mdi-icon-name'
const convertedActivities = {};
Object.entries(event.data.activities).forEach(([state, config]) => {
convertedActivities[state] = {
...config,
icon: config.icon ? config.icon.replace(':', '-') : config.icon
};
});
Object.assign(ACTIVITIES, convertedActivities);
console.log('Activities updated:', ACTIVITIES);
}
if (event.data.marker_border_radius || event.data.badge_border_radius) {
// Update border radius styles
const styleSheet = document.getElementById('dynamic-styles');
if (styleSheet && styleSheet.sheet) {
// Remove old border-radius rules (they're the last two rules)
const rulesCount = styleSheet.sheet.cssRules.length;
if (rulesCount >= 2) {
styleSheet.sheet.deleteRule(rulesCount - 1);
styleSheet.sheet.deleteRule(rulesCount - 2);
}
// Add new border-radius rules
const markerRadius = event.data.marker_border_radius || '50%';
const badgeRadius = event.data.badge_border_radius || '50%';
styleSheet.sheet.insertRule(`.custom-marker-image { border-radius: ${markerRadius}; }`, styleSheet.sheet.cssRules.length);
styleSheet.sheet.insertRule(`.custom-marker-badge { border-radius: ${badgeRadius}; }`, styleSheet.sheet.cssRules.length);
console.log('Border radius updated:', markerRadius, badgeRadius);
}
}
// Re-render all markers with new config
updateAllMarkers();
if (window.parent !== window) {
window.parent.postMessage({ type: 'config-received' }, '*');
}
}
});
// Send ready signals to parent
function sendReadySignal() {
if (window.parent !== window) {
window.parent.postMessage({ type: 'iframe-ready' }, '*');
}
}
sendReadySignal();
setTimeout(sendReadySignal, 500);
setTimeout(sendReadySignal, 1500);
setTimeout(sendReadySignal, 3000);
// Request data periodically if not receiving updates
let requestCount = 0;
setInterval(() => {
const now = Date.now();
const timeSinceUpdate = lastUpdate ? (now - lastUpdate) / 1000 : Infinity;
if (timeSinceUpdate > 20) {
requestCount++;
if (window.parent !== window) {
window.parent.postMessage({ type: 'request-data' }, '*');
window.parent.postMessage({ type: 'iframe-ready' }, '*');
}
}
}, 10000);
</script>
</body>
</html>