Move map settings to the map itself

This commit is contained in:
Eugene Burmakin 2024-08-28 20:24:35 +02:00
parent d11cfd864f
commit df588d1e07
15 changed files with 787 additions and 357 deletions

View file

@ -1 +1 @@
0.12.1
0.12.2

View file

@ -5,6 +5,17 @@ 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
### 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

@ -19,8 +19,9 @@ export default class extends Controller {
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];
@ -103,6 +104,8 @@ export default class extends Controller {
this.map.removeControl(this.drawControl);
}
});
this.addSettingsButton();
}
disconnect() {
@ -318,8 +321,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) {
@ -515,12 +518,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 +533,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 +552,385 @@ export default class extends Controller {
console.error('There was a problem with the fetch request:', error);
});
}
addSettingsButton() {
// 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 = '48px';
button.style.height = '48px';
button.style.border = 'none';
button.style.borderRadius = '50%';
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;
},
onRemove: (map) => {
// hide settings menu
}
});
// Add the control to the map
this.map.addControl(new SettingsControl({ position: 'topright' }));
}
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: 'topright' });
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();
const controlsLayer = {
Points: this.markersLayer,
Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay,
Areas: this.areasLayer,
};
// Remove old control and add the new one
if (this.layerControl) {
this.map.removeControl(this.layerControl);
}
this.layerControl = L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// 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.addSettingsButton();
this.applyLayerControlStates(currentLayerStates);
}
getLayerControlStates() {
const controls = {};
this.map.eachLayer((layer) => {
const layerName = this.getLayerName(layer);
console.log('Layer name:', layerName, 'Layer details:', layer);
if (layerName) {
controls[layerName] = this.map.hasLayer(layer);
}
});
console.log('Current layer states:', controls);
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];
console.log(`Applying layer state: ${name}, visible: ${isVisible}`);
if (isVisible) {
if (!this.map.hasLayer(layer)) {
console.log(`Adding layer: ${name}`);
this.map.addLayer(layer);
}
} else {
if (this.map.hasLayer(layer)) {
console.log(`Removing layer: ${name}`);
this.map.removeLayer(layer);
}
}
}
// Ensure the layer control reflects the current state
this.layerControl.remove();
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

@ -1,81 +0,0 @@
# frozen_string_literal: true
require 'swagger_helper'
describe 'Points API', type: :request do
path '/api/v1/points' do
get 'Retrieves all points' do
tags 'Points'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
parameter name: :start_at, in: :query, type: :string,
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)'
response '200', 'points found' do
schema type: :array,
items: {
type: :object,
properties: {
id: { type: :integer },
battery_status: { type: :number },
ping: { type: :number },
battery: { type: :number },
tracker_id: { type: :string },
topic: { type: :string },
altitude: { type: :number },
longitude: { type: :number },
velocity: { type: :number },
trigger: { type: :string },
bssid: { type: :string },
ssid: { type: :string },
connection: { type: :string },
vertical_accuracy: { type: :number },
accuracy: { type: :number },
timestamp: { type: :number },
latitude: { type: :number },
mode: { type: :number },
inrids: { type: :array },
in_regions: { type: :array },
raw_data: { type: :string },
import_id: { type: :string },
city: { type: :string },
country: { type: :string },
created_at: { type: :string },
updated_at: { type: :string },
user_id: { type: :integer },
geodata: { type: :string },
visit_id: { type: :string }
}
}
let(:user) { create(:user) }
let(:areas) { create_list(:area, 3, user:) }
let(:api_key) { user.api_key }
let(:start_at) { Time.zone.now - 1.day }
let(:end_at) { Time.zone.now }
let(:points) { create_list(:point, 10, user:, timestamp: 2.hours.ago) }
run_test!
end
end
end
path '/api/v1/points/{id}' do
delete 'Deletes a point' do
tags 'Points'
produces 'application/json'
parameter name: :api_key, in: :query, type: :string, required: true, description: 'API Key'
parameter name: :id, in: :path, type: :string, required: true, description: 'Point ID'
response '200', 'point deleted' do
let(:user) { create(:user) }
let(:point) { create(:point, user:) }
let(:api_key) { user.api_key }
let(:id) { point.id }
run_test!
end
end
end
end

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

@ -304,11 +304,11 @@ paths:
isorcv: '2024-02-03T13:00:03Z'
isotst: '2024-02-03T13:00:03Z'
disptst: '2024-02-03 13:00:03'
"/api/v1/points":
get:
summary: Retrieves all points
"/api/v1/settings":
patch:
summary: Updates user settings
tags:
- Points
- Settings
parameters:
- name: api_key
in: query
@ -316,105 +316,86 @@ paths:
description: API Key
schema:
type: string
- name: start_at
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
description: Start date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)
schema:
type: string
- name: end_at
in: query
description: End date (i.e. 2024-02-03T13:00:03Z or 2024-02-03)
required: true
description: API Key
schema:
type: string
responses:
'200':
description: points found
description: settings found
content:
application/json:
schema:
type: array
items:
type: object
properties:
id:
type: integer
battery_status:
type: number
ping:
type: number
battery:
type: number
tracker_id:
type: string
topic:
type: string
altitude:
type: number
longitude:
type: number
velocity:
type: number
trigger:
type: string
bssid:
type: string
ssid:
type: string
connection:
type: string
vertical_accuracy:
type: number
accuracy:
type: number
timestamp:
type: number
latitude:
type: number
mode:
type: number
inrids:
type: array
in_regions:
type: array
raw_data:
type: string
import_id:
type: string
city:
type: string
country:
type: string
created_at:
type: string
updated_at:
type: string
user_id:
type: integer
geodata:
type: string
visit_id:
type: string
"/api/v1/points/{id}":
delete:
summary: Deletes a point
tags:
- Points
parameters:
- name: api_key
in: query
required: true
description: API Key
schema:
type: string
- name: id
in: path
required: true
description: Point ID
schema:
type: string
responses:
'200':
description: point deleted
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