weather charts
This commit is contained in:
parent
4baf3cffe7
commit
c8ee465015
10 changed files with 388 additions and 5 deletions
137
assets/js/app.js
137
assets/js/app.js
|
|
@ -21,6 +21,7 @@ import "phoenix_html";
|
|||
import { Socket } from "phoenix";
|
||||
import { LiveSocket } from "phoenix_live_view";
|
||||
import topbar from "../vendor/topbar";
|
||||
import "../vendor/oms.min.js";
|
||||
|
||||
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content");
|
||||
|
||||
|
|
@ -74,6 +75,139 @@ let Hooks = {};
|
|||
Hooks.APRSMap = MapAPRSMap;
|
||||
Hooks.ResponsiveSlideoverHook = ResponsiveSlideoverHook;
|
||||
|
||||
Hooks.PlotlyTempChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const temps = data.map(d => d.temperature);
|
||||
const dews = data.map(d => d.dew_point);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: temps, name: "Temperature", type: "scatter", line: { color: "red" } },
|
||||
{ x: times, y: dews, name: "Dew Point", type: "scatter", line: { color: "blue" } }
|
||||
], {
|
||||
title: "Temperature & Dew Point (°F)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "°F" }
|
||||
}, {
|
||||
responsive: true,
|
||||
staticPlot: true,
|
||||
displayModeBar: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyHumidityChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const hums = data.map(d => d.humidity);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: hums, name: "Humidity", type: "scatter", line: { color: "blue" } }
|
||||
], {
|
||||
title: "Humidity (%)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "%" }
|
||||
}, {
|
||||
responsive: true,
|
||||
staticPlot: true,
|
||||
displayModeBar: false
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyPressureChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const pressures = data.map(d => d.pressure);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: pressures, name: "Pressure", type: "scatter", line: { color: "green" } }
|
||||
], {
|
||||
title: "Pressure (hPa)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "hPa" }
|
||||
}, {responsive: true, staticPlot: true, displayModeBar: false});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyWindDirectionChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const dirs = data.map(d => d.wind_direction);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: dirs, name: "Wind Direction", type: "scatter", line: { color: "orange" } }
|
||||
], {
|
||||
title: "Wind Direction (°)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "°" }
|
||||
}, {responsive: true, staticPlot: true, displayModeBar: false});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyWindSpeedChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const speeds = data.map(d => d.wind_speed);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: speeds, name: "Wind Speed", type: "scatter", line: { color: "purple" } }
|
||||
], {
|
||||
title: "Wind Speed (mph)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "mph" }
|
||||
}, {responsive: true, staticPlot: true, displayModeBar: false});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyRainChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const rain1h = data.map(d => d.rain_1h);
|
||||
const rain24h = data.map(d => d.rain_24h);
|
||||
const rainMid = data.map(d => d.rain_since_midnight);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: rain1h, name: "Rain (1h)", type: "scatter", line: { color: "blue" } },
|
||||
{ x: times, y: rain24h, name: "Rain (24h)", type: "scatter", line: { color: "navy" } },
|
||||
{ x: times, y: rainMid, name: "Rain (since midnight)", type: "scatter", line: { color: "teal" } }
|
||||
], {
|
||||
title: "Rain (inches)",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "in" }
|
||||
}, {responsive: true, staticPlot: true, displayModeBar: false});
|
||||
}
|
||||
};
|
||||
|
||||
Hooks.PlotlyLuminosityChart = {
|
||||
mounted() { this.renderChart(); },
|
||||
updated() { this.renderChart(); },
|
||||
renderChart() {
|
||||
const data = JSON.parse(this.el.dataset.weatherHistory);
|
||||
const times = data.map(d => d.timestamp);
|
||||
const lum = data.map(d => d.luminosity);
|
||||
Plotly.newPlot(this.el, [
|
||||
{ x: times, y: lum, name: "Luminosity", type: "scatter", line: { color: "gold" } }
|
||||
], {
|
||||
title: "Luminosity",
|
||||
xaxis: { title: "Time" },
|
||||
yaxis: { title: "" }
|
||||
}, {responsive: true, staticPlot: true, displayModeBar: false});
|
||||
}
|
||||
};
|
||||
|
||||
let liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken },
|
||||
|
|
@ -93,3 +227,6 @@ liveSocket.connect();
|
|||
// >> liveSocket.enableLatencySim(1000) // enabled for duration of browser session
|
||||
// >> liveSocket.disableLatencySim()
|
||||
window.liveSocket = liveSocket;
|
||||
|
||||
window.Hooks = Hooks;
|
||||
export default Hooks;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
// Declare Leaflet as a global variable
|
||||
declare const L: any;
|
||||
declare const OverlappingMarkerSpiderfier: any;
|
||||
|
||||
// Import trail management functionality
|
||||
import { TrailManager } from "./features/trail_manager";
|
||||
|
|
@ -23,6 +24,7 @@ type LiveViewHookContext = {
|
|||
maxInitializationAttempts?: number;
|
||||
lastZoom?: number;
|
||||
currentPopupMarkerId?: string | null;
|
||||
oms?: any;
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
|
|
@ -351,6 +353,10 @@ let MapAPRSMap = {
|
|||
|
||||
// LiveView event handlers
|
||||
self.setupLiveViewHandlers();
|
||||
|
||||
if (typeof OverlappingMarkerSpiderfier !== "undefined") {
|
||||
self.oms = new OverlappingMarkerSpiderfier(self.map);
|
||||
}
|
||||
},
|
||||
|
||||
handleFatalError(message: string) {
|
||||
|
|
@ -861,6 +867,10 @@ let MapAPRSMap = {
|
|||
if (data.openPopup && marker.openPopup) {
|
||||
marker.openPopup();
|
||||
}
|
||||
|
||||
if (self.oms && marker) {
|
||||
self.oms.addMarker(marker);
|
||||
}
|
||||
},
|
||||
|
||||
removeMarker(id: string | any) {
|
||||
|
|
@ -882,6 +892,10 @@ let MapAPRSMap = {
|
|||
const trailId = markerState?.callsign_group || markerState?.callsign || markerId;
|
||||
self.trailManager.removeTrail(trailId);
|
||||
}
|
||||
|
||||
if (self.oms && marker) {
|
||||
self.oms.removeMarker(marker);
|
||||
}
|
||||
},
|
||||
|
||||
updateMarker(data: MarkerData) {
|
||||
|
|
|
|||
19
assets/vendor/oms.min.js
vendored
Normal file
19
assets/vendor/oms.min.js
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
(function(){/*
|
||||
OverlappingMarkerSpiderfier
|
||||
https://github.com/jawj/OverlappingMarkerSpiderfier-Leaflet
|
||||
Copyright (c) 2011 - 2012 George MacKerron
|
||||
Released under the MIT licence: http://opensource.org/licenses/mit-license
|
||||
Note: The Leaflet maps API must be included *before* this code
|
||||
*/
|
||||
(function(){var q={}.hasOwnProperty,r=[].slice;null!=this.L&&(this.OverlappingMarkerSpiderfier=function(){function n(c,b){var a,e,g,f,d=this;this.map=c;null==b&&(b={});for(a in b)q.call(b,a)&&(e=b[a],this[a]=e);this.initMarkerArrays();this.listeners={};f=["click","zoomend"];e=0;for(g=f.length;e<g;e++)a=f[e],this.map.addEventListener(a,function(){return d.unspiderfy()})}var d,k;d=n.prototype;d.VERSION="0.2.6";k=2*Math.PI;d.keepSpiderfied=!1;d.nearbyDistance=20;d.circleSpiralSwitchover=9;d.circleFootSeparation=
|
||||
25;d.circleStartAngle=k/12;d.spiralFootSeparation=28;d.spiralLengthStart=11;d.spiralLengthFactor=5;d.legWeight=1.5;d.legColors={usual:"#222",highlighted:"#f00"};d.initMarkerArrays=function(){this.markers=[];return this.markerListeners=[]};d.addMarker=function(c){var b,a=this;if(null!=c._oms)return this;c._oms=!0;b=function(){return a.spiderListener(c)};c.addEventListener("click",b);this.markerListeners.push(b);this.markers.push(c);return this};d.getMarkers=function(){return this.markers.slice(0)};
|
||||
d.removeMarker=function(c){var b,a;null!=c._omsData&&this.unspiderfy();b=this.arrIndexOf(this.markers,c);if(0>b)return this;a=this.markerListeners.splice(b,1)[0];c.removeEventListener("click",a);delete c._oms;this.markers.splice(b,1);return this};d.clearMarkers=function(){var c,b,a,e,g;this.unspiderfy();g=this.markers;c=a=0;for(e=g.length;a<e;c=++a)b=g[c],c=this.markerListeners[c],b.removeEventListener("click",c),delete b._oms;this.initMarkerArrays();return this};d.addListener=function(c,b){var a,
|
||||
e;(null!=(e=(a=this.listeners)[c])?e:a[c]=[]).push(b);return this};d.removeListener=function(c,b){var a;a=this.arrIndexOf(this.listeners[c],b);0>a||this.listeners[c].splice(a,1);return this};d.clearListeners=function(c){this.listeners[c]=[];return this};d.trigger=function(){var c,b,a,e,g,f;b=arguments[0];c=2<=arguments.length?r.call(arguments,1):[];b=null!=(a=this.listeners[b])?a:[];f=[];e=0;for(g=b.length;e<g;e++)a=b[e],f.push(a.apply(null,c));return f};d.generatePtsCircle=function(c,b){var a,e,
|
||||
g,f,d;g=this.circleFootSeparation*(2+c)/k;e=k/c;d=[];for(a=f=0;0<=c?f<c:f>c;a=0<=c?++f:--f)a=this.circleStartAngle+a*e,d.push(new L.Point(b.x+g*Math.cos(a),b.y+g*Math.sin(a)));return d};d.generatePtsSpiral=function(c,b){var a,e,g,f,d;g=this.spiralLengthStart;a=0;d=[];for(e=f=0;0<=c?f<c:f>c;e=0<=c?++f:--f)a+=this.spiralFootSeparation/g+5E-4*e,e=new L.Point(b.x+g*Math.cos(a),b.y+g*Math.sin(a)),g+=k*this.spiralLengthFactor/a,d.push(e);return d};d.spiderListener=function(c){var b,a,e,g,f,d,h,k,l;(b=null!=
|
||||
c._omsData)&&this.keepSpiderfied||this.unspiderfy();if(b)return this.trigger("click",c);g=[];f=[];d=this.nearbyDistance*this.nearbyDistance;e=this.map.latLngToLayerPoint(c.getLatLng());l=this.markers;h=0;for(k=l.length;h<k;h++)b=l[h],this.map.hasLayer(b)&&(a=this.map.latLngToLayerPoint(b.getLatLng()),this.ptDistanceSq(a,e)<d?g.push({marker:b,markerPt:a}):f.push(b));return 1===g.length?this.trigger("click",c):this.spiderfy(g,f)};d.makeHighlightListeners=function(c){var b=this;return{highlight:function(){return c._omsData.leg.setStyle({color:b.legColors.highlighted})},
|
||||
unhighlight:function(){return c._omsData.leg.setStyle({color:b.legColors.usual})}}};d.spiderfy=function(c,b){var a,e,g,d,p,h,k,l,n,m;this.spiderfying=!0;m=c.length;a=this.ptAverage(function(){var a,b,e;e=[];a=0;for(b=c.length;a<b;a++)k=c[a],e.push(k.markerPt);return e}());d=m>=this.circleSpiralSwitchover?this.generatePtsSpiral(m,a).reverse():this.generatePtsCircle(m,a);a=function(){var a,b,k,m=this;k=[];a=0;for(b=d.length;a<b;a++)g=d[a],e=this.map.layerPointToLatLng(g),n=this.minExtract(c,function(a){return m.ptDistanceSq(a.markerPt,
|
||||
g)}),h=n.marker,p=new L.Polyline([h.getLatLng(),e],{color:this.legColors.usual,weight:this.legWeight,clickable:!1}),this.map.addLayer(p),h._omsData={usualPosition:h.getLatLng(),leg:p},this.legColors.highlighted!==this.legColors.usual&&(l=this.makeHighlightListeners(h),h._omsData.highlightListeners=l,h.addEventListener("mouseover",l.highlight),h.addEventListener("mouseout",l.unhighlight)),h.setLatLng(e),h.setZIndexOffset(1E6),k.push(h);return k}.call(this);delete this.spiderfying;this.spiderfied=!0;
|
||||
return this.trigger("spiderfy",a,b)};d.unspiderfy=function(c){var b,a,e,d,f,k,h;null==c&&(c=null);if(null==this.spiderfied)return this;this.unspiderfying=!0;d=[];e=[];h=this.markers;f=0;for(k=h.length;f<k;f++)b=h[f],null!=b._omsData?(this.map.removeLayer(b._omsData.leg),b!==c&&b.setLatLng(b._omsData.usualPosition),b.setZIndexOffset(0),a=b._omsData.highlightListeners,null!=a&&(b.removeEventListener("mouseover",a.highlight),b.removeEventListener("mouseout",a.unhighlight)),delete b._omsData,d.push(b)):
|
||||
e.push(b);delete this.unspiderfying;delete this.spiderfied;this.trigger("unspiderfy",d,e);return this};d.ptDistanceSq=function(c,b){var a,e;a=c.x-b.x;e=c.y-b.y;return a*a+e*e};d.ptAverage=function(c){var b,a,e,d,f;d=a=e=0;for(f=c.length;d<f;d++)b=c[d],a+=b.x,e+=b.y;c=c.length;return new L.Point(a/c,e/c)};d.minExtract=function(c,b){var a,d,g,f,k,h;g=k=0;for(h=c.length;k<h;g=++k)if(f=c[g],f=b(f),"undefined"===typeof a||null===a||f<d)d=f,a=g;return c.splice(a,1)[0]};d.arrIndexOf=function(c,b){var a,
|
||||
d,g,f;if(null!=c.indexOf)return c.indexOf(b);a=g=0;for(f=c.length;g<f;a=++g)if(d=c[a],d===b)return a;return-1};return n}())}).call(this);}).call(this);
|
||||
/* Mon 14 Oct 2013 10:54:59 BST */
|
||||
|
|
@ -46,6 +46,14 @@
|
|||
<.link navigate={~p"/packets/#{@callsign}"} class="ml-2 text-sm text-blue-600 hover:underline">
|
||||
View packets
|
||||
</.link>
|
||||
<%= if AprsmeWeb.MapLive.PacketUtils.has_weather_packets?(@callsign) do %>
|
||||
<.link
|
||||
navigate={~p"/weather/#{@callsign}"}
|
||||
class="ml-2 text-sm text-blue-600 hover:underline"
|
||||
>
|
||||
Weather charts
|
||||
</.link>
|
||||
<% end %>
|
||||
</h1>
|
||||
<%= if @packet do %>
|
||||
<div class="text-sm text-gray-500 mb-6">
|
||||
|
|
|
|||
|
|
@ -101,6 +101,21 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
data_type == "weather" or (symbol_table_id == "/" and symbol_code == "_")
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a station has sent weather packets by looking at recent packets.
|
||||
"""
|
||||
@spec has_weather_packets?(String.t()) :: boolean()
|
||||
# Get recent packets for this callsign and check if any are weather packets
|
||||
def has_weather_packets?(callsign) when is_binary(callsign) do
|
||||
%{callsign: callsign, limit: 10}
|
||||
|> Aprsme.Packets.get_recent_packets()
|
||||
|> Enum.any?(&weather_packet?/1)
|
||||
rescue
|
||||
_ -> false
|
||||
end
|
||||
|
||||
def has_weather_packets?(_), do: false
|
||||
|
||||
@doc """
|
||||
Recursively converts tuples in data structures to strings for JSON serialization.
|
||||
"""
|
||||
|
|
@ -200,9 +215,16 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
""
|
||||
end
|
||||
|
||||
weather_link_html =
|
||||
if has_weather_packets?(packet_info.callsign) do
|
||||
~s( <a href="/weather/#{packet_info.callsign}" class="aprs-info-link">weather charts</a>)
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
"""
|
||||
<div class="aprs-popup">
|
||||
<div class="aprs-callsign"><strong><a href="/#{packet_info.callsign}">#{packet_info.callsign}</a></strong> <a href="/info/#{packet_info.callsign}" class="aprs-info-link">info</a></div>
|
||||
<div class="aprs-callsign"><strong><a href="/#{packet_info.callsign}">#{packet_info.callsign}</a></strong> <a href="/info/#{packet_info.callsign}" class="aprs-info-link">info</a>#{weather_link_html}</div>
|
||||
#{comment_html}
|
||||
#{timestamp_html}
|
||||
</div>
|
||||
|
|
@ -230,7 +252,7 @@ defmodule AprsmeWeb.MapLive.PacketUtils do
|
|||
|
||||
"""
|
||||
<div class="aprs-popup" data-timestamp="#{cache_buster}">
|
||||
<div class="aprs-callsign"><strong><a href="/#{sender}">#{sender}</a></strong> <a href="/info/#{sender}" class="aprs-info-link">info</a></div>
|
||||
<div class="aprs-callsign"><strong><a href="/#{sender}">#{sender}</a></strong> <a href="/info/#{sender}" class="aprs-info-link">info</a> <a href="/weather/#{sender}" class="aprs-info-link">weather charts</a></div>
|
||||
<div class="aprs-comment">Weather Report</div>
|
||||
#{timestamp_html}
|
||||
<hr style="margin-top: 4px; margin-bottom: 4px;">
|
||||
|
|
|
|||
|
|
@ -26,11 +26,11 @@
|
|||
<span class="text-sm text-gray-600 font-mono">
|
||||
<%= case packet.received_at do %>
|
||||
<% %DateTime{} = dt -> %>
|
||||
{Calendar.strftime(dt, "%H:%M:%S")}
|
||||
{Calendar.strftime(dt, "%Y-%m-%d %H:%M:%S")}
|
||||
<% dt when is_binary(dt) -> %>
|
||||
<%= case DateTime.from_iso8601(dt) do %>
|
||||
<% {:ok, parsed_dt, _} -> %>
|
||||
{Calendar.strftime(parsed_dt, "%H:%M:%S")}
|
||||
{Calendar.strftime(parsed_dt, "%Y-%m-%d %H:%M:%S")}
|
||||
<% _ -> %>
|
||||
{dt}
|
||||
<% end %>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
|
|||
@moduledoc false
|
||||
use AprsmeWeb, :live_view
|
||||
|
||||
import Jason
|
||||
|
||||
alias Aprsme.Packets
|
||||
alias AprsmeWeb.MapLive.PacketUtils
|
||||
|
||||
|
|
@ -9,12 +11,40 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
|
|||
def mount(%{"callsign" => callsign}, _session, socket) do
|
||||
normalized_callsign = String.upcase(String.trim(callsign))
|
||||
weather_packet = get_latest_weather_packet(normalized_callsign)
|
||||
{start_time, end_time} = default_time_range()
|
||||
weather_history = get_weather_history(normalized_callsign, start_time, end_time)
|
||||
|
||||
weather_history_json =
|
||||
weather_history
|
||||
|> Enum.map(fn pkt ->
|
||||
dew_point =
|
||||
if is_number(pkt.temperature) and is_number(pkt.humidity) do
|
||||
calc_dew_point(pkt.temperature, pkt.humidity)
|
||||
end
|
||||
|
||||
%{
|
||||
timestamp: pkt.received_at,
|
||||
temperature: pkt.temperature,
|
||||
dew_point: dew_point,
|
||||
humidity: pkt.humidity,
|
||||
pressure: pkt.pressure,
|
||||
wind_direction: pkt.wind_direction,
|
||||
wind_speed: pkt.wind_speed,
|
||||
rain_1h: pkt.rain_1h,
|
||||
rain_24h: pkt.rain_24h,
|
||||
rain_since_midnight: pkt.rain_since_midnight,
|
||||
luminosity: pkt.luminosity
|
||||
}
|
||||
end)
|
||||
|> Jason.encode!()
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> assign(:callsign, normalized_callsign)
|
||||
|> assign(:weather_packet, weather_packet)
|
||||
|> assign(:page_title, "Weather for #{normalized_callsign}")
|
||||
|> assign(:weather_history, weather_history)
|
||||
|> assign(:weather_history_json, weather_history_json)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
|
@ -25,4 +55,66 @@ defmodule AprsmeWeb.WeatherLive.CallsignView do
|
|||
|> Packets.get_recent_packets()
|
||||
|> Enum.find(&PacketUtils.weather_packet?/1)
|
||||
end
|
||||
|
||||
defp get_weather_history(callsign, start_time, end_time) do
|
||||
opts = %{callsign: callsign, start_time: start_time, end_time: end_time, limit: 1000}
|
||||
|
||||
opts
|
||||
|> Packets.get_packets_for_replay()
|
||||
|> Enum.filter(&PacketUtils.weather_packet?/1)
|
||||
|> Enum.sort_by(& &1.received_at)
|
||||
end
|
||||
|
||||
defp default_time_range do
|
||||
now = DateTime.utc_now()
|
||||
{DateTime.add(now, -48 * 3600, :second), now}
|
||||
end
|
||||
|
||||
defp build_temp_chart_svg(history) do
|
||||
data =
|
||||
history
|
||||
|> Enum.map(fn pkt ->
|
||||
[pkt.received_at, pkt.temperature, pkt.dew_point]
|
||||
end)
|
||||
|> Enum.filter(fn [_, t, d] -> not is_nil(t) and not is_nil(d) end)
|
||||
|
||||
if data == [] do
|
||||
"<svg width=\"600\" height=\"300\"><text x=\"50\" y=\"150\">No data</text></svg>"
|
||||
else
|
||||
dataset = Dataset.new(data, ["Time", "Temperature", "Dew Point"])
|
||||
|
||||
dataset
|
||||
|> Plot.new(LinePlot, 600, 300, smoothed: false)
|
||||
|> Plot.titles("Temperature & Dew Point (°F)", nil)
|
||||
|> Plot.axis_labels("Time", "°F")
|
||||
|> Plot.to_svg()
|
||||
end
|
||||
end
|
||||
|
||||
defp build_humidity_chart_svg(history) do
|
||||
data =
|
||||
history
|
||||
|> Enum.map(fn pkt ->
|
||||
[pkt.received_at, pkt.humidity]
|
||||
end)
|
||||
|> Enum.filter(fn [_, h] -> not is_nil(h) end)
|
||||
|
||||
if data == [] do
|
||||
"<svg width=\"600\" height=\"300\"><text x=\"50\" y=\"150\">No data</text></svg>"
|
||||
else
|
||||
dataset = Dataset.new(data, ["Time", "Humidity"])
|
||||
|
||||
dataset
|
||||
|> Plot.new(LinePlot, 600, 300, smoothed: false)
|
||||
|> Plot.titles("Humidity (%)", nil)
|
||||
|> Plot.axis_labels("Time", "%")
|
||||
|> Plot.to_svg()
|
||||
end
|
||||
end
|
||||
|
||||
defp calc_dew_point(temp, humidity) when is_number(temp) and is_number(humidity) do
|
||||
temp - (100 - humidity) / 5
|
||||
end
|
||||
|
||||
defp calc_dew_point(_, _), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
<%= if @weather_packet do %>
|
||||
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow">
|
||||
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
|
||||
<h1 class="text-2xl font-bold mb-2 flex items-center gap-2">
|
||||
Weather for {@callsign}
|
||||
<.link
|
||||
navigate={~p"/#{@callsign}"}
|
||||
class="ml-4 text-blue-600 hover:underline text-base font-normal"
|
||||
>
|
||||
view on map
|
||||
</.link>
|
||||
</h1>
|
||||
<div class="text-gray-600 mb-4">
|
||||
{PacketUtils.get_timestamp(@weather_packet)}
|
||||
</div>
|
||||
|
|
@ -45,6 +53,74 @@
|
|||
<strong>Raw comment:</strong> {@weather_packet.comment || @weather_packet["comment"]}
|
||||
</div>
|
||||
</div>
|
||||
<div class="max-w-3xl mx-auto mt-8 p-6 bg-white rounded shadow">
|
||||
<h2 class="text-xl font-bold mb-4">Weather History Graphs</h2>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :temperature, nil)))) do %>
|
||||
<div
|
||||
id="temp-chart"
|
||||
phx-hook="PlotlyTempChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :humidity, nil)))) do %>
|
||||
<div
|
||||
id="humidity-chart"
|
||||
phx-hook="PlotlyHumidityChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :pressure, nil)))) do %>
|
||||
<div
|
||||
id="pressure-chart"
|
||||
phx-hook="PlotlyPressureChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_direction, nil)))) do %>
|
||||
<div
|
||||
id="wind-direction-chart"
|
||||
phx-hook="PlotlyWindDirectionChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :wind_speed, nil)))) do %>
|
||||
<div
|
||||
id="wind-speed-chart"
|
||||
phx-hook="PlotlyWindSpeedChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_1h, nil)))) or Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_24h, nil)))) or Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :rain_since_midnight, nil)))) do %>
|
||||
<div
|
||||
id="rain-chart"
|
||||
phx-hook="PlotlyRainChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<%= if Enum.any?(@weather_history, &(not is_nil(Map.get(&1, :luminosity, nil)))) do %>
|
||||
<div
|
||||
id="luminosity-chart"
|
||||
phx-hook="PlotlyLuminosityChart"
|
||||
data-weather-history={@weather_history_json}
|
||||
style="height:350px;"
|
||||
>
|
||||
</div>
|
||||
<% end %>
|
||||
<script src="https://cdn.plot.ly/plotly-latest.min.js">
|
||||
</script>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="max-w-lg mx-auto mt-8 p-6 bg-white rounded shadow text-center">
|
||||
<h1 class="text-2xl font-bold mb-2">Weather for {@callsign}</h1>
|
||||
|
|
|
|||
2
mix.lock
2
mix.lock
|
|
@ -8,6 +8,7 @@
|
|||
"combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"},
|
||||
"contex": {:hex, :contex, "0.5.0", "5d8a6defbeb41f54adfcb0f85c4756d4f2b84aa5b0d809d45a5d2e90d91d0392", [:mix], [{:nimble_strftime, "~> 0.1.0", [hex: :nimble_strftime, repo: "hexpm", optional: false]}], "hexpm", "b7497a1790324d84247859df44ba4bcf2489d9bba1812a5375b2f2046b9e6fd7"},
|
||||
"convertat": {:hex, :convertat, "1.1.0", "00c125f00a2e06be74d27df5a6b93f9b2d7c27aaa6ecb41d96f009ee7d7bd978", [:mix], [], "hexpm", "603229c43df6769f2166c78c5c3f31316390bf6e19fa8e15f02026170ab51a79"},
|
||||
"cowboy": {:hex, :cowboy, "2.13.0", "09d770dd5f6a22cc60c071f432cd7cb87776164527f205c5a6b0f24ff6b38990", [:make, :rebar3], [{:cowlib, ">= 2.14.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "e724d3a70995025d654c1992c7b11dbfea95205c047d86ff9bf1cda92ddc5614"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
|
|
@ -54,6 +55,7 @@
|
|||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_ownership": {:hex, :nimble_ownership, "1.0.1", "f69fae0cdd451b1614364013544e66e4f5d25f36a2056a9698b793305c5aa3a6", [:mix], [], "hexpm", "3825e461025464f519f3f3e4a1f9b68c47dc151369611629ad08b636b73bb22d"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"nimble_strftime": {:hex, :nimble_strftime, "0.1.1", "b988184d1bd945bc139b2c27dd00a6c0774ec94f6b0b580083abd62d5d07818b", [:mix], [], "hexpm", "89e599c9b8b4d1203b7bb5c79eb51ef7c6a28fbc6228230b312f8b796310d755"},
|
||||
"oban": {:hex, :oban, "2.19.4", "045adb10db1161dceb75c254782f97cdc6596e7044af456a59decb6d06da73c1", [:mix], [{:ecto_sql, "~> 3.10", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:ecto_sqlite3, "~> 0.9", [hex: :ecto_sqlite3, repo: "hexpm", optional: true]}, {:igniter, "~> 0.5", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16", [hex: :postgrex, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5fcc6219e6464525b808d97add17896e724131f498444a292071bf8991c99f97"},
|
||||
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.0-rc.3", "6ae19e57b9c109556f1b8abdb992d96d443b0ae28e03b604f3dc6c75d9f7d35f", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "419422afc33e965c0dbf181cbedc77b4cfd024dac0db7d9d2287656043d48e24"},
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do
|
|||
assert popup =~ "TEST-1"
|
||||
assert popup =~ "/TEST-1"
|
||||
assert popup =~ "/info/TEST-1"
|
||||
assert popup =~ "/weather/TEST-1"
|
||||
assert popup =~ "weather charts"
|
||||
end
|
||||
|
||||
test "generates weather popup with N/A for missing weather data" do
|
||||
|
|
@ -291,6 +293,17 @@ defmodule AprsmeWeb.MapLive.PacketUtilsTest do
|
|||
|
||||
refute PacketUtils.weather_packet?(regular_packet)
|
||||
end
|
||||
|
||||
test "has_weather_packets? returns false for non-existent callsign" do
|
||||
# This should return false since the database call will fail in test context
|
||||
refute PacketUtils.has_weather_packets?("NONEXISTENT")
|
||||
end
|
||||
|
||||
test "has_weather_packets? handles invalid input gracefully" do
|
||||
refute PacketUtils.has_weather_packets?(nil)
|
||||
refute PacketUtils.has_weather_packets?("")
|
||||
refute PacketUtils.has_weather_packets?(123)
|
||||
end
|
||||
end
|
||||
|
||||
describe "weather field extraction" do
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue