132 custom map tile servers (#134)

This commit is contained in:
Daniel Graf
2025-07-19 06:23:26 +02:00
committed by GitHub
parent a9dc1a4c27
commit 8eba663a18
10 changed files with 255 additions and 20 deletions

BIN
.github/screenshots/multiple-users.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View File

@@ -10,6 +10,10 @@ Reitti is a comprehensive personal location tracking and analysis application th
![](.github/screenshots/main.png)
### Multiple Users View
![](.github/screenshots/multiple-users.png)
### Statistics View
![](.github/screenshots/statistics.png)
@@ -20,9 +24,10 @@ Reitti is a comprehensive personal location tracking and analysis application th
- **Significant Places**: Recognize and categorize frequently visited locations with custom naming
- **Timeline View**: Interactive daily timeline showing visits and trips with duration and distance information
- **Raw Location Tracking**: Visualize your complete movement path with detailed GPS tracks
- **Multi-User-View**: Visualize all your family and friends on a single map
### Data Import & Integration
- **Multiple Import Formats**: Support for GPX files, Google Takeout JSON, and GeoJSON files
- **Multiple Import Formats**: Support for GPX files, Google Takeout JSON, Google Timeline Exports and GeoJSON files
- **Real-time Data Ingestion**: Live location updates via OwnTracks and GPSLogger mobile apps
- **Batch Processing**: Efficient handling of large location datasets with queue-based processing
- **API Integration**: RESTful API for programmatic data access and ingestion
@@ -45,6 +50,7 @@ Reitti is a comprehensive personal location tracking and analysis application th
### Customization & Localization
- **Multi-language Support**: Available in English, Finnish, German, and French
- **Unit System**: Display distances in the Imperial or Metric system
- **Queue Monitoring**: Real-time job status and processing queue visibility
### Privacy & Self-hosting
@@ -189,6 +195,8 @@ The included `docker-compose.yml` provides a complete setup with:
| `PHOTON_BASE_URL` | Base URL for Photon geocoding service | |
| `PROCESSING_WAIT_TIME` | How many seconds to wait after the last data input before starting to process all unprocessed data. (⚠️ This needs to be lower than your integrated app reports data in Reitti) | 15 |
| `DANGEROUS_LIFE` | Enables data management features that can reset/delete all database data (⚠️ USE WITH CAUTION) | false |
| `CUSTOM_TILES_SERVICE` | Custom tile service URL template (e.g., `https://tiles.example.com/{z}/{x}/{y}.png`) | |
| `CUSTOM_TILES_ATTRIBUTION` | Custom attribution text for the tile service | |
| `SERVER_PORT` | Application server port | 8080 |
| `APP_UID` | User ID to run the application as | 1000 |
| `APP_GID` | Group ID to run the application as | 1000 |

View File

@@ -5,6 +5,7 @@ import com.dedicatedcode.reitti.model.UnitSystem;
import com.dedicatedcode.reitti.model.User;
import com.dedicatedcode.reitti.repository.UserJdbcService;
import com.dedicatedcode.reitti.repository.UserSettingsJdbcService;
import com.dedicatedcode.reitti.service.TilesCustomizationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.ControllerAdvice;
@@ -18,10 +19,12 @@ public class GlobalControllerAdvice {
private final UserJdbcService userJdbcService;
private final UserSettingsJdbcService userSettingsJdbcService;
public GlobalControllerAdvice(UserJdbcService userJdbcService, UserSettingsJdbcService userSettingsJdbcService) {
private final TilesCustomizationProvider tilesCustomizationProvider;
public GlobalControllerAdvice(UserJdbcService userJdbcService, UserSettingsJdbcService userSettingsJdbcService, TilesCustomizationProvider tilesCustomizationProvider) {
this.userJdbcService = userJdbcService;
this.userSettingsJdbcService = userSettingsJdbcService;
this.tilesCustomizationProvider = tilesCustomizationProvider;
}
@ModelAttribute("userSettings")
@@ -31,7 +34,7 @@ public class GlobalControllerAdvice {
if (authentication == null || !authentication.isAuthenticated() ||
"anonymousUser".equals(authentication.getPrincipal())) {
// Return default settings for anonymous users
return new UserSettingsDTO(false, "en", List.of(), UnitSystem.METRIC);
return new UserSettingsDTO(false, "en", List.of(), UnitSystem.METRIC, tilesCustomizationProvider.getTilesConfiguration());
}
String username = authentication.getName();
@@ -40,10 +43,10 @@ public class GlobalControllerAdvice {
if (userOptional.isPresent()) {
User user = userOptional.get();
com.dedicatedcode.reitti.model.UserSettings dbSettings = userSettingsJdbcService.getOrCreateDefaultSettings(user.getId());
return new UserSettingsDTO(dbSettings.isPreferColoredMap(), dbSettings.getSelectedLanguage(), dbSettings.getConnectedUserAccounts(), dbSettings.getUnitSystem());
return new UserSettingsDTO(dbSettings.isPreferColoredMap(), dbSettings.getSelectedLanguage(), dbSettings.getConnectedUserAccounts(), dbSettings.getUnitSystem(), tilesCustomizationProvider.getTilesConfiguration());
}
// Fallback for authenticated users not found in database
return new UserSettingsDTO(false, "en", List.of(), UnitSystem.METRIC);
return new UserSettingsDTO(false, "en", List.of(), UnitSystem.METRIC, tilesCustomizationProvider.getTilesConfiguration());
}
}

View File

@@ -5,6 +5,8 @@ import com.dedicatedcode.reitti.model.UnitSystem;
import java.util.List;
public record UserSettingsDTO(boolean preferColoredMap, String selectedLanguage,
List<ConnectedUserAccount> connectedUserAccounts, UnitSystem unitSystem) {
List<ConnectedUserAccount> connectedUserAccounts, UnitSystem unitSystem, TilesCustomizationDTO tiles) {
public record TilesCustomizationDTO(String service, String attribution){}
}

View File

@@ -0,0 +1,33 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.dto.UserSettingsDTO;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@Service
public class TilesCustomizationProvider {
private final String defaultService;
private final String defaultAttribution;
private final String customService;
private final String customAttribution;
public TilesCustomizationProvider(
@Value("${reitti.ui.tiles.default.service}") String defaultService,
@Value("${reitti.ui.tiles.default.attribution}") String defaultAttribution,
@Value("${reitti.ui.tiles.custom.service:}") String customService,
@Value("${reitti.ui.tiles.custom.attribution:}") String customAttribution) {
this.defaultService = defaultService;
this.defaultAttribution = defaultAttribution;
this.customService = customService;
this.customAttribution = customAttribution;
}
public UserSettingsDTO.TilesCustomizationDTO getTilesConfiguration() {
return new UserSettingsDTO.TilesCustomizationDTO(
StringUtils.hasText(customService) ? customService : defaultService,
StringUtils.hasText(customAttribution) ? customAttribution : defaultAttribution
);
}
}

View File

@@ -24,4 +24,7 @@ reitti.geocoding.photon.base-url=${PHOTON_BASE_URL:}
reitti.process-data.schedule=${REITTI_PROCESS_DATA_CRON:0 */10 * * * *}
reitti.ui.tiles.custom.service=${CUSTOM_TILES_SERVICE:}
reitti.ui.tiles.custom.attribution=${CUSTOM_TILES_ATTRIBUTION:}
logging.level.root = INFO

View File

@@ -1,8 +1,6 @@
# Server configuration
server.port=8080
server.tomcat.max-part-count=100
# Logging configuration
logging.level.root = INFO
@@ -42,6 +40,12 @@ spring.rabbitmq.listener.simple.prefetch=10
spring.cache.cache-names=processed-visits,significant-places
spring.cache.redis.time-to-live=1d
# Upload configuration
spring.servlet.multipart.max-file-size=5GB
spring.servlet.multipart.max-request-size=5GB
server.tomcat.max-part-count=100
# Application specific settings
reitti.import.batch-size=1000
# How many seconds should we wait after the last data input before starting to process all unprocessed data?
@@ -71,8 +75,13 @@ reitti.geocoding.max-errors=10
# Geocoding fixed service configuration
reitti.geocoding.photon.base-url=
# Tiles Configuration
reitti.ui.tiles.default.service=https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png
reitti.ui.tiles.default.attribution=&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>
# You can set custom tiles service and attribution by uncommenting the next two keys and setting them to appropriate values
#reitti.ui.tiles.custom.service=
#reitti.ui.tiles.custom.attribution=
# Data management configuration
reitti.data-management.enabled=false
spring.servlet.multipart.max-file-size=5GB
spring.servlet.multipart.max-request-size=5GB

View File

@@ -143,18 +143,21 @@
// Initialize the map
const map = L.map('map', {zoomControl: false, attributionControl: false}).setView([60.1699, 24.9384], 12); // Helsinki coordinates as default
const tilesUrl = window.userSettings.tiles.service;
const tilesAttribution = window.userSettings.tiles.attribution;
if (window.userSettings.preferColoredMap) {
L.tileLayer('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
L.tileLayer(tilesUrl, {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>'
attribution: tilesAttribution
}).addTo(map);
} else {
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
L.tileLayer.grayscale(tilesUrl, {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>'
attribution: tilesAttribution
}).addTo(map);
}
L.control.attribution({position: 'topright'}).addAttribution('&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>')
L.control.attribution({position: 'topright'}).addAttribution(tilesAttribution)
.addTo(map)
// Initialize photo client

View File

@@ -122,7 +122,8 @@
</div>
</div>
<script>
<script th:inline="javascript">
window.userSettings = /*[[${userSettings}]]*/ {}
function removeCurrentAvatar() {
// Hide the current avatar preview
@@ -160,10 +161,12 @@
dragging: false,
scrollWheelZoom: false
})
const tilesUrl = window.userSettings.tiles.service;
const tilesAttribution = window.userSettings.tiles.attribution;
L.tileLayer.grayscale('https://{s}.tile.openstreetmap.fr/hot/{z}/{x}/{y}.png', {
L.tileLayer.grayscale(tilesUrl, {
maxZoom: 19,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors, Tiles style by <a href="https://www.hotosm.org/" target="_blank">Humanitarian OpenStreetMap Team</a> hosted by <a href="https://openstreetmap.fr/" target="_blank">OpenStreetMap France</a>'
attribution: tilesAttribution
}).addTo(placeMap);
if (!isNaN(lat) && !isNaN(lng)) {

View File

@@ -0,0 +1,171 @@
package com.dedicatedcode.reitti.service;
import com.dedicatedcode.reitti.dto.UserSettingsDTO;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import static org.assertj.core.api.Assertions.assertThat;
class TilesCustomizationProviderTest {
@Test
void getTilesConfiguration_WithCustomServiceAndAttribution_ShouldReturnCustomValues() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "https://custom.tiles.com/{z}/{x}/{y}.png";
String customAttribution = "Custom Attribution";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(customService);
assertThat(result.attribution()).isEqualTo(customAttribution);
}
@Test
void getTilesConfiguration_WithEmptyCustomService_ShouldReturnDefaultService() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "";
String customAttribution = "Custom Attribution";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(defaultService);
assertThat(result.attribution()).isEqualTo(customAttribution);
}
@Test
void getTilesConfiguration_WithEmptyCustomAttribution_ShouldReturnDefaultAttribution() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "https://custom.tiles.com/{z}/{x}/{y}.png";
String customAttribution = "";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(customService);
assertThat(result.attribution()).isEqualTo(defaultAttribution);
}
@Test
void getTilesConfiguration_WithNullCustomValues_ShouldReturnDefaultValues() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = null;
String customAttribution = null;
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(defaultService);
assertThat(result.attribution()).isEqualTo(defaultAttribution);
}
@Test
void getTilesConfiguration_WithWhitespaceOnlyCustomValues_ShouldReturnDefaultValues() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = " ";
String customAttribution = "\t\n";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(defaultService);
assertThat(result.attribution()).isEqualTo(defaultAttribution);
}
@Test
void getTilesConfiguration_WithBothEmptyCustomValues_ShouldReturnDefaultValues() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "";
String customAttribution = "";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(defaultService);
assertThat(result.attribution()).isEqualTo(defaultAttribution);
}
@Test
void getTilesConfiguration_WithValidCustomServiceOnly_ShouldReturnCustomServiceAndDefaultAttribution() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "https://custom.tiles.com/{z}/{x}/{y}.png";
String customAttribution = "";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(customService);
assertThat(result.attribution()).isEqualTo(defaultAttribution);
}
@Test
void getTilesConfiguration_WithValidCustomAttributionOnly_ShouldReturnDefaultServiceAndCustomAttribution() {
// Given
String defaultService = "https://default.tiles.com/{z}/{x}/{y}.png";
String defaultAttribution = "Default Attribution";
String customService = "";
String customAttribution = "Custom Attribution";
TilesCustomizationProvider provider = new TilesCustomizationProvider(
defaultService, defaultAttribution, customService, customAttribution
);
// When
UserSettingsDTO.TilesCustomizationDTO result = provider.getTilesConfiguration();
// Then
assertThat(result.service()).isEqualTo(defaultService);
assertThat(result.attribution()).isEqualTo(customAttribution);
}
}