First Commit
This commit is contained in:
commit
a4a7768cd5
7 changed files with 1866 additions and 0 deletions
138
README.md
Normal file
138
README.md
Normal 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.
|
||||
|
||||

|
||||

|
||||
|
||||

|
||||
|
||||
## 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
|
||||
|
||||

|
||||
|
||||
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
BIN
assets/gmaps.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
BIN
assets/gmaps_45.png
Normal file
BIN
assets/gmaps_45.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
assets/home.png
Normal file
BIN
assets/home.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 336 KiB |
BIN
assets/map.png
Normal file
BIN
assets/map.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 944 KiB |
1112
map-badge-card.js
Normal file
1112
map-badge-card.js
Normal file
File diff suppressed because it is too large
Load diff
616
map-badge-v2.html
Normal file
616
map-badge-v2.html
Normal 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>
|
||||
Loading…
Reference in a new issue