diff --git a/app/frontend/entrypoints/map.js b/app/frontend/entrypoints/map.js index 2e6d257..9dcda6a 100644 --- a/app/frontend/entrypoints/map.js +++ b/app/frontend/entrypoints/map.js @@ -1,4 +1,198 @@ -import * as d3 from "d3"; -import * as topojson from "topojson"; -window.d3 = d3; -window.topojson = topojson; \ No newline at end of file +import L from "leaflet"; +import "leaflet/dist/leaflet.css"; +import "leaflet-arc"; + +window.L = L; + +// Function to create curved path between two points +function createCurvedPath(startLat, startLon, endLat, endLon, numPoints = 20) { + const points = []; + + for (let i = 0; i <= numPoints; i++) { + const fraction = i / numPoints; + + // Simple linear interpolation with a curve offset + const lat = startLat + (endLat - startLat) * fraction; + const lon = startLon + (endLon - startLon) * fraction; + + // Add curve by offsetting the midpoint + const distanceFactor = Math.sin(fraction * Math.PI); + const curvature = 0.3; // Adjust this to control curve height + const latOffset = distanceFactor * curvature * Math.abs(endLon - startLon) * 0.1; + + points.push([lat + latOffset, lon]); + } + + return points; +} + +document.addEventListener('DOMContentLoaded', function () { + // Get map data from JSON script block + const mapDataElement = document.getElementById('map-data'); + if (!mapDataElement) return; + + const mapData = JSON.parse(mapDataElement.textContent); + const lettersData = mapData.letters; + const strokeWidth = mapData.strokeWidth; + + console.log('Map data loaded:', { lettersData, strokeWidth }); + console.log('Number of letters:', lettersData ? lettersData.length : 'undefined'); + + // Debug: Check for invalid coordinates + lettersData.forEach((letter, index) => { + if (letter.current_location) { + const lat = letter.current_location.lat; + const lon = letter.current_location.lon; + if (isNaN(lat) || isNaN(lon) || !isFinite(lat) || !isFinite(lon)) { + console.warn(`Letter ${index} has invalid current_location:`, { lat, lon }); + } + } + }); + + // Initialize the Leaflet map + const map = L.map('map', { + center: [39.8, -98.5], // Center on United States + zoom: 2, + worldCopyJump: false + }); + + // Add OpenStreetMap tile layer + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + attribution: '© OpenStreetMap contributors', + maxZoom: 18 + }).addTo(map); + + // Create layer groups for different types of elements + const markersLayer = L.layerGroup().addTo(map); + const pathsLayer = L.layerGroup().addTo(map); + const projectionsLayer = L.layerGroup().addTo(map); + + lettersData.forEach(letter => { + // Add current location marker with letter emoji + if (letter.current_location && + letter.current_location.lat && + letter.current_location.lon && + !isNaN(letter.current_location.lat) && + !isNaN(letter.current_location.lon) && + isFinite(letter.current_location.lat) && + isFinite(letter.current_location.lon) && + letter.current_location.lat >= -90 && letter.current_location.lat <= 90 && + letter.current_location.lon >= -180 && letter.current_location.lon <= 180) { + const isReceived = letter.aasm_state === 'received'; + const letterIcon = L.divIcon({ + html: `
${isReceived ? '✅' : '✉️'}
`, + className: 'custom-div-icon', + iconSize: [10, 10], + iconAnchor: [5, 5] + }); + + const marker = L.marker( + L.latLng(letter.current_location.lat, letter.current_location.lon), + { icon: letterIcon } + ); + + if (letter.bubble_title) { + marker.bindPopup(letter.bubble_title); + } + + markersLayer.addLayer(marker); + } + + // Add traveled path (solid green lines) + const coords = letter.coordinates || []; + const validCoords = coords.filter(coord => { + if (!coord || coord.lat == null || coord.lon == null) return false; + + const lat = Number(coord.lat); + const lon = Number(coord.lon); + + // Check if numbers are valid and within reasonable ranges + return !isNaN(lat) && !isNaN(lon) && + isFinite(lat) && isFinite(lon) && + lat >= -90 && lat <= 90 && + lon >= -180 && lon <= 180; + }); + + if (validCoords.length > 1) { + for (let i = 0; i < validCoords.length - 1; i++) { + const current = validCoords[i]; + const next = validCoords[i + 1]; + + // Double-check coordinates are valid and within Earth bounds + const currentLat = Number(current.lat); + const currentLon = Number(current.lon); + const nextLat = Number(next.lat); + const nextLon = Number(next.lon); + + if (!isNaN(currentLat) && !isNaN(currentLon) && + !isNaN(nextLat) && !isNaN(nextLon) && + isFinite(currentLat) && isFinite(currentLon) && + isFinite(nextLat) && isFinite(nextLon) && + currentLat >= -90 && currentLat <= 90 && + currentLon >= -180 && currentLon <= 180 && + nextLat >= -90 && nextLat <= 90 && + nextLon >= -180 && nextLon <= 180) { + + try { + const polyline = L.polyline([ + [currentLat, currentLon], + [nextLat, nextLon] + ], { + color: '#00e12c', + weight: 1, + opacity: 0.8 + }); + pathsLayer.addLayer(polyline); + } catch (error) { + console.error('Error creating polyline:', error, { currentLat, currentLon, nextLat, nextLon }); + } + } + } + } + + // Add projected path (dashed blue line) from last coordinate to destination + const lastCoord = validCoords[validCoords.length - 1]; + if (lastCoord && + letter.destination_coords && + letter.destination_coords.lat && + letter.destination_coords.lon && + !isNaN(letter.destination_coords.lat) && + !isNaN(letter.destination_coords.lon) && + isFinite(letter.destination_coords.lat) && + isFinite(letter.destination_coords.lon) && + letter.aasm_state !== 'received') { + // Double-check coordinates are valid before creating projected arc + const lastLat = Number(lastCoord.lat); + const lastLon = Number(lastCoord.lon); + const destLat = Number(letter.destination_coords.lat); + const destLon = Number(letter.destination_coords.lon); + + if (!isNaN(lastLat) && !isNaN(lastLon) && + !isNaN(destLat) && !isNaN(destLon) && + isFinite(lastLat) && isFinite(lastLon) && + isFinite(destLat) && isFinite(destLon)) { + + try { + const projectedPolyline = L.polyline([ + [lastLat, lastLon], + [destLat, destLon] + ], { + color: '#0f95ef', + weight: 1, + dashArray: '5, 5', + opacity: 0.6 + }); + projectionsLayer.addLayer(projectedPolyline); + } catch (error) { + console.error('Error creating projected polyline:', error, { lastLat, lastLon, destLat, destLon }); + } + } + } + }); + + // Fit map to show all markers if any exist + if (markersLayer.getLayers().length > 0) { + const group = new L.featureGroup([markersLayer, pathsLayer, projectionsLayer]); + map.fitBounds(group.getBounds().pad(0.1)); + } +}); \ No newline at end of file diff --git a/app/jobs/public/update_map_data_job.rb b/app/jobs/public/update_map_data_job.rb index 99a84d4..a084e5a 100644 --- a/app/jobs/public/update_map_data_job.rb +++ b/app/jobs/public/update_map_data_job.rb @@ -90,6 +90,8 @@ class Public::UpdateMapDataJob < ApplicationJob # Use non-exact geocoding to avoid doxing result = GeocodingService.geocode_return_address(return_address, exact: false) + return nil unless result && result[:lat] && result[:lon] + { lat: result[:lat].to_f, lon: result[:lon].to_f, @@ -99,6 +101,8 @@ class Public::UpdateMapDataJob < ApplicationJob def geocode_destination(address) # Use non-exact geocoding (city only) to avoid doxing result = GeocodingService.geocode_address_model(address, exact: false) + return nil unless result && result[:lat] && result[:lon] + { lat: result[:lat].to_f, lon: result[:lon].to_f, @@ -107,7 +111,7 @@ class Public::UpdateMapDataJob < ApplicationJob def geocode_usps_facility(locale_key, event) result = GeocodingService::USPSFacilities.coords_for_locale_key(locale_key, event) - return nil unless result + return nil unless result && result[:lat] && result[:lon] { lat: result[:lat].to_f, diff --git a/app/services/geocoding_service.rb b/app/services/geocoding_service.rb index 405cc85..be83a98 100644 --- a/app/services/geocoding_service.rb +++ b/app/services/geocoding_service.rb @@ -18,14 +18,18 @@ module GeocodingService def geocode_address_model(address, exact: false) Rails.cache.fetch("geocode_address_#{address.id}", expires_in: 5.months) do params = { + street: address.line_1, city: address.city, state: address.state, postalcode: address.postal_code, country: address.country, } - params[:street] = address.line_1 if exact - first_hit(params) || FIFTEEN_FALLS + result = first_hit(params) || FIFTEEN_FALLS + result[:lat] = result[:lat].to_f + result[:lon] = result[:lon].to_f + result = fuzz_coordinates(result[:lat], result[:lon]) unless exact + result end end @@ -47,6 +51,41 @@ module GeocodingService hackclub_geocode(params) end + def fuzz_coordinates(lat, lon) + # Add random offset within 5 mile radius, with minimum of 3 miles for privacy + # 1 degree latitude ≈ 69 miles, so 5 miles ≈ 0.0725 degrees + # 1 degree longitude varies by latitude, but at ~45°N ≈ 49 miles, so 5 miles ≈ 0.102 degrees + max_lat_offset = 0.0725 + max_lon_offset = 0.102 * Math.cos(lat * Math::PI / 180) + + # Generate random angle and distance within the annular ring (3-5 miles) + angle = rand * 2 * Math::PI + + # For uniform distribution in annular ring, we need to account for quadratic area growth + # Area of annular ring = π(r_max² - r_min²) + # For uniform distribution: rand = (r² - r_min²) / (r_max² - r_min²) + # Solving for r: r = sqrt(r_min² + rand * (r_max² - r_min²)) + min_distance_factor = 0.6 # 3 miles / 5 miles + max_distance_factor = 1.0 # 5 miles / 5 miles + + # Calculate distance factor using proper area-based distribution + min_squared = min_distance_factor ** 2 + max_squared = max_distance_factor ** 2 + distance_factor = Math.sqrt(min_squared + rand * (max_squared - min_squared)) + + lat_offset = distance_factor * max_lat_offset * Math.sin(angle) + lon_offset = distance_factor * max_lon_offset * Math.cos(angle) + + fuzzed_lat = lat + lat_offset + fuzzed_lon = lon + lon_offset + + # Reduce precision to ~100m resolution (3 decimal places) + { + lat: fuzzed_lat.round(3), + lon: fuzzed_lon.round(3), + } + end + def hackclub_geocode(params) return nil if params.nil? diff --git a/app/views/public/maps/show.html.erb b/app/views/public/maps/show.html.erb index 8371a54..cf2e037 100644 --- a/app/views/public/maps/show.html.erb +++ b/app/views/public/maps/show.html.erb @@ -1,3 +1,6 @@ +<% content_for :head do %> + <%= vite_javascript_tag "map" %> +<% end %> <% content_for :window_title, "Letter Tracking Map" %>
@@ -13,7 +16,7 @@ Projected Path
-
+

(coords not exact for obvious reasons)

- - - - \ No newline at end of file + + .current-letter { + color: #ff6b6b; + } + + .received-letter { + color: #00e12c; + } + + diff --git a/app/views/public/static_pages/root.html.erb b/app/views/public/static_pages/root.html.erb index 3dd0368..7656c93 100644 --- a/app/views/public/static_pages/root.html.erb +++ b/app/views/public/static_pages/root.html.erb @@ -41,6 +41,6 @@
- +
\ No newline at end of file diff --git a/package.json b/package.json index 0046585..502fbc0 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "@tsparticles/confetti": "^3.8.1", "d3": "^7.9.0", "datamaps": "^0.5.9", + "leaflet": "^1.9.4", + "leaflet-arc": "^1.0.2", "dreamland": "^0.0.25", "jquery": "^3.7.1", "qz-tray": "^2.2.4", diff --git a/yarn.lock b/yarn.lock index 043c3f3..fe72ea1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -649,6 +649,10 @@ ansicolors@~0.2.1: resolved "https://registry.npmjs.org/ansicolors/-/ansicolors-0.2.1.tgz" integrity sha512-tOIuy1/SK/dr94ZA0ckDohKXNeBNqZ4us6PjMVLs5h1w2GBB6uPtOknp2+VF4F/zcy9LI70W+Z+pE2Soajky1w== +"arc@git+https://github.com/springmeyer/arc.js.git#b005df058b010d1c7aaf3aa451516d41294dac0e": + version "0.1.0" + resolved "git+https://github.com/springmeyer/arc.js.git#b005df058b010d1c7aaf3aa451516d41294dac0e" + async@^2.6.0: version "2.6.4" resolved "https://registry.npmjs.org/async/-/async-2.6.4.tgz" @@ -1289,6 +1293,18 @@ jquery-ui@^1.13.2: resolved "https://registry.npmjs.org/jquery/-/jquery-3.7.1.tgz" integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== +leaflet-arc@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/leaflet-arc/-/leaflet-arc-1.0.2.tgz#b1e2bdb7bc4c382071fd5bffba299793d263f527" + integrity sha512-WBmTEjf2O1IH7Ol6XFbv96l5RYVqO8hvUaR+GsL4TcbeMS9u3PHgZWVUF/ay0CG9HWhiHoJAHJ62Itcqduo6qQ== + dependencies: + arc "git+https://github.com/springmeyer/arc.js.git#b005df058b010d1c7aaf3aa451516d41294dac0e" + +leaflet@^1.9.4: + version "1.9.4" + resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" + integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== + levn@~0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/levn/-/levn-0.3.0.tgz#3b09924edf9f083c0490fdd4c0bc4421e04764ee"