Move fetchAndDisplayPhotos to maps/helpers.js

This commit is contained in:
Eugene Burmakin 2024-11-27 21:37:21 +01:00
parent 198bf3128a
commit 9522f81abf
7 changed files with 390 additions and 81 deletions

View file

@ -8,7 +8,12 @@ class TripsController < ApplicationController
@trips = current_user.trips
end
def show; end
def show
@coordinates = @trip.points.pluck(
:latitude, :longitude, :battery, :altitude, :timestamp, :velocity, :id,
:country
).map { [_1.to_f, _2.to_f, _3.to_s, _4.to_s, _5.to_s, _6.to_s, _7.to_s, _8.to_s] }
end
def new
@trip = Trip.new

View file

@ -106,4 +106,8 @@ module ApplicationHelper
'text-blue-600'
end
def human_date(date)
date.strftime('%e %B %Y')
end
end

View file

@ -12,6 +12,7 @@ import { fetchAndDrawAreas } from "../maps/areas";
import { handleAreaCreated } from "../maps/areas";
import { showFlashMessage } from "../maps/helpers";
import { fetchAndDisplayPhotos } from '../maps/helpers';
import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers";
@ -83,14 +84,13 @@ export default class extends Controller {
Photos: this.photoMarkers
};
L.control
.scale({
position: "bottomright",
metric: true,
imperial: true,
maxWidth: 120,
})
.addTo(this.map);
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
@ -132,16 +132,22 @@ export default class extends Controller {
this.initializeDrawControl();
// Add event listeners to toggle draw controls
this.map.on('overlayadd', (e) => {
this.map.on('overlayadd', async (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
if (e.name === 'Photos') {
// Extract dates from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const startDate = urlParams.get('start_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
const endDate = urlParams.get('end_at')?.split('T')[0] || new Date().toISOString().split('T')[0];
this.fetchAndDisplayPhotos(startDate, endDate);
await fetchAndDisplayPhotos({
map: this.map,
photoMarkers: this.photoMarkers,
apiKey: this.apiKey,
startDate: startDate,
endDate: endDate,
userSettings: this.userSettings
});
}
});
@ -782,87 +788,87 @@ export default class extends Controller {
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds
// async fetchAndDisplayPhotos(startDate, endDate, retryCount = 0) {
// const MAX_RETRIES = 3;
// const RETRY_DELAY = 3000; // 3 seconds
// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});
// // Create loading control
// const LoadingControl = L.Control.extend({
// onAdd: (map) => {
// const container = L.DomUtil.create('div', 'leaflet-loading-control');
// container.innerHTML = '<div class="loading-spinner"></div>';
// return container;
// }
// });
const loadingControl = new LoadingControl({ position: 'topleft' });
this.map.addControl(loadingControl);
// const loadingControl = new LoadingControl({ position: 'topleft' });
// this.map.addControl(loadingControl);
try {
const params = new URLSearchParams({
api_key: this.apiKey,
start_date: startDate,
end_date: endDate
});
// try {
// const params = new URLSearchParams({
// api_key: this.apiKey,
// start_date: startDate,
// end_date: endDate
// });
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
// const response = await fetch(`/api/v1/photos?${params}`);
// if (!response.ok) {
// throw new Error(`HTTP error! status: ${response.status}`);
// }
const photos = await response.json();
this.photoMarkers.clearLayers();
// const photos = await response.json();
// this.photoMarkers.clearLayers();
// Create a promise for each photo to track when it's fully loaded
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;
// // Create a promise for each photo to track when it's fully loaded
// const photoLoadPromises = photos.map(photo => {
// return new Promise((resolve) => {
// const img = new Image();
// const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}`;
img.onload = () => {
this.createPhotoMarker(photo);
resolve();
};
// img.onload = () => {
// this.createPhotoMarker(photo);
// resolve();
// };
img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};
// img.onerror = () => {
// console.error(`Failed to load photo ${photo.id}`);
// resolve(); // Resolve anyway to not block other photos
// };
img.src = thumbnailUrl;
});
});
// img.src = thumbnailUrl;
// });
// });
// Wait for all photos to be loaded and rendered
await Promise.all(photoLoadPromises);
// // Wait for all photos to be loaded and rendered
// await Promise.all(photoLoadPromises);
if (!this.map.hasLayer(this.photoMarkers)) {
this.photoMarkers.addTo(this.map);
}
// if (!this.map.hasLayer(this.photoMarkers)) {
// this.photoMarkers.addTo(this.map);
// }
// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');
// // Show checkmark for 1 second before removing
// const loadingSpinner = document.querySelector('.loading-spinner');
// loadingSpinner.classList.add('done');
await new Promise(resolve => setTimeout(resolve, 1000));
// await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');
// } catch (error) {
// console.error('Error fetching photos:', error);
// showFlashMessage('error', 'Failed to fetch photos');
if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
// Remove loading control after the delay
this.map.removeControl(loadingControl);
}
}
// if (retryCount < MAX_RETRIES) {
// console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
// setTimeout(() => {
// this.fetchAndDisplayPhotos(startDate, endDate, retryCount + 1);
// }, RETRY_DELAY);
// } else {
// showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
// }
// } finally {
// // Remove loading control after the delay
// this.map.removeControl(loadingControl);
// }
// }
createPhotoMarker(photo) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;

View file

@ -0,0 +1,139 @@
import { Controller } from "@hotwired/stimulus"
import L from "leaflet"
import { osmMapLayer } from "../maps/layers"
import { createPopupContent } from "../maps/popups"
import { osmHotMapLayer } from "../maps/layers"
import { OPNVMapLayer } from "../maps/layers"
import { openTopoMapLayer } from "../maps/layers"
import { cyclOsmMapLayer } from "../maps/layers"
import { esriWorldStreetMapLayer } from "../maps/layers"
import { esriWorldTopoMapLayer } from "../maps/layers"
import { esriWorldImageryMapLayer } from "../maps/layers"
import { esriWorldGrayCanvasMapLayer } from "../maps/layers"
// import { fetchAndDisplayPhotos } from "../helpers/photoFetcher";
export default class extends Controller {
static targets = ["container"]
connect() {
this.coordinates = JSON.parse(this.element.dataset.coordinates)
this.apiKey = this.element.dataset.api_key
this.userSettings = JSON.parse(this.element.dataset.user_settings)
this.timezone = this.element.dataset.timezone
this.distanceUnit = this.element.dataset.distance_unit
// Initialize layer groups
this.markersLayer = L.layerGroup()
this.polylinesLayer = L.layerGroup()
this.photoMarkers = L.layerGroup()
const center = [this.coordinates[0][0], this.coordinates[0][1]]
// Initialize map
this.map = L.map(this.containerTarget).setView(center, 14)
// Add base map layer
osmMapLayer(this.map, "OpenStreetMap")
// Add scale control to bottom right
L.control.scale({
position: 'bottomright',
imperial: this.distanceUnit === 'mi',
metric: this.distanceUnit === 'km',
maxWidth: 120
}).addTo(this.map)
const overlayMaps = {
"Points": this.markersLayer,
"Route": this.polylinesLayer,
"Photos": this.photoMarkers
}
// Add layer control
L.control.layers(this.baseMaps(), overlayMaps).addTo(this.map)
// Add markers for each coordinate
if (this.coordinates?.length > 0) {
this.addMarkers()
this.addPolyline()
this.fitMapToBounds()
}
}
disconnect() {
if (this.map) {
this.map.remove()
}
}
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
}
addMarkers() {
this.coordinates.forEach(coord => {
const marker = L.circleMarker([coord[0], coord[1]], {radius: 4})
const popupContent = createPopupContent(coord, this.timezone, this.distanceUnit)
marker.bindPopup(popupContent)
// Add to markers layer instead of directly to map
this.markersLayer.addTo(this.map)
marker.addTo(this.markersLayer)
})
}
addPolyline() {
const points = this.coordinates.map(coord => [coord[0], coord[1]])
const polyline = L.polyline(points, {
color: 'blue',
weight: 3,
opacity: 0.6
})
// Add to polylines layer instead of directly to map
this.polylinesLayer.addTo(this.map)
polyline.addTo(this.polylinesLayer)
}
fitMapToBounds() {
const bounds = L.latLngBounds(
this.coordinates.map(coord => [coord[0], coord[1]])
)
this.map.fitBounds(bounds, { padding: [50, 50] })
}
baseMaps() {
let selectedLayerName = this.userSettings.preferred_map_layer || "OpenStreetMap";
return {
OpenStreetMap: osmMapLayer(this.map, selectedLayerName),
"OpenStreetMap.HOT": osmHotMapLayer(this.map, selectedLayerName),
OPNV: OPNVMapLayer(this.map, selectedLayerName),
openTopo: openTopoMapLayer(this.map, selectedLayerName),
cyclOsm: cyclOsmMapLayer(this.map, selectedLayerName),
esriWorldStreet: esriWorldStreetMapLayer(this.map, selectedLayerName),
esriWorldTopo: esriWorldTopoMapLayer(this.map, selectedLayerName),
esriWorldImagery: esriWorldImageryMapLayer(this.map, selectedLayerName),
esriWorldGrayCanvas: esriWorldGrayCanvasMapLayer(this.map, selectedLayerName)
};
}
someMethod() {
// Example usage
const startDate = '2023-01-01';
const endDate = '2023-12-31';
fetchAndDisplayPhotos(this.map, this.apiKey, this.photoMarkers, startDate, endDate);
}
}

View file

@ -136,3 +136,131 @@ function classesForFlash(type) {
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
export async function fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate, userSettings }, retryCount = 0) {
const MAX_RETRIES = 3;
const RETRY_DELAY = 3000; // 3 seconds
// Create loading control
const LoadingControl = L.Control.extend({
onAdd: (map) => {
const container = L.DomUtil.create('div', 'leaflet-loading-control');
container.innerHTML = '<div class="loading-spinner"></div>';
return container;
}
});
const loadingControl = new LoadingControl({ position: 'topleft' });
map.addControl(loadingControl);
try {
const params = new URLSearchParams({
api_key: apiKey,
start_date: startDate,
end_date: endDate
});
const response = await fetch(`/api/v1/photos?${params}`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const photos = await response.json();
photoMarkers.clearLayers();
const photoLoadPromises = photos.map(photo => {
return new Promise((resolve) => {
const img = new Image();
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
img.onload = () => {
createPhotoMarker(photo, userSettings.immich_url, photoMarkers, apiKey);
resolve();
};
img.onerror = () => {
console.error(`Failed to load photo ${photo.id}`);
resolve(); // Resolve anyway to not block other photos
};
img.src = thumbnailUrl;
});
});
await Promise.all(photoLoadPromises);
if (!map.hasLayer(photoMarkers)) {
photoMarkers.addTo(map);
}
// Show checkmark for 1 second before removing
const loadingSpinner = document.querySelector('.loading-spinner');
loadingSpinner.classList.add('done');
await new Promise(resolve => setTimeout(resolve, 1000));
} catch (error) {
console.error('Error fetching photos:', error);
showFlashMessage('error', 'Failed to fetch photos');
if (retryCount < MAX_RETRIES) {
console.log(`Retrying in ${RETRY_DELAY/1000} seconds... (Attempt ${retryCount + 1}/${MAX_RETRIES})`);
setTimeout(() => {
fetchAndDisplayPhotos({ map, photoMarkers, apiKey, startDate, endDate }, retryCount + 1);
}, RETRY_DELAY);
} else {
showFlashMessage('error', 'Failed to fetch photos after multiple attempts');
}
} finally {
map.removeControl(loadingControl);
}
}
export function createPhotoMarker(photo, immichUrl, photoMarkers,apiKey) {
if (!photo.exifInfo?.latitude || !photo.exifInfo?.longitude) return;
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${apiKey}`;
const icon = L.divIcon({
className: 'photo-marker',
html: `<img src="${thumbnailUrl}" style="width: 48px; height: 48px;">`,
iconSize: [48, 48]
});
const marker = L.marker(
[photo.exifInfo.latitude, photo.exifInfo.longitude],
{ icon }
);
const startOfDay = new Date(photo.localDateTime);
startOfDay.setHours(0, 0, 0, 0);
const endOfDay = new Date(photo.localDateTime);
endOfDay.setHours(23, 59, 59, 999);
const queryParams = {
takenAfter: startOfDay.toISOString(),
takenBefore: endOfDay.toISOString()
};
const encodedQuery = encodeURIComponent(JSON.stringify(queryParams));
const immich_photo_link = `${immichUrl}/search?query=${encodedQuery}`;
const popupContent = `
<div class="max-w-xs">
<a href="${immich_photo_link}" target="_blank" onmouseover="this.firstElementChild.style.boxShadow = '0 4px 8px rgba(0, 0, 0, 0.3)';"
onmouseout="this.firstElementChild.style.boxShadow = '';">
<img src="${thumbnailUrl}"
class="w-8 h-8 mb-2 rounded"
style="transition: box-shadow 0.3s ease;"
alt="${photo.originalFileName}">
</a>
<h3 class="font-bold">${photo.originalFileName}</h3>
<p>Taken: ${new Date(photo.localDateTime).toLocaleString()}</p>
<p>Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}</p>
${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'}
</div>
`;
marker.bindPopup(popupContent);
photoMarkers.addLayer(marker);
}

View file

@ -4,4 +4,12 @@ class Trip < ApplicationRecord
belongs_to :user
validates :name, :started_at, :ended_at, presence: true
def points
user.points.where(timestamp: started_at.to_i..ended_at.to_i).order(:timestamp)
end
def countries
points.pluck(:country).uniq.compact
end
end

View file

@ -4,14 +4,33 @@
<!-- Header Section -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold mb-2"><%= @trip.name %></h1>
<p class="text-lg text-base-content/60">Countries visited: [Placeholder]</p>
<p class="text-md text-base-content/60">
<%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %>
</p>
<% if @trip.countries.any? %>
<p class="text-lg text-base-content/60">
<%= @trip.countries.join(', ') %>
</p>
<% end %>
</div>
<!-- Map and Description Section -->
<div class="bg-base-100 mb-8">
<div class="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
<div class="w-full">
<div id="map" class="w-full h-96 bg-base-200">Map Placeholder</div>
<div
id='map'
class="w-full h-full"
data-controller="trips"
data-trips-target="container"
data-distance_unit="<%= DISTANCE_UNIT %>"
data-api_key="<%= current_user.api_key %>"
data-user_settings="<%= current_user.settings.to_json %>"
data-coordinates="<%= @coordinates.to_json %>"
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-trips-target="container" class="h-[25rem] w-full min-h-screen">
</div>
</div>
</div>
<div class="w-full">
<div class="prose">
@ -54,7 +73,7 @@
<%= link_to "Destroy this trip",
trip_path(@trip),
data: {
turbo_confirm: "Are you sure? This action will delete all points imported with this file",
turbo_confirm: "Are you sure?",
turbo_method: :delete
},
class: "btn" %>