mirror of
https://github.com/System-End/theseus.git
synced 2026-04-19 16:38:18 +00:00
new map!
This commit is contained in:
parent
4f5111a00c
commit
b68d19dd34
7 changed files with 285 additions and 112 deletions
|
|
@ -1,4 +1,198 @@
|
|||
import * as d3 from "d3";
|
||||
import * as topojson from "topojson";
|
||||
window.d3 = d3;
|
||||
window.topojson = topojson;
|
||||
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: `<div class="letter-emoji ${isReceived ? 'received-letter' : 'current-letter'}">${isReceived ? '✅' : '✉️'}</div>`,
|
||||
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));
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,6 @@
|
|||
<% content_for :head do %>
|
||||
<%= vite_javascript_tag "map" %>
|
||||
<% end %>
|
||||
<% content_for :window_title, "Letter Tracking Map" %>
|
||||
<div id="map-legend" style="padding: 4px; font-size: 12px; display: flex; align-items: center; gap: 20px; justify-content: center; margin: 0;">
|
||||
<span class="legend-item" style="display: flex; align-items: center; gap: 6px;">
|
||||
|
|
@ -13,7 +16,7 @@
|
|||
Projected Path
|
||||
</span>
|
||||
</div>
|
||||
<div id="map" style="width: 100%; height: <%= @framed ? '100%' : '250px' %>; position: relative;"></div>
|
||||
<div id="map" style="width: <%= @framed ? '300px' : '100%' %>; height: <%= @framed ? '200px' : '250px' %>; position: relative;"></div>
|
||||
<p>(coords not exact for obvious reasons)</p>
|
||||
<style>
|
||||
.legend-color {
|
||||
|
|
@ -24,108 +27,23 @@
|
|||
width: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
<script src="/map_js/d3.v3.min.js"></script>
|
||||
<script src="/map_js/topojson.v1.min.js"></script>
|
||||
<script src="/map_js/datamaps.world.hires.min.js"></script>
|
||||
<script>
|
||||
// this is bad, im really sorry
|
||||
const lettersData = <%= raw @letters_data.to_json %>;
|
||||
const strokeWidth = <%= @framed ? 0.13 : 0.5 %>;
|
||||
// Initialize the map
|
||||
var map = new Datamap({
|
||||
element: document.getElementById("map"),
|
||||
scope: 'world',
|
||||
fills: {
|
||||
defaultFill: '#F5F5F5',
|
||||
current: '#ff6b6b', // Red for current location
|
||||
mailed: '#4ecdc4', // Teal for mailed events
|
||||
usps_tracking: '#feca57', // Yellow for USPS tracking events
|
||||
received: '#00e12c', // Green for received letters
|
||||
destination: '#a55eea' // Purple for final destination
|
||||
}
|
||||
});
|
||||
|
||||
// Add bubbles for letter locations
|
||||
const bubbleData = [];
|
||||
|
||||
lettersData.forEach(letter => {
|
||||
// Current location (where the letter is now - red dot for mailed, green dot for received)
|
||||
if (letter.current_location && letter.current_location.lat && letter.current_location.lon) {
|
||||
const fillKey = letter.aasm_state === 'received' ? 'received' : 'current';
|
||||
|
||||
// Debug logging to see what's happening
|
||||
console.log('Letter state:', letter.aasm_state, 'fillKey:', fillKey, 'title:', letter.bubble_title);
|
||||
|
||||
bubbleData.push({
|
||||
latitude: letter.current_location.lat,
|
||||
longitude: letter.current_location.lon,
|
||||
radius: 2, // Made larger for better visibility
|
||||
fillKey: fillKey,
|
||||
name: letter.bubble_title
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// Add arcs for letter paths
|
||||
const arcs = [];
|
||||
|
||||
lettersData.forEach(letter => {
|
||||
const coords = letter.coordinates || [];
|
||||
const validCoords = coords.filter(coord => coord && coord.lat && coord.lon);
|
||||
|
||||
// Solid great circle arcs between consecutive coordinates (traveled path)
|
||||
for (let i = 0; i < validCoords.length - 1; i++) {
|
||||
const current = validCoords[i];
|
||||
const next = validCoords[i + 1];
|
||||
|
||||
arcs.push({
|
||||
origin: {
|
||||
latitude: current.lat,
|
||||
longitude: current.lon
|
||||
},
|
||||
destination: {
|
||||
latitude: next.lat,
|
||||
longitude: next.lon
|
||||
},
|
||||
options: {
|
||||
strokeWidth: strokeWidth,
|
||||
strokeColor: '#00e12c', // Green for traveled path
|
||||
greatArc: true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Dotted great circle arc from last coordinate to final destination
|
||||
const lastCoord = validCoords[validCoords.length - 1];
|
||||
if (lastCoord && letter.destination_coords && letter.aasm_state !== 'received') {
|
||||
arcs.push({
|
||||
origin: {
|
||||
latitude: lastCoord.lat,
|
||||
longitude: lastCoord.lon
|
||||
},
|
||||
destination: {
|
||||
latitude: letter.destination_coords.lat,
|
||||
longitude: letter.destination_coords.lon
|
||||
},
|
||||
options: {
|
||||
strokeWidth: strokeWidth,
|
||||
strokeColor: '#0f95ef', // Blue for projected path
|
||||
strokeDashArray: '5,5', // Dotted line
|
||||
greatArc: true
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (arcs.length > 0) {
|
||||
map.arc(arcs);
|
||||
.letter-emoji {
|
||||
font-size: 8px;
|
||||
text-shadow: 1px 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
map.bubbles(bubbleData, {
|
||||
borderWidth: 1,
|
||||
borderColor: '#FFFFFF',
|
||||
fillOpacity: 0.8
|
||||
});
|
||||
</script>
|
||||
|
||||
.current-letter {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.received-letter {
|
||||
color: #00e12c;
|
||||
}
|
||||
</style>
|
||||
<script type="application/json" id="map-data">
|
||||
{
|
||||
"letters": <%= raw @letters_data.to_json %>,
|
||||
"strokeWidth": <%= @framed ? 2 : 3 %>
|
||||
}
|
||||
</script>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,6 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="window-body" style="padding: 0">
|
||||
<iframe src="<%= map_path %>"></iframe>
|
||||
<iframe src="<%= map_path %>" style="width: 320px; height: 200px; border: none;"></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
16
yarn.lock
16
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"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue