Merge pull request #199 from Freika/feature/map-settings

Map settings
This commit is contained in:
Evgenii Burmakin 2024-08-28 22:39:11 +03:00 committed by GitHub
commit 0cc5ac0860
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 801 additions and 183 deletions

View file

@ -1 +1 @@
0.12.1
0.12.2

View file

@ -5,6 +5,19 @@ 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.12.2] — 2024-08-28
### Added
- `PATCH /api/v1/settings` endpoint to update user settings with swagger docs
- `GET /api/v1/settings` endpoint to get user settings with swagger docs
- Missing `page` and `per_page` query parameters to the `GET /api/v1/points` endpoint swagger docs
### Changed
- Map settings moved to the map itself and are available in the top right corner of the map under the gear icon.
## [0.12.1] — 2024-08-25
### Fixed

File diff suppressed because one or more lines are too long

View file

@ -23,3 +23,37 @@
.timeline-box {
overflow: visible !important;
}
/* Style for the settings panel */
.leaflet-settings-panel {
background-color: white;
padding: 10px;
border: 1px solid #ccc;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.3);
}
.leaflet-settings-panel label {
display: block;
margin-bottom: 5px;
}
.leaflet-settings-panel input {
width: 100%;
margin-bottom: 10px;
padding: 5px;
border: 1px solid #ccc;
border-radius: 3px;
}
.leaflet-settings-panel button {
padding: 5px 10px;
background-color: #007bff;
color: white;
border: none;
border-radius: 3px;
cursor: pointer;
}
.leaflet-settings-panel button:hover {
background-color: #0056b3;
}

View file

@ -0,0 +1,36 @@
# frozen_string_literal: true
class Api::V1::SettingsController < ApiController
def index
render json: {
settings: current_api_user.settings,
status: 'success'
}, status: :ok
end
def update
settings_params.each { |key, value| current_api_user.settings[key] = value }
if current_api_user.save
render json: {
message: 'Settings updated',
settings: current_api_user.settings,
status: 'success'
}, status: :ok
else
render json: {
message: 'Something went wrong',
errors: current_api_user.errors.full_messages
}, status: :unprocessable_entity
end
end
private
def settings_params
params.require(:settings).permit(
:meters_between_routes, :minutes_between_routes, :fog_of_war_meters,
:time_threshold_minutes, :merge_threshold_minutes, :route_opacity
)
end
end

View file

@ -13,14 +13,18 @@ import "leaflet-draw";
export default class extends Controller {
static targets = ["container"];
settingsButtonAdded = false;
layerControl = null;
connect() {
console.log("Map controller connected");
this.apiKey = this.element.dataset.api_key;
this.markers = JSON.parse(this.element.dataset.coordinates);
this.timezone = this.element.dataset.timezone;
this.clearFogRadius = this.element.dataset.fog_of_war_meters;
this.routeOpacity = parseInt(this.element.dataset.route_opacity) / 100 || 0.6;
this.userSettings = JSON.parse(this.element.dataset.user_settings);
this.clearFogRadius = parseInt(this.userSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(this.userSettings.route_opacity) || 0.6;
this.center = this.markers[this.markers.length - 1] || [52.514568, 13.350111];
@ -35,6 +39,10 @@ export default class extends Controller {
this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
if (!this.settingsButtonAdded) {
this.addSettingsButton();
}
const controlsLayer = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
@ -52,7 +60,7 @@ export default class extends Controller {
})
.addTo(this.map);
L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Fetch and draw areas when the map is loaded
this.fetchAndDrawAreas(this.apiKey);
@ -318,8 +326,8 @@ export default class extends Controller {
createPolylinesLayer(markers, map, timezone, routeOpacity) {
const splitPolylines = [];
let currentPolyline = [];
const distanceThresholdMeters = parseInt(this.element.dataset.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(this.element.dataset.minutes_between_routes) || 60;
const distanceThresholdMeters = parseInt(this.userSettings.meters_between_routes) || 500;
const timeThresholdMinutes = parseInt(this.userSettings.minutes_between_routes) || 60;
for (let i = 0, len = markers.length; i < len; i++) {
if (currentPolyline.length === 0) {
@ -459,7 +467,6 @@ export default class extends Controller {
return response.json();
})
.then(data => {
console.log('Circle saved:', data);
layer.closePopup();
layer.bindPopup(`
Name: ${data.name}<br>
@ -515,12 +522,7 @@ export default class extends Controller {
return response.json();
})
.then(data => {
console.log('Fetched areas:', data); // Debugging line to check response
data.forEach(area => {
// Log each area to verify the structure
console.log('Area:', area);
// Check if necessary fields are present
if (area.latitude && area.longitude && area.radius && area.name && area.id) {
const layer = L.circle([area.latitude, area.longitude], {
@ -535,7 +537,6 @@ export default class extends Controller {
`);
this.areasLayer.addLayer(layer); // Add to areas layer group
console.log('Added layer to areasLayer:', layer); // Debugging line to confirm addition
// Add event listener for the delete button
layer.on('popupopen', () => {
@ -555,4 +556,361 @@ export default class extends Controller {
console.error('There was a problem with the fetch request:', error);
});
}
addSettingsButton() {
if (this.settingsButtonAdded) return;
console.log('Adding settings button');
// Define the custom control
const SettingsControl = L.Control.extend({
onAdd: (map) => {
const button = L.DomUtil.create('button', 'map-settings-button');
button.innerHTML = '⚙️'; // Gear icon
// Style the button
button.style.backgroundColor = 'white';
button.style.width = '32px';
button.style.height = '32px';
button.style.border = 'none';
button.style.cursor = 'pointer';
button.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Disable map interactions when clicking the button
L.DomEvent.disableClickPropagation(button);
// Toggle settings menu on button click
L.DomEvent.on(button, 'click', () => {
this.toggleSettingsMenu();
});
return button;
}
});
// Add the control to the map
this.map.addControl(new SettingsControl({ position: 'topleft' }));
this.settingsButtonAdded = true;
}
toggleSettingsMenu() {
// If the settings panel already exists, just show/hide it
if (this.settingsPanel) {
if (this.settingsPanel._map) {
this.map.removeControl(this.settingsPanel);
} else {
this.map.addControl(this.settingsPanel);
}
return;
}
// Create the settings panel for the first time
this.settingsPanel = L.control({ position: 'topleft' });
this.settingsPanel.onAdd = () => {
const div = L.DomUtil.create('div', 'leaflet-settings-panel');
// Form HTML
div.innerHTML = `
<form id="settings-form" class="w-48">
<label for="route-opacity">Route Opacity</label>
<div class="join">
<input type="number" class="input input-ghost join-item focus:input-ghost input-xs input-bordered w-full max-w-xs" id="route-opacity" name="route_opacity" min="0" max="1" step="0.1" value="${this.routeOpacity}">
<label for="route_opacity_info" class="btn-xs join-item ">?</label>
</div>
<label for="fog_of_war_meters">Fog of War radius</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="fog_of_war_meters" name="fog_of_war_meters" min="5" max="100" step="1" value="${this.clearFogRadius}">
<label for="fog_of_war_meters_info" class="btn-xs join-item">?</label>
</div>
<label for="meters_between_routes">Meters between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="meters_between_routes" name="meters_between_routes" step="1" value="${this.userSettings.meters_between_routes}">
<label for="meters_between_routes_info" class="btn-xs join-item">?</label>
</div>
<label for="minutes_between_routes">Minutes between routes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="minutes_between_routes" name="minutes_between_routes" step="1" value="${this.userSettings.minutes_between_routes}">
<label for="minutes_between_routes_info" class="btn-xs join-item">?</label>
</div>
<label for="time_threshold_minutes">Time threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="time_threshold_minutes" name="time_threshold_minutes" step="1" value="${this.userSettings.time_threshold_minutes}">
<label for="time_threshold_minutes_info" class="btn-xs join-item">?</label>
</div>
<label for="merge_threshold_minutes">Merge threshold minutes</label>
<div class="join">
<input type="number" class="join-item input input-ghost focus:input-ghost input-xs input-bordered w-full max-w-xs" id="merge_threshold_minutes" name="merge_threshold_minutes" step="1" value="${this.userSettings.merge_threshold_minutes}">
<label for="merge_threshold_minutes_info" class="btn-xs join-item">?</label>
</div>
<button type="submit">Update</button>
</form>
`;
// Style the panel
div.style.backgroundColor = 'white';
div.style.padding = '10px';
div.style.border = '1px solid #ccc';
div.style.boxShadow = '0 1px 4px rgba(0,0,0,0.3)';
// Prevent map interactions when interacting with the form
L.DomEvent.disableClickPropagation(div);
// Add event listener to the form submission
div.querySelector('#settings-form').addEventListener(
'submit', this.updateSettings.bind(this)
);
return div;
};
this.map.addControl(this.settingsPanel);
}
updateSettings(event) {
event.preventDefault();
fetch(`/api/v1/settings?api_key=${this.apiKey}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
route_opacity: event.target.route_opacity.value,
fog_of_war_meters: event.target.fog_of_war_meters.value,
meters_between_routes: event.target.meters_between_routes.value,
minutes_between_routes: event.target.minutes_between_routes.value,
time_threshold_minutes: event.target.time_threshold_minutes.value,
merge_threshold_minutes: event.target.merge_threshold_minutes.value,
},
}),
})
.then((response) => response.json())
.then((data) => {
if (data.status === 'success') {
this.showFlashMessage('notice', data.message);
this.updateMapWithNewSettings(data.settings);
} else {
this.showFlashMessage('error', data.message);
}
});
}
showFlashMessage(type, message) {
// Create the outer flash container div
const flashDiv = document.createElement('div');
flashDiv.setAttribute('data-controller', 'removals');
flashDiv.className = `flex items-center fixed top-5 right-5 ${this.classesForFlash(type)} py-3 px-5 rounded-lg`;
// Create the message div
const messageDiv = document.createElement('div');
messageDiv.className = 'mr-4';
messageDiv.innerText = message;
// Create the close button
const closeButton = document.createElement('button');
closeButton.setAttribute('type', 'button');
closeButton.setAttribute('data-action', 'click->removals#remove');
// Create the SVG icon for the close button
const closeIcon = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
closeIcon.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
closeIcon.setAttribute('class', 'h-6 w-6');
closeIcon.setAttribute('fill', 'none');
closeIcon.setAttribute('viewBox', '0 0 24 24');
closeIcon.setAttribute('stroke', 'currentColor');
const closeIconPath = document.createElementNS('http://www.w3.org/2000/svg', 'path');
closeIconPath.setAttribute('stroke-linecap', 'round');
closeIconPath.setAttribute('stroke-linejoin', 'round');
closeIconPath.setAttribute('stroke-width', '2');
closeIconPath.setAttribute('d', 'M6 18L18 6M6 6l12 12');
// Append the path to the SVG
closeIcon.appendChild(closeIconPath);
// Append the SVG to the close button
closeButton.appendChild(closeIcon);
// Append the message and close button to the flash div
flashDiv.appendChild(messageDiv);
flashDiv.appendChild(closeButton);
// Append the flash message to the body or a specific flash container
document.body.appendChild(flashDiv);
// Optional: Automatically remove the flash message after 5 seconds
setTimeout(() => {
flashDiv.remove();
}, 5000);
}
// Helper function to get flash classes based on type
classesForFlash(type) {
switch (type) {
case 'error':
return 'bg-red-100 text-red-700 border-red-300';
case 'notice':
return 'bg-blue-100 text-blue-700 border-blue-300';
default:
return 'bg-blue-100 text-blue-700 border-blue-300';
}
}
updateMapWithNewSettings(newSettings) {
const currentLayerStates = this.getLayerControlStates();
// Update local state with new settings
this.clearFogRadius = parseInt(newSettings.fog_of_war_meters) || 50;
this.routeOpacity = parseFloat(newSettings.route_opacity) || 0.6;
// Preserve existing layer instances if they exist
const preserveLayers = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
// Clear all layers except base layers
this.map.eachLayer((layer) => {
if (!(layer instanceof L.TileLayer)) {
this.map.removeLayer(layer);
}
});
// Recreate layers only if they don't exist
this.markersLayer = preserveLayers.Points || L.layerGroup(this.createMarkersArray(this.markers));
this.polylinesLayer = preserveLayers.Polylines || this.createPolylinesLayer(this.markers, this.map, this.timezone, this.routeOpacity);
this.heatmapLayer = preserveLayers.Heatmap || L.heatLayer(this.markers.map((element) => [element[0], element[1], 0.2]), { radius: 20 });
this.fogOverlay = preserveLayers["Fog of War"] || L.layerGroup();
this.areasLayer = preserveLayers.Areas || L.layerGroup();
// Redraw areas
this.fetchAndDrawAreas(this.apiKey);
let fogEnabled = false;
document.getElementById('fog').style.display = 'none';
this.map.on('overlayadd', (e) => {
if (e.name === 'Fog of War') {
fogEnabled = true;
document.getElementById('fog').style.display = 'block';
this.updateFog(this.markers, this.clearFogRadius);
}
});
this.map.on('overlayremove', (e) => {
if (e.name === 'Fog of War') {
fogEnabled = false;
document.getElementById('fog').style.display = 'none';
}
});
this.map.on('zoomend moveend', () => {
if (fogEnabled) {
this.updateFog(this.markers, this.clearFogRadius);
}
});
this.addLastMarker(this.map, this.markers);
this.addEventListeners();
this.initializeDrawControl();
this.updatePolylinesOpacity(this.routeOpacity);
this.map.on('overlayadd', (e) => {
if (e.name === 'Areas') {
this.map.addControl(this.drawControl);
}
});
this.map.on('overlayremove', (e) => {
if (e.name === 'Areas') {
this.map.removeControl(this.drawControl);
}
});
this.applyLayerControlStates(currentLayerStates);
}
getLayerControlStates() {
const controls = {};
this.map.eachLayer((layer) => {
const layerName = this.getLayerName(layer);
if (layerName) {
controls[layerName] = this.map.hasLayer(layer);
}
});
return controls;
}
getLayerName(layer) {
const controlLayers = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
for (const [name, val] of Object.entries(controlLayers)) {
if (val && val.hasLayer && layer && val.hasLayer(layer)) // Check if the group layer contains the current layer
return name;
}
// Direct instance matching
for (const [name, val] of Object.entries(controlLayers)) {
if (val === layer) return name;
}
return undefined; // Indicate no matching layer name found
}
applyLayerControlStates(states) {
const layerControl = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
for (const [name, isVisible] of Object.entries(states)) {
const layer = layerControl[name];
if (isVisible && !this.map.hasLayer(layer)) {
this.map.addLayer(layer);
} else if (this.map.hasLayer(layer)) {
this.map.removeLayer(layer);
}
}
// Ensure the layer control reflects the current state
this.map.removeControl(this.layerControl);
this.layerControl = L.control.layers(this.baseMaps(), layerControl).addTo(this.map);
}
updatePolylinesOpacity(opacity) {
this.polylinesLayer.eachLayer((layer) => {
if (layer instanceof L.Polyline) {
layer.setStyle({ opacity: opacity });
}
});
}
}

View file

@ -0,0 +1,97 @@
<!-- Put this part before </body> tag -->
<input type="checkbox" id="route_opacity_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Route opacity</h3>
<p class="py-4">
Value in percent.
</p>
<p class="py-4">
This value is the opacity of the route on the map. The value is in percent, and it can be set from 0 to 100. The default value is 100, which means that the route is fully visible. If you set the value to 0, the route will be invisible.
</p>
</div>
<label class="modal-backdrop" for="route_opacity_info">Close</label>
</div>
<input type="checkbox" id="fog_of_war_meters_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Fog of War</h3>
<p class="py-4">
Value in meters.
</p>
<p class="py-4">
Here you can set the radius of the "cleared" area around a point when Fog of War mode is enabled. The area around the point will be cleared, and the rest of the map will be covered with fog. The cleared area will be a circle with the point as the center and the radius as the value you set here.
</p>
</div>
<label class="modal-backdrop" for="fog_of_war_meters_info">Close</label>
</div>
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Meters between routes</h3>
<p class="py-4">
Value in meters.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum distance between two points to be connected by a line. If the distance between two points is greater than this value, they will not be connected, and the line will not be drawn. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far from each other.
</p>
</div>
<label class="modal-backdrop" for="meters_between_routes_info">Close</label>
</div>
<input type="checkbox" id="minutes_between_routes_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Minutes between routes</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far in time from each other.
</p>
</div>
<label class="modal-backdrop" for="minutes_between_routes_info">Close</label>
</div>
<input type="checkbox" id="time_threshold_minutes_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Visit time threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which a visit is calculated. If the time between two consequent points is greater than this value, the visit is considered a new visit. If the time between two points is less than this value, the visit is considered as a continuation of the previous visit.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have four points with a time difference of 20 minutes between them, they will be considered as one visit. If the time difference between two first points is 20 minutes, and between third and fourth point is 40 minutes, the visit will be split into two visits.
</p>
<p class="py-4">
Default value is 30 minutes.
</p>
</div>
<label class="modal-backdrop" for="time_threshold_minutes_info">Close</label>
</div>
<input type="checkbox" id="merge_threshold_minutes_info" class="modal-toggle" />
<div class="modal focus:z-99" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Merge threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which two visits are merged into one. If the time between two consequent visits is less than this value, the visits are merged into one visit. If the time between two visits is greater than this value, the visits are considered as separate visits.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have two visits with a time difference of 20 minutes between them, they will be merged into one visit. If the time difference between two visits is 40 minutes, the visits will be considered as separate visits.
</p>
<p class="py-4">
Default value is 15 minutes.
</p>
</div>
<label class="modal-backdrop" for="merge_threshold_minutes_info">Close</label>
</div>

View file

@ -42,13 +42,10 @@
<div
class="w-full"
data-api_key="<%= current_user.api_key %>"
data-route_opacity="<%= current_user.settings['route_opacity'] %>"
data-user_settings=<%= current_user.settings.to_json %>
data-controller="maps"
data-coordinates="<%= @coordinates %>"
data-timezone="<%= Rails.configuration.time_zone %>"
data-meters_between_routes="<%= current_user.settings['meters_between_routes'] %>"
data-minutes_between_routes="<%= current_user.settings['minutes_between_routes'] %>"
data-fog_of_war_meters="<%= current_user.settings['fog_of_war_meters'] %>">
data-timezone="<%= Rails.configuration.time_zone %>">
<div data-maps-target="container" class="h-[25rem] w-auto min-h-screen">
<div id="fog" class="fog"></div>
</div>
@ -60,4 +57,4 @@
<%= render 'shared/right_sidebar' %>
</div>
<%= render 'map/settings_modals' %>

View file

@ -7,163 +7,6 @@
<div class="card flex-shrink-0 w-full max-w-sm shadow-2xl bg-base-100 px-5 py-5 mx-5">
<h2 class="text-2xl font-bold">Edit your Dawarich settings!</h1>
<%= form_for :settings, url: settings_path, method: :patch, data: { turbo_method: :patch, turbo: false } do |f| %>
<div class="form-control my-2">
<%= f.label :meters_between_routes do %>
Meters between routes
<!-- The button to open modal -->
<label for="meters_between_routes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="meters_between_routes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Meters between routes</h3>
<p class="py-4">
Value in meters.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum distance between two points to be connected by a line. If the distance between two points is greater than this value, they will not be connected, and the line will not be drawn. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far from each other.
</p>
</div>
<label class="modal-backdrop" for="meters_between_routes_info">Close</label>
</div>
<% end %>
<%= f.number_field :meters_between_routes, value: current_user.settings['meters_between_routes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :minutes_between_routes do %>
Minutes between routes
<!-- The button to open modal -->
<label for="minutes_between_routes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="minutes_between_routes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Minutes between routes</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
Points on the map are connected by lines. This value is the maximum time between two points to be connected by a line. If the time between two points is greater than this value, they will not be connected. This allows to split the route into smaller segments, and to avoid drawing lines between two points that are far in time from each other.
</p>
</div>
<label class="modal-backdrop" for="minutes_between_routes_info">Close</label>
</div>
<% end %>
<%= f.number_field :minutes_between_routes, value: current_user.settings['minutes_between_routes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :fog_of_war_meters do %>
Fog of War meters
<!-- The button to open modal -->
<label for="fog_of_war_meters_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="fog_of_war_meters_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Fog of War</h3>
<p class="py-4">
Value in meters.
</p>
<p class="py-4">
Here you can set the radius of the "cleared" area around a point when Fog of War mode is enabled. The area around the point will be cleared, and the rest of the map will be covered with fog. The cleared area will be a circle with the point as the center and the radius as the value you set here.
</p>
</div>
<label class="modal-backdrop" for="fog_of_war_meters_info">Close</label>
</div>
<% end %>
<%= f.number_field :fog_of_war_meters, value: current_user.settings['fog_of_war_meters'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :time_threshold_minutes do %>
Visit time threshold
<!-- The button to open modal -->
<label for="time_threshold_minutes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="time_threshold_minutes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Visit time threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which a visit is calculated. If the time between two consequent points is greater than this value, the visit is considered a new visit. If the time between two points is less than this value, the visit is considered as a continuation of the previous visit.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have four points with a time difference of 20 minutes between them, they will be considered as one visit. If the time difference between two first points is 20 minutes, and between third and fourth point is 40 minutes, the visit will be split into two visits.
</p>
<p class="py-4">
Default value is 30 minutes.
</p>
</div>
<label class="modal-backdrop" for="time_threshold_minutes_info">Close</label>
</div>
<% end %>
<%= f.number_field :time_threshold_minutes, value: current_user.settings['time_threshold_minutes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :merge_threshold_minutes do %>
Merge time threshold
<!-- The button to open modal -->
<label for="merge_threshold_minutes_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="merge_threshold_minutes_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Merge threshold</h3>
<p class="py-4">
Value in minutes.
</p>
<p class="py-4">
This value is the threshold, based on which two visits are merged into one. If the time between two consequent visits is less than this value, the visits are merged into one visit. If the time between two visits is greater than this value, the visits are considered as separate visits.
</p>
<p class="py-4">
For example, if you set this value to 30 minutes, and you have two visits with a time difference of 20 minutes between them, they will be merged into one visit. If the time difference between two visits is 40 minutes, the visits will be considered as separate visits.
</p>
<p class="py-4">
Default value is 15 minutes.
</p>
</div>
<label class="modal-backdrop" for="merge_threshold_minutes_info">Close</label>
</div>
<% end %>
<%= f.number_field :merge_threshold_minutes, value: current_user.settings['merge_threshold_minutes'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :route_opacity do %>
Route opacity percent
<!-- The button to open modal -->
<label for="route_opacity_info" class="btn">?</label>
<!-- Put this part before </body> tag -->
<input type="checkbox" id="route_opacity_info" class="modal-toggle" />
<div class="modal" role="dialog">
<div class="modal-box">
<h3 class="text-lg font-bold">Route opacity</h3>
<p class="py-4">
Value in percent.
</p>
<p class="py-4">
This value is the opacity of the route on the map. The value is in percent, and it can be set from 0 to 100. The default value is 100, which means that the route is fully visible. If you set the value to 0, the route will be invisible.
</p>
</div>
<label class="modal-backdrop" for="route_opacity_info">Close</label>
</div>
<% end %>
<%= f.number_field :route_opacity, value: current_user.settings['route_opacity'], class: "input input-bordered" %>
</div>
<div class="form-control my-2">
<%= f.label :immich_url %>
<%= f.text_field :immich_url, value: current_user.settings['immich_url'], class: "input input-bordered", placeholder: 'http://192.168.0.1:2283' %>

View file

@ -56,10 +56,13 @@ Rails.application.routes.draw do
namespace :api do
namespace :v1 do
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[index destroy]
resources :visits, only: %i[update]
resources :stats, only: :index
patch 'settings', to: 'settings#update'
get 'settings', to: 'settings#index'
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[index destroy]
resources :visits, only: %i[update]
resources :stats, only: :index
namespace :overland do
resources :batches, only: :create

View file

@ -8,6 +8,17 @@ FactoryBot.define do
password { SecureRandom.hex(8) }
settings do
{
route_opacity: '0.5',
meters_between_routes: '100',
minutes_between_routes: '100',
fog_of_war_meters: '100',
time_threshold_minutes: '100',
merge_threshold_minutes: '100'
}
end
trait :admin do
admin { true }
end

View file

@ -0,0 +1,48 @@
# frozen_string_literal: true
require 'rails_helper'
RSpec.describe 'Api::V1::Settings', type: :request do
let!(:user) { create(:user) }
let!(:api_key) { user.api_key }
describe 'PATCH /update' do
context 'with valid request' do
it 'returns http success' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 0.3 } }
expect(response).to have_http_status(:success)
end
it 'updates the settings' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 0.3 } }
expect(user.reload.settings['route_opacity'].to_f).to eq(0.3)
end
it 'returns the updated settings' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 0.3 } }
expect(response.parsed_body['settings']['route_opacity'].to_f).to eq(0.3)
end
end
context 'with invalid request' do
before do
allow_any_instance_of(User).to receive(:save).and_return(false)
end
it 'returns http unprocessable entity' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 'invalid' } }
expect(response).to have_http_status(:unprocessable_entity)
end
it 'returns an error message' do
patch "/api/v1/settings?api_key=#{api_key}", params: { settings: { route_opacity: 'invalid' } }
expect(response.parsed_body['message']).to eq('Something went wrong')
end
end
end
end

View file

@ -12,6 +12,8 @@ describe 'Points API', type: :request do
description: 'Start date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)'
parameter name: :end_at, in: :query, type: :string,
description: 'End date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)'
parameter name: :page, in: :query, type: :integer, required: false, description: 'Page number'
parameter name: :per_page, in: :query, type: :integer, required: false, description: 'Number of points per page'
response '200', 'points found' do
schema type: :array,
items: {

View file

@ -0,0 +1,72 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'Settings API', type: :request do
path '/api/v1/settings' do
patch 'Updates user settings' do
request_body_example value: {
'settings': {
'route_opacity': 0.3,
'meters_between_routes': 100,
'minutes_between_routes': 100,
'fog_of_war_meters': 100,
'time_threshold_minutes': 100,
'merge_threshold_minutes': 100
}
}
tags 'Settings'
consumes 'application/json'
parameter name: :settings, in: :body, schema: {
type: :object,
properties: {
route_opacity: { type: :number },
meters_between_routes: { type: :number },
minutes_between_routes: { type: :number },
fog_of_war_meters: { type: :number },
time_threshold_minutes: { type: :number },
merge_threshold_minutes: { type: :number }
},
optional: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes]
}
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'settings updated' do
let(:settings) { { settings: { route_opacity: 0.3 } } }
let(:api_key) { create(:user).api_key }
run_test!
end
end
get 'Retrieves user settings' do
tags 'Settings'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
response '200', 'settings found' do
schema type: :object,
properties: {
settings: {
type: :object,
properties: {
route_opacity: { type: :string },
meters_between_routes: { type: :string },
minutes_between_routes: { type: :string },
fog_of_war_meters: { type: :string },
time_threshold_minutes: { type: :string },
merge_threshold_minutes: { type: :string }
},
required: %w[route_opacity meters_between_routes minutes_between_routes fog_of_war_meters
time_threshold_minutes merge_threshold_minutes]
}
}
let(:user) { create(:user) }
let(:settings) { { settings: user.settings } }
let(:api_key) { user.api_key }
run_test!
end
end
end
end

View file

@ -326,6 +326,18 @@ paths:
description: End date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)
schema:
type: string
- name: page
in: query
required: false
description: Page number
schema:
type: integer
- name: per_page
in: query
required: false
description: Number of points per page
schema:
type: integer
responses:
'200':
description: points found
@ -415,6 +427,98 @@ paths:
responses:
'200':
description: point deleted
"/api/v1/settings":
patch:
summary: Updates user settings
tags:
- Settings
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: settings updated
requestBody:
content:
application/json:
schema:
type: object
properties:
route_opacity:
type: number
meters_between_routes:
type: number
minutes_between_routes:
type: number
fog_of_war_meters:
type: number
time_threshold_minutes:
type: number
merge_threshold_minutes:
type: number
optional:
- route_opacity
- meters_between_routes
- minutes_between_routes
- fog_of_war_meters
- time_threshold_minutes
- merge_threshold_minutes
examples:
'0':
summary: Updates user settings
value:
settings:
route_opacity: 0.3
meters_between_routes: 100
minutes_between_routes: 100
fog_of_war_meters: 100
time_threshold_minutes: 100
merge_threshold_minutes: 100
get:
summary: Retrieves user settings
tags:
- Settings
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
responses:
'200':
description: settings found
content:
application/json:
schema:
type: object
properties:
settings:
type: object
properties:
route_opacity:
type: string
meters_between_routes:
type: string
minutes_between_routes:
type: string
fog_of_war_meters:
type: string
time_threshold_minutes:
type: string
merge_threshold_minutes:
type: string
required:
- route_opacity
- meters_between_routes
- minutes_between_routes
- fog_of_war_meters
- time_threshold_minutes
- merge_threshold_minutes
"/api/v1/stats":
get:
summary: Retrieves all stats