Add areas

This commit is contained in:
Eugene Burmakin 2024-07-21 20:09:42 +02:00
parent bd8517709b
commit 151e5cf042
18 changed files with 445 additions and 12 deletions

View file

@ -1 +1 @@
0.9.4 0.9.5

View file

@ -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/). 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 ## [0.9.4] — 2024-07-21
### Added ### Added

File diff suppressed because one or more lines are too long

View 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

View file

@ -9,6 +9,7 @@ import { haversineDistance } from "../maps/helpers";
import { osmMapLayer } from "../maps/layers"; import { osmMapLayer } from "../maps/layers";
import { osmHotMapLayer } from "../maps/layers"; import { osmHotMapLayer } from "../maps/layers";
import { addTileLayer } from "../maps/layers"; import { addTileLayer } from "../maps/layers";
import "leaflet-draw";
export default class extends Controller { export default class extends Controller {
static targets = ["container"]; static targets = ["container"];
@ -33,25 +34,21 @@ export default class extends Controller {
this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone); this.polylinesLayer = this.createPolylinesLayer(this.markers, this.map, this.timezone);
this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map); this.heatmapLayer = L.heatLayer(this.heatmapMarkers, { radius: 20 }).addTo(this.map);
this.fogOverlay = L.layerGroup(); // Initialize fog layer this.fogOverlay = L.layerGroup(); // Initialize fog layer
this.areasLayer = L.layerGroup(); // Initialize areas layer
const controlsLayer = { const controlsLayer = {
Points: this.markersLayer, Points: this.markersLayer,
Polylines: this.polylinesLayer, Polylines: this.polylinesLayer,
Heatmap: this.heatmapLayer, Heatmap: this.heatmapLayer,
"Fog of War": this.fogOverlay, "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); L.control.layers(this.baseMaps(), controlsLayer).addTo(this.map);
// Fetch and draw areas when the map is loaded
this.fetchAndDrawAreas();
let fogEnabled = false; let fogEnabled = false;
// Hide fog by default // Hide fog by default
@ -83,6 +80,22 @@ export default class extends Controller {
addTileLayer(this.map); addTileLayer(this.map);
this.addLastMarker(this.map, this.markers); this.addLastMarker(this.map, this.markers);
this.addEventListeners(); 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() { disconnect() {
@ -330,4 +343,193 @@ export default class extends Controller {
}) })
).addTo(map); ).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
View file

@ -0,0 +1,7 @@
# frozen_string_literal: true
class Area < ApplicationRecord
belongs_to :user
validates :name, :latitude, :longitude, :radius, presence: true
end

View file

@ -12,6 +12,7 @@ class User < ApplicationRecord
has_many :tracked_points, class_name: 'Point', dependent: :destroy has_many :tracked_points, class_name: 'Point', dependent: :destroy
has_many :exports, dependent: :destroy has_many :exports, dependent: :destroy
has_many :notifications, dependent: :destroy has_many :notifications, dependent: :destroy
has_many :areas, dependent: :destroy
after_create :create_api_key after_create :create_api_key

View file

@ -8,6 +8,8 @@
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.12.10/dist/full.css" rel="stylesheet" type="text/css"> <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://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 "tailwind", "inter-font", "data-turbo-track": "reload" %>
<%= stylesheet_link_tag "application", "data-turbo-track": "reload" %> <%= stylesheet_link_tag "application", "data-turbo-track": "reload" %>

View file

@ -16,3 +16,4 @@ pin 'leaflet-providers' # @2.0.0
pin 'chartkick', to: 'chartkick.js' pin 'chartkick', to: 'chartkick.js'
pin 'Chart.bundle', to: 'Chart.bundle.js' pin 'Chart.bundle', to: 'Chart.bundle.js'
pin 'leaflet.heat' # @0.2.0 pin 'leaflet.heat' # @0.2.0
pin "leaflet-draw" # @1.0.4

View file

@ -54,6 +54,7 @@ Rails.application.routes.draw do
namespace :api do namespace :api do
namespace :v1 do namespace :v1 do
resources :areas, only: %i[index create update destroy]
resources :points, only: %i[destroy] resources :points, only: %i[destroy]
namespace :overland do namespace :overland do

View file

@ -1,3 +1,5 @@
# frozen_string_literal: true
class CreatePoints < ActiveRecord::Migration[7.1] class CreatePoints < ActiveRecord::Migration[7.1]
def change def change
create_table :points do |t| create_table :points do |t|

View 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
View file

@ -10,7 +10,7 @@
# #
# It's strongly recommended that you check this file into your version control system. # 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 # These are extensions that must be enabled in order to support this database
enable_extension "plpgsql" 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 t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end 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| create_table "data_migrations", primary_key: "version", id: :string, force: :cascade do |t|
end 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_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "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 "notifications", "users"
add_foreign_key "points", "users" add_foreign_key "points", "users"
add_foreign_key "stats", "users" add_foreign_key "stats", "users"

11
spec/factories/areas.rb Normal file
View 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
View 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

View file

@ -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(:tracked_points).class_name('Point').dependent(:destroy) }
it { is_expected.to have_many(:exports).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(:notifications).dependent(:destroy) }
it { is_expected.to have_many(:areas).dependent(:destroy) }
end end
describe 'callbacks' do describe 'callbacks' do

View 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

File diff suppressed because one or more lines are too long