Add privacy zones to tags

This commit is contained in:
Eugene Burmakin 2025-11-18 21:57:06 +01:00
parent bce1052608
commit c711bed383
13 changed files with 309 additions and 6 deletions

File diff suppressed because one or more lines are too long

View file

@ -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
}

View 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

View file

@ -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

View file

@ -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);
}
}
}

View 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`
}
}

View 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);
}
}

View file

@ -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

View file

@ -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" %>

View file

@ -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">

View file

@ -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

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

@ -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