diff --git a/.app_version b/.app_version index d77011c2..473f1fb3 100644 --- a/.app_version +++ b/.app_version @@ -1 +1 @@ -0.30.7 +0.30.8 diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ecd06e5..30f554a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,16 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +# [0.30.8] - 2025-08-01 + +## Fixed + +- Fog of war is now working correctly on zoom and map movement. #1603 +- Possibly fixed a bug where visits were no suggested correctly. #984 +- Scratch map is now working correctly. + + + # [0.30.7] - 2025-08-01 ## Fixed diff --git a/app/controllers/api/v1/countries/borders_controller.rb b/app/controllers/api/v1/countries/borders_controller.rb index 1c3d13a8..6be8195a 100644 --- a/app/controllers/api/v1/countries/borders_controller.rb +++ b/app/controllers/api/v1/countries/borders_controller.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -class Api::V1::Countries::BordersController < ApplicationController +class Api::V1::Countries::BordersController < ApiController def index countries = Rails.cache.fetch('dawarich/countries_codes', expires_in: 1.day) do Oj.load(File.read(Rails.root.join('lib/assets/countries.geojson'))) diff --git a/app/javascript/controllers/maps_controller.js b/app/javascript/controllers/maps_controller.js index 8e3349b6..a1c22787 100644 --- a/app/javascript/controllers/maps_controller.js +++ b/app/javascript/controllers/maps_controller.js @@ -35,6 +35,7 @@ import { showFlashMessage } from "../maps/helpers"; import { fetchAndDisplayPhotos } from "../maps/photos"; import { countryCodesMap } from "../maps/country_codes"; import { VisitsManager } from "../maps/visits"; +import { ScratchLayer } from "../maps/scratch_layer"; import "leaflet-draw"; import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war"; @@ -49,7 +50,6 @@ export default class extends BaseController { layerControl = null; visitedCitiesCache = new Map(); trackedMonthsCache = null; - currentPopup = null; tracksLayer = null; tracksVisible = false; tracksSubscription = null; @@ -181,7 +181,7 @@ export default class extends BaseController { this.areasLayer = new L.FeatureGroup(); this.photoMarkers = L.layerGroup(); - this.setupScratchLayer(this.countryCodesMap); + this.initializeScratchLayer(); if (!this.settingsButtonAdded) { this.addSettingsButton(); @@ -197,7 +197,7 @@ export default class extends BaseController { Tracks: this.tracksLayer, Heatmap: this.heatmapLayer, "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer, + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer, Photos: this.photoMarkers, "Suggested Visits": this.visitsManager.getVisitCirclesLayer(), @@ -348,127 +348,23 @@ export default class extends BaseController { appendPoint(data) { if (this.liveMapHandler && this.liveMapEnabled) { this.liveMapHandler.appendPoint(data); + // Update scratch layer manager with new markers + if (this.scratchLayerManager) { + this.scratchLayerManager.updateMarkers(this.markers); + } } else { console.warn('LiveMapHandler not initialized or live mode not enabled'); } } - async setupScratchLayer(countryCodesMap) { - this.scratchLayer = L.geoJSON(null, { - style: { - fillColor: '#FFD700', - fillOpacity: 0.3, - color: '#FFA500', - weight: 1 - } - }) - - try { - // Up-to-date version can be found on Github: - // https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson - const response = await fetch('/api/v1/countries/borders.json', { - headers: { - 'Accept': 'application/geo+json,application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - const worldData = await response.json(); - // Cache the world borders data for future use - this.worldBordersData = worldData; - - const visitedCountries = this.getVisitedCountries(countryCodesMap) - const filteredFeatures = worldData.features.filter(feature => - visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) - ) - - this.scratchLayer.addData({ - type: 'FeatureCollection', - features: filteredFeatures - }) - } catch (error) { - console.error('Error loading GeoJSON:', error); - } + async initializeScratchLayer() { + this.scratchLayerManager = new ScratchLayer(this.map, this.markers, this.countryCodesMap, this.apiKey); + this.scratchLayer = await this.scratchLayerManager.setup(); } - getVisitedCountries(countryCodesMap) { - if (!this.markers) return []; - - return [...new Set( - this.markers - .filter(marker => marker[7]) // Ensure country exists - .map(marker => { - // Convert country name to ISO code, or return the original if not found - return countryCodesMap[marker[7]] || marker[7]; - }) - )]; - } - - // Optional: Add methods to handle user interactions toggleScratchLayer() { - if (this.map.hasLayer(this.scratchLayer)) { - this.map.removeLayer(this.scratchLayer) - } else { - this.scratchLayer.addTo(this.map) - } - } - - async refreshScratchLayer() { - console.log('Refreshing scratch layer with current data'); - - if (!this.scratchLayer) { - console.log('Scratch layer not initialized, setting up'); - await this.setupScratchLayer(this.countryCodesMap); - return; - } - - try { - // Clear existing data - this.scratchLayer.clearLayers(); - - // Get current visited countries based on current markers - const visitedCountries = this.getVisitedCountries(this.countryCodesMap); - console.log('Current visited countries:', visitedCountries); - - if (visitedCountries.length === 0) { - console.log('No visited countries found'); - return; - } - - // Fetch country borders data (reuse if already loaded) - if (!this.worldBordersData) { - console.log('Loading world borders data'); - const response = await fetch('/api/v1/countries/borders.json', { - headers: { - 'Accept': 'application/geo+json,application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - - this.worldBordersData = await response.json(); - } - - // Filter for visited countries - const filteredFeatures = this.worldBordersData.features.filter(feature => - visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) - ); - - console.log('Filtered features for visited countries:', filteredFeatures.length); - - // Add the filtered country data to the scratch layer - this.scratchLayer.addData({ - type: 'FeatureCollection', - features: filteredFeatures - }); - - } catch (error) { - console.error('Error refreshing scratch layer:', error); + if (this.scratchLayerManager) { + this.scratchLayerManager.toggle(); } } @@ -591,9 +487,11 @@ export default class extends BaseController { this.visitsManager.fetchAndDisplayVisits(); } } else if (event.name === 'Scratch map') { - // Refresh scratch map with current visited countries + // Add scratch map layer console.log('Scratch map layer enabled via layer control'); - this.refreshScratchLayer(); + if (this.scratchLayerManager) { + this.scratchLayerManager.addToMap(); + } } else if (event.name === 'Fog of War') { // Enable fog of war when layer is added this.fogOverlay = event.layer; @@ -626,6 +524,12 @@ export default class extends BaseController { // Clear the visit circles when layer is disabled this.visitsManager.visitCircles.clearLayers(); } + } else if (event.name === 'Scratch map') { + // Handle scratch map layer removal + console.log('Scratch map layer disabled via layer control'); + if (this.scratchLayerManager) { + this.scratchLayerManager.remove(); + } } else if (event.name === 'Fog of War') { // Fog canvas will be automatically removed by the layer's onRemove method this.fogOverlay = null; @@ -703,7 +607,7 @@ export default class extends BaseController { Routes: this.polylinesLayer || L.layerGroup(), Heatmap: this.heatmapLayer || L.layerGroup(), "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer || L.layerGroup(), + "Scratch map": this.scratchLayerManager?.getLayer() || L.layerGroup(), Areas: this.areasLayer || L.layerGroup(), Photos: this.photoMarkers || L.layerGroup() }; @@ -741,24 +645,26 @@ export default class extends BaseController { const markerId = parseInt(marker[6]); return markerId !== numericId; }); + + // Update scratch layer manager with updated markers + if (this.scratchLayerManager) { + this.scratchLayerManager.updateMarkers(this.markers); + } } } - addLastMarker(map, markers) { - if (markers.length > 0) { - const lastMarker = markers[markers.length - 1].slice(0, 2); - const marker = L.marker(lastMarker).addTo(map); - return marker; // Return marker reference for tracking - } - return null; - } - updateFog(markers, clearFogRadius, fogLineThreshold) { - const fog = document.getElementById('fog'); - if (!fog) { - initializeFogCanvas(this.map); + // Call the fog overlay's updateFog method if it exists + if (this.fogOverlay && typeof this.fogOverlay.updateFog === 'function') { + this.fogOverlay.updateFog(markers, clearFogRadius, fogLineThreshold); + } else { + // Fallback for when fog overlay isn't available + const fog = document.getElementById('fog'); + if (!fog) { + initializeFogCanvas(this.map); + } + requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); } - requestAnimationFrame(() => drawFogCanvas(this.map, markers, clearFogRadius, fogLineThreshold)); } initializeDrawControl() { @@ -1098,7 +1004,7 @@ export default class extends BaseController { Tracks: this.tracksLayer ? this.map.hasLayer(this.tracksLayer) : false, Heatmap: this.map.hasLayer(this.heatmapLayer), "Fog of War": this.map.hasLayer(this.fogOverlay), - "Scratch map": this.map.hasLayer(this.scratchLayer), + "Scratch map": this.scratchLayerManager?.isVisible() || false, Areas: this.map.hasLayer(this.areasLayer), Photos: this.map.hasLayer(this.photoMarkers) }; @@ -1640,14 +1546,6 @@ export default class extends BaseController { } } - chunk(array, size) { - const chunked = []; - for (let i = 0; i < array.length; i += size) { - chunked.push(array.slice(i, i + size)); - } - return chunked; - } - getWholeYearLink() { // First try to get year from URL parameters const urlParams = new URLSearchParams(window.location.search); @@ -1912,30 +1810,6 @@ export default class extends BaseController { }); } - updateLayerControl() { - if (!this.layerControl) return; - - // Remove existing layer control - this.map.removeControl(this.layerControl); - - // Create new controls layer object - const controlsLayer = { - Points: this.markersLayer || L.layerGroup(), - Routes: this.polylinesLayer || L.layerGroup(), - Tracks: this.tracksLayer || L.layerGroup(), - Heatmap: this.heatmapLayer || L.heatLayer([]), - "Fog of War": this.fogOverlay, - "Scratch map": this.scratchLayer || L.layerGroup(), - Areas: this.areasLayer || L.layerGroup(), - Photos: this.photoMarkers || L.layerGroup(), - "Suggested Visits": this.visitsManager?.getVisitCirclesLayer() || L.layerGroup(), - "Confirmed Visits": this.visitsManager?.getConfirmedVisitCirclesLayer() || L.layerGroup() - }; - - // Re-add the layer control - this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map); - } - toggleTracksVisibility(event) { this.tracksVisible = event.target.checked; @@ -1943,8 +1817,4 @@ export default class extends BaseController { toggleTracksVisibility(this.tracksLayer, this.map, this.tracksVisible); } } - - - - } diff --git a/app/javascript/maps/fog_of_war.js b/app/javascript/maps/fog_of_war.js index 1b13dc54..927d85e9 100644 --- a/app/javascript/maps/fog_of_war.js +++ b/app/javascript/maps/fog_of_war.js @@ -33,7 +33,12 @@ export function drawFogCanvas(map, markers, clearFogRadius, fogLineThreshold) { const size = map.getSize(); - // 1) Paint base fog + // Update canvas size if needed + if (fog.width !== size.x || fog.height !== size.y) { + fog.width = size.x; + fog.height = size.y; + } +// 1) Paint base fog ctx.clearRect(0, 0, size.x, size.y); ctx.fillStyle = 'rgba(0, 0, 0, 0.4)'; ctx.fillRect(0, 0, size.x, size.y); @@ -106,23 +111,17 @@ export function createFogOverlay() { return L.Layer.extend({ onAdd: function(map) { this._map = map; + + // Initialize storage for fog parameters + this._markers = []; + this._clearFogRadius = 50; + this._fogLineThreshold = 90; // Initialize the fog canvas initializeFogCanvas(map); - // Get the map controller to access markers and settings - const mapElement = document.getElementById('map'); - if (mapElement && mapElement._stimulus_controllers) { - const controller = mapElement._stimulus_controllers.find(c => c.identifier === 'maps'); - if (controller) { - this._controller = controller; - - // Draw initial fog if we have markers - if (controller.markers && controller.markers.length > 0) { - drawFogCanvas(map, controller.markers, controller.clearFogRadius, controller.fogLineThreshold); - } - } - } + // Fog overlay will be initialized via updateFog() call from maps controller + // No need to try to access controller data here // Add resize event handlers to update fog size this._onResize = () => { @@ -139,7 +138,31 @@ export function createFogOverlay() { } }; + // Add event handlers for zoom and pan to update fog position + this._onMoveEnd = () => { + console.log('Fog: moveend event fired'); + if (this._markers && this._markers.length > 0) { + console.log('Fog: redrawing after move with stored data'); + drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold); + } else { + console.log('Fog: no stored markers available'); + } + }; + + this._onZoomEnd = () => { + console.log('Fog: zoomend event fired'); + if (this._markers && this._markers.length > 0) { + console.log('Fog: redrawing after zoom with stored data'); + drawFogCanvas(map, this._markers, this._clearFogRadius, this._fogLineThreshold); + } else { + console.log('Fog: no stored markers available'); + } + }; + + // Bind event listeners map.on('resize', this._onResize); + map.on('moveend', this._onMoveEnd); + map.on('zoomend', this._onZoomEnd); }, onRemove: function(map) { @@ -148,16 +171,28 @@ export function createFogOverlay() { fog.remove(); } - // Clean up event listener + // Clean up event listeners if (this._onResize) { map.off('resize', this._onResize); } + if (this._onMoveEnd) { + map.off('moveend', this._onMoveEnd); + } + if (this._onZoomEnd) { + map.off('zoomend', this._onZoomEnd); + } }, // Method to update fog when markers change updateFog: function(markers, clearFogRadius, fogLineThreshold) { if (this._map) { - drawFogCanvas(this._map, markers, clearFogRadius, fogLineThreshold); + // Store the updated parameters + this._markers = markers || []; + this._clearFogRadius = clearFogRadius || 50; + this._fogLineThreshold = fogLineThreshold || 90; + + console.log('Fog: updateFog called with', markers?.length || 0, 'markers'); + drawFogCanvas(this._map, this._markers, this._clearFogRadius, this._fogLineThreshold); } } }); diff --git a/app/javascript/maps/scratch_layer.js b/app/javascript/maps/scratch_layer.js new file mode 100644 index 00000000..f83844ae --- /dev/null +++ b/app/javascript/maps/scratch_layer.js @@ -0,0 +1,171 @@ +import L from "leaflet"; + +export class ScratchLayer { + constructor(map, markers, countryCodesMap, apiKey) { + this.map = map; + this.markers = markers; + this.countryCodesMap = countryCodesMap; + this.apiKey = apiKey; + this.scratchLayer = null; + this.worldBordersData = null; + } + + async setup() { + this.scratchLayer = L.geoJSON(null, { + style: { + fillColor: '#FFD700', + fillOpacity: 0.3, + color: '#FFA500', + weight: 1 + } + }); + + try { + // Up-to-date version can be found on Github: + // https://raw.githubusercontent.com/datasets/geo-countries/master/data/countries.geojson + const worldData = await this._fetchWorldBordersData(); + + const visitedCountries = this.getVisitedCountries(); + console.log('Current visited countries:', visitedCountries); + + if (visitedCountries.length === 0) { + console.log('No visited countries found'); + return this.scratchLayer; + } + + const filteredFeatures = worldData.features.filter(feature => + visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) + ); + + console.log('Filtered features for visited countries:', filteredFeatures.length); + + this.scratchLayer.addData({ + type: 'FeatureCollection', + features: filteredFeatures + }); + } catch (error) { + console.error('Error loading GeoJSON:', error); + } + + return this.scratchLayer; + } + + async _fetchWorldBordersData() { + if (this.worldBordersData) { + return this.worldBordersData; + } + + console.log('Loading world borders data'); + const response = await fetch('/api/v1/countries/borders.json', { + headers: { + 'Accept': 'application/geo+json,application/json', + 'Authorization': `Bearer ${this.apiKey}` + } + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + this.worldBordersData = await response.json(); + return this.worldBordersData; + } + + getVisitedCountries() { + if (!this.markers) return []; + + return [...new Set( + this.markers + .filter(marker => marker[7]) // Ensure country exists + .map(marker => { + // Convert country name to ISO code, or return the original if not found + return this.countryCodesMap[marker[7]] || marker[7]; + }) + )]; + } + + toggle() { + if (!this.scratchLayer) { + console.warn('Scratch layer not initialized'); + return; + } + + if (this.map.hasLayer(this.scratchLayer)) { + this.map.removeLayer(this.scratchLayer); + } else { + this.scratchLayer.addTo(this.map); + } + } + + async refresh() { + console.log('Refreshing scratch layer with current data'); + + if (!this.scratchLayer) { + console.log('Scratch layer not initialized, setting up'); + await this.setup(); + return; + } + + try { + // Clear existing data + this.scratchLayer.clearLayers(); + + // Get current visited countries based on current markers + const visitedCountries = this.getVisitedCountries(); + console.log('Current visited countries:', visitedCountries); + + if (visitedCountries.length === 0) { + console.log('No visited countries found'); + return; + } + + // Fetch country borders data (reuse if already loaded) + const worldData = await this._fetchWorldBordersData(); + + // Filter for visited countries + const filteredFeatures = worldData.features.filter(feature => + visitedCountries.includes(feature.properties["ISO3166-1-Alpha-2"]) + ); + + console.log('Filtered features for visited countries:', filteredFeatures.length); + + // Add the filtered country data to the scratch layer + this.scratchLayer.addData({ + type: 'FeatureCollection', + features: filteredFeatures + }); + + } catch (error) { + console.error('Error refreshing scratch layer:', error); + } + } + + // Update markers reference when they change + updateMarkers(markers) { + this.markers = markers; + } + + // Get the Leaflet layer for use in layer controls + getLayer() { + return this.scratchLayer; + } + + // Check if layer is currently visible on map + isVisible() { + return this.scratchLayer && this.map.hasLayer(this.scratchLayer); + } + + // Remove layer from map + remove() { + if (this.scratchLayer && this.map.hasLayer(this.scratchLayer)) { + this.map.removeLayer(this.scratchLayer); + } + } + + // Add layer to map + addToMap() { + if (this.scratchLayer) { + this.scratchLayer.addTo(this.map); + } + } +} diff --git a/app/services/visits/place_finder.rb b/app/services/visits/place_finder.rb index 86f0a547..e2f3a3ab 100644 --- a/app/services/visits/place_finder.rb +++ b/app/services/visits/place_finder.rb @@ -114,7 +114,7 @@ module Visits # Look for existing place with this name existing = Place.where(name: name) - .near([point.latitude, point.longitude], SIMILARITY_RADIUS, :m) + .near([point.lat, point.lon], SIMILARITY_RADIUS, :m) .first return existing if existing @@ -122,9 +122,9 @@ module Visits # Create new place place = Place.new( name: name, - lonlat: "POINT(#{point.longitude} #{point.latitude})", - latitude: point.latitude, - longitude: point.longitude, + lonlat: "POINT(#{point.lon} #{point.lat})", + latitude: point.lat, + longitude: point.lon, city: properties['city'], country: properties['country'], geodata: point.geodata, diff --git a/spec/requests/api/v1/countries/borders_spec.rb b/spec/requests/api/v1/countries/borders_spec.rb index 1162e198..d0717dcf 100644 --- a/spec/requests/api/v1/countries/borders_spec.rb +++ b/spec/requests/api/v1/countries/borders_spec.rb @@ -4,12 +4,24 @@ require 'rails_helper' RSpec.describe 'Api::V1::Countries::Borders', type: :request do describe 'GET /index' do - it 'returns a list of countries with borders' do - get '/api/v1/countries/borders' + let(:user) { create(:user) } - expect(response).to have_http_status(:success) - expect(response.body).to include('AF') - expect(response.body).to include('ZW') + context 'when user is not authenticated' do + it 'returns http unauthorized' do + get '/api/v1/countries/borders' + + expect(response).to have_http_status(:unauthorized) + end + end + + context 'when user is authenticated' do + it 'returns a list of countries with borders' do + get '/api/v1/countries/borders', headers: { 'Authorization' => "Bearer #{user.api_key}" } + + expect(response).to have_http_status(:success) + expect(response.body).to include('AF') + expect(response.body).to include('ZW') + end end end end diff --git a/spec/services/visits/place_finder_spec.rb b/spec/services/visits/place_finder_spec.rb index b924ffae..3da17828 100644 --- a/spec/services/visits/place_finder_spec.rb +++ b/spec/services/visits/place_finder_spec.rb @@ -58,8 +58,7 @@ RSpec.describe Visits::PlaceFinder do context 'with places from points data' do let(:point_with_geodata) do build_stubbed(:point, - latitude: latitude, - longitude: longitude, + lonlat: "POINT(#{longitude} #{latitude})", geodata: { 'properties' => { 'name' => 'POI from Point',