mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-10 17:21:38 -05:00
Add areas
This commit is contained in:
parent
bd8517709b
commit
151e5cf042
18 changed files with 445 additions and 12 deletions
|
|
@ -1 +1 @@
|
|||
0.9.4
|
||||
0.9.5
|
||||
|
|
|
|||
|
|
@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
|||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
|
||||
|
||||
## [0.9.5] — 2024-07-22
|
||||
|
||||
### Added
|
||||
|
||||
- A possibility to create areas. To create an area, click on the Areas checkbox in map controls (top right corner of the map), then in the top left corner of the map, click on a small circle icon. This will enable draw tool, allowing you to draw an area. When you finish drawing, release the mouse button, and the area will be created. Click on the area, set the name and click "Save" to save the area. You can also delete the area by clicking on the trash icon in the area popup.
|
||||
|
||||
---
|
||||
|
||||
## [0.9.4] — 2024-07-21
|
||||
|
||||
### Added
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
46
app/controllers/api/v1/areas_controller.rb
Normal file
46
app/controllers/api/v1/areas_controller.rb
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AreasController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
before_action :set_area, only: %i[update destroy]
|
||||
|
||||
def index
|
||||
@areas = current_user.areas
|
||||
|
||||
render json: @areas, status: :ok
|
||||
end
|
||||
|
||||
def create
|
||||
@area = current_user.areas.build(area_params)
|
||||
|
||||
if @area.save
|
||||
render json: @area, status: :created
|
||||
else
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update
|
||||
if @area.update(area_params)
|
||||
render json: @area, status: :ok
|
||||
else
|
||||
render json: { errors: @area.errors.full_messages }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@area.destroy!
|
||||
|
||||
render json: { message: 'Area was successfully deleted' }, status: :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_area
|
||||
@area = current_user.areas.find(params[:id])
|
||||
end
|
||||
|
||||
def area_params
|
||||
params.require(:area).permit(:name, :latitude, :longitude, :radius)
|
||||
end
|
||||
end
|
||||
|
|
@ -9,6 +9,7 @@ import { haversineDistance } from "../maps/helpers";
|
|||
import { osmMapLayer } from "../maps/layers";
|
||||
import { osmHotMapLayer } from "../maps/layers";
|
||||
import { addTileLayer } from "../maps/layers";
|
||||
import "leaflet-draw";
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["container"];
|
||||
|
|
@ -33,25 +34,21 @@ export default class extends Controller {
|
|||
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone);
|
||||
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
|
||||
this.fogOverlay = L.layerGroup(); // Initialize fog layer
|
||||
this.areasLayer = L.layerGroup(); // Initialize areas layer
|
||||
|
||||
const controlsLayer = {
|
||||
Points: this.markersLayer,
|
||||
Polylines: this.polylinesLayer,
|
||||
Heatmap: this.heatmapLayer,
|
||||
"Fog of War": this.fogOverlay,
|
||||
Areas: this.areasLayer // Add the areas layer to the controls
|
||||
};
|
||||
|
||||
L.control
|
||||
.scale({
|
||||
position: "bottomright",
|
||||
metric: true,
|
||||
imperial: false,
|
||||
maxWidth: 120,
|
||||
})
|
||||
.addTo(this.map);
|
||||
|
||||
L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
|
||||
|
||||
// Fetch and draw areas when the map is loaded
|
||||
this.fetchAndDrawAreas();
|
||||
|
||||
let fogEnabled = false;
|
||||
|
||||
// Hide fog by default
|
||||
|
|
@ -83,6 +80,22 @@ export default class extends Controller {
|
|||
addTileLayer(this.map);
|
||||
this.addLastMarker(this.map, this.markers);
|
||||
this.addEventListeners();
|
||||
|
||||
// Initialize Leaflet.draw
|
||||
this.initializeDrawControl();
|
||||
|
||||
// Add event listeners to toggle draw controls
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
|
|
@ -330,4 +343,193 @@ export default class extends Controller {
|
|||
})
|
||||
).addTo(map);
|
||||
}
|
||||
|
||||
initializeDrawControl() {
|
||||
// Initialize the FeatureGroup to store editable layers
|
||||
this.drawnItems = new L.FeatureGroup();
|
||||
this.map.addLayer(this.drawnItems);
|
||||
|
||||
// Initialize the draw control and pass it the FeatureGroup of editable layers
|
||||
this.drawControl = new L.Control.Draw({
|
||||
draw: {
|
||||
polyline: false,
|
||||
polygon: false,
|
||||
rectangle: false,
|
||||
marker: false,
|
||||
circlemarker: false,
|
||||
circle: {
|
||||
shapeOptions: {
|
||||
color: 'red',
|
||||
fillColor: '#f03',
|
||||
fillOpacity: 0.5,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Handle circle creation
|
||||
this.map.on(L.Draw.Event.CREATED, (event) => {
|
||||
const layer = event.layer;
|
||||
|
||||
if (event.layerType === 'circle') {
|
||||
this.handleCircleCreated(layer);
|
||||
}
|
||||
|
||||
this.drawnItems.addLayer(layer);
|
||||
});
|
||||
}
|
||||
|
||||
handleCircleCreated(layer) {
|
||||
const radius = layer.getRadius();
|
||||
const center = layer.getLatLng();
|
||||
|
||||
const formHtml = `
|
||||
<form id="circle-form">
|
||||
<label for="circle-name">Name:</label>
|
||||
<input type="text" id="circle-name" name="area[name]" required>
|
||||
<input type="hidden" name="area[latitude]" value="${center.lat}">
|
||||
<input type="hidden" name="area[longitude]" value="${center.lng}">
|
||||
<input type="hidden" name="area[radius]" value="${radius}">
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
`;
|
||||
|
||||
layer.bindPopup(formHtml).openPopup();
|
||||
|
||||
layer.on('popupopen', () => {
|
||||
const form = document.getElementById('circle-form');
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
this.saveCircle(new FormData(form), layer);
|
||||
});
|
||||
});
|
||||
|
||||
// Add the layer to the areas layer group
|
||||
this.areasLayer.addLayer(layer);
|
||||
}
|
||||
|
||||
saveCircle(formData, layer) {
|
||||
const data = {};
|
||||
formData.forEach((value, key) => {
|
||||
const keys = key.split('[').map(k => k.replace(']', ''));
|
||||
if (keys.length > 1) {
|
||||
if (!data[keys[0]]) data[keys[0]] = {};
|
||||
data[keys[0]][keys[1]] = value;
|
||||
} else {
|
||||
data[keys[0]] = value;
|
||||
}
|
||||
});
|
||||
|
||||
fetch('/api/v1/areas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Circle saved:', data);
|
||||
layer.closePopup();
|
||||
layer.bindPopup(`
|
||||
Name: ${data.name}<br>
|
||||
Radius: ${Math.round(data.radius)} meters<br>
|
||||
<a href="#" data-id="${marker[6]}" class="delete-area">[Delete]</a>
|
||||
`).openPopup();
|
||||
|
||||
// Add event listener for the delete button
|
||||
layer.on('popupopen', () => {
|
||||
document.querySelector('.delete-area').addEventListener('click', () => {
|
||||
this.deleteArea(data.id, layer);
|
||||
});
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('There was a problem with the save request:', error);
|
||||
});
|
||||
}
|
||||
|
||||
deleteArea(id, layer) {
|
||||
fetch(`/api/v1/areas/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]').getAttribute('content')
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Area deleted:', data);
|
||||
this.areasLayer.removeLayer(layer); // Remove the layer from the areas layer group
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('There was a problem with the delete request:', error);
|
||||
});
|
||||
}
|
||||
|
||||
fetchAndDrawAreas() {
|
||||
fetch('/api/v1/areas', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
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], {
|
||||
radius: area.radius,
|
||||
color: 'red',
|
||||
fillColor: '#f03',
|
||||
fillOpacity: 0.5
|
||||
}).bindPopup(`
|
||||
Name: ${area.name}<br>
|
||||
Radius: ${Math.round(area.radius)} meters<br>
|
||||
<a href="#" data-id="${area.id}" class="delete-area">[Delete]</a>
|
||||
`);
|
||||
|
||||
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', () => {
|
||||
document.querySelector('.delete-area').addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
if (confirm('Are you sure you want to delete this area?')) {
|
||||
this.deleteArea(area.id, layer);
|
||||
}
|
||||
});
|
||||
});
|
||||
} else {
|
||||
console.error('Area missing required fields:', area);
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('There was a problem with the fetch request:', error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
7
app/models/area.rb
Normal file
7
app/models/area.rb
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Area < ApplicationRecord
|
||||
belongs_to :user
|
||||
|
||||
validates :name, :latitude, :longitude, :radius, presence: true
|
||||
end
|
||||
|
|
@ -12,6 +12,7 @@ class User < ApplicationRecord
|
|||
has_many :tracked_points, class_name: 'Point', dependent: :destroy
|
||||
has_many :exports, dependent: :destroy
|
||||
has_many :notifications, dependent: :destroy
|
||||
has_many :areas, dependent: :destroy
|
||||
|
||||
after_create :create_api_key
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
|
||||
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css">
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet.draw/1.0.4/leaflet.draw.css"/>
|
||||
|
||||
|
||||
<%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
|
||||
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>
|
||||
|
|
|
|||
|
|
@ -16,3 +16,4 @@ pin 'leaflet-providers' # @2.0.0
|
|||
pin 'chartkick', to: 'chartkick.js'
|
||||
pin 'Chart.bundle', to: 'Chart.bundle.js'
|
||||
pin 'leaflet.heat' # @0.2.0
|
||||
pin "leaflet-draw" # @1.0.4
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ Rails.application.routes.draw do
|
|||
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :points, only: %i[destroy]
|
||||
|
||||
namespace :overland do
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreatePoints < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :points do |t|
|
||||
|
|
|
|||
15
db/migrate/20240721165313_create_areas.rb
Normal file
15
db/migrate/20240721165313_create_areas.rb
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class CreateAreas < ActiveRecord::Migration[7.1]
|
||||
def change
|
||||
create_table :areas do |t|
|
||||
t.string :name, null: false
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.decimal :longitude, precision: 10, scale: 6, null: false
|
||||
t.decimal :latitude, precision: 10, scale: 6, null: false
|
||||
t.integer :radius, null: false
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
14
db/schema.rb
generated
14
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
|
||||
ActiveRecord::Schema[7.1].define(version: 2024_07_21_165313) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "plpgsql"
|
||||
|
||||
|
|
@ -42,6 +42,17 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
|
|||
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
|
||||
end
|
||||
|
||||
create_table "areas", force: :cascade do |t|
|
||||
t.string "name", null: false
|
||||
t.bigint "user_id", null: false
|
||||
t.decimal "longitude", precision: 10, scale: 6, null: false
|
||||
t.decimal "latitude", precision: 10, scale: 6, null: false
|
||||
t.integer "radius", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.index ["user_id"], name: "index_areas_on_user_id"
|
||||
end
|
||||
|
||||
create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
|
||||
end
|
||||
|
||||
|
|
@ -157,6 +168,7 @@ ActiveRecord::Schema[7.1].define(version: 2024_07_13_103051) do
|
|||
|
||||
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
|
||||
add_foreign_key "areas", "users"
|
||||
add_foreign_key "notifications", "users"
|
||||
add_foreign_key "points", "users"
|
||||
add_foreign_key "stats", "users"
|
||||
|
|
|
|||
11
spec/factories/areas.rb
Normal file
11
spec/factories/areas.rb
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
FactoryBot.define do
|
||||
factory :area do
|
||||
name { 'Adlershof' }
|
||||
user
|
||||
latitude { 52.437 }
|
||||
longitude { 13.539 }
|
||||
radius { 100 }
|
||||
end
|
||||
end
|
||||
20
spec/models/area_spec.rb
Normal file
20
spec/models/area_spec.rb
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Area, type: :model do
|
||||
describe 'associations' do
|
||||
it { is_expected.to belong_to(:user) }
|
||||
end
|
||||
|
||||
describe 'validations' do
|
||||
it { is_expected.to validate_presence_of(:name) }
|
||||
it { is_expected.to validate_presence_of(:latitude) }
|
||||
it { is_expected.to validate_presence_of(:longitude) }
|
||||
it { is_expected.to validate_presence_of(:radius) }
|
||||
end
|
||||
|
||||
describe 'factory' do
|
||||
it { expect(build(:area)).to be_valid }
|
||||
end
|
||||
end
|
||||
|
|
@ -10,6 +10,7 @@ RSpec.describe User, type: :model do
|
|||
it { is_expected.to have_many(:tracked_points).class_name('Point').dependent(:destroy) }
|
||||
it { is_expected.to have_many(:exports).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:notifications).dependent(:destroy) }
|
||||
it { is_expected.to have_many(:areas).dependent(:destroy) }
|
||||
end
|
||||
|
||||
describe 'callbacks' do
|
||||
|
|
|
|||
102
spec/requests/api/v1/areas_spec.rb
Normal file
102
spec/requests/api/v1/areas_spec.rb
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe '/api/v1/areas', type: :request do
|
||||
let(:user) { create(:user) }
|
||||
|
||||
before { sign_in user }
|
||||
|
||||
describe 'GET /index' do
|
||||
it 'renders a successful response' do
|
||||
get api_v1_areas_url
|
||||
expect(response).to be_successful
|
||||
end
|
||||
end
|
||||
|
||||
describe 'POST /create' do
|
||||
context 'with valid parameters' do
|
||||
let(:valid_attributes) do
|
||||
attributes_for(:area).merge(user_id: user.id)
|
||||
end
|
||||
|
||||
it 'creates a new Area' do
|
||||
expect do
|
||||
post api_v1_areas_url, params: { area: valid_attributes }
|
||||
end.to change(Area, :count).by(1)
|
||||
end
|
||||
|
||||
it 'redirects to the created api_v1_area' do
|
||||
post api_v1_areas_url, params: { area: valid_attributes }
|
||||
|
||||
expect(response).to have_http_status(:created)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:invalid_attributes) do
|
||||
attributes_for(:area, name: nil).merge(user_id: user.id)
|
||||
end
|
||||
|
||||
it 'does not create a new Area' do
|
||||
expect do
|
||||
post api_v1_areas_url, params: { area: invalid_attributes }
|
||||
end.to change(Area, :count).by(0)
|
||||
end
|
||||
|
||||
it 'renders a response with 422 status' do
|
||||
post api_v1_areas_url, params: { area: invalid_attributes }
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'PATCH /update' do
|
||||
context 'with valid parameters' do
|
||||
let(:area) { create(:area, user:) }
|
||||
|
||||
let(:new_attributes) { attributes_for(:area).merge(name: 'New Name') }
|
||||
|
||||
it 'updates the requested api_v1_area' do
|
||||
patch api_v1_area_url(area), params: { area: new_attributes }
|
||||
area.reload
|
||||
|
||||
expect(area.reload.name).to eq('New Name')
|
||||
end
|
||||
|
||||
it 'redirects to the api_v1_area' do
|
||||
patch api_v1_area_url(area), params: { area: new_attributes }
|
||||
area.reload
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with invalid parameters' do
|
||||
let(:area) { create(:area, user:) }
|
||||
let(:invalid_attributes) { attributes_for(:area, name: nil) }
|
||||
|
||||
it 'renders a response with 422 status' do
|
||||
patch api_v1_area_url(area), params: { area: invalid_attributes }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe 'DELETE /destroy' do
|
||||
let!(:area) { create(:area, user:) }
|
||||
|
||||
it 'destroys the requested api_v1_area' do
|
||||
expect do
|
||||
delete api_v1_area_url(area)
|
||||
end.to change(Area, :count).by(-1)
|
||||
end
|
||||
|
||||
it 'redirects to the api_v1_areas list' do
|
||||
delete api_v1_area_url(area)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
2
vendor/javascript/leaflet-draw.js
vendored
Normal file
2
vendor/javascript/leaflet-draw.js
vendored
Normal file
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue