Merge pull request #1605 from Freika/dev

0.30.8
This commit is contained in:
Evgenii Burmakin
2025-08-10 12:15:40 +02:00
committed by GitHub
9 changed files with 295 additions and 198 deletions

View File

@@ -1 +1 @@
0.30.7
0.30.8

View File

@@ -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

View File

@@ -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')))

View File

@@ -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);
}
}
}

View File

@@ -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);
}
}
});

View File

@@ -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);
}
}
}

View File

@@ -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,

View File

@@ -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

View File

@@ -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',