mirror of
https://github.com/Freika/dawarich.git
synced 2026-01-09 08:47:11 -05:00
Add privacy zones to tags
This commit is contained in:
parent
bce1052608
commit
c711bed383
13 changed files with 309 additions and 6 deletions
File diff suppressed because one or more lines are too long
|
|
@ -111,7 +111,8 @@ module Api
|
|||
id: tag.id,
|
||||
name: tag.name,
|
||||
icon: tag.icon,
|
||||
color: tag.color
|
||||
color: tag.color,
|
||||
privacy_radius_meters: tag.privacy_radius_meters
|
||||
}
|
||||
end
|
||||
}
|
||||
|
|
|
|||
29
app/controllers/api/v1/tags_controller.rb
Normal file
29
app/controllers/api/v1/tags_controller.rb
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module Api
|
||||
module V1
|
||||
class TagsController < ApiController
|
||||
def privacy_zones
|
||||
zones = current_api_user.tags.privacy_zones.includes(:places)
|
||||
|
||||
render json: zones.map { |tag|
|
||||
{
|
||||
tag_id: tag.id,
|
||||
tag_name: tag.name,
|
||||
tag_icon: tag.icon,
|
||||
tag_color: tag.color,
|
||||
radius_meters: tag.privacy_radius_meters,
|
||||
places: tag.places.map { |place|
|
||||
{
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -57,6 +57,6 @@ class TagsController < ApplicationController
|
|||
end
|
||||
|
||||
def tag_params
|
||||
params.require(:tag).permit(:name, :icon, :color)
|
||||
params.require(:tag).permit(:name, :icon, :color, :privacy_radius_meters)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ import { VisitsManager } from "../maps/visits";
|
|||
import { ScratchLayer } from "../maps/scratch_layer";
|
||||
import { LocationSearch } from "../maps/location_search";
|
||||
import { PlacesManager } from "../maps/places";
|
||||
import { PrivacyZoneManager } from "../maps/privacy_zones";
|
||||
|
||||
import "leaflet-draw";
|
||||
import { initializeFogCanvas, drawFogCanvas, createFogOverlay } from "../maps/fog_of_war";
|
||||
|
|
@ -63,7 +64,7 @@ export default class extends BaseController {
|
|||
tracksVisible = false;
|
||||
tracksSubscription = null;
|
||||
|
||||
connect() {
|
||||
async connect() {
|
||||
super.connect();
|
||||
console.log("Map controller connected");
|
||||
|
||||
|
|
@ -164,6 +165,12 @@ export default class extends BaseController {
|
|||
|
||||
this.map.setMaxBounds(bounds);
|
||||
|
||||
// Initialize privacy zone manager
|
||||
this.privacyZoneManager = new PrivacyZoneManager(this.map, this.apiKey);
|
||||
|
||||
// Load privacy zones and apply filtering BEFORE creating map layers
|
||||
await this.initializePrivacyZones();
|
||||
|
||||
this.markersArray = createMarkersArray(this.markers, this.userSettings, this.apiKey);
|
||||
this.markersLayer = L.layerGroup(this.markersArray);
|
||||
this.heatmapMarkers = this.markersArray.map((element) => [element._latlng.lat, element._latlng.lng, 0.2]);
|
||||
|
|
@ -2360,4 +2367,24 @@ export default class extends BaseController {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initializePrivacyZones() {
|
||||
try {
|
||||
await this.privacyZoneManager.loadPrivacyZones();
|
||||
|
||||
if (this.privacyZoneManager.hasPrivacyZones()) {
|
||||
console.log(`[Privacy Zones] Loaded ${this.privacyZoneManager.getZoneCount()} zones covering ${this.privacyZoneManager.getTotalPlacesCount()} places`);
|
||||
|
||||
// Apply filtering to markers BEFORE they're rendered
|
||||
this.markers = this.privacyZoneManager.filterPoints(this.markers);
|
||||
|
||||
// Apply filtering to tracks if they exist
|
||||
if (this.tracksData && Array.isArray(this.tracksData)) {
|
||||
this.tracksData = this.privacyZoneManager.filterTracks(this.tracksData);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Privacy Zones] Error initializing privacy zones:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
30
app/javascript/controllers/privacy_radius_controller.js
Normal file
30
app/javascript/controllers/privacy_radius_controller.js
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["toggle", "radiusInput", "slider", "field", "label"]
|
||||
|
||||
toggleRadius(event) {
|
||||
if (event.target.checked) {
|
||||
// Enable privacy zone
|
||||
this.radiusInputTarget.classList.remove('hidden')
|
||||
|
||||
// Set default value if not already set
|
||||
if (!this.fieldTarget.value || this.fieldTarget.value === '') {
|
||||
const defaultValue = 1000
|
||||
this.fieldTarget.value = defaultValue
|
||||
this.sliderTarget.value = defaultValue
|
||||
this.labelTarget.textContent = `${defaultValue}m`
|
||||
}
|
||||
} else {
|
||||
// Disable privacy zone
|
||||
this.radiusInputTarget.classList.add('hidden')
|
||||
this.fieldTarget.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
updateFromSlider(event) {
|
||||
const value = event.target.value
|
||||
this.fieldTarget.value = value
|
||||
this.labelTarget.textContent = `${value}m`
|
||||
}
|
||||
}
|
||||
139
app/javascript/maps/privacy_zones.js
Normal file
139
app/javascript/maps/privacy_zones.js
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Privacy Zones Manager
|
||||
// Handles filtering of map data (points, tracks) based on privacy zones defined by tags
|
||||
|
||||
import L from 'leaflet';
|
||||
import { haversineDistance } from './helpers';
|
||||
|
||||
export class PrivacyZoneManager {
|
||||
constructor(map, apiKey) {
|
||||
this.map = map;
|
||||
this.apiKey = apiKey;
|
||||
this.zones = [];
|
||||
this.visualLayers = L.layerGroup();
|
||||
this.showCircles = false;
|
||||
}
|
||||
|
||||
async loadPrivacyZones() {
|
||||
try {
|
||||
const response = await fetch('/api/v1/tags/privacy_zones', {
|
||||
headers: { 'Authorization': `Bearer ${this.apiKey}` }
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to load privacy zones:', response.status);
|
||||
return;
|
||||
}
|
||||
|
||||
this.zones = await response.json();
|
||||
console.log(`[PrivacyZones] Loaded ${this.zones.length} privacy zones`);
|
||||
} catch (error) {
|
||||
console.error('Error loading privacy zones:', error);
|
||||
this.zones = [];
|
||||
}
|
||||
}
|
||||
|
||||
isPointInPrivacyZone(lat, lng) {
|
||||
if (!this.zones || this.zones.length === 0) return false;
|
||||
|
||||
return this.zones.some(zone =>
|
||||
zone.places.some(place => {
|
||||
const distanceKm = haversineDistance(lat, lng, place.latitude, place.longitude);
|
||||
const distanceMeters = distanceKm * 1000;
|
||||
return distanceMeters <= zone.radius_meters;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
filterPoints(points) {
|
||||
if (!this.zones || this.zones.length === 0) return points;
|
||||
|
||||
return points.filter(point => {
|
||||
// Point format: [lat, lng, ...]
|
||||
const lat = point[0];
|
||||
const lng = point[1];
|
||||
return !this.isPointInPrivacyZone(lat, lng);
|
||||
});
|
||||
}
|
||||
|
||||
filterTracks(tracks) {
|
||||
if (!this.zones || this.zones.length === 0) return tracks;
|
||||
|
||||
return tracks.map(track => {
|
||||
const filteredPoints = track.points.filter(point => {
|
||||
const lat = point[0];
|
||||
const lng = point[1];
|
||||
return !this.isPointInPrivacyZone(lat, lng);
|
||||
});
|
||||
|
||||
return {
|
||||
...track,
|
||||
points: filteredPoints
|
||||
};
|
||||
}).filter(track => track.points.length > 0);
|
||||
}
|
||||
|
||||
showPrivacyCircles() {
|
||||
this.visualLayers.clearLayers();
|
||||
|
||||
if (!this.zones || this.zones.length === 0) return;
|
||||
|
||||
this.zones.forEach(zone => {
|
||||
zone.places.forEach(place => {
|
||||
const circle = L.circle([place.latitude, place.longitude], {
|
||||
radius: zone.radius_meters,
|
||||
color: zone.tag_color || '#ff4444',
|
||||
fillColor: zone.tag_color || '#ff4444',
|
||||
fillOpacity: 0.1,
|
||||
dashArray: '10, 10',
|
||||
weight: 2,
|
||||
interactive: false,
|
||||
className: 'privacy-zone-circle'
|
||||
});
|
||||
|
||||
// Add popup with zone info
|
||||
circle.bindPopup(`
|
||||
<div class="privacy-zone-popup">
|
||||
<strong>${zone.tag_icon || '🔒'} ${zone.tag_name}</strong><br>
|
||||
<small>${place.name}</small><br>
|
||||
<small>Privacy radius: ${zone.radius_meters}m</small>
|
||||
</div>
|
||||
`);
|
||||
|
||||
circle.addTo(this.visualLayers);
|
||||
});
|
||||
});
|
||||
|
||||
this.visualLayers.addTo(this.map);
|
||||
this.showCircles = true;
|
||||
}
|
||||
|
||||
hidePrivacyCircles() {
|
||||
if (this.map.hasLayer(this.visualLayers)) {
|
||||
this.map.removeLayer(this.visualLayers);
|
||||
}
|
||||
this.showCircles = false;
|
||||
}
|
||||
|
||||
togglePrivacyCircles(show = null) {
|
||||
const shouldShow = show !== null ? show : !this.showCircles;
|
||||
|
||||
if (shouldShow) {
|
||||
this.showPrivacyCircles();
|
||||
} else {
|
||||
this.hidePrivacyCircles();
|
||||
}
|
||||
}
|
||||
|
||||
hasPrivacyZones() {
|
||||
return this.zones && this.zones.length > 0;
|
||||
}
|
||||
|
||||
getZoneCount() {
|
||||
return this.zones ? this.zones.length : 0;
|
||||
}
|
||||
|
||||
getTotalPlacesCount() {
|
||||
if (!this.zones) return 0;
|
||||
return this.zones.reduce((sum, zone) => sum + zone.places.length, 0);
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,17 @@ class Tag < ApplicationRecord
|
|||
|
||||
validates :name, presence: true, uniqueness: { scope: :user_id }
|
||||
validates :color, format: { with: /\A#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\z/, allow_blank: true }
|
||||
validates :privacy_radius_meters, numericality: {
|
||||
greater_than: 0,
|
||||
less_than_or_equal: 5000,
|
||||
allow_nil: true
|
||||
}
|
||||
|
||||
scope :for_user, ->(user) { where(user: user) }
|
||||
scope :ordered, -> { order(:name) }
|
||||
scope :privacy_zones, -> { where.not(privacy_radius_meters: nil) }
|
||||
|
||||
def privacy_zone?
|
||||
privacy_radius_meters.present?
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -57,6 +57,51 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Privacy Zone Settings -->
|
||||
<div data-controller="privacy-radius">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer">
|
||||
<span class="label-text font-semibold">🔒 Privacy Zone</span>
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-error"
|
||||
data-privacy-radius-target="toggle"
|
||||
data-action="change->privacy-radius#toggleRadius"
|
||||
<%= 'checked' if tag.privacy_radius_meters.present? %>>
|
||||
</label>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Hide map data around places with this tag</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-control <%= 'hidden' unless tag.privacy_radius_meters.present? %>"
|
||||
data-privacy-radius-target="radiusInput">
|
||||
<%= f.label :privacy_radius_meters, "Privacy Radius", class: "label" %>
|
||||
<div class="flex flex-col gap-2">
|
||||
<input type="range"
|
||||
min="50"
|
||||
max="5000"
|
||||
step="50"
|
||||
value="<%= tag.privacy_radius_meters || 1000 %>"
|
||||
class="range range-error"
|
||||
data-privacy-radius-target="slider"
|
||||
data-action="input->privacy-radius#updateFromSlider">
|
||||
<div class="flex justify-between text-xs px-2">
|
||||
<span>50m</span>
|
||||
<span class="font-semibold" data-privacy-radius-target="label">
|
||||
<%= tag.privacy_radius_meters || 1000 %>m
|
||||
</span>
|
||||
<span>5000m</span>
|
||||
</div>
|
||||
<%= f.hidden_field :privacy_radius_meters,
|
||||
value: tag.privacy_radius_meters || 1000,
|
||||
data: { privacy_radius_target: "field" } %>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Data within this radius will be hidden from the map</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control mt-6">
|
||||
<div class="flex gap-2">
|
||||
<%= f.submit class: "btn btn-primary" %>
|
||||
|
|
|
|||
|
|
@ -20,7 +20,14 @@
|
|||
<% @tags.each do |tag| %>
|
||||
<tr>
|
||||
<td class="text-2xl"><%= tag.icon %></td>
|
||||
<td class="font-semibold">#<%= tag.name %></td>
|
||||
<td class="font-semibold">
|
||||
#<%= tag.name %>
|
||||
<% if tag.privacy_zone? %>
|
||||
<span class="badge badge-sm badge-error gap-1 ml-2">
|
||||
🔒 <%= tag.privacy_radius_meters %>m
|
||||
</span>
|
||||
<% end %>
|
||||
</td>
|
||||
<td>
|
||||
<% if tag.color.present? %>
|
||||
<div class="flex items-center gap-2">
|
||||
|
|
|
|||
|
|
@ -144,6 +144,11 @@ Rails.application.routes.draw do
|
|||
end
|
||||
end
|
||||
resources :stats, only: :index
|
||||
resources :tags, only: [] do
|
||||
collection do
|
||||
get 'privacy_zones'
|
||||
end
|
||||
end
|
||||
|
||||
namespace :overland do
|
||||
resources :batches, only: :create
|
||||
|
|
|
|||
8
db/migrate/20251118204141_add_privacy_radius_to_tags.rb
Normal file
8
db/migrate/20251118204141_add_privacy_radius_to_tags.rb
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
class AddPrivacyRadiusToTags < ActiveRecord::Migration[8.0]
|
||||
disable_ddl_transaction!
|
||||
|
||||
def change
|
||||
add_column :tags, :privacy_radius_meters, :integer
|
||||
add_index :tags, :privacy_radius_meters, where: "privacy_radius_meters IS NOT NULL", algorithm: :concurrently
|
||||
end
|
||||
end
|
||||
4
db/schema.rb
generated
4
db/schema.rb
generated
|
|
@ -10,7 +10,7 @@
|
|||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_16_134520) do
|
||||
ActiveRecord::Schema[8.0].define(version: 2025_11_18_204141) do
|
||||
# These are extensions that must be enabled in order to support this database
|
||||
enable_extension "pg_catalog.plpgsql"
|
||||
enable_extension "postgis"
|
||||
|
|
@ -285,6 +285,8 @@ ActiveRecord::Schema[8.0].define(version: 2025_11_16_134520) do
|
|||
t.bigint "user_id", null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "privacy_radius_meters"
|
||||
t.index ["privacy_radius_meters"], name: "index_tags_on_privacy_radius_meters", where: "(privacy_radius_meters IS NOT NULL)"
|
||||
t.index ["user_id", "name"], name: "index_tags_on_user_id_and_name", unique: true
|
||||
t.index ["user_id"], name: "index_tags_on_user_id"
|
||||
end
|
||||
|
|
|
|||
Loading…
Reference in a new issue