a better map

556557f · gmorell · 2025-09-13 23:57

22 files +529 -7

Files changed

modified apps/room_gtfs/lib/room_gtfs/gtfs_worker.ex
+1 −0
@@ -52,6 +52,7 @@ defmodule RoomGtfs.Worker do
52 52 end
53 53
54 54 def get_current_realtime(name, trips, stop) do
55 + IO.inspect({name, trips, stop})
55 56 "gtfs-rt#{name}"
56 57 |> via_tuple
57 58 |> GenServer.call({:query_realtime, trips, stop}, 30_000)
modified apps/room_sanctum/assets/js/app.js
+4 −0
@@ -32,10 +32,14 @@ import topbar from "../vendor/topbar"
32 32
33 33 import L from 'leaflet'
34 34 import 'leaflet-centermarker'
35 +import LeafletMap from './hooks/leaflet_map.js'
35 36
36 37 let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
37 38
38 39 let Hooks = {};
40 +
41 +// Add our new LeafletMap hook
42 +Hooks.LeafletMap = LeafletMap;
39 43 Hooks.MapPush = {
40 44 mounted() {
41 45 window.addEventListener("focus", e => {
modified apps/room_sanctum/lib/room_sanctum/storage.ex
+21 −2
@@ -8,6 +8,7 @@ defmodule RoomSanctum.Storage do
8 8 alias RoomSanctum.Repo
9 9
10 10 alias RoomSanctum.Storage.GTFS.Agency
11 + alias RoomSanctum.Storage.GBFS.V1.StationInfo
11 12
12 13 @doc """
13 14 Returns the list of agencies.
@@ -751,6 +752,26 @@ defmodule RoomSanctum.Storage do
751 752 Stop.changeset(stop, attrs)
752 753 end
753 754
755 + def get_stop_by_id(source_id, stop_id) do
756 + from(s in Stop,
757 + where: s.source_id == ^source_id and s.stop_id == ^stop_id,
758 + limit: 1
759 + )
760 + |> Repo.one()
761 + end
762 +
763 + def get_gbfs_station_by_id(source_id, station_id) do
764 + from(s in StationInfo,
765 + where: s.source_id == ^source_id and s.station_id == ^stringify(station_id),
766 + limit: 1
767 + )
768 + |> Repo.one()
769 + end
770 +
771 + def get_foci_by_id(foci_id) do
772 + Configuration.get_foci!(foci_id)
773 + end
774 +
754 775 alias RoomSanctum.Storage.GTFS.Trip
755 776
756 777 @doc """
@@ -1223,8 +1244,6 @@ defmodule RoomSanctum.Storage do
1223 1244 SysInfo.changeset(sys_info, attrs)
1224 1245 end
1225 1246
1226 − alias RoomSanctum.Storage.GBFS.V1.StationInfo
1227 −
1228 1247 @doc """
1229 1248 Returns the list of gbfs_station_information.
1230 1249
added apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+280 −0
@@ -0,0 +1,280 @@
1 +defmodule RoomSanctumWeb.Components.QueryGeospatialMap do
2 + use Phoenix.Component
3 + import RoomSanctumWeb.CoreComponents
4 +
5 + @doc """
6 + Renders an interactive Leaflet map showing queries based on their geospatial point data.
7 +
8 + ## Examples
9 +
10 + <.query_geospatial_map queries={@queries} />
11 +
12 + <.query_geospatial_map queries={@queries} selected_tint={@tint} />
13 + """
14 + attr :queries, :list, required: true
15 + attr :selected_tint, :string, default: nil
16 + attr :class, :string, default: ""
17 + attr :height, :string, default: "500px"
18 +
19 + def query_geospatial_map(assigns) do
20 + assigns = assign(assigns, :map_queries, get_mappable_queries(assigns.queries))
21 +
22 + ~H"""
23 + <div class={"query-geospatial-map w-full #{@class}"}>
24 + <!-- Map container -->
25 + <div
26 + id="leaflet-map"
27 + class="w-full border border-gray-300 rounded-lg shadow-sm overflow-hidden"
28 + style={"height: #{@height}; width: 100%; max-width: 100%; position: relative; box-sizing: border-box;"}
29 + phx-hook="LeafletMap"
30 + data-queries={Jason.encode!(@map_queries)}
31 + data-selected-tint={@selected_tint}
32 + >
33 + <!-- Loading state -->
34 + <div class="flex items-center justify-center h-full bg-gray-50">
35 + <div class="text-center">
36 + <i class="fa-solid fa-map text-4xl text-gray-400 mb-2"></i>
37 + <p class="text-gray-500">Loading map...</p>
38 + </div>
39 + </div>
40 + </div>
41 +
42 + <!-- Map legend/info -->
43 + <div class="mt-4 bg-white border border-gray-200 rounded-lg p-4">
44 + <div class="flex items-center justify-between mb-3">
45 + <h3 class="text-sm font-semibold text-gray-700">
46 + Map Legend
47 + <span class="ml-2 text-xs text-gray-500">
48 + (<%= length(@map_queries) %> queries with location data)
49 + </span>
50 + </h3>
51 +
52 + <%= if @selected_tint do %>
53 + <span class={"inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-#{@selected_tint}-100 text-#{@selected_tint}-800"}>
54 + <i class={"fa-solid fa-circle text-#{@selected_tint}-500 mr-1"}></i>
55 + Filtered: <%= @selected_tint %>
56 + </span>
57 + <% end %>
58 + </div>
59 +
60 + <div class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-2 text-xs">
61 + <%= for type <- get_unique_types(@map_queries) do %>
62 + <div class="flex items-center">
63 + <i class={"fas fa-fw #{get_icon(type)} mr-1 text-gray-600"}></i>
64 + <span class="capitalize text-gray-700"><%= type %></span>
65 + </div>
66 + <% end %>
67 + </div>
68 +
69 + <%= if length(@map_queries) == 0 do %>
70 + <div class="text-center py-4 text-gray-500">
71 + <i class="fa-solid fa-map-location-dot text-2xl mb-2"></i>
72 + <p>No queries with location data found</p>
73 + <%= if @selected_tint do %>
74 + <p class="text-sm">Try removing the tint filter or adding location data to your sources</p>
75 + <% else %>
76 + <p class="text-sm">Add geospatial data to your sources to see them on the map</p>
77 + <% end %>
78 + </div>
79 + <% end %>
80 + </div>
81 + </div>
82 + """
83 + end
84 +
85 + # Helper function to get queries that have geospatial data
86 + defp get_mappable_queries(queries) do
87 + # Debug log all incoming queries
88 + IO.inspect(length(queries), label: "Total queries received")
89 +
90 + filtered = queries
91 + |> Enum.filter(fn query ->
92 + # Check if query has geom field, source has point data, or internal query data has coordinates
93 + has_geom = query.geom != nil
94 + has_source_point = query.source && has_point_data?(query.source)
95 + has_query_point = extract_from_query_data(query) != false
96 +
97 + result = has_geom || has_source_point || has_query_point
98 + IO.inspect({query.id, query.name, has_geom, has_source_point, has_query_point, result}, label: "Query mappability check")
99 + result
100 + end)
101 +
102 + IO.inspect(length(filtered), label: "Filtered mappable queries")
103 +
104 + mapped = filtered |> Enum.map(&format_query_for_map/1)
105 +
106 + IO.inspect(length(mapped), label: "Final mapped queries")
107 +
108 + mapped
109 + end
110 +
111 + # Check if source has point data - adapt this to your source schema
112 + defp has_point_data?(source) do
113 + # Check if source struct has geom field and it's not nil
114 + # For structs, we need to pattern match or use Map.get with a default
115 + geom = Map.get(source, :geom, nil)
116 + lat = Map.get(source, :lat, nil)
117 + lng = Map.get(source, :lng, nil)
118 + latitude = Map.get(source, :latitude, nil)
119 + longitude = Map.get(source, :longitude, nil)
120 + coordinates = Map.get(source, :coordinates, nil)
121 +
122 + geom != nil ||
123 + (lat != nil && lng != nil) ||
124 + (latitude != nil && longitude != nil) ||
125 + coordinates != nil
126 + end
127 +
128 + # Format query data for the JavaScript map component
129 + defp format_query_for_map(query) do
130 + {lat, lng} = extract_coordinates(query)
131 +
132 + # Debug log the coordinate extraction
133 + IO.inspect({query.id, query.name, lat, lng}, label: "Map coordinates for query")
134 +
135 + tint = cond do
136 + query.meta && query.meta.tint -> query.meta.tint
137 + query.source && query.source.meta && query.source.meta.tint -> query.source.meta.tint
138 + true -> nil
139 + end
140 +
141 + %{
142 + id: query.id,
143 + name: query.name,
144 + source_name: query.source.name,
145 + source_type: query.source.type,
146 + lat: lat,
147 + lng: lng,
148 + tint: tint,
149 + icon: get_icon(query.source.type),
150 + notes: query.notes
151 + }
152 + end
153 +
154 + # Extract lat/lng coordinates from query or source
155 + defp extract_coordinates(query) do
156 + cond do
157 + query.geom != nil ->
158 + # Handle PostGIS geometry - you may need to adjust this based on your Geo setup
159 + case query.geom do
160 + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng}
161 + _ -> {0, 0} # fallback
162 + end
163 +
164 + query.source && has_point_data?(query.source) ->
165 + source = query.source
166 + geom = Map.get(source, :geom, nil)
167 + lat = Map.get(source, :lat, nil)
168 + lng = Map.get(source, :lng, nil)
169 + latitude = Map.get(source, :latitude, nil)
170 + longitude = Map.get(source, :longitude, nil)
171 +
172 + cond do
173 + geom != nil ->
174 + case geom do
175 + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng}
176 + _ -> {0, 0}
177 + end
178 + lat != nil && lng != nil ->
179 + {lat, lng}
180 + latitude != nil && longitude != nil ->
181 + {latitude, longitude}
182 + true -> {0, 0}
183 + end
184 +
185 + # Handle data from the query's underlying stop/thing being queried
186 + query.query && extract_from_query_data(query) ->
187 + extract_from_query_data(query)
188 +
189 + true -> {0, 0}
190 + end
191 + end
192 +
193 + # Extract coordinates from the internal query data (stops, stations, etc.)
194 + defp extract_from_query_data(query) do
195 + case query.source.type do
196 + :gtfs ->
197 + # For GTFS, get coordinates from the stop being queried
198 + if query.query && Map.has_key?(query.query, :stop) do
199 + get_gtfs_stop_coordinates(query.source.id, query.query.stop)
200 + else
201 + false
202 + end
203 +
204 + :gbfs ->
205 + # For GBFS, get coordinates from the station being queried
206 + if query.query && Map.has_key?(query.query, :stop_id) do
207 + get_gbfs_station_coordinates(query.source.id, query.query.stop_id)
208 + else
209 + false
210 + end
211 +
212 + :aqi ->
213 + # For AQI, get coordinates from the monitoring site/foci
214 + if query.query && Map.has_key?(query.query, :foci_id) do
215 + get_aqi_foci_coordinates(query.query.foci_id)
216 + else
217 + false
218 + end
219 +
220 + _ ->
221 + false
222 + end
223 + end
224 +
225 + # Get coordinates for GTFS stop
226 + defp get_gtfs_stop_coordinates(source_id, stop_id) do
227 + case RoomSanctum.Storage.get_stop_by_id(source_id, stop_id) do
228 + nil -> false
229 + stop when stop.stop_lat != nil and stop.stop_lon != nil ->
230 + {stop.stop_lat, stop.stop_lon}
231 + _ -> false
232 + end
233 + end
234 +
235 + # Get coordinates for GBFS station
236 + defp get_gbfs_station_coordinates(source_id, station_id) do
237 + case RoomSanctum.Storage.get_gbfs_station_by_id(source_id, station_id) do
238 + nil -> false
239 + station ->
240 + cond do
241 + # First try the PostGIS geometry field if it exists
242 + station.place != nil ->
243 + case station.place do
244 + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng}
245 + _ -> false
246 + end
247 + # Fall back to separate lat/lon fields
248 + station.lat != nil && station.lon != nil ->
249 + {station.lat, station.lon}
250 + true -> false
251 + end
252 + end
253 + end
254 +
255 + # Get coordinates for AQI foci
256 + defp get_aqi_foci_coordinates(foci_id) do
257 + case RoomSanctum.Storage.get_foci_by_id(foci_id) do
258 + nil -> false
259 + foci when foci.point != nil ->
260 + case foci.point do
261 + %Geo.Point{coordinates: {lng, lat}} -> {lat, lng}
262 + _ -> false
263 + end
264 + _ -> false
265 + end
266 + end
267 +
268 + # Get unique source types for the legend
269 + defp get_unique_types(queries) do
270 + queries
271 + |> Enum.map(& &1.source_type)
272 + |> Enum.uniq()
273 + |> Enum.sort()
274 + end
275 +
276 + # Icon helper (delegate to existing IconHelpers)
277 + defp get_icon(type) do
278 + RoomSanctumWeb.IconHelpers.icon(type)
279 + end
280 +end
added apps/room_sanctum/lib/room_sanctum_web/components/query_tint_map.ex
+139 −0
@@ -0,0 +1,139 @@
1 +defmodule RoomSanctumWeb.Components.QueryTintMap do
2 + use Phoenix.Component
3 + import RoomSanctumWeb.CoreComponents
4 +
5 + @doc """
6 + Renders a map visualization of queries organized by tint.
7 +
8 + ## Examples
9 +
10 + <.query_tint_map queries={@queries} available_tints={@available_tints} />
11 +
12 + <.query_tint_map queries={@queries} available_tints={@available_tints}
13 + selected_tint={@tint} on_tint_select={JS.push("set-tint")} />
14 + """
15 + attr :queries, :list, required: true
16 + attr :available_tints, :list, required: true
17 + attr :selected_tint, :string, default: nil
18 + attr :on_tint_select, :any, default: nil
19 + attr :class, :string, default: ""
20 +
21 + def query_tint_map(assigns) do
22 + ~H"""
23 + <div class={"query-tint-map #{@class}"}>
24 + <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 p-4">
25 + <%= for tint <- @available_tints do %>
26 + <div class={"tint-group border-2 border-#{tint}-300 rounded-lg p-4 #{if @selected_tint == tint, do: "ring-2 ring-#{tint}-500"}"}>
27 + <div class="flex items-center justify-between mb-3">
28 + <h3 class={"text-lg font-semibold text-#{tint}-700 capitalize flex items-center gap-2"}>
29 + <i class={"fa-solid fa-circle text-#{tint}-500"}></i>
30 + <%= tint %>
31 + </h3>
32 + <span class={"badge badge-sm bg-#{tint}-100 text-#{tint}-700"}>
33 + <%= length(get_queries_by_tint(@queries, tint)) %>
34 + </span>
35 + </div>
36 +
37 + <div class="space-y-2 max-h-64 overflow-y-auto">
38 + <%= for query <- get_queries_by_tint(@queries, tint) do %>
39 + <div class={"query-item p-2 rounded bg-#{tint}-50 border border-#{tint}-200 hover:bg-#{tint}-100 transition-colors"}>
40 + <div class="flex items-center gap-2">
41 + <span class="tooltip tooltip-primary" data-tip={query.source.type}>
42 + <i class={"fas fa-fw #{get_icon(query.source.type)}"}></i>
43 + </span>
44 + <div class="flex-1 min-w-0">
45 + <div class={"text-sm font-medium text-#{tint}-800 truncate"}>
46 + <%= query.name %>
47 + </div>
48 + <div class={"text-xs text-#{tint}-600 truncate"}>
49 + <%= query.source.name %>
50 + </div>
51 + </div>
52 + <div class="flex items-center gap-1">
53 + <%= render_tint_indicator(query, tint) %>
54 + </div>
55 + </div>
56 + </div>
57 + <% end %>
58 + </div>
59 +
60 + <%= if @on_tint_select do %>
61 + <div class="mt-3 pt-3 border-t border-gray-200">
62 + <button
63 + class={"btn btn-sm btn-outline w-full text-#{tint}-600 border-#{tint}-300 hover:bg-#{tint}-100"}
64 + phx-click={@on_tint_select}
65 + phx-value-tint={tint}
66 + >
67 + <%= if @selected_tint == tint do %>
68 + <i class="fa-solid fa-eye-slash mr-1"></i>
69 + Clear Filter
70 + <% else %>
71 + <i class="fa-solid fa-filter mr-1"></i>
72 + Filter by <%= tint %>
73 + <% end %>
74 + </button>
75 + </div>
76 + <% end %>
77 + </div>
78 + <% end %>
79 +
80 + <%= if length(@available_tints) == 0 do %>
81 + <div class="col-span-full text-center py-8 text-gray-500">
82 + <i class="fa-solid fa-palette text-4xl mb-2"></i>
83 + <p>No tinted queries found</p>
84 + <p class="text-sm">Add tints to your queries and sources to see them organized here</p>
85 + </div>
86 + <% end %>
87 + </div>
88 + </div>
89 + """
90 + end
91 +
92 + # Helper function to get queries by tint
93 + defp get_queries_by_tint(queries, tint) do
94 + queries
95 + |> Enum.filter(fn query ->
96 + (query.meta && query.meta.tint == tint) ||
97 + (query.source && query.source.meta && query.source.meta.tint == tint)
98 + end)
99 + end
100 +
101 + # Helper to render tint indicators (similar to the table view)
102 + defp render_tint_indicator(query, _current_tint) do
103 + case {query.meta && query.meta.tint, query.source && query.source.meta && query.source.meta.tint} do
104 + {query_tint, source_tint} when query_tint != nil and source_tint != nil ->
105 + # Both query and source tints - use fa-stack
106 + assigns = %{query_tint: query_tint, source_tint: source_tint}
107 + ~H"""
108 + <span class="fa-stack text-xs">
109 + <i class={"fa-solid fa-circle fa-stack-1x text-#{@source_tint}-500"} data-fa-transform="shrink-3"></i>
110 + <i class={"fa-solid fa-circle fa-stack-1x text-#{@query_tint}-500"} data-fa-transform="shrink-4 right-4"></i>
111 + </span>
112 + """
113 +
114 + {query_tint, _} when query_tint != nil ->
115 + # Query tint only
116 + assigns = %{query_tint: query_tint}
117 + ~H"""
118 + <i class={"fa-solid fa-circle text-#{@query_tint}-500 text-xs"} data-fa-transform="shrink-3"></i>
119 + """
120 +
121 + {_, source_tint} when source_tint != nil ->
122 + # Source tint only
123 + assigns = %{source_tint: source_tint}
124 + ~H"""
125 + <i class={"fa-solid fa-circle text-#{@source_tint}-500 text-xs"}></i>
126 + """
127 +
128 + _ ->
129 + # No tints
130 + assigns = %{}
131 + ~H""
132 + end
133 + end
134 +
135 + # Icon helper (delegate to existing IconHelpers)
136 + defp get_icon(type) do
137 + RoomSanctumWeb.IconHelpers.icon(type)
138 + end
139 +end
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.ex
+25 −3
@@ -1,5 +1,6 @@
1 1 defmodule RoomSanctumWeb.QueryLive.Index do
2 2 use RoomSanctumWeb, :live_view_a
3 + import RoomSanctumWeb.Components.QueryGeospatialMap
3 4
4 5 alias RoomSanctum.Configuration
5 6 alias RoomSanctum.Configuration.Query
@@ -13,7 +14,9 @@ defmodule RoomSanctumWeb.QueryLive.Index do
13 14 socket
14 15 |> assign(:show_info, false)
15 16 |> assign(:tint, nil)
17 + |> assign(:view_mode, :table)
16 18 |> assign(:available_tints, available_tints)
19 + |> assign(:queries, queries)
17 20 |> stream(:cfg_queries, queries)}
18 21 end
19 22
@@ -48,6 +51,7 @@ defmodule RoomSanctumWeb.QueryLive.Index do
48 51 {:noreply,
49 52 socket
50 53 |> assign(:available_tints, available_tints)
54 + |> assign(:queries, queries)
51 55 |> stream_insert(:cfg_queries, query)}
52 56 end
53 57
@@ -63,12 +67,30 @@ defmodule RoomSanctumWeb.QueryLive.Index do
63 67 {:noreply, socket |> assign(:show_info, !socket.assigns.show_info)}
64 68 end
65 69
70 + def handle_event("toggle-view", _params, socket) do
71 + new_mode = case socket.assigns.view_mode do
72 + :table -> :map
73 + :map -> :table
74 + end
75 +
76 + {:noreply, socket |> assign(:view_mode, new_mode)}
77 + end
78 +
66 79 def handle_event("set-tint", %{"tint"=> tint}, socket) do
67 80 IO.inspect({"set-tint", tint, socket.assigns.tint})
68 − case socket.assigns.tint == tint do
69 − true -> {:noreply, socket |> assign(:tint, nil) |> stream(:cfg_queries, list_cfg_queries(socket.assigns.current_user.id), reset: true)}
70 − false -> {:noreply, socket |> assign(:tint, tint) |> stream(:cfg_queries, list_cfg_queries(socket.assigns.current_user.id, tint), reset: true)}
81 + queries = case socket.assigns.tint == tint do
82 + true ->
83 + list_cfg_queries(socket.assigns.current_user.id)
84 + false ->
85 + list_cfg_queries(socket.assigns.current_user.id, tint)
71 86 end
87 +
88 + new_tint = if socket.assigns.tint == tint, do: nil, else: tint
89 +
90 + {:noreply, socket
91 + |> assign(:tint, new_tint)
92 + |> assign(:queries, queries)
93 + |> stream(:cfg_queries, queries, reset: true)}
72 94 end
73 95
74 96 defp list_cfg_queries(uid) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/index.html.heex
+32 −3
@@ -1,5 +1,22 @@
1 +<link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css"
2 + integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
3 + crossorigin=""/>
4 +<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js"
5 + integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
6 + crossorigin=""></script>
7 +
1 8 <.itb icon="fa-plus" to={Routes.query_index_path(@socket, :new)} what="Query"></.itb>
2 −<h1 class="text-3xl font-bold text-accent mx-4 bg-secondary-300 py-2">Available Queries <i class="fa-regular fa-circle-question fa-xs" phx-click="info"></i></h1>
9 +<h1 class="text-3xl font-bold text-accent mx-4 bg-secondary-300 py-2">
10 + Available Queries
11 + <i class="fa-regular fa-circle-question fa-xs" phx-click="info"></i>
12 + <button
13 + class="btn btn-sm btn-outline ml-4 text-xs"
14 + phx-click="toggle-view"
15 + >
16 + <i class={"fa-solid #{if @view_mode == :table, do: "fa-map", else: "fa-table"} mr-1"}></i>
17 + <%= if @view_mode == :table, do: "Map View", else: "Table View" %>
18 + </button>
19 +</h1>
3 20
4 21 <%= if @show_info do %>
5 22 <div class="card card-compact w-full bg-base-100 shadow-xl mx-6 my-2">
@@ -22,6 +39,17 @@
22 39 />
23 40 </.modal>
24 41
42 +<%= if @view_mode == :map do %>
43 + <div class="px-6">
44 + <.query_geospatial_map
45 + queries={@queries}
46 + selected_tint={@tint}
47 + class=""
48 + height="600px"
49 + />
50 + </div>
51 +<% else %>
52 +
25 53 <table class="table table-compact w-full mx-6">
26 54 <thead>
27 55 <tr>
@@ -71,7 +99,7 @@
71 99 </span>
72 100 <% query.meta && query.meta.tint -> %>
73 101 <!-- Query tint only -->
74 − <i class={"fa-solid fa-circle-half-stroke fa-fw text-#{query.meta.tint}-500 cursor-pointer"} phx-click="set-tint" phx-value-tint={query.meta.tint}></i>
102 + <i class={"fa-solid fa-circle fa-2x fa-fw text-#{query.meta.tint}-500 cursor-pointer"} phx-click="set-tint" phx-value-tint={query.meta.tint} data-fa-transform="shrink-3"></i>
75 103 <% query.source.meta.tint -> %>
76 104 <!-- Source tint only -->
77 105 <i class={"fa-solid fa-circle text-#{query.source.meta.tint}-500 cursor-pointer"} phx-click="set-tint" phx-value-tint={query.source.meta.tint}></i>
@@ -95,4 +123,5 @@
95 123 </tr>
96 124 <% end %>
97 125 </tbody>
98 −</table>
126 +</table>
127 +<% end %>
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+1 −0
@@ -1,6 +1,7 @@
1 1 defmodule RoomSanctumWeb.QueryLive.Show do
2 2 use RoomSanctumWeb, :live_view_a
3 3 import RoomSanctumWeb.LivePreview
4 + import RoomSanctumWeb.Components.QueryGeospatialMap
4 5
5 6 alias RoomSanctum.Configuration
6 7
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+27 −0
@@ -1,3 +1,10 @@
1 +<link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css"
2 + integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
3 + crossorigin=""/>
4 +<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js"
5 + integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
6 + crossorigin=""></script>
7 +
1 8 <.itb icon="fa-chevron-left" to={Routes.query_index_path(@socket, :index)} what="Back"></.itb>
2 9 <.itb icon="fa-pencil" to={Routes.query_show_path(@socket, :edit, @query)} what="Edit"></.itb>
3 10
@@ -58,6 +65,7 @@
58 65 </div>
59 66 </div>
60 67 </div>
68 + <!-- Map and Data Preview Row -->
61 69 <div class="flex">
62 70 <div class="card card-compact w-1/2 bg-base-100 shadow-xl m-2">
63 71 <div class="card-body">
@@ -99,7 +107,26 @@
99 107 <% end %>
100 108 </div>
101 109 </div>
110 +
111 + <!-- Map Display -->
102 112 <div class="card card-compact w-1/2 bg-base-100 shadow-xl m-2">
113 + <div class="card-body">
114 + <h3 class="card-title">Location Map</h3>
115 + <div style="width: 100%; max-width: 100%; overflow: hidden;">
116 + <.query_geospatial_map
117 + queries={[@query]}
118 + selected_tint={if @query.meta && @query.meta.tint, do: @query.meta.tint, else: nil}
119 + class=""
120 + height="350px"
121 + />
122 + </div>
123 + </div>
124 + </div>
125 + </div>
126 +
127 + <!-- Visions Row -->
128 + <div class="flex">
129 + <div class="card card-compact w-full bg-base-100 shadow-xl m-2">
103 130 <div class="card-body">
104 131 <h3 class="card-title flex justify-between">
105 132 Visions

Parents: 13a07de