The smokesignal.events web application

refactor: tuning location page map

+98 -35
+23 -5
src/http/handle_location.rs
··· 27 27 middleware_auth::Auth, 28 28 middleware_i18n::Language, 29 29 }, 30 - search_index::SearchIndexManager, 30 + search_index::{GeoCenter, SearchIndexManager}, 31 31 select_template, 32 32 storage::{event::event_get, identity_profile::handles_by_did}, 33 33 }; ··· 43 43 44 44 /// Search radius in kilometers for H3-based queries 45 45 /// Resolution 6 cells have ~36km edge length, so 50km covers neighbors well 46 - const H3_SEARCH_RADIUS_KM: f64 = 50.0; 46 + const H3_SEARCH_RADIUS_KM: f64 = 25.0; 47 + 48 + /// H3 precision for geo aggregation (matches resolution 6) 49 + const GEO_AGGREGATION_PRECISION: u8 = 6; 50 + 51 + /// Search radius in miles for geo aggregation (covers cell + neighbors) 52 + const GEO_AGGREGATION_RADIUS_MILES: f64 = 15.0; 47 53 48 54 /// The type of location being queried. 49 55 #[derive(Debug, Clone)] ··· 187 193 }; 188 194 189 195 // Search for events based on location type 190 - let (event_uris, h3_info) = match &location_type { 196 + let (event_uris, h3_info, geo_buckets) = match &location_type { 191 197 LocationType::Cid(cid) => { 192 198 let uris = search_manager 193 199 .search_events_by_location_with_time(cid, START_FROM_TIME, EVENT_LIMIT) 194 200 .await 195 201 .unwrap_or_default(); 196 - (uris, None) 202 + (uris, None, Vec::new()) 197 203 } 198 204 LocationType::H3(cell) => { 199 205 // Get H3 location info for the heatmap ··· 232 238 .await 233 239 .unwrap_or_default(); 234 240 235 - (uris, Some(info)) 241 + // Get geo aggregation for heatmap display 242 + let center = GeoCenter { 243 + lat: info.center_lat, 244 + lon: info.center_lon, 245 + distance_miles: GEO_AGGREGATION_RADIUS_MILES, 246 + }; 247 + let buckets = search_manager 248 + .get_event_geo_aggregation(GEO_AGGREGATION_PRECISION, Some(center), true) 249 + .await 250 + .unwrap_or_default(); 251 + 252 + (uris, Some(info), buckets) 236 253 } 237 254 }; 238 255 ··· 303 320 location_display => location_display, 304 321 is_h3_location => is_h3_location, 305 322 h3_info => h3_info, 323 + geo_buckets => geo_buckets, 306 324 events => event_views, 307 325 event_count => event_views.len(), 308 326 }},
+75 -30
templates/en-us/location.common.html
··· 30 30 <span class="icon"> 31 31 <i class="fas fa-map-marker-alt"></i> 32 32 </span> 33 - <span>Events at {{ location_display }}</span> 33 + <span>Events</span> 34 34 </span> 35 35 </h1> 36 36 37 37 {% if is_h3_location and h3_info %} 38 - <div class="box mb-5"> 39 38 <h3 class="title is-6 mb-3"> 40 39 <span class="icon-text"> 41 40 <span class="icon"> ··· 45 44 </span> 46 45 </h3> 47 46 <div id="location-heatmap"></div> 48 - </div> 49 47 {% endif %} 50 48 51 49 <div id="location-results"> 52 50 {% if event_count > 0 %} 53 - <p class="subtitle is-6 has-text-grey mb-4"> 54 - {{ event_count }} event{% if event_count != 1 %}s{% endif %} found 55 - </p> 56 51 57 52 {% for event in events %} 58 53 <div style="padding: 1.25rem 0; {% if not loop.last %}border-bottom: 1px solid #f5f5f5;{% endif %}"> ··· 131 126 const centerLat = {{ h3_info.center_lat }}; 132 127 const centerLon = {{ h3_info.center_lon }}; 133 128 const centerCell = "{{ h3_info.cell_index }}"; 134 - const displayCells = {{ h3_info.display_cells | tojson }}; 129 + const geoBuckets = {{ geo_buckets | tojson }}; 135 130 136 131 // Create map centered on the H3 cell 137 132 const map = L.map('location-heatmap', { 138 133 center: [centerLat, centerLon], 139 134 zoom: 9, 140 - zoomControl: true, 141 - dragging: true, 142 - touchZoom: true, 135 + zoomControl: false, 136 + dragging: false, 137 + touchZoom: false, 143 138 scrollWheelZoom: false, 144 - doubleClickZoom: true, 139 + doubleClickZoom: false, 145 140 boxZoom: false, 146 141 keyboard: false, 147 142 attributionControl: true ··· 153 148 maxZoom: 19 154 149 }).addTo(map); 155 150 156 - // Draw all cells 157 - displayCells.forEach(cellIndex => { 158 - try { 159 - const boundary = h3.cellToBoundary(cellIndex); 160 - const latLngs = boundary.map(coord => [coord[0], coord[1]]); 151 + // Heatmap color scale from low (blue) to high (red) 152 + function getHeatmapColor(value, min, max) { 153 + if (max === min) return '#3273dc'; 154 + const ratio = (value - min) / (max - min); 155 + // Blue (#3273dc) -> Purple (#8957e5) -> Orange (#f39c12) -> Red (#e74c3c) 156 + if (ratio < 0.33) { 157 + const t = ratio / 0.33; 158 + return lerpColor('#3273dc', '#8957e5', t); 159 + } else if (ratio < 0.66) { 160 + const t = (ratio - 0.33) / 0.33; 161 + return lerpColor('#8957e5', '#f39c12', t); 162 + } else { 163 + const t = (ratio - 0.66) / 0.34; 164 + return lerpColor('#f39c12', '#e74c3c', t); 165 + } 166 + } 167 + 168 + function lerpColor(a, b, t) { 169 + const ah = parseInt(a.replace('#', ''), 16); 170 + const bh = parseInt(b.replace('#', ''), 16); 171 + const ar = ah >> 16, ag = (ah >> 8) & 0xff, ab = ah & 0xff; 172 + const br = bh >> 16, bg = (bh >> 8) & 0xff, bb = bh & 0xff; 173 + const rr = Math.round(ar + (br - ar) * t); 174 + const rg = Math.round(ag + (bg - ag) * t); 175 + const rb = Math.round(ab + (bb - ab) * t); 176 + return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb).toString(16).slice(1); 177 + } 178 + 179 + // Only draw hexes with events 180 + if (geoBuckets && geoBuckets.length > 0) { 181 + const counts = geoBuckets.map(b => b.doc_count); 182 + const minCount = Math.min(...counts); 183 + const maxCount = Math.max(...counts); 184 + 185 + geoBuckets.forEach(bucket => { 186 + try { 187 + const cellIndex = bucket.key; 188 + const count = bucket.doc_count; 189 + const boundary = h3.cellToBoundary(cellIndex); 190 + const latLngs = boundary.map(coord => [coord[0], coord[1]]); 161 191 162 - const isCenter = cellIndex === centerCell; 163 - L.polygon(latLngs, { 164 - color: isCenter ? '#3273dc' : '#485fc7', 165 - fillColor: isCenter ? '#3273dc' : '#485fc7', 166 - fillOpacity: isCenter ? 0.4 : 0.2, 167 - weight: isCenter ? 3 : 1, 168 - className: isCenter ? 'h3-hex-center' : 'h3-hex' 169 - }).addTo(map); 170 - } catch (e) { 171 - console.warn('Failed to draw hex:', cellIndex, e); 172 - } 173 - }); 192 + const isCenter = cellIndex === centerCell; 193 + const color = getHeatmapColor(count, minCount, maxCount); 174 194 175 - // Add a marker at the center 176 - L.marker([centerLat, centerLon]).addTo(map); 195 + const polygon = L.polygon(latLngs, { 196 + color: isCenter ? '#1a1a1a' : color, 197 + fillColor: color, 198 + fillOpacity: 0.5, 199 + weight: isCenter ? 3 : 2, 200 + className: isCenter ? 'h3-hex-center' : 'h3-hex' 201 + }).addTo(map); 202 + } catch (e) { 203 + console.warn('Failed to draw hex:', bucket.key, e); 204 + } 205 + }); 206 + 207 + // Fit bounds to show all hexes 208 + const allCoords = geoBuckets.flatMap(bucket => { 209 + try { 210 + return h3.cellToBoundary(bucket.key).map(coord => [coord[0], coord[1]]); 211 + } catch (e) { 212 + return []; 213 + } 214 + }); 215 + if (allCoords.length > 0) { 216 + map.fitBounds(allCoords, { padding: [10, 10] }); 217 + } 218 + } else { 219 + // No events - just show center marker 220 + L.marker([centerLat, centerLon]).addTo(map); 221 + } 177 222 })(); 178 223 </script> 179 224 {% endif %}