Skip to content

Commit

Permalink
World Map clustering (#1619)
Browse files Browse the repository at this point in the history
* Change dashboard map to use clustering to handle many devices

Alos adds a mix task for generating devices in development

---------

Co-authored-by: elliotjon <[email protected]>
Co-authored-by: Nate Shoemaker <[email protected]>
  • Loading branch information
3 people authored Nov 22, 2024
1 parent c761c8c commit 2c9e880
Show file tree
Hide file tree
Showing 7 changed files with 123 additions and 89 deletions.
121 changes: 45 additions & 76 deletions assets/js/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { LiveSocket } from 'phoenix_live_view'
import L from 'leaflet/dist/leaflet.js'
import Chart from 'chart.js/auto'
import 'chartjs-adapter-date-fns';
import 'leaflet.markercluster/dist/leaflet.markercluster.js'

import hljs from 'highlight.js/lib/core'
import bash from 'highlight.js/lib/languages/bash'
Expand All @@ -20,6 +21,8 @@ hljs.registerLanguage('shell', shell)

import 'highlight.js/styles/stackoverflow-light.css'
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
import 'leaflet.markercluster/dist/MarkerCluster.Default.css'

import TimeAgo from 'javascript-time-ago'
import en from 'javascript-time-ago/locale/en'
Expand Down Expand Up @@ -238,14 +241,17 @@ Hooks.WorldMap = {
let mapId = this.el.id;
this.markers = [];

var mapOptionsNoZoom = {
var mapOptionsZoom = {
attributionControl: false,
zoomControl: false,
scrollWheelZoom: false,
zoomControl: true,
scrollWheelZoom: true,
boxZoom: false,
doubleClickZoom: false,
dragging: false,
keyboard: false
dragging: true,
keyboard: false,
maxZoom: 18,
minZoom: 1.4,
renderer: L.canvas()
};

var mapStyle = {
Expand All @@ -258,7 +264,8 @@ Hooks.WorldMap = {
};

// initialize the map
this.map = L.map(mapId, mapOptionsNoZoom).setView([40.5, 10], 2);
this.map = L.map(mapId, mapOptionsZoom).setView([0, 0], 1);
this.map.setMaxBounds(this.map.getBounds());
this.handleEvent(
"markers",
({ markers }) => {
Expand All @@ -278,87 +285,49 @@ Hooks.WorldMap = {
let mode = this.el.dataset.mode;
var devices = [];

for (let i = 0; i < markers.length; i++) {
let marker = markers[i];
let location = marker["location"];
if (location["longitude"] !== undefined && location["latitude"] !== undefined) {
let newMarker = {
type: "Feature",
properties: {
name: marker["identifier"],
status: marker["status"],
latest_firmware: marker["latest_firmware"]
},
geometry: {
type: "Point",
coordinates: [location["longitude"], location["latitude"]]
}
}
devices.push(newMarker);
}
}
var myRenderer = L.canvas({ padding: 0.5 });
var clusterLayer = L.markerClusterGroup({ chunkedLoading: true, chunkProgress: this.updateProgressBar });

var markerConnectedOptions = {
var defaultOptions = {
radius: 6,
fillColor: "#4dd54f",
weight: 1,
opacity: 0,
fillOpacity: 1
};
fillOpacity: 1,
renderer: myRenderer,
fillColor: "#4dd54f"
}

var markerOfflineOptions = {
radius: 6,
fillColor: "rgba(196,49,49,1)",
weight: 1,
opacity: 0,
fillOpacity: 1
};
var offlineOptions = Object.assign(defaultOptions, { fillColor: "rgba(196,49,49,1)" })
var outdatedOptions = Object.assign(defaultOptions, { fillColor: "rgba(99,99,99,1)" })

var markerUpdatedOptions = {
radius: 6,
fillColor: "#4dd54f",
weight: 1,
opacity: 0,
fillOpacity: 1
};
var devices = this.markers.reduce(function(acc, marker) {
let location = marker["l"]
var latLng = [location["at"], location["ng"]]

var markerOutdatedOptions = {
radius: 6,
fillColor: "rgba(99,99,99,1)",
weight: 1,
opacity: 0,
fillOpacity: 1
};
// if no location or we don't care about mode, move on
if (!location["ng"] && !location["at"]) return acc
if (!["connected", "updated"].includes(mode)) return acc

// Clear previous defined device layer before adding markers
if (this.deviceLayer !== undefined) { this.map.removeLayer(this.deviceLayer); }

this.deviceLayer = L.geoJson(devices, {
pointToLayer: function (feature, latlng) {
switch (mode) {
case 'connected':
if (feature.properties.status == "connected") {
return L.circleMarker(latlng, markerConnectedOptions);
} else {
return L.circleMarker(latlng, markerOfflineOptions);
}
break;
case 'updated':
// Only show connected ones, the offline ones are just confusing
if (feature.properties.status == "connected") {
if (feature.properties.latest_firmware) {
return L.circleMarker(latlng, markerUpdatedOptions);
} else {
return L.circleMarker(latlng, markerOutdatedOptions);
}
}
break;
default:
if (mode == "connected") {
if (marker["s"] == "connected") {
acc.push(L.circleMarker(latLng, defaultOptions))
} else {
acc.push(L.circleMarker(latLng, offlineOptions))
}
}
});

this.deviceLayer.addTo(this.map);
if (mode == "updated") {
if (marker["lf"]) {
acc.push(L.circleMarker(latLng, defaultOptions))
} else {
acc.push(L.circleMarker(latLng, outdatedOptions))
}
}
return acc
}, [])

clusterLayer.addLayers(devices);
this.map.addLayer(clusterLayer)
}
}

Expand Down
15 changes: 15 additions & 0 deletions assets/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"javascript-time-ago": "^2.5.10",
"jquery": "^3.6.0",
"leaflet": "^1.9.4",
"leaflet.markercluster": "^1.5.3",
"moment": "^2.29.4",
"phoenix": "file:../deps/phoenix",
"phoenix_html": "file:../deps/phoenix_html",
Expand Down
41 changes: 41 additions & 0 deletions lib/mix/tasks/gen.devices.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
defmodule Mix.Tasks.Gen.Devices do
use Mix.Task

@shortdoc "Generate a mass of devices"

def run([org_name, product_name, count]) do
Mix.Task.run("app.start")
count = String.to_integer(count)
{:ok, org} = NervesHub.Accounts.get_org_by_name(org_name)
{:ok, product} = NervesHub.Products.get_product_by_org_id_and_name(org.id, product_name)

current_count =
NervesHub.Devices.get_device_count_by_org_id_and_product_id(org.id, product.id)

current_count..(current_count + count)
|> Enum.map(fn i ->
if rem(i, 1000) == 0 do
IO.puts("Created #{i} devices...")
end

lng = -180..180 |> Enum.random()
lat = -90..90 |> Enum.random()

NervesHub.Devices.create_device(%{
org_id: org.id,
product_id: product.id,
identifier: "generated-#{i}",
connection_status: :connected,
connection_established_at: DateTime.now!("Etc/UTC"),
connection_last_seen_at: DateTime.now!("Etc/UTC"),
connection_metadata: %{
"location" => %{
"longitude" => lng,
"latitude" => lat,
"source" => "generated"
}
}
})
end)
end
end
17 changes: 14 additions & 3 deletions lib/nerves_hub/devices.ex
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,19 @@ defmodule NervesHub.Devices do
|> Repo.paginate(pagination)
end

def get_device_count_by_org_id_and_product_id(org_id, product_id) do
query =
from(
d in Device,
select: count(d.id),
where: d.org_id == ^org_id,
where: d.product_id == ^product_id
)

query
|> Repo.one!()
end

def filter(product_id, opts) do
pagination = Map.get(opts, :pagination, %{})
sorting = Map.get(opts, :sort, {:asc, :identifier})
Expand All @@ -109,12 +122,10 @@ defmodule NervesHub.Devices do
|> where(product_id: ^product_id)
|> where([d], not is_nil(fragment("?->'location'->'latitude'", d.connection_metadata)))
|> where([d], not is_nil(fragment("?->'location'->'longitude'", d.connection_metadata)))
|> join(:left, [d], dc in subquery(Connections.latest_row_query()), on: dc.device_id == d.id)
|> where([d, dc], dc.rn == 1)
|> select([d, dc], %{
id: d.id,
identifier: d.identifier,
connection_status: dc.status,
connection_status: d.connection_status,
latitude: fragment("?->'location'->'latitude'", d.connection_metadata),
longitude: fragment("?->'location'->'longitude'", d.connection_metadata),
firmware_uuid: fragment("?->'uuid'", d.firmware_metadata)
Expand Down
14 changes: 5 additions & 9 deletions lib/nerves_hub_web/live/dashboard/index.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule NervesHubWeb.Live.Dashboard.Index do

alias Phoenix.Socket.Broadcast

@default_refresh 5000
@default_refresh 53000
@delay 500

@impl Phoenix.LiveView
Expand All @@ -19,7 +19,7 @@ defmodule NervesHubWeb.Live.Dashboard.Index do
|> assign(:time, time())
|> assign(:next_timer, nil)
|> assign(:loading?, true)
|> assign(:mode, "updated")
|> assign(:mode, "connected")
|> assign(:device_count, 0)
|> assign(:marker_count, 0)
|> refresh_after(1)
Expand Down Expand Up @@ -118,8 +118,6 @@ defmodule NervesHubWeb.Live.Dashboard.Index do

defp generate_map_marker(
%{
id: id,
identifier: identifier,
connection_status: connection_status,
longitude: longitude,
latitude: latitude,
Expand All @@ -131,11 +129,9 @@ defmodule NervesHubWeb.Live.Dashboard.Index do
when is_number(longitude) and is_number(latitude) do
new_marker =
%{
id: id,
identifier: identifier,
status: get_connection_status(connection_status),
latest_firmware: Map.has_key?(latest_firmwares, firmware_uuid),
location: %{"longitude" => longitude, "latitude" => latitude}
s: get_connection_status(connection_status),
lf: Map.has_key?(latest_firmwares, firmware_uuid),
l: %{"ng" => longitude, "at" => latitude}
}

[new_marker | markers]
Expand Down
3 changes: 2 additions & 1 deletion lib/nerves_hub_web/live/dashboard/index.html.heex
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,8 @@
location from devices.
</p>
</div>
<div id="map" class="opacity-50" phx-hook="WorldMap" phx-update="ignore" style="height: 720px; background: #2A2D30; margin-top: 48px;"></div>
<div id="progress" phx-update="ignore" class="block hidden" style="background-color: cyan;"></div>
<div id="map" class="opacity-50" phx-hook="WorldMap" phx-update="ignore" data-mode={@mode} style="height: 720px; background: #2A2D30; margin-top: 48px;"></div>
<% else %>
<%= if @loading? do %>
<div class="no-results-blowup-wrapper no-map-results-positioned">
Expand Down

0 comments on commit 2c9e880

Please sign in to comment.