-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBetter_Map_Widget.json
More file actions
134 lines (134 loc) · 43.1 KB
/
Better_Map_Widget.json
File metadata and controls
134 lines (134 loc) · 43.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
{
"santabaRelease": 235,
"defaultDashboardFilters": {
"defaultDashboardFilterDetails": []
},
"widgetTokens": [
{
"name": "MapGroupPathFilter",
"value": "*"
},
{
"name": "MapShowWeather",
"value": "global"
},
{
"name": "MapIgnoreCleared",
"value": "false"
},
{
"name": "MapSourceType",
"value": "resources"
},
{
"name": "HideMapOptions",
"value": "false"
},
{
"name": "MapOverlayOption",
"value": "earthquakes"
},
{
"name": "MapIgnoreWarnings",
"value": "false"
},
{
"name": "MapIgnoreErrors",
"value": "false"
},
{
"name": "MapIgnoreCriticals",
"value": "false"
},
{
"name": "isMapWidgetVirtualizationDisabled",
"value": "true"
},
{
"name": "AutoResetMapOnRefresh",
"value": "false"
},
{
"name": "MapShowTiltControls",
"value": "true"
},
{
"name": "MapDisplayProperties",
"value": "region"
},
{
"name": "MapDisableClustering",
"value": "false"
},
{
"name": "MapStyle",
"value": "silverblue"
},
{
"name": "MapShowRoadLabels",
"value": "false"
},
{
"name": "MapLocationProperty",
"value": "location"
},
{
"name": "ShowMapSidebar",
"value": "false"
},
{
"name": "MapMarkerStyle",
"value": "default"
}
],
"name": "Better Map Widget",
"description": "This is a custom-developed widget to demonstrate several concepts I'd love to see rolled into LogicMonitor's core Map widget.",
"overwriteGroupFields": false,
"widgetsConfigVersion": 2,
"type": "dashboard",
"widgets": [
{
"position": {
"col": 1,
"sizex": 10,
"row": 1,
"sizey": 12
},
"config": {
"displaySettings": {},
"isSupportCustomProperty": false,
"supportCustomProperty": false,
"name": "Better Map Widget",
"description": "",
"theme": "newBorderGray",
"interval": 15,
"type": "text",
"timescale": "day",
"version": 2,
"content": "<!--\n\tBetter Map Widget\n\tVersion 1.21 - CDN Version\n\tDeveloped by Kevin Ford\n\n\tSome of the ideas behind this project:\n\t* Support for thousands pins on the map, though be aware that Google Maps will start to struggle if too many pins.\n\t* Adjacent pins get grouped/clustered together for a cleaner map display.\n\t* Clusters use a donut chart to represent the severities of the grouped markers.\n\t* Easy toggling of weather layers.\n\t* Display more information when clicking a marker.\n\t* Quick & easy filtering of what's displayed on the map.\n\t* Provide a way to plot connections between resources (and eventually groups).\n\n\tPrerequisites:\n\t* The user needs \"View\" permissions to the dashboard plus any displayed groups and/or resources.\n\t* Group's or resources with valid addresses set in the usual 'location' property.\n\n\tTO USE:\n\t1. Create a new Text widget to the dashboard.\n\t2. On the Text widget's edit dialog, click the \"Source\" button and paste in the contents of this script, then save the widget.\n\t3. At this point the widget should be ready to use. You can customize the defaults for the widget either via dashboard tokens or by changing variables here within the script.\n\t For instance, the script defaults to using \"*\" for the group filter. You can change the default by setting a \"MapGroupPathFilter\" dashboard token, or by hard-coding it in the script's \"groupPathFilter\" variable below.\n\t NOTE: defaults set via dashboard tokens will take precedence over those hard-coded in the script.\n\n\tOPTIONAL DASHBOARD TOKENS:\n\t* 'MapGroupPathFilter': Allows setting a default group path to start. Default is \"*\".\n\t* 'MapLocationProperty': The property to use for the location of the items on the map. Default is \"location\".\n\t* 'MapMarkerStyle': The style of marker to use for the items on the map. Use \"pins\" (default pin markers) or \"circles\" (color-coded circles). Legacy values \"default\", \"pin\", \"dot\", \"dots\", and \"circle\" are also accepted. Default is \"pins\".\n\t* 'MapShowWeather': If weather should be shown by default. Options are \"global\", \"nexrad\", \"openweather\", or \"xweather\". Default is \"no\".\n\t* 'OpenWeatherAPIKey': API key for OpenWeather radar tiles (required when using \"openweather\" weather option).\n\t* 'XweatherAPIID': API ID for Xweather radar tiles (required when using \"xweather\" weather option).\n\t* 'XweatherAPIKey': API secret for Xweather radar tiles (required when using \"xweather\" weather option).\n\t* 'MapOverlayOption': Which optional overlay to default to when weather is shown. Options are \"wildfires\", \"us-wildfires\", \"outages\", \"us-poweroutages\", \"us-flooding\" or \"earthquakes\". Default is \"earthquakes\".\n\t* 'HideMapOptions': If \"true\" then will hide the options bar by default. Default is \"false\".\n\t* 'ShowMapSidebar': If \"true\" then will show the sidebar by default. Default is \"false\".\n\t* 'MapSourceType': Whether to map \"groups\", \"resources\", or \"services\". Default is \"groups\".\n\t* 'MapIgnoreCleared': If \"true\" then will only show items currently alerting (useful for maps with thousands of markers). Default is \"false\".\n\t* 'MapIgnoreWarnings': If \"true\" then won't show items in \"Warning\" status. Default is \"false\".\n\t* 'MapIgnoreErrors': If \"true\" then won't show items in \"Error\" status. Default is \"false\".\n\t* 'MapIgnoreCriticals': If \"true\" then won't show items in \"Critical\" status. Default is \"false\".\n\t* 'MapIgnoreSDT': If \"true\" then won't show items in \"SDT\" status. Default is \"false\".\n\t* 'AutoResetMapOnRefresh': If \"true\" then the map will automatically zoom to encompass all items on timed refreshes. Default is \"false\".\n\t* 'MapShowTiltControls': If \"true\" then the tilt & rotation controls for the map will be shown. Default is \"false\".\n\t* 'MapDisableClustering': If \"true\" then clustering of adjacent markers on the map will be disabled. Might be desirable if showing connections between locations since clustering might hide markers at certain zoom levels. Default is \"false\".\n\t* 'MapDisplayProperties': An optional comma-delimited list of custom properties to show when viewing a group's/resource's details.\n\t* 'MapStyle': Allows one of the following available map style options: \"silver\", \"standard\", \"dark\", \"aubergine\", \"satellite\", or \"silverblue\". Default is \"silverblue\".\n\t* 'MapShowRoadLabels': If \"true\" then road labels will be shown on the map. Default is \"false\".\n\t* 'apiBearerToken', or 'apiID' + 'apiKey': Optional LogicMonitor API bearer token or API ID & key to use for the widget. If not specified then the widget will use integrated portal authentication.\n\n\tLIMITATIONS:\n\t* This widget is currently unable to leverage LogicMonitor's native dashboard filters.\n\t* Being custom-developed, LogicMonitor's support teams will be limited in what they can assist with regarding questions/issues.\n--><!-- Here you can change specific defaults for the map. Many of these can be overridden via the dashboard tokens mentioned above. --><script>\n\t// Optional: You can specify a LogicMonitor API bearer token or API ID & key to use for the widget...\n\tlet apiBearerToken = \"\";\n\tlet lmAPIID = \"\";\n\tlet lmAPIKey = \"\";\n\n\t// Whether we're plotting \"groups\" or \"resources\" or \"services\" (strongly recommend staying with groups or services)...\n\t// You can either set it here or in a dashboard token named 'MapSourceType'...\n\tlet mapSourceType = \"groups\";\n\n\t// The property to use for the location of the items on the map. Default is \"location\"...\n\t// You can either set it here or in a dashboard token named 'MapLocationProperty'...\n\tlet mapLocationProperty = \"location\";\n\n\t// Preferred map style. Available options: \"silver\" (the default), \"standard\", \"dark\", \"aubergine\", \"silverblue\", \"satellite\", or \"satellite-light\"...\n\tlet mapStyle = \"silverblue\";\n\n\t// Whether to ignore items with no active alerts (useful for maps with thousands of markers)...\n\t// You can either set it here or in a dashboard token named 'MapIgnoreCleared'...\n\tlet showCleared = true;\n\tlet showWarnings = true;\n\tlet showErrors = true;\n\tlet showCriticals = true;\n\tlet showSDT = true;\n\n\t// Capture if a group filter...\n\t// You can set it here or in a dashboard token named \"MapGroupPathFilter\"...\n\tlet groupPathFilter = \"*\";\n\n\t// Interval for updating group status data (in minutes)...\n\tlet statusUpdateIntervalMinutes = 2;\n\n\t// Flag to disable marker clustering if needed...\n\tlet disableClustering = false;\n\n\t// Whether to show weather by default. Options are: \"no\", \"global\", \"nexrad\", \"openweather\", \"xweather\"...\n\t// You can set it here or in a dashboard token named \"MapShowWeather\"...\n\tlet showWeatherDefault = \"no\";\n\n\t// If weather is shown, whether to show \"wildfires\" or \"us-wildfires\" or \"outages\" or \"us-poweroutages\" or \"us-flooding\" or \"earthquakes\"...\n\t// You can set it here or in a dashboard token named \"MapOverlayOption\"...\n\tlet additionalOverlayOption = \"earthquakes\";\n\n\t// Whether to show or hide the map options along the top of the widget by default...\n\t// You can set it here or in a dashboard token named \"HideMapOptions\"...\n\tlet hideMapOptionsByDefault = false;\n\n\t// Whether to show the map sidebar by default...\n\t// You can set it here or in a dashboard token named \"ShowMapSidebar\"...\n\tlet showMapSidebarByDefault = false;\n\n\t// Whether to automatically center the map to encompass all items during timed refreshes...\n\t// You can set it here or in a dashboard token named \"AutoResetMapOnRefresh\"...\n\tlet autoResetMapOnRefresh = false;\n\n\t// When true will not refresh the data on a timed interval (useful ONLY during development)...\n\tlet developmentFlag = false;\n\n\t// Since we generally don't need to poll all properties every time, we can just grab them initially then occasionally every x number of polls based on the following variable (set to 0 to perform a full refresh every time)...\n\tconst fullRefreshInterval = 0;\n\n\t// Optional angle & heading for the Google Map...\n\tlet showMapTiltControls = false;\n\tlet mapTilt = 0;\n\tlet mapHeading = 0;\n\n\t// Whether to include inherited locations in addition to those directly set on resources and/or services (disabling this can greatly increase refresh speed)...\n\tconst pollInheritedLocations = true;\n\n\t// Typically if both a 'latitude' & 'longitude' property are set, then we can assume the address is already geocoded. Set this to \"true\" to force geocoding the address instead...\n\tconst ignoreLatLongProps = false;\n\n\t// Whether the Google Maps uses the \"cooperative\" gesture handling, or \"greedy\" that allows mouse-wheel zooming without having to hold a modifier key (Google's default is \"cooperative\")...\n\tconst mapGestureHandling = \"cooperative\";\n\t// Whether to show road labels...\n\tlet showRoadLabels = \"off\";\n\n\t// An optional comma-delimited list of custom properties to show when viewing a group's/resource's details...\n\t// You can set it here or in a dashboard token named \"MapDisplayProperties\"...\n\tlet displayProps = \"\";\n\n\t// Property to look for connecting information in...\n\tconst connectionInfoProp = \"auto.custom_map_connection_data\";\n\t// Stroke weight of connecting lines...\n\tconst connectingLineWeight = 3;\n\t// Whether to use geodesic lines when connecting two locations (I recommend not so it just plots a straight line vs curve of the Earth)...\n\tconst useGeodesicLines = false;\n\n\t// OpenWeather API key (required for OpenWeather radar)...\n\t// You can set it here or in a dashboard token named \"OpenWeatherAPIKey\"...\n\tlet openWeatherAPIKey = \"\";\n\n\t// Xweather API credentials (required for Xweather radar)...\n\t// You can set them here or in dashboard tokens named \"XweatherAPIID\" and \"XweatherAPIKey\"...\n\tlet xweatherAPIID = \"\";\n\tlet xweatherAPIKey = \"\";\n\n\t// Default opacity for weather layers...\n\tlet weatherOpacity = 0.35;\n\t// Default opacity for satellite weather layers...\n\tconst satelliteWeatherOpacity = 0.6;\n\t// Color scheme for the \"global\" weather option...\n\t// See https://rainviewer.com/api/color-schemes.html for color scheme options (values are 0-8)...\n\tconst rvOptionColorScheme = 8;\n\t// Weather refresh interval in minutes...\n\tconst weatherRefreshMinutes = 5;\n\t// Whether to display details about a wildfire on \"click\" or \"mouseover\"...\n\tconst showWildfireInfoEvent = \"click\";\n\t// Whether the opacity of an earthquake's icon reflects \"time\" since the event, or \"magnitude\"...\n\tlet quakeMode = \"time\";\n</script><!-- Load our Javascript from the CDN... --><script>document.write('<script src=\"https://cdn.jsdelivr.net/gh/logicmonitor/custom_widgets@main/src/Better_Map_Widget.js?ts=' + Date.now() + '\" defer><\\/script>');</script><!-- Load our CSS styles from the CDN... --><script>document.write('<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/gh/logicmonitor/custom_widgets@main/src/Better_Map_Widget.css?ts=' + Date.now() + '\" />');</script><!-- Our main widget body... --><div class=\"customMapBody\"> </div><!-- Area for capturing dashboard tokens... --><div id=\"tokenCaptureArea\" style=\"display: none;\"><div id=\"mapSourceTypeToken\">##MapSourceType##</div><div id=\"mapLocationPropertyToken\">##MapLocationProperty##</div><div id=\"mapStyleToken\">##MapStyle##</div><div id=\"hideMapOptionsByDefaultToken\">##HideMapOptions##</div><div id=\"showMapSidebarByDefaultToken\">##ShowMapSidebar##</div><div id=\"ignoreClearedToken\">##MapIgnoreCleared##</div><div id=\"ignoreWarningsToken\">##MapIgnoreWarnings##</div><div id=\"ignoreErrorsToken\">##MapIgnoreErrors##</div><div id=\"ignoreCriticalsToken\">##MapIgnoreCriticals##</div><div id=\"ignoreSDTToken\">##MapIgnoreSDT##</div><div id=\"showMapTiltControlsToken\">##MapShowTiltControls##</div><div id=\"autoResetMapOnRefreshToken\">##AutoResetMapOnRefresh##</div><div id=\"dashboardGroupPathToken\">##MapGroupPathFilter##</div><div id=\"dashboardShowWeatherToken\">##MapShowWeather##</div><div id=\"openWeatherAPIKeyToken\">##OpenWeatherAPIKey##</div><div id=\"xweatherAPIIDToken\">##XweatherAPIID##</div><div id=\"xweatherAPIKeyToken\">##XweatherAPIKey##</div><div id=\"dashboardAddlOverlayToken\">##MapOverlayOption##</div><div id=\"displayPropsToken\">##MapDisplayProperties##</div><div id=\"disableClusteringToken\">##MapDisableClustering##</div><div id=\"showRoadLabelsToken\">##MapShowRoadLabels##</div><div id=\"apiBearerTokenToken\">##apiBearerToken##</div><div id=\"apiIDToken\">##apiID##</div><div id=\"apiKeyToken\">##apiKey##</div><div id=\"markerStyleToken\">##MapMarkerStyle##</div></div><!-- If used as an embedded widget outside a normal LM dashboard then trying to load Google's marker clustering script externally breaks due to XSS, so I'm embedding the entire script here. Normally we would load this from https://unpkg.com/@googlemaps/markerclusterer@2.5.3/dist/index.min.js.--><script>\n\tconst markerClusterer=function(t){\"use strict\";function e(t,e){var s={};for(var r in t)Object.prototype.hasOwnProperty.call(t,r)&&e.indexOf(r)<0&&(s[r]=t[r]);if(null!=t&&\"function\"==typeof Object.getOwnPropertySymbols){var o=0;for(r=Object.getOwnPropertySymbols(t);o<r.length;o++)e.indexOf(r[o])<0&&Object.prototype.propertyIsEnumerable.call(t,r[o])&&(s[r[o]]=t[r[o]])}return s}class s{static isAdvancedMarkerAvailable(t){return google.maps.marker&&!0===t.getMapCapabilities().isAdvancedMarkersAvailable}static isAdvancedMarker(t){return google.maps.marker&&t instanceof google.maps.marker.AdvancedMarkerElement}static setMap(t,e){this.isAdvancedMarker(t)?t.map=e:t.setMap(e)}static getPosition(t){if(this.isAdvancedMarker(t)){if(t.position){if(t.position instanceof google.maps.LatLng)return t.position;if(t.position.lat&&t.position.lng)return new google.maps.LatLng(t.position.lat,t.position.lng)}return new google.maps.LatLng(null)}return t.getPosition()}static getVisible(t){return!!this.isAdvancedMarker(t)||t.getVisible()}}class r{constructor(t){let{markers:e,position:s}=t;this.markers=e,s&&(s instanceof google.maps.LatLng?this._position=s:this._position=new google.maps.LatLng(s))}get bounds(){if(0===this.markers.length&&!this._position)return;const t=new google.maps.LatLngBounds(this._position,this._position);for(const e of this.markers)t.extend(s.getPosition(e));return t}get position(){return this._position||this.bounds.getCenter()}get count(){return this.markers.filter((t=>s.getVisible(t))).length}push(t){this.markers.push(t)}delete(){this.marker&&(s.setMap(this.marker,null),this.marker=void 0),this.markers.length=0}}const o=(t,e,r,o)=>{const n=i(t.getBounds(),e,o);return r.filter((t=>n.contains(s.getPosition(t))))},i=(t,e,s)=>{const{northEast:r,southWest:o}=h(t,e),i=l({northEast:r,southWest:o},s);return c(i,e)},n=(t,e,s)=>{const r=i(t,e,s),o=r.getNorthEast(),n=r.getSouthWest();return[n.lng(),n.lat(),o.lng(),o.lat()]},a=(t,e)=>{const s=(e.lat-t.lat)*Math.PI/180,r=(e.lng-t.lng)*Math.PI/180,o=Math.sin(s/2),i=Math.sin(r/2),n=o*o+Math.cos(t.lat*Math.PI/180)*Math.cos(e.lat*Math.PI/180)*i*i;return 6371*(2*Math.atan2(Math.sqrt(n),Math.sqrt(1-n)))},h=(t,e)=>({northEast:e.fromLatLngToDivPixel(t.getNorthEast()),southWest:e.fromLatLngToDivPixel(t.getSouthWest())}),l=(t,e)=>{let{northEast:s,southWest:r}=t;return s.x+=e,s.y-=e,r.x-=e,r.y+=e,{northEast:s,southWest:r}},c=(t,e)=>{let{northEast:s,southWest:r}=t;const o=e.fromDivPixelToLatLng(r),i=e.fromDivPixelToLatLng(s);return new google.maps.LatLngBounds(o,i)};class u{constructor(t){let{maxZoom:e=16}=t;this.maxZoom=e}noop(t){let{markers:e}=t;return m(e)}}class p extends u{constructor(t){var{viewportPadding:s=60}=t;super(e(t,[\"viewportPadding\"])),this.viewportPadding=60,this.viewportPadding=s}calculate(t){let{markers:e,map:s,mapCanvasProjection:r}=t;return s.getZoom()>=this.maxZoom?{clusters:this.noop({markers:e}),changed:!1}:{clusters:this.cluster({markers:o(s,r,e,this.viewportPadding),map:s,mapCanvasProjection:r})}}}const m=t=>t.map((t=>new r({position:s.getPosition(t),markers:[t]})));function d(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,\"default\")?t.default:t}var g=function t(e,s){if(e===s)return!0;if(e&&s&&\"object\"==typeof e&&\"object\"==typeof s){if(e.constructor!==s.constructor)return!1;var r,o,i;if(Array.isArray(e)){if((r=e.length)!=s.length)return!1;for(o=r;0!=o--;)if(!t(e[o],s[o]))return!1;return!0}if(e.constructor===RegExp)return e.source===s.source&&e.flags===s.flags;if(e.valueOf!==Object.prototype.valueOf)return e.valueOf()===s.valueOf();if(e.toString!==Object.prototype.toString)return e.toString()===s.toString();if((r=(i=Object.keys(e)).length)!==Object.keys(s).length)return!1;for(o=r;0!=o--;)if(!Object.prototype.hasOwnProperty.call(s,i[o]))return!1;for(o=r;0!=o--;){var n=i[o];if(!t(e[n],s[n]))return!1}return!0}return e!=e&&s!=s},f=d(g);const k=[Int8Array,Uint8Array,Uint8ClampedArray,Int16Array,Uint16Array,Int32Array,Uint32Array,Float32Array,Float64Array];class w{static from(t){if(!(t instanceof ArrayBuffer))throw new Error(\"Data must be an instance of ArrayBuffer.\");const[e,s]=new Uint8Array(t,0,2);if(219!==e)throw new Error(\"Data does not appear to be in a KDBush format.\");const r=s>>4;if(1!==r)throw new Error(`Got v${r} data when expected v1.`);const o=k[15&s];if(!o)throw new Error(\"Unrecognized array type.\");const[i]=new Uint16Array(t,2,1),[n]=new Uint32Array(t,4,1);return new w(n,i,o,t)}constructor(t,e=64,s=Float64Array,r){if(isNaN(t)||t<0)throw new Error(`Unpexpected numItems value: ${t}.`);this.numItems=+t,this.nodeSize=Math.min(Math.max(+e,2),65535),this.ArrayType=s,this.IndexArrayType=t<65536?Uint16Array:Uint32Array;const o=k.indexOf(this.ArrayType),i=2*t*this.ArrayType.BYTES_PER_ELEMENT,n=t*this.IndexArrayType.BYTES_PER_ELEMENT,a=(8-n%8)%8;if(o<0)throw new Error(`Unexpected typed array class: ${s}.`);r&&r instanceof ArrayBuffer?(this.data=r,this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+n+a,2*t),this._pos=2*t,this._finished=!0):(this.data=new ArrayBuffer(8+i+n+a),this.ids=new this.IndexArrayType(this.data,8,t),this.coords=new this.ArrayType(this.data,8+n+a,2*t),this._pos=0,this._finished=!1,new Uint8Array(this.data,0,2).set([219,16+o]),new Uint16Array(this.data,2,1)[0]=e,new Uint32Array(this.data,4,1)[0]=t)}add(t,e){const s=this._pos>>1;return this.ids[s]=s,this.coords[this._pos++]=t,this.coords[this._pos++]=e,s}finish(){const t=this._pos>>1;if(t!==this.numItems)throw new Error(`Added ${t} items when expected ${this.numItems}.`);return y(this.ids,this.coords,this.nodeSize,0,this.numItems-1,0),this._finished=!0,this}range(t,e,s,r){if(!this._finished)throw new Error(\"Data not yet indexed - call index.finish().\");const{ids:o,coords:i,nodeSize:n}=this,a=[0,o.length-1,0],h=[];for(;a.length;){const l=a.pop()||0,c=a.pop()||0,u=a.pop()||0;if(c-u<=n){for(let n=u;n<=c;n++){const a=i[2*n],l=i[2*n+1];a>=t&&a<=s&&l>=e&&l<=r&&h.push(o[n])}continue}const p=u+c>>1,m=i[2*p],d=i[2*p+1];m>=t&&m<=s&&d>=e&&d<=r&&h.push(o[p]),(0===l?t<=m:e<=d)&&(a.push(u),a.push(p-1),a.push(1-l)),(0===l?s>=m:r>=d)&&(a.push(p+1),a.push(c),a.push(1-l))}return h}within(t,e,s){if(!this._finished)throw new Error(\"Data not yet indexed - call index.finish().\");const{ids:r,coords:o,nodeSize:i}=this,n=[0,r.length-1,0],a=[],h=s*s;for(;n.length;){const l=n.pop()||0,c=n.pop()||0,u=n.pop()||0;if(c-u<=i){for(let s=u;s<=c;s++)C(o[2*s],o[2*s+1],t,e)<=h&&a.push(r[s]);continue}const p=u+c>>1,m=o[2*p],d=o[2*p+1];C(m,d,t,e)<=h&&a.push(r[p]),(0===l?t-s<=m:e-s<=d)&&(n.push(u),n.push(p-1),n.push(1-l)),(0===l?t+s>=m:e+s>=d)&&(n.push(p+1),n.push(c),n.push(1-l))}return a}}function y(t,e,s,r,o,i){if(o-r<=s)return;const n=r+o>>1;M(t,e,n,r,o,i),y(t,e,s,r,n-1,1-i),y(t,e,s,n+1,o,1-i)}function M(t,e,s,r,o,i){for(;o>r;){if(o-r>600){const n=o-r+1,a=s-r+1,h=Math.log(n),l=.5*Math.exp(2*h/3),c=.5*Math.sqrt(h*l*(n-l)/n)*(a-n/2<0?-1:1);M(t,e,s,Math.max(r,Math.floor(s-a*l/n+c)),Math.min(o,Math.floor(s+(n-a)*l/n+c)),i)}const n=e[2*s+i];let a=r,h=o;for(v(t,e,r,s),e[2*o+i]>n&&v(t,e,r,o);a<h;){for(v(t,e,a,h),a++,h--;e[2*a+i]<n;)a++;for(;e[2*h+i]>n;)h--}e[2*r+i]===n?v(t,e,r,h):(h++,v(t,e,h,o)),h<=s&&(r=h+1),s<=h&&(o=h-1)}}function v(t,e,s,r){x(t,s,r),x(e,2*s,2*r),x(e,2*s+1,2*r+1)}function x(t,e,s){const r=t[e];t[e]=t[s],t[s]=r}function C(t,e,s,r){const o=t-s,i=e-r;return o*o+i*i}const P={minZoom:0,maxZoom:16,minPoints:2,radius:40,extent:512,nodeSize:64,log:!1,generateId:!1,reduce:null,map:t=>t},_=Math.fround||(E=new Float32Array(1),t=>(E[0]=+t,E[0]));var E;const A=3,b=5,L=6;class O{constructor(t){this.options=Object.assign(Object.create(P),t),this.trees=new Array(this.options.maxZoom+1),this.stride=this.options.reduce?7:6,this.clusterProps=[]}load(t){const{log:e,minZoom:s,maxZoom:r}=this.options;e&&console.time(\"total time\");const o=`prepare ${t.length} points`;e&&console.time(o),this.points=t;const i=[];for(let e=0;e<t.length;e++){const s=t[e];if(!s.geometry)continue;const[r,o]=s.geometry.coordinates,n=_(T(r)),a=_(j(o));i.push(n,a,1/0,e,-1,1),this.options.reduce&&i.push(0)}let n=this.trees[r+1]=this._createTree(i);e&&console.timeEnd(o);for(let t=r;t>=s;t--){const s=+Date.now();n=this.trees[t]=this._createTree(this._cluster(n,t)),e&&console.log(\"z%d: %d clusters in %dms\",t,n.numItems,+Date.now()-s)}return e&&console.timeEnd(\"total time\"),this}getClusters(t,e){let s=((t[0]+180)%360+360)%360-180;const r=Math.max(-90,Math.min(90,t[1]));let o=180===t[2]?180:((t[2]+180)%360+360)%360-180;const i=Math.max(-90,Math.min(90,t[3]));if(t[2]-t[0]>=360)s=-180,o=180;else if(s>o){const t=this.getClusters([s,r,180,i],e),n=this.getClusters([-180,r,o,i],e);return t.concat(n)}const n=this.trees[this._limitZoom(e)],a=n.range(T(s),j(i),T(o),j(r)),h=n.data,l=[];for(const t of a){const e=this.stride*t;l.push(h[e+b]>1?Z(h,e,this.clusterProps):this.points[h[e+A]])}return l}getChildren(t){const e=this._getOriginId(t),s=this._getOriginZoom(t),r=\"No cluster with the specified id.\",o=this.trees[s];if(!o)throw new Error(r);const i=o.data;if(e*this.stride>=i.length)throw new Error(r);const n=this.options.radius/(this.options.extent*Math.pow(2,s-1)),a=i[e*this.stride],h=i[e*this.stride+1],l=o.within(a,h,n),c=[];for(const e of l){const s=e*this.stride;i[s+4]===t&&c.push(i[s+b]>1?Z(i,s,this.clusterProps):this.points[i[s+A]])}if(0===c.length)throw new Error(r);return c}getLeaves(t,e,s){e=e||10,s=s||0;const r=[];return this._appendLeaves(r,t,e,s,0),r}getTile(t,e,s){const r=this.trees[this._limitZoom(t)],o=Math.pow(2,t),{extent:i,radius:n}=this.options,a=n/i,h=(s-a)/o,l=(s+1+a)/o,c={features:[]};return this._addTileFeatures(r.range((e-a)/o,h,(e+1+a)/o,l),r.data,e,s,o,c),0===e&&this._addTileFeatures(r.range(1-a/o,h,1,l),r.data,o,s,o,c),e===o-1&&this._addTileFeatures(r.range(0,h,a/o,l),r.data,-1,s,o,c),c.features.length?c:null}getClusterExpansionZoom(t){let e=this._getOriginZoom(t)-1;for(;e<=this.options.maxZoom;){const s=this.getChildren(t);if(e++,1!==s.length)break;t=s[0].properties.cluster_id}return e}_appendLeaves(t,e,s,r,o){const i=this.getChildren(e);for(const e of i){const i=e.properties;if(i&&i.cluster?o+i.point_count<=r?o+=i.point_count:o=this._appendLeaves(t,i.cluster_id,s,r,o):o<r?o++:t.push(e),t.length===s)break}return o}_createTree(t){const e=new w(t.length/this.stride|0,this.options.nodeSize,Float32Array);for(let s=0;s<t.length;s+=this.stride)e.add(t[s],t[s+1]);return e.finish(),e.data=t,e}_addTileFeatures(t,e,s,r,o,i){for(const n of t){const t=n*this.stride,a=e[t+b]>1;let h,l,c;if(a)h=I(e,t,this.clusterProps),l=e[t],c=e[t+1];else{const s=this.points[e[t+A]];h=s.properties;const[r,o]=s.geometry.coordinates;l=T(r),c=j(o)}const u={type:1,geometry:[[Math.round(this.options.extent*(l*o-s)),Math.round(this.options.extent*(c*o-r))]],tags:h};let p;p=a||this.options.generateId?e[t+A]:this.points[e[t+A]].id,void 0!==p&&(u.id=p),i.features.push(u)}}_limitZoom(t){return Math.max(this.options.minZoom,Math.min(Math.floor(+t),this.options.maxZoom+1))}_cluster(t,e){const{radius:s,extent:r,reduce:o,minPoints:i}=this.options,n=s/(r*Math.pow(2,e)),a=t.data,h=[],l=this.stride;for(let s=0;s<a.length;s+=l){if(a[s+2]<=e)continue;a[s+2]=e;const r=a[s],c=a[s+1],u=t.within(a[s],a[s+1],n),p=a[s+b];let m=p;for(const t of u){const s=t*l;a[s+2]>e&&(m+=a[s+b])}if(m>p&&m>=i){let t,i=r*p,n=c*p,d=-1;const g=((s/l|0)<<5)+(e+1)+this.points.length;for(const r of u){const h=r*l;if(a[h+2]<=e)continue;a[h+2]=e;const c=a[h+b];i+=a[h]*c,n+=a[h+1]*c,a[h+4]=g,o&&(t||(t=this._map(a,s,!0),d=this.clusterProps.length,this.clusterProps.push(t)),o(t,this._map(a,h)))}a[s+4]=g,h.push(i/m,n/m,1/0,g,-1,m),o&&h.push(d)}else{for(let t=0;t<l;t++)h.push(a[s+t]);if(m>1)for(const t of u){const s=t*l;if(!(a[s+2]<=e)){a[s+2]=e;for(let t=0;t<l;t++)h.push(a[s+t])}}}}return h}_getOriginId(t){return t-this.points.length>>5}_getOriginZoom(t){return(t-this.points.length)%32}_map(t,e,s){if(t[e+b]>1){const r=this.clusterProps[t[e+L]];return s?Object.assign({},r):r}const r=this.points[t[e+A]].properties,o=this.options.map(r);return s&&o===r?Object.assign({},o):o}}function Z(t,e,s){return{type:\"Feature\",id:t[e+A],properties:I(t,e,s),geometry:{type:\"Point\",coordinates:[(r=t[e],360*(r-.5)),S(t[e+1])]}};var r}function I(t,e,s){const r=t[e+b],o=r>=1e4?`${Math.round(r/1e3)}k`:r>=1e3?Math.round(r/100)/10+\"k\":r,i=t[e+L],n=-1===i?{}:Object.assign({},s[i]);return Object.assign(n,{cluster:!0,cluster_id:t[e+A],point_count:r,point_count_abbreviated:o})}function T(t){return t/360+.5}function j(t){const e=Math.sin(t*Math.PI/180),s=.5-.25*Math.log((1+e)/(1-e))/Math.PI;return s<0?0:s>1?1:s}function S(t){const e=(180-360*t)*Math.PI/180;return 360*Math.atan(Math.exp(e))/Math.PI-90}class z extends u{constructor(t){var{maxZoom:s,radius:r=60}=t,o=e(t,[\"maxZoom\",\"radius\"]);super({maxZoom:s}),this.state={zoom:-1},this.superCluster=new O(Object.assign({maxZoom:this.maxZoom,radius:r},o))}calculate(t){let e=!1;const r={zoom:t.map.getZoom()};if(!f(t.markers,this.markers)){e=!0,this.markers=[...t.markers];const r=this.markers.map((t=>{const e=s.getPosition(t);return{type:\"Feature\",geometry:{type:\"Point\",coordinates:[e.lng(),e.lat()]},properties:{marker:t}}}));this.superCluster.load(r)}return e||(this.state.zoom<=this.maxZoom||r.zoom<=this.maxZoom)&&(e=!f(this.state,r)),this.state=r,e&&(this.clusters=this.cluster(t)),{clusters:this.clusters,changed:e}}cluster(t){let{map:e}=t;return this.superCluster.getClusters([-180,-90,180,90],Math.round(e.getZoom())).map((t=>this.transformCluster(t)))}transformCluster(t){let{geometry:{coordinates:[e,o]},properties:i}=t;if(i.cluster)return new r({markers:this.superCluster.getLeaves(i.cluster_id,1/0).map((t=>t.properties.marker)),position:{lat:o,lng:e}});const n=i.marker;return new r({markers:[n],position:s.getPosition(n)})}}class U{constructor(t,e){this.markers={sum:t.length};const s=e.map((t=>t.count)),r=s.reduce(((t,e)=>t+e),0);this.clusters={count:e.length,markers:{mean:r/e.length,sum:r,min:Math.min(...s),max:Math.max(...s)}}}}class B{render(t,e,r){let{count:o,position:i}=t;const n=`<svg fill=\"${o>Math.max(10,e.clusters.markers.mean)?\"#ff0000\":\"#0000ff\"}\" xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 240 240\" width=\"50\" height=\"50\">\\n<circle cx=\"120\" cy=\"120\" opacity=\".6\" r=\"70\" />\\n<circle cx=\"120\" cy=\"120\" opacity=\".3\" r=\"90\" />\\n<circle cx=\"120\" cy=\"120\" opacity=\".2\" r=\"110\" />\\n<text x=\"50%\" y=\"50%\" style=\"fill:#fff\" text-anchor=\"middle\" font-size=\"50\" dominant-baseline=\"middle\" font-family=\"roboto,arial,sans-serif\">${o}</text>\\n</svg>`,a=`Cluster of ${o} markers`,h=Number(google.maps.Marker.MAX_ZINDEX)+o;if(s.isAdvancedMarkerAvailable(r)){const t=(new DOMParser).parseFromString(n,\"image/svg+xml\").documentElement;t.setAttribute(\"transform\",\"translate(0 25)\");const e={map:r,position:i,zIndex:h,title:a,content:t};return new google.maps.marker.AdvancedMarkerElement(e)}const l={position:i,zIndex:h,title:a,icon:{url:`data:image/svg+xml;base64,${btoa(n)}`,anchor:new google.maps.Point(25,25)}};return new google.maps.Marker(l)}}class D{constructor(){!function(t,e){for(let s in e.prototype)t.prototype[s]=e.prototype[s]}(D,google.maps.OverlayView)}}var N;t.MarkerClustererEvents=void 0,(N=t.MarkerClustererEvents||(t.MarkerClustererEvents={})).CLUSTERING_BEGIN=\"clusteringbegin\",N.CLUSTERING_END=\"clusteringend\",N.CLUSTER_CLICK=\"click\";const F=(t,e,s)=>{s.fitBounds(e.bounds)};return t.AbstractAlgorithm=u,t.AbstractViewportAlgorithm=p,t.Cluster=r,t.ClusterStats=U,t.DefaultRenderer=B,t.GridAlgorithm=class extends p{constructor(t){var{maxDistance:s=4e4,gridSize:r=40}=t;super(e(t,[\"maxDistance\",\"gridSize\"])),this.clusters=[],this.state={zoom:-1},this.maxDistance=s,this.gridSize=r}calculate(t){let{markers:e,map:s,mapCanvasProjection:r}=t;const i={zoom:s.getZoom()};let n=!1;return this.state.zoom>=this.maxZoom&&i.zoom>=this.maxZoom||(n=!f(this.state,i)),this.state=i,s.getZoom()>=this.maxZoom?{clusters:this.noop({markers:e}),changed:n}:{clusters:this.cluster({markers:o(s,r,e,this.viewportPadding),map:s,mapCanvasProjection:r})}}cluster(t){let{markers:e,map:s,mapCanvasProjection:r}=t;return this.clusters=[],e.forEach((t=>{this.addToClosestCluster(t,s,r)})),this.clusters}addToClosestCluster(t,e,o){let n=this.maxDistance,h=null;for(let e=0;e<this.clusters.length;e++){const r=this.clusters[e],o=a(r.bounds.getCenter().toJSON(),s.getPosition(t).toJSON());o<n&&(n=o,h=r)}if(h&&i(h.bounds,o,this.gridSize).contains(s.getPosition(t)))h.push(t);else{const e=new r({markers:[t]});this.clusters.push(e)}}},t.MarkerClusterer=class extends D{constructor(t){let{map:e,markers:s=[],algorithmOptions:r={},algorithm:o=new z(r),renderer:i=new B,onClusterClick:n=F}=t;super(),this.markers=[...s],this.clusters=[],this.algorithm=o,this.renderer=i,this.onClusterClick=n,e&&this.setMap(e)}addMarker(t,e){this.markers.includes(t)||(this.markers.push(t),e||this.render())}addMarkers(t,e){t.forEach((t=>{this.addMarker(t,!0)})),e||this.render()}removeMarker(t,e){const r=this.markers.indexOf(t);return-1!==r&&(s.setMap(t,null),this.markers.splice(r,1),e||this.render(),!0)}removeMarkers(t,e){let s=!1;return t.forEach((t=>{s=this.removeMarker(t,!0)||s})),s&&!e&&this.render(),s}clearMarkers(t){this.markers.length=0,t||this.render()}render(){const e=this.getMap();if(e instanceof google.maps.Map&&e.getProjection()){google.maps.event.trigger(this,t.MarkerClustererEvents.CLUSTERING_BEGIN,this);const{clusters:r,changed:o}=this.algorithm.calculate({markers:this.markers,map:e,mapCanvasProjection:this.getProjection()});if(o||null==o){const t=new Set;for(const e of r)1==e.markers.length&&t.add(e.markers[0]);const e=[];for(const r of this.clusters)null!=r.marker&&(1==r.markers.length?t.has(r.marker)||s.setMap(r.marker,null):e.push(r.marker));this.clusters=r,this.renderClusters(),requestAnimationFrame((()=>e.forEach((t=>s.setMap(t,null)))))}google.maps.event.trigger(this,t.MarkerClustererEvents.CLUSTERING_END,this)}}onAdd(){this.idleListener=this.getMap().addListener(\"idle\",this.render.bind(this)),this.render()}onRemove(){google.maps.event.removeListener(this.idleListener),this.reset()}reset(){this.markers.forEach((t=>s.setMap(t,null))),this.clusters.forEach((t=>t.delete())),this.clusters=[]}renderClusters(){const e=new U(this.markers,this.clusters),r=this.getMap();this.clusters.forEach((o=>{1===o.markers.length?o.marker=o.markers[0]:(o.marker=this.renderer.render(o,e,r),o.markers.forEach((t=>s.setMap(t,null))),this.onClusterClick&&o.marker.addListener(\"click\",(e=>{google.maps.event.trigger(this,t.MarkerClustererEvents.CLUSTER_CLICK,o),this.onClusterClick(e,o,r)}))),s.setMap(o.marker,r)}))}},t.MarkerUtils=s,t.NoopAlgorithm=class extends u{constructor(t){super(e(t,[]))}calculate(t){let{markers:e,map:s,mapCanvasProjection:r}=t;return{clusters:this.cluster({markers:e,map:s,mapCanvasProjection:r}),changed:!1}}cluster(t){return this.noop(t)}},t.SuperClusterAlgorithm=z,t.SuperClusterViewportAlgorithm=class extends p{constructor(t){var{maxZoom:s,radius:r=60,viewportPadding:o=60}=t,i=e(t,[\"maxZoom\",\"radius\",\"viewportPadding\"]);super({maxZoom:s,viewportPadding:o}),this.superCluster=new O(Object.assign({maxZoom:this.maxZoom,radius:r},i)),this.state={zoom:-1,view:[0,0,0,0]}}calculate(t){const e={zoom:Math.round(t.map.getZoom()),view:n(t.map.getBounds(),t.mapCanvasProjection,this.viewportPadding)};let r=!f(this.state,e);if(!f(t.markers,this.markers)){r=!0,this.markers=[...t.markers];const e=this.markers.map((t=>{const e=s.getPosition(t);return{type:\"Feature\",geometry:{type:\"Point\",coordinates:[e.lng(),e.lat()]},properties:{marker:t}}}));this.superCluster.load(e)}return r&&(this.clusters=this.cluster(t),this.state=e),{clusters:this.clusters,changed:r}}cluster(t){let{map:e,mapCanvasProjection:s}=t;const r={zoom:Math.round(e.getZoom()),view:n(e.getBounds(),s,this.viewportPadding)};return this.superCluster.getClusters(r.view,r.zoom).map((t=>this.transformCluster(t)))}transformCluster(t){let{geometry:{coordinates:[e,o]},properties:i}=t;if(i.cluster)return new r({markers:this.superCluster.getLeaves(i.cluster_id,1/0).map((t=>t.properties.marker)),position:{lat:o,lng:e}});const n=i.marker;return new r({markers:[n],position:s.getPosition(n)})}},t.defaultOnClusterClickHandler=F,t.distanceBetweenPoints=a,t.extendBoundsToPaddedViewport=i,t.extendPixelBounds=l,t.filterMarkersToPaddedViewport=o,t.getPaddedViewport=n,t.noop=m,t.pixelBoundsToLatLngBounds=c,Object.defineProperty(t,\"__esModule\",{value:!0}),t}({});\n//# sourceMappingURL=index.min.js.map\n</script>"
}
},
{
"position": {
"col": 11,
"sizex": 2,
"row": 1,
"sizey": 12
},
"config": {
"displaySettings": {},
"isSupportCustomProperty": false,
"supportCustomProperty": false,
"name": "Description",
"description": "",
"theme": "newSolidBlue",
"interval": 15,
"type": "text",
"timescale": "day",
"version": 2,
"content": "<div style=\"font-size: 0.9em; line-height: 1.25em;\"><h3>Overview:</h3><p id=\"isPasted\">On this dashboard is a fully custom, standalone map widget to overcome some limitations with our core Map widget. The ideas behind this include:</p><ul><li>Support for 1,000+ pins on the map (vs 400) to accommodate larger customers</li><li>Marker clustering to group adjacent pins together until zoomed in (along with easy zoom reset)</li><li>A donut chart to represent the severities of clustered markers</li><li>Quick & easy on-the-fly filtering based on path and severities</li><li>Easy toggling of weather layers, earthquakes, US wildfires, and US power outages</li><li>More informational tips when clicking a pin</li><li>The ability to show colored lines representing connections between locations</li></ul><h3 id=\"isPasted\">Prerequisites:</h3><ul><li>Groups, resources, or services with valid addresses set in the usual 'location' property.</li><li>If you want to show connections between locations and their status, load the "Set Better Map Widget Connections" PropertySource.</li></ul><h3 id=\"isPasted\">Initial Configuration:</h3><ol><li>Create a new Text widget to the dashboard.</li><li>On the Text widget's edit dialog, click the "Source" button and paste in the contents of the script, then save the widget.</li><li>Add dashboard tokens to modify the defaults of the widget as desired. A list of token options is provided below. (can also be found at top of the widget's source script)</li></ol><h3>Usage:</h3><p>Clicking on a marker cluster will display more info about that group, including the option to zoom in to see the clustered markers. To quickly reset the zoom, just click the bottom button in the upper-left corner of the map (the one with the 4 arrows).</p><p>By default the map will auto-refresh every 2 minutes, though that's configurable by changing the 'statusUpdateIntervalMinutes' variable near the top of the script.</p><p>Note that by default the zoom level of the map will <em>not</em> auto-reset on refreshes to ensure that all markers are visible. Enabling that feature can be very useful when filtering on specific severities and new matching items appear, especially when displayed on overhead monitors.</p><p>Visibility of the toolbar along the top of the widget can be toggled using the button in the upper-left corner of the map.</p><h3>Optional Customization:</h3><p>Behavior of the widget can be customized using the following optional dashboard tokens.</p><ul><li id=\"isPasted\"><strong>MapGroupPathFilter</strong>: Allows setting a default group path to start. Default is "<span style=\"font-family: monospace;\">*</span>".</li><li id=\"isPasted\"><strong>MapLocationProperty</strong>: The property to use for the location of the items on the map. Default is "location".<strong></strong><br></li><li id=\"isPasted\"><strong>MapShowWeather</strong>: If weather should be shown by default. Options are "global", "nexrad", "xweather", or "openweather". Default is "global".</li><li id=\"isPasted\"><strong>MapOverlayOption</strong>: Which optional overlay to default to when weather is shown. Options are "earthquakes", "us-poweroutages" (or "outages"), "wildfires", or "us-flooding". Default is "earthquakes".</li><li id=\"isPasted\"><strong>HideMapOptions</strong>: If "true" then will hide the options bar by default. Default is "false".</li><li id=\"isPasted\"><strong>ShowMapSidebar</strong>:If "true" then will show the sidebar by default. Default is "false".<strong></strong></li><li id=\"isPasted\"><strong>MapSourceType</strong>: Whether to map "groups", "resources", or "services". Default is "groups".</li><li id=\"isPasted\"><strong>MapIgnoreCleared</strong>: If "true" then will only show items currently alerting (useful for maps with thousands of markers). Default is "false".</li><li id=\"isPasted\"><strong>MapIgnoreWarnings</strong>: If "true" then won't show items in "Warning" status. Default is "false".</li><li id=\"isPasted\"><strong>MapIgnoreErrors</strong>: If "true" then won't show items in "Error" status. Default is "false".</li><li id=\"isPasted\"><strong>MapIgnoreCriticals</strong>: If "true" then won't show items in "Critical" status. Default is "false".</li><li id=\"isPasted\"><strong>AutoResetMapOnRefresh</strong>: If "true" then the map will automatically zoom to encompass all items on timed refreshes. Default is "false".</li><li id=\"isPasted\"><strong>MapDisableClustering</strong>: If "true" then clustering of adjacent markers on the map will be disabled. Might be desirable if showing connections between locations since clustering might hide markers at certain zoom levels. Default is "false".</li><li id=\"isPasted\"><strong>MapDisplayProperties</strong>: An optional comma-delimited list of custom properties to show when viewing a group's/resource's details.</li><li id=\"isPasted\"><strong>MapStyle</strong>: Allows one of the following available map style options: "silver", "standard", "dark", "aubergine", "satellite", "satellite-light", or "silverblue". Default is "silverblue".</li><li id=\"isPasted\"><strong>MapMarkerStyle</strong>: The style of marker to use for the items on the map. Options are "pins" or "circles". Default is "pins".<strong></strong></li><li id=\"isPasted\"><strong>MapShowRoadLabels</strong>: If "true" then road labels will be shown on the map. Default is "false".</li><li id=\"isPasted\">'<strong>XweatherAPIID</strong>' & '<strong>XweatherAPIKey</strong>': Optional <a href=\"https://www.xweather.com/weather-api\" target=\"_blank\" rel=\"noopener noreferrer\">Xweather</a> API ID & key to use for global weather radar. Xweather is offers a great deal of optional details such as lightning strikes, hail, wind gusts, etc. Many personal weather stations such as Ecowitt provide free Xweather API access if you feed them your weather data.</li><li id=\"isPasted\"><strong></strong>'<strong>OpenWeatherAPIKey</strong>': Optional <a href=\"https://openweathermap.org/api\" target=\"_blank\" rel=\"noopener noreferrer\">OpenWeather</a> API key for global weather radar. A free API key will work for very basic radar information.<strong></strong></li></ul><h3>Showing Connections between Locations</h3><p>You can represent connectivity between locations. This only supports alert status of instances of the "SNMP_Network_Interfaces" datasource and any datasource with "VPN" in the name.</p><p>To configure, go to the specific instance of one of those datasources and add an instance-level property called 'custom_map_connection'. The value should be in the following format:</p><p>{Connection Title} > {Hostname/IP of the connected resource}</p><p>For example: "London WAN > 192.168.1.10" would show a line titled "London WAN" representing this specific interface connected to the resource monitored as 192.168.1.10. A PropertySource - "Set Better Map Widget Connections" - configures those instance-level properties as resource-level properties along with other necessary data for the widget to use.</p></div>"
}
}
],
"version": 2
}