feat: add geographic location to sites with geocoding, address fields, and Leaflet.js network map
This commit is contained in:
parent
2ea849d622
commit
5df5c97c45
10 changed files with 688 additions and 2 deletions
125
assets/js/app.ts
125
assets/js/app.ts
|
|
@ -931,6 +931,129 @@ const MikrotikPortSync = {
|
|||
}
|
||||
}
|
||||
|
||||
// Sites Map hook for Leaflet.js geographic visualization
|
||||
const SitesMap = {
|
||||
map: null as any,
|
||||
markers: null as any,
|
||||
|
||||
mounted(this: any) {
|
||||
// Wait for Leaflet to load
|
||||
if (typeof L === 'undefined') {
|
||||
setTimeout(() => this.mounted(), 100)
|
||||
return
|
||||
}
|
||||
|
||||
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
||||
this.initializeLeaflet(sitesData)
|
||||
|
||||
// Listen for site clicks from the map
|
||||
this.handleEvent("site_clicked", (payload: any) => {
|
||||
// Push event back to LiveView
|
||||
this.pushEvent("site_clicked", payload)
|
||||
})
|
||||
},
|
||||
|
||||
updated(this: any) {
|
||||
if (this.map) {
|
||||
const sitesData = JSON.parse(this.el.dataset.sites || '[]')
|
||||
this.updateSites(sitesData)
|
||||
}
|
||||
},
|
||||
|
||||
destroyed(this: any) {
|
||||
if (this.map) {
|
||||
this.map.remove()
|
||||
this.map = null
|
||||
this.markers = null
|
||||
}
|
||||
},
|
||||
|
||||
initializeLeaflet(this: any, sites: any[]) {
|
||||
// Initialize the map
|
||||
this.map = L.map(this.el, {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true
|
||||
})
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||
maxZoom: 18
|
||||
}).addTo(this.map)
|
||||
|
||||
this.updateSites(sites)
|
||||
},
|
||||
|
||||
updateSites(this: any, sites: any[]) {
|
||||
if (!this.map) return
|
||||
|
||||
// Clear existing markers
|
||||
if (this.markers) {
|
||||
this.map.removeLayer(this.markers)
|
||||
}
|
||||
|
||||
// Create marker cluster group
|
||||
this.markers = L.layerGroup()
|
||||
|
||||
if (sites.length === 0) {
|
||||
// Default view if no sites
|
||||
this.map.setView([39.8283, -98.5795], 4) // Center on USA
|
||||
this.markers.addTo(this.map)
|
||||
return
|
||||
}
|
||||
|
||||
// Add markers for each site
|
||||
const bounds = L.latLngBounds([])
|
||||
|
||||
sites.forEach((site: any) => {
|
||||
if (site.latitude && site.longitude) {
|
||||
const marker = L.marker([site.latitude, site.longitude])
|
||||
|
||||
// Create popup content
|
||||
let popupContent = `<div class="p-2">
|
||||
<h3 class="font-semibold text-lg mb-1">${site.name}</h3>`
|
||||
|
||||
if (site.description) {
|
||||
popupContent += `<p class="text-sm text-gray-600 mb-1">${site.description}</p>`
|
||||
}
|
||||
|
||||
if (site.address) {
|
||||
popupContent += `<p class="text-xs text-gray-500 mb-1">${site.address}</p>`
|
||||
}
|
||||
|
||||
popupContent += `<p class="text-xs text-gray-500 mb-2">
|
||||
${site.latitude.toFixed(4)}, ${site.longitude.toFixed(4)}
|
||||
</p>`
|
||||
|
||||
if (site.device_count > 0) {
|
||||
popupContent += `<p class="text-xs text-blue-600 mb-2">
|
||||
${site.device_count} device${site.device_count !== 1 ? 's' : ''}
|
||||
</p>`
|
||||
}
|
||||
|
||||
popupContent += `<button
|
||||
onclick="window.liveSocket.execJS(document.querySelector('#leaflet-map'), 'this.pushEvent(\\'site_clicked\\', {site_id: \\'${site.id}\\'})')"
|
||||
class="text-xs bg-blue-600 text-white px-2 py-1 rounded hover:bg-blue-700"
|
||||
>
|
||||
View Site →
|
||||
</button></div>`
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
this.markers.addLayer(marker)
|
||||
bounds.extend([site.latitude, site.longitude])
|
||||
}
|
||||
})
|
||||
|
||||
// Add markers to map
|
||||
this.markers.addTo(this.map)
|
||||
|
||||
// Fit map to show all markers
|
||||
if (bounds.isValid()) {
|
||||
this.map.fitBounds(bounds, { padding: [20, 20] })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const csrfToken = document.querySelector<HTMLMetaElement>("meta[name='csrf-token']")?.getAttribute("content")
|
||||
if (!csrfToken) {
|
||||
throw new Error('CSRF token meta tag not found')
|
||||
|
|
@ -974,7 +1097,7 @@ const GlobalSearch = {
|
|||
const liveSocket = new LiveSocket("/live", Socket, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: { _csrf_token: csrfToken, timezone: userTimezone },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger },
|
||||
hooks: { ...colocatedHooks, SensorChart, CopyToClipboard, ScrollToTop, AutoDismissFlash, BetaBannerDismiss, NetworkMap, SitesMap, DeviceListReorder, MikrotikPortSync, GlobalSearch, GlobalSearchTrigger },
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
|
|
|
|||
|
|
@ -22,6 +22,9 @@ end
|
|||
|
||||
config :towerops, ToweropsWeb.Endpoint, http: [port: String.to_integer(System.get_env("PORT", "4000"))]
|
||||
|
||||
# Configure Google Maps API key for geocoding (available in all environments)
|
||||
config :towerops, google_maps_api_key: System.get_env("GOOGLE_MAPS_API_KEY")
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
|
|
|
|||
105
lib/towerops/geocoding.ex
Normal file
105
lib/towerops/geocoding.ex
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
defmodule Towerops.Geocoding do
|
||||
@moduledoc """
|
||||
Service for geocoding addresses using Google Maps Geocoding API.
|
||||
|
||||
Supports both system-wide API keys and organization-specific encrypted API keys.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@google_geocoding_url "https://maps.googleapis.com/maps/api/geocode/json"
|
||||
|
||||
@doc """
|
||||
Geocode an address to latitude/longitude coordinates.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `address`: The address string to geocode
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, %{latitude: float, longitude: float, formatted_address: string}}` on success
|
||||
- `{:error, reason}` on failure
|
||||
"""
|
||||
def geocode(address) when is_binary(address) do
|
||||
with {:ok, api_key} <- get_api_key(),
|
||||
{:ok, response} <- make_geocoding_request(address, api_key),
|
||||
{:ok, result} <- parse_geocoding_response(response) do
|
||||
{:ok, result}
|
||||
else
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_api_key do
|
||||
api_key = get_system_api_key()
|
||||
|
||||
if api_key && String.trim(api_key) != "" do
|
||||
{:ok, String.trim(api_key)}
|
||||
else
|
||||
{:error, :no_api_key}
|
||||
end
|
||||
end
|
||||
|
||||
defp get_system_api_key do
|
||||
Application.get_env(:towerops, :google_maps_api_key) ||
|
||||
System.get_env("GOOGLE_MAPS_API_KEY")
|
||||
end
|
||||
|
||||
defp make_geocoding_request(address, api_key) do
|
||||
params = %{
|
||||
"address" => address,
|
||||
"key" => api_key
|
||||
}
|
||||
|
||||
case Req.get(@google_geocoding_url, params: params, timeout: 10_000) do
|
||||
{:ok, %Req.Response{status: 200, body: body}} ->
|
||||
{:ok, body}
|
||||
|
||||
{:ok, %Req.Response{status: status_code, body: body}} ->
|
||||
Logger.warning("Geocoding API returned status #{status_code}: #{inspect(body)}")
|
||||
{:error, "Geocoding API error (status #{status_code})"}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to make geocoding request: #{inspect(reason)}")
|
||||
{:error, "Network error: #{inspect(reason)}"}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => "OK", "results" => [result | _]}) do
|
||||
with %{"geometry" => %{"location" => %{"lat" => lat, "lng" => lng}}} <- result,
|
||||
%{"formatted_address" => formatted_address} <- result do
|
||||
{:ok, %{
|
||||
latitude: lat,
|
||||
longitude: lng,
|
||||
formatted_address: formatted_address
|
||||
}}
|
||||
else
|
||||
_ -> {:error, "Invalid response format from Google Maps API"}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => "ZERO_RESULTS"}) do
|
||||
{:error, "No results found for the given address"}
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => "OVER_QUERY_LIMIT"}) do
|
||||
{:error, "API quota exceeded. Please check your Google Maps API usage."}
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => "REQUEST_DENIED", "error_message" => message}) do
|
||||
{:error, "API request denied: #{message}"}
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => "INVALID_REQUEST"}) do
|
||||
{:error, "Invalid request. Please check the address format."}
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(%{"status" => status}) do
|
||||
{:error, "Geocoding failed with status: #{status}"}
|
||||
end
|
||||
|
||||
defp parse_geocoding_response(_response) do
|
||||
{:error, "Unexpected response format from Google Maps API"}
|
||||
end
|
||||
end
|
||||
|
|
@ -23,6 +23,11 @@ defmodule Towerops.Sites.Site do
|
|||
field :location, :string
|
||||
field :display_order, :integer
|
||||
|
||||
# Geographic location fields
|
||||
field :address, :string
|
||||
field :latitude, :float
|
||||
field :longitude, :float
|
||||
|
||||
# SNMP configuration (overrides organization default)
|
||||
field :snmp_version, :string
|
||||
field :snmp_community, :string
|
||||
|
|
@ -60,6 +65,9 @@ defmodule Towerops.Sites.Site do
|
|||
description: String.t() | nil,
|
||||
location: String.t() | nil,
|
||||
display_order: integer() | nil,
|
||||
address: String.t() | nil,
|
||||
latitude: float() | nil,
|
||||
longitude: float() | nil,
|
||||
snmp_version: String.t() | nil,
|
||||
snmp_community: String.t() | nil,
|
||||
snmp_port: integer() | nil,
|
||||
|
|
@ -96,6 +104,9 @@ defmodule Towerops.Sites.Site do
|
|||
:description,
|
||||
:location,
|
||||
:display_order,
|
||||
:address,
|
||||
:latitude,
|
||||
:longitude,
|
||||
:organization_id,
|
||||
:agent_token_id,
|
||||
:parent_site_id,
|
||||
|
|
@ -120,6 +131,9 @@ defmodule Towerops.Sites.Site do
|
|||
|> validate_length(:name, min: 2, max: 200)
|
||||
|> validate_length(:description, max: 1000)
|
||||
|> validate_length(:location, max: 200)
|
||||
|> validate_length(:address, max: 500)
|
||||
|> validate_latitude_range()
|
||||
|> validate_longitude_range()
|
||||
|> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3")
|
||||
|> validate_number(:snmp_port, greater_than: 0, less_than: 65_536)
|
||||
|> validate_snmpv3_fields()
|
||||
|
|
@ -236,4 +250,30 @@ defmodule Towerops.Sites.Site do
|
|||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_latitude_range(changeset) do
|
||||
case get_change(changeset, :latitude) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
latitude when latitude < -90 or latitude > 90 ->
|
||||
add_error(changeset, :latitude, "must be between -90 and 90")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_longitude_range(changeset) do
|
||||
case get_change(changeset, :longitude) do
|
||||
nil ->
|
||||
changeset
|
||||
|
||||
longitude when longitude < -180 or longitude > 180 ->
|
||||
add_error(changeset, :longitude, "must be between -180 and 180")
|
||||
|
||||
_ ->
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
63
lib/towerops_web/live/map_live/index.ex
Normal file
63
lib/towerops_web/live/map_live/index.ex
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
defmodule ToweropsWeb.MapLive.Index do
|
||||
@moduledoc """
|
||||
Geographic map showing sites with latitude/longitude coordinates using Leaflet.js
|
||||
"""
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Towerops.Sites
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
organization = socket.assigns.current_scope.organization
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Sites Map")
|
||||
|> assign(:organization, organization)
|
||||
|> load_sites()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_params(_params, _url, socket) do
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("site_clicked", %{"site_id" => site_id}, socket) do
|
||||
# Navigate to site detail page
|
||||
{:noreply, push_navigate(socket, to: ~p"/sites/#{site_id}")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("refresh_map", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> load_sites()
|
||||
|> put_flash(:info, "Sites map refreshed")}
|
||||
end
|
||||
|
||||
defp load_sites(socket) do
|
||||
organization = socket.assigns.organization
|
||||
sites = Sites.list_organization_sites(organization.id)
|
||||
|
||||
# Filter sites that have geographic coordinates
|
||||
sites_with_coords = Enum.filter(sites, fn site ->
|
||||
site.latitude != nil && site.longitude != nil
|
||||
end)
|
||||
|
||||
# Prepare sites data for the map
|
||||
sites_data = Enum.map(sites_with_coords, fn site ->
|
||||
%{
|
||||
id: site.id,
|
||||
name: site.name,
|
||||
description: site.description,
|
||||
address: site.address,
|
||||
latitude: site.latitude,
|
||||
longitude: site.longitude,
|
||||
device_count: length(site.device || [])
|
||||
}
|
||||
end)
|
||||
|
||||
assign(socket, :sites, sites_data)
|
||||
end
|
||||
end
|
||||
187
lib/towerops_web/live/map_live/index.html.heex
Normal file
187
lib/towerops_web/live/map_live/index.html.heex
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
<Layouts.authenticated
|
||||
flash={@flash}
|
||||
current_scope={@current_scope}
|
||||
active_page="sites-map"
|
||||
>
|
||||
<.header>
|
||||
<span class="flex items-center gap-2">
|
||||
Sites Map
|
||||
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-700 ring-1 ring-inset ring-blue-700/10 dark:bg-blue-400/10 dark:text-blue-400 dark:ring-blue-400/30">
|
||||
Geographic
|
||||
</span>
|
||||
</span>
|
||||
<:subtitle>Geographic locations of your sites</:subtitle>
|
||||
<:actions>
|
||||
<.button phx-click="refresh_map">
|
||||
<.icon name="hero-arrow-path" class="h-5 w-5" /> Refresh
|
||||
</.button>
|
||||
<.button navigate={~p"/sites/new"} variant="primary">
|
||||
<.icon name="hero-plus" class="h-5 w-5" /> Add Site
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<div class="mb-6">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 overflow-hidden">
|
||||
<div class="p-4 border-b border-gray-200 dark:border-white/10">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Geographic Site Locations
|
||||
</h3>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Click markers to view site details
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
Showing <%= length(@sites) %> sites with coordinates
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container -->
|
||||
<div
|
||||
id="leaflet-map"
|
||||
phx-hook="SitesMap"
|
||||
class="w-full"
|
||||
style="height: 600px;"
|
||||
data-sites={Jason.encode!(@sites)}
|
||||
>
|
||||
<!-- Loading State -->
|
||||
<div class="flex items-center justify-center h-full">
|
||||
<div class="text-center">
|
||||
<.icon name="hero-arrow-path" class="h-8 w-8 text-gray-400 animate-spin mx-auto" />
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading map...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats and Site List -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<!-- Site Statistics -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10 p-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-4">Statistics</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Sites</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
<%= Sites.count_organization_sites(@organization.id) %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Geocoded Sites</div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
<%= length(@sites) %>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-sm font-medium text-gray-500 dark:text-gray-400">Coverage</div>
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<%= if Sites.count_organization_sites(@organization.id) > 0 do %>
|
||||
<%= round(length(@sites) / Sites.count_organization_sites(@organization.id) * 100) %>%
|
||||
<% else %>
|
||||
0%
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sites with Coordinates -->
|
||||
<div class="lg:col-span-2 bg-white dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-white/10">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-white/10">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
Sites with Geographic Data
|
||||
</h3>
|
||||
</div>
|
||||
<div class="max-h-96 overflow-y-auto">
|
||||
<%= if length(@sites) > 0 do %>
|
||||
<div class="divide-y divide-gray-200 dark:divide-white/10">
|
||||
<%= for site <- @sites do %>
|
||||
<div class="px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center">
|
||||
<h4 class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<%= site.name %>
|
||||
</h4>
|
||||
<%= if site.device_count > 0 do %>
|
||||
<span class="ml-2 inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900 dark:text-blue-300">
|
||||
<%= site.device_count %> devices
|
||||
</span>
|
||||
<% end %>
|
||||
</div>
|
||||
<%= if site.address do %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= site.address %>
|
||||
</p>
|
||||
<% end %>
|
||||
<%= if site.description do %>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<%= site.description %>
|
||||
</p>
|
||||
<% end %>
|
||||
<p class="mt-1 text-xs text-gray-400">
|
||||
<%= Float.round(site.latitude, 4) %>, <%= Float.round(site.longitude, 4) %>
|
||||
</p>
|
||||
</div>
|
||||
<div class="ml-4">
|
||||
<.link
|
||||
navigate={~p"/sites/#{site.id}"}
|
||||
class="text-blue-600 hover:text-blue-500 dark:text-blue-400 dark:hover:text-blue-300 text-sm font-medium"
|
||||
>
|
||||
View →
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="px-6 py-8 text-center">
|
||||
<.icon name="hero-map-pin" class="mx-auto h-12 w-12 text-gray-400" />
|
||||
<h3 class="mt-2 text-sm font-medium text-gray-900 dark:text-white">
|
||||
No geocoded sites
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Add addresses and coordinates to your sites to see them on the map.
|
||||
</p>
|
||||
<div class="mt-4">
|
||||
<.button navigate={~p"/sites"}>
|
||||
Manage Sites
|
||||
</.button>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Layouts.authenticated>
|
||||
|
||||
<script>
|
||||
// Add Leaflet CSS and JS
|
||||
document.addEventListener("DOMContentLoaded", function() {
|
||||
if (!document.getElementById("leaflet-css")) {
|
||||
const css = document.createElement("link");
|
||||
css.id = "leaflet-css";
|
||||
css.rel = "stylesheet";
|
||||
css.href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css";
|
||||
css.integrity = "sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=";
|
||||
css.crossOrigin = "";
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
|
||||
if (!document.getElementById("leaflet-js")) {
|
||||
const js = document.createElement("script");
|
||||
js.id = "leaflet-js";
|
||||
js.src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js";
|
||||
js.integrity = "sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=";
|
||||
js.crossOrigin = "";
|
||||
document.head.appendChild(js);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
|
@ -2,6 +2,7 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
@moduledoc false
|
||||
use ToweropsWeb, :live_view
|
||||
|
||||
alias Phoenix.HTML.Form
|
||||
alias Towerops.Agents
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Sites.Site
|
||||
|
|
@ -79,6 +80,49 @@ defmodule ToweropsWeb.SiteLive.Form do
|
|||
save_site(socket, socket.assigns.live_action, site_params)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("geocode", _params, socket) do
|
||||
address = Form.input_value(socket.assigns.form, :address)
|
||||
|
||||
if address && String.trim(address) != "" do
|
||||
case Towerops.Geocoding.geocode(address) do
|
||||
{:ok, %{latitude: lat, longitude: lng, formatted_address: formatted_address}} ->
|
||||
# Update the form with the geocoded coordinates
|
||||
updated_params = %{
|
||||
"latitude" => lat,
|
||||
"longitude" => lng,
|
||||
"address" => formatted_address
|
||||
}
|
||||
|
||||
changeset =
|
||||
socket.assigns.site
|
||||
|> Sites.change_site(updated_params)
|
||||
|> Map.put(:action, :validate)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:form, to_form(changeset))
|
||||
|> put_flash(:info, "Address geocoded successfully!")}
|
||||
|
||||
{:error, :no_api_key} ->
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
"Geocoding is not configured. Please contact your administrator to configure the Google Maps API key."
|
||||
)}
|
||||
|
||||
{:error, reason} when is_binary(reason) ->
|
||||
{:noreply, put_flash(socket, :error, "Geocoding failed: #{reason}")}
|
||||
|
||||
{:error, _} ->
|
||||
{:noreply, put_flash(socket, :error, "Geocoding failed. Please check the address and try again.")}
|
||||
end
|
||||
else
|
||||
{:noreply, put_flash(socket, :error, "Please enter an address to geocode")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("delete", _params, socket) do
|
||||
case Sites.delete_site(socket.assigns.site) do
|
||||
|
|
|
|||
|
|
@ -64,6 +64,113 @@
|
|||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Geographic Location Section -->
|
||||
<div class="mt-6 border-t pt-6">
|
||||
<h3 class="text-base font-medium mb-4">
|
||||
Geographic Location
|
||||
<span class="text-gray-400 dark:text-gray-500 font-normal">(optional)</span>
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Add geographic coordinates to enable network mapping and spatial analysis.
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Address
|
||||
</span>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id={@form[:address].id}
|
||||
name={@form[:address].name}
|
||||
value={@form[:address].value}
|
||||
placeholder="123 Main St, City, State, Country"
|
||||
class="flex-1 rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
phx-click="geocode"
|
||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 dark:focus:ring-offset-gray-800"
|
||||
>
|
||||
Geocode
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter a full address and click Geocode to automatically fill latitude/longitude
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Latitude
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
id={@form[:latitude].id}
|
||||
name={@form[:latitude].name}
|
||||
value={@form[:latitude].value}
|
||||
placeholder="40.7128"
|
||||
min="-90"
|
||||
max="90"
|
||||
class="block w-full rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
-90 to 90
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Longitude
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
step="any"
|
||||
id={@form[:longitude].id}
|
||||
name={@form[:longitude].name}
|
||||
value={@form[:longitude].value}
|
||||
placeholder="-74.0060"
|
||||
min="-180"
|
||||
max="180"
|
||||
class="block w-full rounded-lg border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-inset focus:ring-blue-600 sm:text-sm sm:leading-6 dark:bg-gray-800/50 dark:text-white dark:ring-white/10 dark:focus:ring-blue-500"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
-180 to 180
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Preview -->
|
||||
<%= if @form[:latitude].value && @form[:longitude].value do %>
|
||||
<div class="mt-4">
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
Map Preview
|
||||
</span>
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<img
|
||||
src={"https://api.mapbox.com/styles/v1/mapbox/streets-v11/static/#{@form[:longitude].value},#{@form[:latitude].value},14,0/400x200?access_token=pk.1234"}
|
||||
alt="Location preview"
|
||||
class="w-full h-48 object-cover"
|
||||
onerror="this.src='data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iNDAwIiBoZWlnaHQ9IjIwMCIgdmlld0JveD0iMCAwIDQwMCAyMDAiIGZpbGw9Im5vbmUiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+CjxyZWN0IHdpZHRoPSI0MDAiIGhlaWdodD0iMjAwIiBmaWxsPSIjRjNGNEY2Ii8+Cjx0ZXh0IHg9IjIwMCIgeT0iMTAwIiBmb250LWZhbWlseT0ic2Fucy1zZXJpZiIgZm9udC1zaXplPSIxNiIgZmlsbD0iIzZCNzI4MCIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZG9taW5hbnQtYmFzZWxpbmU9ImNlbnRyYWwiPk1hcCBwcmV2aWV3IG5vdCBhdmFpbGFibGU8L3RleHQ+Cjwvc3ZnPgo='; this.alt='Map preview not available';"
|
||||
/>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Coordinates: {@form[:latitude].value}, {@form[:longitude].value}
|
||||
</p>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-4">
|
||||
<label>
|
||||
<span class="block text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||
|
|
|
|||
|
|
@ -412,8 +412,9 @@ defmodule ToweropsWeb.Router do
|
|||
# Changelog
|
||||
live "/changelog", ChangelogLive, :index
|
||||
|
||||
# Network map route
|
||||
# Network map routes
|
||||
live "/network-map", NetworkMapLive, :index
|
||||
live "/sites-map", MapLive.Index, :index
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
defmodule Towerops.Repo.Migrations.AddGeographicLocationToSites do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
alter table(:sites) do
|
||||
add :address, :string
|
||||
add :latitude, :float
|
||||
add :longitude, :float
|
||||
end
|
||||
|
||||
create index(:sites, [:organization_id, :latitude, :longitude])
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue