cleanup
This commit is contained in:
parent
51cce0594c
commit
feab00ba23
8 changed files with 106 additions and 780 deletions
|
|
@ -5,11 +5,6 @@ defmodule AprsWeb.PageController do
|
||||||
render(conn, :home)
|
render(conn, :home)
|
||||||
end
|
end
|
||||||
|
|
||||||
def map(conn, _params) do
|
|
||||||
# This serves the legacy JavaScript-based map at /old
|
|
||||||
render(conn, :map)
|
|
||||||
end
|
|
||||||
|
|
||||||
def packets(conn, _params) do
|
def packets(conn, _params) do
|
||||||
render(conn, :packets)
|
render(conn, :packets)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,52 +0,0 @@
|
||||||
<div class="legacy-map-wrapper">
|
|
||||||
<div class="legacy-map-header">
|
|
||||||
<h1 class="text-xl font-bold text-gray-800 mb-2">Legacy APRS Map</h1>
|
|
||||||
<p class="text-sm text-gray-600 mb-4">
|
|
||||||
This is the legacy JavaScript-based map.
|
|
||||||
<a href="/" class="text-blue-600 hover:text-blue-800 underline">
|
|
||||||
Try the new LiveView-based map
|
|
||||||
</a>
|
|
||||||
or <a href="/enhanced" class="text-blue-600 hover:text-blue-800 underline">enhanced version</a>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"
|
|
||||||
/>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"
|
|
||||||
/>
|
|
||||||
<!-- Make sure you put this AFTER Leaflet's CSS -->
|
|
||||||
<script
|
|
||||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
||||||
crossorigin=""
|
|
||||||
>
|
|
||||||
</script>
|
|
||||||
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js">
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div id="map" style="height: 80vh; width: 100%;"></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.legacy-map-wrapper {
|
|
||||||
padding: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legacy-map-header {
|
|
||||||
background: #f9fafb;
|
|
||||||
border: 1px solid #e5e7eb;
|
|
||||||
border-radius: 0.5rem;
|
|
||||||
padding: 1rem;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -1,666 +0,0 @@
|
||||||
defmodule AprsWeb.MapLive.Enhanced do
|
|
||||||
@moduledoc false
|
|
||||||
use AprsWeb, :live_view
|
|
||||||
|
|
||||||
import Ecto.Query
|
|
||||||
import Phoenix.Component, except: [update: 3]
|
|
||||||
|
|
||||||
alias Aprs.Packet
|
|
||||||
alias Aprs.Repo
|
|
||||||
|
|
||||||
# Default map settings
|
|
||||||
@default_center %{lat: 39.8283, lng: -98.5795}
|
|
||||||
@default_zoom 4
|
|
||||||
@max_markers 500
|
|
||||||
# 30 seconds
|
|
||||||
@cleanup_interval 30_000
|
|
||||||
@packet_retention_minutes 60
|
|
||||||
|
|
||||||
def mount(_params, _session, socket) do
|
|
||||||
# Schedule periodic cleanup
|
|
||||||
if connected?(socket) do
|
|
||||||
Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
|
||||||
assign(socket,
|
|
||||||
# Map state
|
|
||||||
map_center: @default_center,
|
|
||||||
map_zoom: @default_zoom,
|
|
||||||
map_bounds: default_bounds(),
|
|
||||||
|
|
||||||
# Marker management
|
|
||||||
active_markers: %{},
|
|
||||||
historical_markers: %{},
|
|
||||||
marker_count: 0,
|
|
||||||
|
|
||||||
# UI state
|
|
||||||
page_title: "APRS Map - Enhanced",
|
|
||||||
loading: false,
|
|
||||||
geolocation_error: nil,
|
|
||||||
|
|
||||||
# Replay functionality
|
|
||||||
replay_active: false,
|
|
||||||
replay_speed: 1000,
|
|
||||||
replay_paused: false,
|
|
||||||
replay_timer: nil,
|
|
||||||
replay_packets: [],
|
|
||||||
replay_index: 0,
|
|
||||||
|
|
||||||
# Trail visualization
|
|
||||||
show_trails: true
|
|
||||||
)
|
|
||||||
|
|
||||||
{:ok, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("map_ready", _params, socket) do
|
|
||||||
# Map is ready, load initial markers
|
|
||||||
{:noreply, load_markers_in_bounds(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("bounds_changed", params, socket) do
|
|
||||||
%{
|
|
||||||
"bounds" => bounds,
|
|
||||||
"center" => center,
|
|
||||||
"zoom" => zoom
|
|
||||||
} = params
|
|
||||||
|
|
||||||
# Normalize center to use atom keys
|
|
||||||
normalized_center = %{
|
|
||||||
lat: center["lat"],
|
|
||||||
lng: center["lng"]
|
|
||||||
}
|
|
||||||
|
|
||||||
# Remove out-of-bounds active markers
|
|
||||||
new_active_markers =
|
|
||||||
socket.assigns.active_markers
|
|
||||||
|> Enum.filter(fn {_k, %{packet: packet}} -> within_bounds?(packet, bounds) end)
|
|
||||||
|> Map.new()
|
|
||||||
|
|
||||||
markers_to_remove =
|
|
||||||
socket.assigns.active_markers
|
|
||||||
|> Enum.reject(fn {_k, %{packet: packet}} -> within_bounds?(packet, bounds) end)
|
|
||||||
|> Enum.map(fn {k, _} -> k end)
|
|
||||||
|
|
||||||
socket =
|
|
||||||
if markers_to_remove == [] do
|
|
||||||
socket
|
|
||||||
else
|
|
||||||
Enum.reduce(markers_to_remove, socket, fn k, acc ->
|
|
||||||
push_event(acc, "remove_marker", %{id: k})
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:map_bounds, bounds)
|
|
||||||
|> assign(:map_center, normalized_center)
|
|
||||||
|> assign(:map_zoom, zoom)
|
|
||||||
|> assign(:active_markers, new_active_markers)
|
|
||||||
|> load_markers_in_bounds()
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("marker_clicked", _params, socket) do
|
|
||||||
# You could add marker click logic here
|
|
||||||
# For example, show detailed info, center map, etc.
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("locate_me", _params, socket) do
|
|
||||||
# Request geolocation from client
|
|
||||||
{:noreply, push_event(socket, "request_geolocation", %{})}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("set_location", %{"lat" => lat, "lng" => lng}, socket) do
|
|
||||||
center = %{lat: lat, lng: lng}
|
|
||||||
zoom = 12
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:map_center, center)
|
|
||||||
|> assign(:map_zoom, zoom)
|
|
||||||
|> assign(:geolocation_error, nil)
|
|
||||||
|
|
||||||
# Tell client to zoom to location
|
|
||||||
socket =
|
|
||||||
push_event(socket, "zoom_to_location", %{
|
|
||||||
lat: lat,
|
|
||||||
lng: lng,
|
|
||||||
zoom: zoom
|
|
||||||
})
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("geolocation_error", %{"error" => error}, socket) do
|
|
||||||
{:noreply, assign(socket, :geolocation_error, error)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("toggle_replay", _params, socket) do
|
|
||||||
if socket.assigns.replay_active do
|
|
||||||
stop_replay(socket)
|
|
||||||
else
|
|
||||||
start_replay(socket)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("pause_replay", _params, socket) do
|
|
||||||
socket =
|
|
||||||
if socket.assigns.replay_timer do
|
|
||||||
Process.cancel_timer(socket.assigns.replay_timer)
|
|
||||||
assign(socket, replay_timer: nil, replay_paused: true)
|
|
||||||
else
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("adjust_replay_speed", %{"speed" => speed}, socket) do
|
|
||||||
speed_ms = String.to_integer(speed)
|
|
||||||
{:noreply, assign(socket, :replay_speed, speed_ms)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("toggle_trails", _params, socket) do
|
|
||||||
show_trails = !Map.get(socket.assigns, :show_trails, true)
|
|
||||||
socket = assign(socket, :show_trails, show_trails)
|
|
||||||
socket = push_event(socket, "toggle_trails", %{show: show_trails})
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info(:cleanup_old_markers, socket) do
|
|
||||||
socket = cleanup_old_markers(socket)
|
|
||||||
|
|
||||||
# Schedule next cleanup
|
|
||||||
Process.send_after(self(), :cleanup_old_markers, @cleanup_interval)
|
|
||||||
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info(:replay_next, socket) do
|
|
||||||
socket = process_next_replay_packet(socket)
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_info({:new_packet, packet}, socket) do
|
|
||||||
id = packet_to_marker_data(packet).id
|
|
||||||
|
|
||||||
if has_position_data?(packet) and Map.has_key?(socket.assigns.active_markers, id) and
|
|
||||||
not within_bounds?(packet, socket.assigns.map_bounds) do
|
|
||||||
socket = push_event(socket, "remove_marker", %{id: id})
|
|
||||||
new_active_markers = Map.delete(socket.assigns.active_markers, id)
|
|
||||||
{:noreply, assign(socket, :active_markers, new_active_markers)}
|
|
||||||
else
|
|
||||||
# Only add marker if it is within bounds
|
|
||||||
if has_position_data?(packet) and within_bounds?(packet, socket.assigns.map_bounds) do
|
|
||||||
socket = add_packet_marker(socket, packet, false)
|
|
||||||
{:noreply, socket}
|
|
||||||
else
|
|
||||||
{:noreply, socket}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<!-- Leaflet CSS -->
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Leaflet JS -->
|
|
||||||
<script
|
|
||||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
|
||||||
crossorigin=""
|
|
||||||
>
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
#aprs-map {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100vh;
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-controls {
|
|
||||||
position: absolute;
|
|
||||||
left: 10px;
|
|
||||||
top: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-button {
|
|
||||||
background: white;
|
|
||||||
border: 2px solid rgba(0,0,0,0.2);
|
|
||||||
border-radius: 4px;
|
|
||||||
padding: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-button:hover {
|
|
||||||
background: #f4f4f4;
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-button:disabled {
|
|
||||||
opacity: 0.6;
|
|
||||||
cursor: not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.map-info {
|
|
||||||
position: absolute;
|
|
||||||
right: 10px;
|
|
||||||
top: 10px;
|
|
||||||
z-index: 1000;
|
|
||||||
background: white;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.replay-controls {
|
|
||||||
position: absolute;
|
|
||||||
bottom: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
z-index: 1000;
|
|
||||||
background: white;
|
|
||||||
padding: 10px;
|
|
||||||
border-radius: 4px;
|
|
||||||
box-shadow: 0 1px 3px rgba(0,0,0,0.3);
|
|
||||||
display: flex;
|
|
||||||
gap: 10px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.aprs-marker {
|
|
||||||
background: transparent !important;
|
|
||||||
border: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.historical-marker {
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
<!-- Map Container -->
|
|
||||||
<div
|
|
||||||
id="aprs-map"
|
|
||||||
phx-hook="APRSMap"
|
|
||||||
phx-update="ignore"
|
|
||||||
data-center={Jason.encode!(@map_center)}
|
|
||||||
data-zoom={@map_zoom}
|
|
||||||
data-lat={@map_center.lat}
|
|
||||||
data-lng={@map_center.lng}
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Map Controls -->
|
|
||||||
<div class="map-controls">
|
|
||||||
<button class="control-button" phx-click="locate_me" title="Find my location">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<circle cx="12" cy="12" r="10" />
|
|
||||||
<line x1="12" y1="8" x2="12" y2="16" />
|
|
||||||
<line x1="8" y1="12" x2="16" y2="12" />
|
|
||||||
</svg>
|
|
||||||
Locate Me
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="control-button" phx-click="toggle_replay">
|
|
||||||
<%= if @replay_active do %>
|
|
||||||
Stop Replay
|
|
||||||
<% else %>
|
|
||||||
Start Replay
|
|
||||||
<% end %>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button class="control-button" phx-click="toggle_trails" title="Toggle position trails">
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
stroke-width="2"
|
|
||||||
>
|
|
||||||
<path d="M3 12h18m-18 0l3-3m-3 3l3 3" />
|
|
||||||
<circle cx="6" cy="12" r="1" fill="currentColor" />
|
|
||||||
<circle cx="12" cy="12" r="1" fill="currentColor" />
|
|
||||||
<circle cx="18" cy="12" r="1" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
<%= if assigns[:show_trails] != false do %>
|
|
||||||
Hide Trails
|
|
||||||
<% else %>
|
|
||||||
Show Trails
|
|
||||||
<% end %>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<%= if @replay_active do %>
|
|
||||||
<button class="control-button" phx-click="pause_replay" disabled={@replay_paused}>
|
|
||||||
<%= if @replay_paused do %>
|
|
||||||
Resume
|
|
||||||
<% else %>
|
|
||||||
Pause
|
|
||||||
<% end %>
|
|
||||||
</button>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Map Info Panel -->
|
|
||||||
<div class="map-info">
|
|
||||||
<div><strong>Active Markers:</strong> {map_size(@active_markers)}</div>
|
|
||||||
<div><strong>Historical:</strong> {map_size(@historical_markers)}</div>
|
|
||||||
<div><strong>Zoom:</strong> {@map_zoom}</div>
|
|
||||||
<div>
|
|
||||||
<strong>Center:</strong> {Float.round(@map_center.lat, 4)}, {Float.round(@map_center.lng, 4)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<%= if @geolocation_error do %>
|
|
||||||
<div style="color: red; margin-top: 5px;">
|
|
||||||
<strong>Location Error:</strong> {@geolocation_error}
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
|
|
||||||
<%= if @loading do %>
|
|
||||||
<div style="color: blue; margin-top: 5px;">Loading markers...</div>
|
|
||||||
<% end %>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Replay Controls -->
|
|
||||||
<%= if @replay_active do %>
|
|
||||||
<div class="replay-controls">
|
|
||||||
<label>Speed:</label>
|
|
||||||
<select phx-change="adjust_replay_speed" name="speed">
|
|
||||||
<option value="2000" selected={@replay_speed == 2000}>Slow</option>
|
|
||||||
<option value="1000" selected={@replay_speed == 1000}>Normal</option>
|
|
||||||
<option value="500" selected={@replay_speed == 500}>Fast</option>
|
|
||||||
<option value="200" selected={@replay_speed == 200}>Very Fast</option>
|
|
||||||
</select>
|
|
||||||
|
|
||||||
<span>Progress: {@replay_index}/{length(@replay_packets)}</span>
|
|
||||||
</div>
|
|
||||||
<% end %>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
# Private helper functions
|
|
||||||
|
|
||||||
defp default_bounds do
|
|
||||||
%{
|
|
||||||
north: 49.0,
|
|
||||||
south: 24.0,
|
|
||||||
east: -66.0,
|
|
||||||
west: -125.0
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp load_markers_in_bounds(socket) do
|
|
||||||
bounds = socket.assigns.map_bounds
|
|
||||||
|
|
||||||
# Don't load if we don't have proper bounds yet
|
|
||||||
if bounds == default_bounds() do
|
|
||||||
socket
|
|
||||||
else
|
|
||||||
socket = assign(socket, :loading, true)
|
|
||||||
|
|
||||||
# Fetch recent packets within bounds
|
|
||||||
packets = fetch_packets_in_bounds(bounds, @max_markers)
|
|
||||||
|
|
||||||
# Convert packets to marker data and send to client
|
|
||||||
markers = Enum.map(packets, &packet_to_marker_data/1)
|
|
||||||
|
|
||||||
# Clear existing markers and add new ones
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> push_event("clear_markers", %{})
|
|
||||||
|> push_event("add_markers", %{markers: markers})
|
|
||||||
|> update_active_markers(packets)
|
|
||||||
|> assign(:loading, false)
|
|
||||||
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_packets_in_bounds(bounds, limit) do
|
|
||||||
cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
|
|
||||||
|
|
||||||
# Create a bounding box polygon for PostGIS spatial query
|
|
||||||
bbox_wkt =
|
|
||||||
"POLYGON((#{bounds["west"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["south"]}))"
|
|
||||||
|
|
||||||
Repo.all(
|
|
||||||
from(p in Packet,
|
|
||||||
where: p.has_position == true,
|
|
||||||
where: p.received_at >= ^cutoff_time,
|
|
||||||
where: not is_nil(p.location),
|
|
||||||
where: fragment("ST_Within(?, ST_GeomFromText(?, 4326))", p.location, ^bbox_wkt),
|
|
||||||
order_by: [desc: p.received_at],
|
|
||||||
limit: ^limit,
|
|
||||||
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp packet_to_marker_data(packet) do
|
|
||||||
data_extended = packet.data_extended || %{}
|
|
||||||
callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
|
|
||||||
|
|
||||||
symbol_table_id =
|
|
||||||
Map.get(data_extended, :symbol_table_id) || Map.get(data_extended, "symbol_table_id") || "/"
|
|
||||||
|
|
||||||
symbol_code =
|
|
||||||
Map.get(data_extended, :symbol_code) || Map.get(data_extended, "symbol_code") || ">"
|
|
||||||
|
|
||||||
%{
|
|
||||||
id: callsign,
|
|
||||||
callsign: callsign,
|
|
||||||
lat: packet.lat,
|
|
||||||
lng: packet.lon,
|
|
||||||
symbol_table: symbol_table_id,
|
|
||||||
symbol_code: symbol_code,
|
|
||||||
historical: false,
|
|
||||||
popup: build_popup_content(packet, callsign, false),
|
|
||||||
timestamp: DateTime.to_unix(packet.created_at, :millisecond)
|
|
||||||
}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp build_popup_content(packet, callsign, historical) do
|
|
||||||
data_extended = packet.data_extended || %{}
|
|
||||||
|
|
||||||
timestamp =
|
|
||||||
if historical do
|
|
||||||
DateTime.to_string(packet.received_at)
|
|
||||||
else
|
|
||||||
packet.received_at |> DateTime.to_time() |> Time.to_string()
|
|
||||||
end
|
|
||||||
|
|
||||||
"""
|
|
||||||
<div style="min-width: 200px;">
|
|
||||||
<h4 style="margin: 0 0 5px 0; font-weight: bold;">#{callsign} #{if historical, do: "(Historical)", else: ""}</h4>
|
|
||||||
<p style="margin: 2px 0; font-size: 12px;">
|
|
||||||
<strong>Position:</strong> #{Float.round(packet.lat, 4)}°, #{Float.round(packet.lon, 4)}°<br>
|
|
||||||
<strong>Type:</strong> #{packet.data_type}<br>
|
|
||||||
#{if data_extended["comment"], do: "<strong>Comment:</strong> #{data_extended["comment"]}<br>", else: ""}
|
|
||||||
<strong>Path:</strong> #{packet.path}<br>
|
|
||||||
<strong>Time:</strong> #{timestamp}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
defp update_active_markers(socket, packets) do
|
|
||||||
active_markers =
|
|
||||||
Map.new(packets, fn packet ->
|
|
||||||
callsign = packet.base_callsign <> if packet.ssid, do: "-#{packet.ssid}", else: ""
|
|
||||||
{callsign, %{packet: packet, added_at: DateTime.utc_now()}}
|
|
||||||
end)
|
|
||||||
|
|
||||||
assign(socket, :active_markers, active_markers)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp add_packet_marker(socket, packet, historical) do
|
|
||||||
if has_position_data?(packet) do
|
|
||||||
marker_data = packet_to_marker_data(packet)
|
|
||||||
marker_data = Map.put(marker_data, :historical, historical)
|
|
||||||
|
|
||||||
socket = push_event(socket, "add_marker", marker_data)
|
|
||||||
|
|
||||||
# Update marker tracking
|
|
||||||
callsign = marker_data.id
|
|
||||||
marker_info = %{packet: packet, added_at: DateTime.utc_now()}
|
|
||||||
|
|
||||||
if historical do
|
|
||||||
Phoenix.Component.update(socket, :historical_markers, &Map.put(&1, callsign, marker_info))
|
|
||||||
else
|
|
||||||
Phoenix.Component.update(socket, :active_markers, &Map.put(&1, callsign, marker_info))
|
|
||||||
end
|
|
||||||
else
|
|
||||||
socket
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp cleanup_old_markers(socket) do
|
|
||||||
cutoff_time = DateTime.add(DateTime.utc_now(), -@packet_retention_minutes * 60, :second)
|
|
||||||
|
|
||||||
# Find old markers
|
|
||||||
old_markers =
|
|
||||||
socket.assigns.active_markers
|
|
||||||
|> Enum.filter(fn {_callsign, %{added_at: added_at}} ->
|
|
||||||
DateTime.before?(added_at, cutoff_time)
|
|
||||||
end)
|
|
||||||
|> Enum.map(fn {callsign, _} -> callsign end)
|
|
||||||
|
|
||||||
# Only update the client if the marker is present
|
|
||||||
socket =
|
|
||||||
if old_markers == [] do
|
|
||||||
socket
|
|
||||||
else
|
|
||||||
Enum.reduce(old_markers, socket, fn callsign, acc_socket ->
|
|
||||||
push_event(acc_socket, "remove_marker", %{id: callsign})
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Use Map.drop/2 for better performance
|
|
||||||
active_markers = Map.drop(socket.assigns.active_markers, old_markers)
|
|
||||||
assign(socket, :active_markers, active_markers)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp start_replay(socket) do
|
|
||||||
# Fetch historical packets for replay
|
|
||||||
bounds = socket.assigns.map_bounds
|
|
||||||
one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second)
|
|
||||||
two_hours_ago = DateTime.add(DateTime.utc_now(), -7200, :second)
|
|
||||||
|
|
||||||
packets = fetch_historical_packets(bounds, two_hours_ago, one_hour_ago)
|
|
||||||
|
|
||||||
socket =
|
|
||||||
socket
|
|
||||||
|> assign(:replay_active, true)
|
|
||||||
|> assign(:replay_packets, packets)
|
|
||||||
|> assign(:replay_index, 0)
|
|
||||||
|> assign(:replay_paused, false)
|
|
||||||
|
|
||||||
# Start replay timer
|
|
||||||
timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
|
|
||||||
assign(socket, :replay_timer, timer)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp stop_replay(socket) do
|
|
||||||
# Cancel timer if running
|
|
||||||
if socket.assigns.replay_timer do
|
|
||||||
Process.cancel_timer(socket.assigns.replay_timer)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Clear historical markers
|
|
||||||
socket = push_event(socket, "clear_markers", %{})
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:replay_active, false)
|
|
||||||
|> assign(:replay_timer, nil)
|
|
||||||
|> assign(:replay_packets, [])
|
|
||||||
|> assign(:replay_index, 0)
|
|
||||||
|> assign(:historical_markers, %{})
|
|
||||||
# Reload current markers
|
|
||||||
|> load_markers_in_bounds()
|
|
||||||
end
|
|
||||||
|
|
||||||
defp process_next_replay_packet(socket) do
|
|
||||||
packets = socket.assigns.replay_packets
|
|
||||||
index = socket.assigns.replay_index
|
|
||||||
|
|
||||||
if index < length(packets) do
|
|
||||||
packet = Enum.at(packets, index)
|
|
||||||
socket = add_packet_marker(socket, packet, true)
|
|
||||||
|
|
||||||
# Schedule next packet
|
|
||||||
timer = Process.send_after(self(), :replay_next, socket.assigns.replay_speed)
|
|
||||||
|
|
||||||
socket
|
|
||||||
|> assign(:replay_index, index + 1)
|
|
||||||
|> assign(:replay_timer, timer)
|
|
||||||
else
|
|
||||||
# Replay finished
|
|
||||||
assign(socket, :replay_timer, nil)
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
||||||
defp fetch_historical_packets(bounds, start_time, end_time) do
|
|
||||||
# Create a bounding box polygon for PostGIS spatial query
|
|
||||||
bbox_wkt =
|
|
||||||
"POLYGON((#{bounds["west"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["south"]}, #{bounds["east"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["north"]}, #{bounds["west"]} #{bounds["south"]}))"
|
|
||||||
|
|
||||||
Repo.all(
|
|
||||||
from(p in Packet,
|
|
||||||
where: p.has_position == true,
|
|
||||||
where: p.received_at >= ^start_time,
|
|
||||||
where: p.received_at <= ^end_time,
|
|
||||||
where: not is_nil(p.location),
|
|
||||||
where: fragment("ST_Within(?, ST_GeomFromText(?, 4326))", p.location, ^bbox_wkt),
|
|
||||||
order_by: [asc: p.received_at],
|
|
||||||
limit: 1000,
|
|
||||||
select: %{p | lat: fragment("ST_Y(?)", p.location), lon: fragment("ST_X(?)", p.location)}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp has_position_data?(packet) do
|
|
||||||
lat = Map.get(packet, :lat) || Map.get(packet, "lat")
|
|
||||||
lon = Map.get(packet, :lon) || Map.get(packet, "lon")
|
|
||||||
not is_nil(lat) and not is_nil(lon)
|
|
||||||
end
|
|
||||||
|
|
||||||
defp within_bounds?(packet, bounds) do
|
|
||||||
lat = packet.lat
|
|
||||||
lng = packet.lon
|
|
||||||
|
|
||||||
lat >= bounds["south"] and lat <= bounds["north"] and
|
|
||||||
lng >= bounds["west"] and lng <= bounds["east"]
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -12,7 +12,6 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
|
|
||||||
@default_center %{lat: 39.8283, lng: -98.5795}
|
@default_center %{lat: 39.8283, lng: -98.5795}
|
||||||
@default_zoom 5
|
@default_zoom 5
|
||||||
@ip_api_url "https://ip-api.com/json/"
|
|
||||||
@finch_name Aprs.Finch
|
@finch_name Aprs.Finch
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
|
|
@ -688,41 +687,46 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
# Helper function to start historical replay
|
# Helper function to start historical replay
|
||||||
@spec start_historical_replay(Socket.t()) :: Socket.t()
|
@spec start_historical_replay(Socket.t()) :: Socket.t()
|
||||||
defp start_historical_replay(socket) do
|
defp start_historical_replay(socket) do
|
||||||
# Get time range for historical data
|
# Only fetch historical packets if map is ready and not already replaying
|
||||||
now = DateTime.utc_now()
|
if socket.assigns.map_ready and not socket.assigns.replay_active do
|
||||||
one_hour_ago = DateTime.add(now, -60 * 60, :second)
|
# Get time range for historical data
|
||||||
|
now = DateTime.utc_now()
|
||||||
|
one_hour_ago = DateTime.add(now, -60 * 60, :second)
|
||||||
|
|
||||||
# Convert map bounds to the format expected by the database query
|
# Convert map bounds to the format expected by the database query
|
||||||
bounds = [
|
bounds = [
|
||||||
socket.assigns.map_bounds.west,
|
socket.assigns.map_bounds.west,
|
||||||
socket.assigns.map_bounds.south,
|
socket.assigns.map_bounds.south,
|
||||||
socket.assigns.map_bounds.east,
|
socket.assigns.map_bounds.east,
|
||||||
socket.assigns.map_bounds.north
|
socket.assigns.map_bounds.north
|
||||||
]
|
]
|
||||||
|
|
||||||
# Fetch historical packets with position data within the current map bounds
|
# Fetch historical packets with position data within the current map bounds
|
||||||
historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
|
historical_packets = fetch_historical_packets(bounds, one_hour_ago, now)
|
||||||
|
|
||||||
if Enum.empty?(historical_packets) do
|
if Enum.empty?(historical_packets) do
|
||||||
# No historical packets found - silently continue without replay
|
# No historical packets found - silently continue without replay
|
||||||
socket
|
socket
|
||||||
|
else
|
||||||
|
# Clear any previous historical packets from the map
|
||||||
|
socket = push_event(socket, "clear_historical_packets", %{})
|
||||||
|
|
||||||
|
# Start replay
|
||||||
|
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
|
||||||
|
|
||||||
|
assign(socket,
|
||||||
|
replay_active: true,
|
||||||
|
replay_packets: historical_packets,
|
||||||
|
replay_index: 0,
|
||||||
|
replay_timer_ref: timer_ref,
|
||||||
|
replay_start_time: one_hour_ago,
|
||||||
|
replay_end_time: now,
|
||||||
|
historical_packets: %{},
|
||||||
|
replay_started: true
|
||||||
|
)
|
||||||
|
end
|
||||||
else
|
else
|
||||||
# Clear any previous historical packets from the map
|
socket
|
||||||
socket = push_event(socket, "clear_historical_packets", %{})
|
|
||||||
|
|
||||||
# Start replay
|
|
||||||
timer_ref = Process.send_after(self(), :replay_next_packet, 1000)
|
|
||||||
|
|
||||||
assign(socket,
|
|
||||||
replay_active: true,
|
|
||||||
replay_packets: historical_packets,
|
|
||||||
replay_index: 0,
|
|
||||||
replay_timer_ref: timer_ref,
|
|
||||||
replay_start_time: one_hour_ago,
|
|
||||||
replay_end_time: now,
|
|
||||||
historical_packets: %{},
|
|
||||||
replay_started: true
|
|
||||||
)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -783,6 +787,7 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
defp build_packet_data(packet) do
|
defp build_packet_data(packet) do
|
||||||
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
|
{lat, lon, data_extended} = MapHelpers.get_coordinates(packet)
|
||||||
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
|
callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
|
||||||
|
|
||||||
# Only include packets with valid position data and a non-empty callsign
|
# Only include packets with valid position data and a non-empty callsign
|
||||||
if lat && lon && callsign != "" && callsign != nil do
|
if lat && lon && callsign != "" && callsign != nil do
|
||||||
build_packet_map(packet, lat, lon, data_extended)
|
build_packet_map(packet, lat, lon, data_extended)
|
||||||
|
|
@ -846,15 +851,23 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
0.0
|
0.0
|
||||||
end
|
end
|
||||||
|
|
||||||
popup = """
|
is_weather_packet =
|
||||||
<div class=\"aprs-popup\">
|
(Map.get(packet, :data_type) || Map.get(packet, "data_type")) == "weather" or
|
||||||
<div class=\"aprs-callsign\"><strong><a href=\"/#{callsign}\">#{callsign}</a></strong></div>
|
(symbol_table_id == "/" and symbol_code == "_")
|
||||||
<div class=\"aprs-symbol-info\">#{symbol_description}</div>
|
|
||||||
#{if comment == "", do: "", else: "<div class=\\\"aprs-comment\\\">#{comment}</div>"}
|
popup =
|
||||||
<div class=\"aprs-coords\">#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}</div>
|
if is_weather_packet do
|
||||||
<div class=\"aprs-time\">#{timestamp}</div>
|
build_weather_popup_html(packet, callsign)
|
||||||
</div>
|
else
|
||||||
"""
|
"""
|
||||||
|
<div class="aprs-popup">
|
||||||
|
<div class="aprs-callsign"><strong><a href="/map/callsign/#{callsign}">#{callsign}</a></strong></div>
|
||||||
|
#{if comment == "", do: "", else: ~s(<div class="aprs-comment">#{comment}</div>)}
|
||||||
|
<div class="aprs-coords">#{Float.round(to_float.(lat), 4)}, #{Float.round(to_float.(lon), 4)}</div>
|
||||||
|
<div class="aprs-time">#{timestamp}</div>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
%{
|
%{
|
||||||
"id" => callsign,
|
"id" => callsign,
|
||||||
|
|
@ -875,6 +888,42 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
defp build_weather_popup_html(packet, callsign) do
|
||||||
|
received_at =
|
||||||
|
cond do
|
||||||
|
Map.has_key?(packet, :received_at) -> packet.received_at
|
||||||
|
Map.has_key?(packet, "received_at") -> packet["received_at"]
|
||||||
|
true -> nil
|
||||||
|
end
|
||||||
|
|
||||||
|
timestamp_str =
|
||||||
|
if received_at,
|
||||||
|
do: Calendar.strftime(received_at, "%Y-%m-%d %H:%M:%S"),
|
||||||
|
else: "N/A"
|
||||||
|
|
||||||
|
"""
|
||||||
|
<strong>#{callsign} - Weather Report</strong><br>
|
||||||
|
<small>#{timestamp_str} UTC</small>
|
||||||
|
<hr>
|
||||||
|
Temperature: #{get_weather_field(packet, :temperature)}°F<br>
|
||||||
|
Humidity: #{get_weather_field(packet, :humidity)}%<br>
|
||||||
|
Wind: #{get_weather_field(packet, :wind_direction)}° at #{get_weather_field(packet, :wind_speed)} mph, gusts to #{get_weather_field(packet, :wind_gust)} mph<br>
|
||||||
|
Pressure: #{get_weather_field(packet, :pressure)} hPa<br>
|
||||||
|
Rain (1h): #{get_weather_field(packet, :rain_1h)} in.<br>
|
||||||
|
Rain (24h): #{get_weather_field(packet, :rain_24h)} in.<br>
|
||||||
|
Rain (since midnight): #{get_weather_field(packet, :rain_since_midnight)} in.<br>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
defp get_weather_field(packet, key) do
|
||||||
|
data_extended = Map.get(packet, "data_extended", %{})
|
||||||
|
|
||||||
|
Map.get(packet, key) ||
|
||||||
|
Map.get(packet, to_string(key)) ||
|
||||||
|
Map.get(data_extended, key) ||
|
||||||
|
Map.get(data_extended, to_string(key)) || "N/A"
|
||||||
|
end
|
||||||
|
|
||||||
@spec generate_callsign(map() | struct()) :: String.t()
|
@spec generate_callsign(map() | struct()) :: String.t()
|
||||||
defp generate_callsign(packet) do
|
defp generate_callsign(packet) do
|
||||||
base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
|
base_callsign = Map.get(packet, :base_callsign, Map.get(packet, "base_callsign", ""))
|
||||||
|
|
@ -894,17 +943,8 @@ defmodule AprsWeb.MapLive.Index do
|
||||||
defp get_ip_location(nil), do: nil
|
defp get_ip_location(nil), do: nil
|
||||||
|
|
||||||
defp get_ip_location(ip) do
|
defp get_ip_location(ip) do
|
||||||
url = "#{@ip_api_url}#{ip}"
|
# Asynchronously fetch IP location
|
||||||
|
case :get |> Finch.build("http://ip-api.com/json/#{ip}") |> Finch.request(@finch_name) do
|
||||||
request =
|
|
||||||
Finch.build(:get, url, [
|
|
||||||
{"User-Agent", "APRS.me/1.0"},
|
|
||||||
{"Accept", "application/json"}
|
|
||||||
])
|
|
||||||
|
|
||||||
Process.sleep(2000)
|
|
||||||
|
|
||||||
case Finch.request(request, @finch_name, receive_timeout: 10_000) do
|
|
||||||
{:ok, %{status: 200, body: body}} -> handle_ip_api_response(body)
|
{:ok, %{status: 200, body: body}} -> handle_ip_api_response(body)
|
||||||
{:ok, _response} -> send_default_ip_location()
|
{:ok, _response} -> send_default_ip_location()
|
||||||
{:error, %{reason: :timeout}} -> send_default_ip_location()
|
{:error, %{reason: :timeout}} -> send_default_ip_location()
|
||||||
|
|
|
||||||
|
|
@ -30,8 +30,6 @@ defmodule AprsWeb.Router do
|
||||||
|
|
||||||
live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
|
live_dashboard "/dashboard", metrics: AprsWeb.Telemetry
|
||||||
live "/", MapLive.Index, :index
|
live "/", MapLive.Index, :index
|
||||||
live "/enhanced", MapLive.Enhanced, :index
|
|
||||||
get "/old", PageController, :map
|
|
||||||
live "/status", StatusLive.Index, :index
|
live "/status", StatusLive.Index, :index
|
||||||
|
|
||||||
live "/packets", PacketsLive.Index, :index
|
live "/packets", PacketsLive.Index, :index
|
||||||
|
|
|
||||||
|
|
@ -458,7 +458,7 @@ defmodule Parser do
|
||||||
(is_number(lat) or is_struct(lat, Decimal)) and
|
(is_number(lat) or is_struct(lat, Decimal)) and
|
||||||
(is_number(lon) or is_struct(lon, Decimal))
|
(is_number(lon) or is_struct(lon, Decimal))
|
||||||
|
|
||||||
%{
|
base_map = %{
|
||||||
latitude: lat,
|
latitude: lat,
|
||||||
longitude: lon,
|
longitude: lon,
|
||||||
timestamp: nil,
|
timestamp: nil,
|
||||||
|
|
@ -475,6 +475,13 @@ defmodule Parser do
|
||||||
has_position: has_position
|
has_position: has_position
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sym_table_id == "/" and symbol_code == "_" do
|
||||||
|
weather_map = Parser.Weather.parse_weather_data(comment)
|
||||||
|
Map.merge(base_map, weather_map)
|
||||||
|
else
|
||||||
|
base_map
|
||||||
|
end
|
||||||
|
|
||||||
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
<<latitude::binary-size(8), sym_table_id::binary-size(1), longitude::binary-size(9)>> ->
|
||||||
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
%{latitude: lat, longitude: lon} = parse_aprs_position(latitude, longitude)
|
||||||
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
ambiguity = Parser.Helpers.calculate_position_ambiguity(latitude, longitude)
|
||||||
|
|
|
||||||
|
|
@ -267,7 +267,7 @@ defmodule Parser.Helpers do
|
||||||
|
|
||||||
@spec parse_wind_speed(String.t()) :: integer() | nil
|
@spec parse_wind_speed(String.t()) :: integer() | nil
|
||||||
def parse_wind_speed(weather_data) do
|
def parse_wind_speed(weather_data) do
|
||||||
case Regex.run(~r'/(\d{3})', weather_data) do
|
case Regex.run(~r/\/(\d{3})/, weather_data) do
|
||||||
[_, speed] -> String.to_integer(speed)
|
[_, speed] -> String.to_integer(speed)
|
||||||
nil -> nil
|
nil -> nil
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,11 @@ defmodule Parser.Weather do
|
||||||
Map.merge(%{data_type: :weather}, weather_data)
|
Map.merge(%{data_type: :weather}, weather_data)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp parse_weather_data(weather_data) do
|
@doc """
|
||||||
|
Parses a weather data string into a map of weather values.
|
||||||
|
"""
|
||||||
|
@spec parse_weather_data(String.t()) :: map()
|
||||||
|
def parse_weather_data(weather_data) do
|
||||||
timestamp = Parser.Helpers.extract_timestamp(weather_data)
|
timestamp = Parser.Helpers.extract_timestamp(weather_data)
|
||||||
weather_data = Parser.Helpers.remove_timestamp(weather_data)
|
weather_data = Parser.Helpers.remove_timestamp(weather_data)
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue