From 9522f81abf19e9a5458532cb94660c066fbd5942 Mon Sep 17 00:00:00 2001 From: Eugene Burmakin Date: Wed, 27 Nov 2024 21:37:21 +0100 Subject: [PATCH] Move fetchAndDisplayPhotos to maps/helpers.js --- app/controllers/trips_controller.rb | 7 +- app/helpers/application_helper.rb | 4 + app/javascript/controllers/maps_controller.js | 160 +++++++++--------- .../controllers/trips_controller.js | 139 +++++++++++++++ app/javascript/maps/helpers.js | 128 ++++++++++++++ app/models/trip.rb | 8 + app/views/trips/show.html.erb | 25 ++- 7 files changed, 390 insertions(+), 81 deletions(-) create mode 100644 app/javascript/controllers/trips_controller.js diff --git a/app/controllers/trips_controller.rb b/app/controllers/trips_controller.rb index c576e2e8..417dcebd 100644 --- a/app/controllers/trips_controller.rb +++ b/app/controllers/trips_controller.rb @@ -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 diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index a5be07f5..3fe89204 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -106,4 +106,8 @@ module ApplicationHelper 'text-blue-600' end + + def human_date(date) + date.strftime('%e %B %Y') + end end diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 97b0a134..b3d62d8a 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -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 = '
'; - return container; - } - }); + // // Create loading control + // const LoadingControl = L.Control.extend({ + // onAdd: (map) => { + // const container = L.DomUtil.create('div', 'leaflet-loading-control'); + // container.innerHTML = '
'; + // 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; diff --git a/app/javascript/controllers/trips_controller.js b/app/javascript/controllers/trips_controller.js new file mode 100644 index 00000000..9763d37c --- /dev/null +++ b/app/javascript/controllers/trips_controller.js @@ -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); + } +} diff --git a/app/javascript/maps/helpers.js b/app/javascript/maps/helpers.js index 784edff8..fafe1a4e 100644 --- a/app/javascript/maps/helpers.js +++ b/app/javascript/maps/helpers.js @@ -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 = '
'; + 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: ``, + 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 = ` +
+ + ${photo.originalFileName} + +

${photo.originalFileName}

+

Taken: ${new Date(photo.localDateTime).toLocaleString()}

+

Location: ${photo.exifInfo.city}, ${photo.exifInfo.state}, ${photo.exifInfo.country}

+ ${photo.type === 'VIDEO' ? '🎥 Video' : '📷 Photo'} +
+ `; + marker.bindPopup(popupContent); + + photoMarkers.addLayer(marker); +} diff --git a/app/models/trip.rb b/app/models/trip.rb index 572a90a4..cae1b1f6 100644 --- a/app/models/trip.rb +++ b/app/models/trip.rb @@ -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 diff --git a/app/views/trips/show.html.erb b/app/views/trips/show.html.erb index 2b6414e8..9db616d7 100644 --- a/app/views/trips/show.html.erb +++ b/app/views/trips/show.html.erb @@ -4,14 +4,33 @@

<%= @trip.name %>

-

Countries visited: [Placeholder]

+

+ <%= human_date(@trip.started_at) %> - <%= human_date(@trip.ended_at) %> +

+ <% if @trip.countries.any? %> +

+ <%= @trip.countries.join(', ') %> +

+ <% end %>
-
Map Placeholder
+
+
+
+
@@ -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" %>