From 052e13140065b3956e73360b525700a635e730e5 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 9 Jul 2025 09:23:54 -0500 Subject: [PATCH] Add URL updating and restoration for map state (zoom/pan) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features implemented: - Parse URL parameters on mount (lat, lng, z) - Update URL when map moves or zooms - Restore map state from URL parameters - Handle URL changes via handle_params - Validate and clamp coordinate values URL format: /?lat=40.7128&lng=-74.0060&z=12 Benefits: - Shareable map links with exact position - Browser back/forward navigation support - Bookmarkable map locations - Deep linking to specific map views 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- assets/js/map.ts | 16 +++- lib/aprsme_web/live/map_live/index.ex | 107 +++++++++++++++++++++++++- mix.lock | 1 + 3 files changed, 121 insertions(+), 3 deletions(-) diff --git a/assets/js/map.ts b/assets/js/map.ts index eef7a57..e2b3e5b 100644 --- a/assets/js/map.ts +++ b/assets/js/map.ts @@ -290,13 +290,19 @@ let MapAPRSMap = { if (self.boundsTimer) clearTimeout(self.boundsTimer); self.boundsTimer = setTimeout(() => { self.sendBoundsToServer(); - // Save map state + // Save map state and update URL const center = self.map.getCenter(); const zoom = self.map.getZoom(); localStorage.setItem( "aprs_map_state", JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), ); + + // Send map state update to server for URL updating + self.pushEvent("update_map_state", { + center: { lat: center.lat, lng: center.lng }, + zoom: zoom + }); }, 300); }); @@ -315,13 +321,19 @@ let MapAPRSMap = { self.sendBoundsToServer(); self.lastZoom = currentZoom; - // Save map state + // Save map state and update URL const center = self.map.getCenter(); const zoom = self.map.getZoom(); localStorage.setItem( "aprs_map_state", JSON.stringify({ lat: center.lat, lng: center.lng, zoom }), ); + + // Send map state update to server for URL updating + self.pushEvent("update_map_state", { + center: { lat: center.lat, lng: center.lng }, + zoom: zoom + }); }, 300); }); diff --git a/lib/aprsme_web/live/map_live/index.ex b/lib/aprsme_web/live/map_live/index.ex index 2a10b94..6cbcacf 100644 --- a/lib/aprsme_web/live/map_live/index.ex +++ b/lib/aprsme_web/live/map_live/index.ex @@ -15,8 +15,54 @@ defmodule AprsmeWeb.MapLive.Index do @default_center %{lat: 39.8283, lng: -98.5795} @default_zoom 5 + # Parse map state from URL parameters + @spec parse_map_params(map()) :: {map(), integer()} + defp parse_map_params(params) do + # Parse latitude (lat parameter) + lat = + case Map.get(params, "lat") do + nil -> + @default_center.lat + + lat_str -> + case Float.parse(lat_str) do + {lat_val, _} when lat_val >= -90 and lat_val <= 90 -> lat_val + _ -> @default_center.lat + end + end + + # Parse longitude (lng parameter) + lng = + case Map.get(params, "lng") do + nil -> + @default_center.lng + + lng_str -> + case Float.parse(lng_str) do + {lng_val, _} when lng_val >= -180 and lng_val <= 180 -> lng_val + _ -> @default_center.lng + end + end + + # Parse zoom level (z parameter) + zoom = + case Map.get(params, "z") do + nil -> + @default_zoom + + zoom_str -> + case Integer.parse(zoom_str) do + {zoom_val, _} when zoom_val >= 1 and zoom_val <= 20 -> zoom_val + _ -> @default_zoom + end + end + + map_center = %{lat: lat, lng: lng} + {map_center, zoom} + end + @impl true - def mount(_params, _session, socket) do + def mount(params, _session, socket) do if connected?(socket) do # Subscribe to packet updates Phoenix.PubSub.subscribe(Aprsme.PubSub, "packets") @@ -31,7 +77,11 @@ defmodule AprsmeWeb.MapLive.Index do one_hour_ago = DateTime.add(DateTime.utc_now(), -3600, :second) + # Parse map state from URL parameters + {map_center, map_zoom} = parse_map_params(params) + socket = assign_defaults(socket, one_hour_ago) + socket = assign(socket, map_center: map_center, map_zoom: map_zoom) socket = assign(socket, packet_buffer: [], buffer_timer: nil) socket = assign(socket, all_packets: %{}, station_popup_open: false) @@ -266,6 +316,61 @@ defmodule AprsmeWeb.MapLive.Index do {:noreply, socket} end + @impl true + def handle_event("update_map_state", %{"center" => center, "zoom" => zoom}, socket) do + # Parse center coordinates + lat = + case center do + %{"lat" => lat_val} -> lat_val + _ -> socket.assigns.map_center.lat + end + + lng = + case center do + %{"lng" => lng_val} -> lng_val + _ -> socket.assigns.map_center.lng + end + + # Validate and clamp values + lat = max(-90.0, min(90.0, lat)) + lng = max(-180.0, min(180.0, lng)) + zoom = max(1, min(20, zoom)) + + map_center = %{lat: lat, lng: lng} + + # Update socket state + socket = assign(socket, map_center: map_center, map_zoom: zoom) + + # Update URL without page reload + new_path = "/?lat=#{lat}&lng=#{lng}&z=#{zoom}" + socket = push_patch(socket, to: new_path, replace: true) + + {:noreply, socket} + end + + @impl true + def handle_params(params, _url, socket) do + # Parse new map state from URL parameters + {map_center, map_zoom} = parse_map_params(params) + + # Update socket state + socket = assign(socket, map_center: map_center, map_zoom: map_zoom) + + # If map is ready, update the client-side map + socket = + if socket.assigns.map_ready do + push_event(socket, "zoom_to_location", %{ + lat: map_center.lat, + lng: map_center.lng, + zoom: map_zoom + }) + else + socket + end + + {:noreply, socket} + end + @impl true def handle_info({:process_bounds_update, map_bounds}, socket), do: handle_info_process_bounds_update(map_bounds, socket) diff --git a/mix.lock b/mix.lock index 0cd3314..867420f 100644 --- a/mix.lock +++ b/mix.lock @@ -1,4 +1,5 @@ %{ + "aprs": {:git, "https://github.com/aprsme/aprs.git", "39cbe6e277a2a98296140ec0ef5073a798c258d0", [branch: "main"]}, "bandit": {:hex, :bandit, "1.7.0", "d1564f30553c97d3e25f9623144bb8df11f3787a26733f00b21699a128105c0c", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "3e2f7a98c7a11f48d9d8c037f7177cd39778e74d55c7af06fe6227c742a8168a"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},