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"