Feature/maplibre frontend (#1953)
* Add a plan to use MapLibre GL JS for the frontend map rendering, replacing Leaflet * Implement phase 1 * Phases 1-3 + part of 4 * Fix e2e tests * Phase 6 * Implement fog of war * Phase 7 * Next step: fix specs, phase 7 done * Use our own map tiles * Extract v2 map logic to separate manager classes * Update settings panel on v2 map * Update v2 e2e tests structure * Reimplement location search in maps v2 * Update speed routes * Implement visits and places creation in v2 * Fix last failing test * Implement visits merging * Fix a routes e2e test and simplify the routes layer styling. * Extract js to modules from maps_v2_controller.js * Implement area creation * Fix spec problem * Fix some e2e tests * Implement live mode in v2 map * Update icons and panel * Extract some styles * Remove unused file * Start adding dark theme to popups on MapLibre maps * Make popups respect dark theme * Move v2 maps to maplibre namespace * Update v2 references to maplibre * Put place, area and visit info into side panel * Update API to use safe settings config method * Fix specs * Fix method name to config in SafeSettings and update usages accordingly * Add missing public files * Add handling for real time points * Fix remembering enabled/disabled layers of the v2 map * Fix lots of e2e tests * Add settings to select map version * Use maps/v2 as main path for MapLibre maps * Update routing * Update live mode
18
CHANGELOG.md
|
|
@ -4,7 +4,23 @@ 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.36.1] - 2025-11-29
|
||||
|
||||
# [0.36.2] - Unreleased
|
||||
|
||||
|
||||
## Fixed
|
||||
|
||||
- Heatmap and Fog of War now are moving correctly during map interactions. #1798
|
||||
- Polyline crossing international date line now are rendered correctly. #1162
|
||||
- Place popup tags parsing (MapLibre GL JS compatibility)
|
||||
- Stats calculation should be faster now.
|
||||
|
||||
## Changed
|
||||
|
||||
- Points on the Map page are now loaded in chunks to improve performance and reduce memory consumption.
|
||||
|
||||
|
||||
# [0.36.1] - 2025-11-29
|
||||
|
||||
## Fixed
|
||||
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
|
|||
1. Clone the repository.
|
||||
2. Run the following command to start the app:
|
||||
```bash
|
||||
docker-compose -f docker/docker-compose.yml up
|
||||
docker compose -f docker/docker-compose.yml up
|
||||
```
|
||||
3. Access the app at `http://localhost:3000`.
|
||||
|
||||
|
|
|
|||
1
app/assets/stylesheets/maplibre-gl.css
Normal file
187
app/assets/stylesheets/maps_maplibre.css
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
/* Maps V2 Styles */
|
||||
|
||||
/* Loading Overlay */
|
||||
.loading-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.loading-overlay.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
margin-top: 16px;
|
||||
font-size: 14px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
/* Popup Styles */
|
||||
.point-popup {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.popup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.popup-row .label {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.popup-row .value {
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
/* MapLibre Popup Theme Support */
|
||||
.maplibregl-popup-content {
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
/* Larger close button */
|
||||
.maplibregl-popup-close-button {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 24px;
|
||||
line-height: 32px;
|
||||
right: 4px;
|
||||
top: 4px;
|
||||
padding: 0;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Light theme (default) */
|
||||
.maplibregl-popup-content {
|
||||
background-color: #ffffff;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button {
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.maplibregl-popup-close-button:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.maplibregl-popup-tip {
|
||||
border-top-color: #ffffff;
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
html[data-theme="dark"] .maplibregl-popup-content,
|
||||
html.dark .maplibregl-popup-content {
|
||||
background-color: #1f2937;
|
||||
color: #f9fafb;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .maplibregl-popup-close-button,
|
||||
html.dark .maplibregl-popup-close-button {
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .maplibregl-popup-close-button:hover,
|
||||
html.dark .maplibregl-popup-close-button:hover {
|
||||
background-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] .maplibregl-popup-tip,
|
||||
html.dark .maplibregl-popup-tip {
|
||||
border-top-color: #1f2937;
|
||||
}
|
||||
|
||||
/* Connection Indicator */
|
||||
.connection-indicator {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 8px 16px;
|
||||
background: white;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
display: none; /* Hidden by default, shown when family sharing is active */
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
z-index: 20;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
/* Show connection indicator when family sharing is active */
|
||||
.connection-indicator.active {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: #ef4444;
|
||||
animation: pulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.connection-indicator.connected .indicator-dot {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.connection-indicator.connected .indicator-text::before {
|
||||
content: 'Connected';
|
||||
}
|
||||
|
||||
.connection-indicator.disconnected .indicator-text::before {
|
||||
content: 'Connecting...';
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
286
app/assets/stylesheets/maps_maplibre_panel.css
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
/* Maps V2 Control Panel Styles */
|
||||
|
||||
.map-control-panel {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -480px; /* Hidden by default */
|
||||
width: 480px;
|
||||
height: 100%;
|
||||
background: oklch(var(--b1));
|
||||
box-shadow: -4px 0 24px rgba(0, 0, 0, 0.15);
|
||||
z-index: 9999;
|
||||
transition: right 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-control-panel.open {
|
||||
right: 0;
|
||||
}
|
||||
|
||||
/* Vertical Tab Bar */
|
||||
.panel-tabs {
|
||||
width: 64px;
|
||||
background: oklch(var(--b2));
|
||||
border-right: 1px solid oklch(var(--bc) / 0.1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 16px 0;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
position: relative;
|
||||
color: oklch(var(--bc) / 0.6);
|
||||
}
|
||||
|
||||
.tab-btn:hover {
|
||||
background: oklch(var(--b3));
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
.tab-btn.active {
|
||||
background: oklch(var(--p));
|
||||
color: oklch(var(--pc));
|
||||
}
|
||||
|
||||
.tab-btn.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: -1px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 24px;
|
||||
background: oklch(var(--p));
|
||||
border-radius: 2px 0 0 2px;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
|
||||
/* Panel Content */
|
||||
.panel-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid oklch(var(--bc) / 0.1);
|
||||
background: oklch(var(--b1));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: oklch(var(--bc));
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
/* Tab Content */
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.panel-body::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-thumb {
|
||||
background: oklch(var(--bc) / 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.panel-body::-webkit-scrollbar-thumb:hover {
|
||||
background: oklch(var(--bc) / 0.3);
|
||||
}
|
||||
|
||||
/* Toggle Focus State - Remove all focus indicators */
|
||||
.toggle:focus,
|
||||
.toggle:focus-visible,
|
||||
.toggle:focus-within {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
border-color: inherit !important;
|
||||
}
|
||||
|
||||
/* Override DaisyUI toggle focus styles */
|
||||
.toggle:focus-visible:checked,
|
||||
.toggle:checked:focus,
|
||||
.toggle:checked:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Ensure no outline on the toggle container */
|
||||
.form-control .toggle:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
|
||||
/* Prevent indeterminate visual state on toggles */
|
||||
.toggle:indeterminate {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure smooth toggle transitions without intermediate states */
|
||||
.toggle {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.toggle:checked {
|
||||
transition: background-color 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Remove any active/pressed state that might cause intermediate appearance */
|
||||
.toggle:active,
|
||||
.toggle:active:focus {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* Responsive Breakpoints */
|
||||
|
||||
/* Large tablets and smaller desktops (1024px - 1280px) */
|
||||
@media (max-width: 1280px) {
|
||||
.map-control-panel {
|
||||
width: 420px;
|
||||
right: -420px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tablets (768px - 1024px) */
|
||||
@media (max-width: 1024px) {
|
||||
.map-control-panel {
|
||||
width: 380px;
|
||||
right: -380px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Small tablets and large phones (640px - 768px) */
|
||||
@media (max-width: 768px) {
|
||||
.map-control-panel {
|
||||
width: 95%;
|
||||
right: -95%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile phones (< 640px) */
|
||||
@media (max-width: 640px) {
|
||||
.map-control-panel {
|
||||
width: 100%;
|
||||
right: -100%;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.panel-tabs {
|
||||
width: 56px;
|
||||
padding: 12px 0;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.tab-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
/* Reduce spacing on mobile */
|
||||
.space-y-4 > * + * {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.space-y-6 > * + * {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Very small phones (< 375px) */
|
||||
@media (max-width: 375px) {
|
||||
.panel-tabs {
|
||||
width: 52px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.tab-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panel-body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
1
app/assets/svg/icons/lucide/outline/circle-plus.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-circle-plus-icon lucide-circle-plus"><circle cx="12" cy="12" r="10"/><path d="M8 12h8"/><path d="M12 8v8"/></svg>
|
||||
|
After Width: | Height: | Size: 316 B |
1
app/assets/svg/icons/lucide/outline/grid2x2.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-grid2x2-icon lucide-grid-2x2"><path d="M12 3v18"/><path d="M3 12h18"/><rect x="3" y="3" width="18" height="18" rx="2"/></svg>
|
||||
|
After Width: | Height: | Size: 328 B |
1
app/assets/svg/icons/lucide/outline/layer.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-layers-icon lucide-layers"><path d="M12.83 2.18a2 2 0 0 0-1.66 0L2.6 6.08a1 1 0 0 0 0 1.83l8.58 3.91a2 2 0 0 0 1.66 0l8.58-3.9a1 1 0 0 0 0-1.83z"/><path d="M2 12a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 12"/><path d="M2 17a1 1 0 0 0 .58.91l8.6 3.91a2 2 0 0 0 1.65 0l8.58-3.9A1 1 0 0 0 22 17"/></svg>
|
||||
|
After Width: | Height: | Size: 526 B |
1
app/assets/svg/icons/lucide/outline/map-pin-check.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map-pin-check-icon lucide-map-pin-check"><path d="M19.43 12.935c.357-.967.57-1.955.57-2.935a8 8 0 0 0-16 0c0 4.993 5.539 10.193 7.399 11.799a1 1 0 0 0 1.202 0 32.197 32.197 0 0 0 .813-.728"/><circle cx="12" cy="10" r="3"/><path d="m16 18 2 2 4-4"/></svg>
|
||||
|
After Width: | Height: | Size: 457 B |
1
app/assets/svg/icons/lucide/outline/pocket-knife.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-pocket-knife-icon lucide-pocket-knife"><path d="M3 2v1c0 1 2 1 2 2S3 6 3 7s2 1 2 2-2 1-2 2 2 1 2 2"/><path d="M18 6h.01"/><path d="M6 18h.01"/><path d="M20.83 8.83a4 4 0 0 0-5.66-5.66l-12 12a4 4 0 1 0 5.66 5.66Z"/><path d="M18 11.66V22a4 4 0 0 0 4-4V6"/></svg>
|
||||
|
After Width: | Height: | Size: 463 B |
1
app/assets/svg/icons/lucide/outline/rotate-ccw.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-rotate-ccw-icon lucide-rotate-ccw"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
|
||||
|
After Width: | Height: | Size: 325 B |
1
app/assets/svg/icons/lucide/outline/route.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-route-icon lucide-route"><circle cx="6" cy="19" r="3"/><path d="M9 19h8.5a3.5 3.5 0 0 0 0-7h-11a3.5 3.5 0 0 1 0-7H15"/><circle cx="18" cy="5" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 358 B |
1
app/assets/svg/icons/lucide/outline/save.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-save-icon lucide-save"><path d="M15.2 3a2 2 0 0 1 1.4.6l3.8 3.8a2 2 0 0 1 .6 1.4V19a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2z"/><path d="M17 21v-7a1 1 0 0 0-1-1H8a1 1 0 0 0-1 1v7"/><path d="M7 3v4a1 1 0 0 0 1 1h7"/></svg>
|
||||
|
After Width: | Height: | Size: 429 B |
1
app/assets/svg/icons/lucide/outline/settings.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-settings-icon lucide-settings"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
|
After Width: | Height: | Size: 610 B |
1
app/assets/svg/icons/lucide/outline/x.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x-icon lucide-x"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
|
After Width: | Height: | Size: 270 B |
|
|
@ -1,7 +1,7 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class Api::V1::AreasController < ApiController
|
||||
before_action :set_area, only: %i[update destroy]
|
||||
before_action :set_area, only: %i[show update destroy]
|
||||
|
||||
def index
|
||||
@areas = current_api_user.areas
|
||||
|
|
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
|
|||
render json: @areas, status: :ok
|
||||
end
|
||||
|
||||
def show
|
||||
render json: @area, status: :ok
|
||||
end
|
||||
|
||||
def create
|
||||
@area = current_api_user.areas.build(area_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -7,8 +7,28 @@ module Api
|
|||
|
||||
def index
|
||||
@places = current_api_user.places.includes(:tags, :visits)
|
||||
@places = @places.with_tags(params[:tag_ids]) if params[:tag_ids].present?
|
||||
@places = @places.without_tags if params[:untagged] == 'true'
|
||||
|
||||
if params[:tag_ids].present?
|
||||
tag_ids = Array(params[:tag_ids])
|
||||
|
||||
# Separate numeric tag IDs from "untagged"
|
||||
numeric_tag_ids = tag_ids.reject { |id| id == 'untagged' }.map(&:to_i)
|
||||
include_untagged = tag_ids.include?('untagged')
|
||||
|
||||
if numeric_tag_ids.any? && include_untagged
|
||||
# Both tagged and untagged: return union (OR logic)
|
||||
tagged = current_api_user.places.includes(:tags, :visits).with_tags(numeric_tag_ids)
|
||||
untagged = current_api_user.places.includes(:tags, :visits).without_tags
|
||||
@places = Place.from("(#{tagged.to_sql} UNION #{untagged.to_sql}) AS places")
|
||||
.includes(:tags, :visits)
|
||||
elsif numeric_tag_ids.any?
|
||||
# Only tagged places with ANY of the selected tags (OR logic)
|
||||
@places = @places.with_tags(numeric_tag_ids)
|
||||
elsif include_untagged
|
||||
# Only untagged places
|
||||
@places = @places.without_tags
|
||||
end
|
||||
end
|
||||
|
||||
render json: @places.map { |place| serialize_place(place) }
|
||||
end
|
||||
|
|
|
|||
|
|
@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController
|
|||
points = current_api_user
|
||||
.points
|
||||
.where(timestamp: start_at..end_at)
|
||||
|
||||
# Filter by geographic bounds if provided
|
||||
if params[:min_longitude].present? && params[:max_longitude].present? &&
|
||||
params[:min_latitude].present? && params[:max_latitude].present?
|
||||
min_lng = params[:min_longitude].to_f
|
||||
max_lng = params[:max_longitude].to_f
|
||||
min_lat = params[:min_latitude].to_f
|
||||
max_lat = params[:max_latitude].to_f
|
||||
|
||||
# Use PostGIS to filter points within bounding box
|
||||
points = points.where(
|
||||
'ST_X(lonlat::geometry) BETWEEN ? AND ? AND ST_Y(lonlat::geometry) BETWEEN ? AND ?',
|
||||
min_lng, max_lng, min_lat, max_lat
|
||||
)
|
||||
end
|
||||
|
||||
points = points
|
||||
.order(timestamp: order)
|
||||
.page(params[:page])
|
||||
.per(params[:per_page] || 100)
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
|||
|
||||
def index
|
||||
render json: {
|
||||
settings: current_api_user.safe_settings,
|
||||
settings: current_api_user.safe_settings.config,
|
||||
status: 'success'
|
||||
}, status: :ok
|
||||
end
|
||||
|
|
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
|
|||
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' },
|
||||
render json: { message: 'Settings updated', settings: current_api_user.safe_settings.config, status: 'success' },
|
||||
status: :ok
|
||||
else
|
||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
||||
|
|
@ -31,6 +31,7 @@ class Api::V1::SettingsController < ApiController
|
|||
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
||||
:maps_v2_style, :maps_maplibre_style,
|
||||
enabled_map_layers: []
|
||||
)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController
|
|||
render json: serialized_visits
|
||||
end
|
||||
|
||||
def show
|
||||
visit = current_api_user.visits.find(params[:id])
|
||||
render json: Api::VisitSerializer.new(visit).call
|
||||
end
|
||||
|
||||
def create
|
||||
service = Visits::Create.new(current_api_user, visit_params)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class HomeController < ApplicationController
|
||||
include ApplicationHelper
|
||||
|
||||
def index
|
||||
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
||||
|
||||
redirect_to map_url if current_user
|
||||
redirect_to preferred_map_path if current_user
|
||||
|
||||
@points = current_user.points.without_raw_data if current_user
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class MapController < ApplicationController
|
||||
class Map::LeafletController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
layout 'map', only: :index
|
||||
|
||||
33
app/controllers/map/maplibre_controller.rb
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
module Map
|
||||
class MaplibreController < ApplicationController
|
||||
before_action :authenticate_user!
|
||||
layout 'map'
|
||||
|
||||
def index
|
||||
@start_at = parsed_start_at
|
||||
@end_at = parsed_end_at
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def start_at
|
||||
return Time.zone.parse(params[:start_at]).to_i if params[:start_at].present?
|
||||
|
||||
Time.zone.today.beginning_of_day.to_i
|
||||
end
|
||||
|
||||
def end_at
|
||||
return Time.zone.parse(params[:end_at]).to_i if params[:end_at].present?
|
||||
|
||||
Time.zone.today.end_of_day.to_i
|
||||
end
|
||||
|
||||
def parsed_start_at
|
||||
Time.zone.at(start_at)
|
||||
end
|
||||
|
||||
def parsed_end_at
|
||||
Time.zone.at(end_at)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -24,6 +24,6 @@ class Settings::MapsController < ApplicationController
|
|||
private
|
||||
|
||||
def settings_params
|
||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
||||
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -142,4 +142,11 @@ module ApplicationHelper
|
|||
|
||||
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||
end
|
||||
|
||||
def preferred_map_path
|
||||
return map_v1_path unless user_signed_in?
|
||||
|
||||
preferred_version = current_user.safe_settings.maps&.dig('preferred_version')
|
||||
preferred_version == 'v2' ? map_v2_path : map_v1_path
|
||||
end
|
||||
end
|
||||
|
|
|
|||
724
app/javascript/README.md
Normal file
|
|
@ -0,0 +1,724 @@
|
|||
# Dawarich JavaScript Architecture
|
||||
|
||||
This document provides a comprehensive guide to the JavaScript architecture used in the Dawarich application, with a focus on the Maps (MapLibre) implementation.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Technology Stack](#technology-stack)
|
||||
- [Architecture Patterns](#architecture-patterns)
|
||||
- [Directory Structure](#directory-structure)
|
||||
- [Core Concepts](#core-concepts)
|
||||
- [Maps (MapLibre) Architecture](#maps-maplibre-architecture)
|
||||
- [Creating New Features](#creating-new-features)
|
||||
- [Best Practices](#best-practices)
|
||||
|
||||
## Overview
|
||||
|
||||
Dawarich uses a modern JavaScript architecture built on **Hotwire (Turbo + Stimulus)** for page interactions and **MapLibre GL JS** for map rendering. The Maps (MapLibre) implementation follows object-oriented principles with clear separation of concerns.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
- **Stimulus** - Modest JavaScript framework for sprinkles of interactivity
|
||||
- **Turbo Rails** - SPA-like page navigation without building an SPA
|
||||
- **MapLibre GL JS** - Open-source map rendering engine
|
||||
- **ES6 Modules** - Modern JavaScript module system
|
||||
- **Tailwind CSS + DaisyUI** - Utility-first CSS framework
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### 1. Stimulus Controllers
|
||||
|
||||
**Purpose:** Connect DOM elements to JavaScript behavior
|
||||
|
||||
**Location:** `app/javascript/controllers/`
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['element']
|
||||
static values = { apiKey: String }
|
||||
|
||||
connect() {
|
||||
// Initialize when element appears in DOM
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
// Cleanup when element is removed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
- Controllers should be stateless when possible
|
||||
- Use `targets` for DOM element references
|
||||
- Use `values` for passing data from HTML
|
||||
- Always cleanup in `disconnect()`
|
||||
|
||||
### 2. Service Classes
|
||||
|
||||
**Purpose:** Encapsulate business logic and API communication
|
||||
|
||||
**Location:** `app/javascript/maps_maplibre/services/`
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
export class ApiClient {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
async fetchData() {
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
- Single responsibility - one service per concern
|
||||
- Consistent error handling
|
||||
- Return promises for async operations
|
||||
- Use constructor injection for dependencies
|
||||
|
||||
### 3. Layer Classes (Map Layers)
|
||||
|
||||
**Purpose:** Manage map visualization layers
|
||||
|
||||
**Location:** `app/javascript/maps_maplibre/layers/`
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
import { BaseLayer } from './base_layer'
|
||||
|
||||
export class CustomLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'custom', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: { /* ... */ }
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Principles:**
|
||||
- All layers extend `BaseLayer`
|
||||
- Implement `getSourceConfig()` and `getLayerConfigs()`
|
||||
- Store data in `this.data`
|
||||
- Use `this.visible` for visibility state
|
||||
- Inherit common methods: `add()`, `update()`, `show()`, `hide()`, `toggle()`
|
||||
|
||||
### 4. Utility Modules
|
||||
|
||||
**Purpose:** Provide reusable helper functions
|
||||
|
||||
**Location:** `app/javascript/maps_maplibre/utils/`
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
export class UtilityClass {
|
||||
static helperMethod(param) {
|
||||
// Static methods for stateless utilities
|
||||
}
|
||||
}
|
||||
|
||||
// Or singleton pattern
|
||||
export const utilityInstance = new UtilityClass()
|
||||
```
|
||||
|
||||
### 5. Component Classes
|
||||
|
||||
**Purpose:** Reusable UI components
|
||||
|
||||
**Location:** `app/javascript/maps_maplibre/components/`
|
||||
|
||||
**Pattern:**
|
||||
```javascript
|
||||
export class PopupFactory {
|
||||
static createPopup(data) {
|
||||
return `<div>${data.name}</div>`
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
```
|
||||
app/javascript/
|
||||
├── application.js # Entry point
|
||||
├── controllers/ # Stimulus controllers
|
||||
│ ├── maps/maplibre_controller.js # Main map controller
|
||||
│ ├── maps_maplibre/ # Controller modules
|
||||
│ │ ├── layer_manager.js # Layer lifecycle management
|
||||
│ │ ├── data_loader.js # API data fetching
|
||||
│ │ ├── event_handlers.js # Map event handling
|
||||
│ │ ├── filter_manager.js # Data filtering
|
||||
│ │ └── date_manager.js # Date range management
|
||||
│ └── ... # Other controllers
|
||||
├── maps_maplibre/ # Maps (MapLibre) implementation
|
||||
│ ├── layers/ # Map layer classes
|
||||
│ │ ├── base_layer.js # Abstract base class
|
||||
│ │ ├── points_layer.js # Point markers
|
||||
│ │ ├── routes_layer.js # Route lines
|
||||
│ │ ├── heatmap_layer.js # Heatmap visualization
|
||||
│ │ ├── visits_layer.js # Visit markers
|
||||
│ │ ├── photos_layer.js # Photo markers
|
||||
│ │ ├── places_layer.js # Places markers
|
||||
│ │ ├── areas_layer.js # User-defined areas
|
||||
│ │ ├── fog_layer.js # Fog of war overlay
|
||||
│ │ └── scratch_layer.js # Scratch map
|
||||
│ ├── services/ # API and external services
|
||||
│ │ ├── api_client.js # REST API wrapper
|
||||
│ │ └── location_search_service.js
|
||||
│ ├── utils/ # Helper utilities
|
||||
│ │ ├── settings_manager.js # User preferences
|
||||
│ │ ├── geojson_transformers.js
|
||||
│ │ ├── performance_monitor.js
|
||||
│ │ ├── lazy_loader.js # Code splitting
|
||||
│ │ └── ...
|
||||
│ ├── components/ # Reusable UI components
|
||||
│ │ ├── popup_factory.js # Map popup generator
|
||||
│ │ ├── toast.js # Toast notifications
|
||||
│ │ └── ...
|
||||
│ └── channels/ # ActionCable channels
|
||||
│ └── map_channel.js # Real-time updates
|
||||
└── maps/ # Legacy Maps V1 (being phased out)
|
||||
```
|
||||
|
||||
## Core Concepts
|
||||
|
||||
### Manager Pattern
|
||||
|
||||
The Maps (MapLibre) controller delegates responsibilities to specialized managers:
|
||||
|
||||
1. **LayerManager** - Layer lifecycle (add/remove/toggle/update)
|
||||
2. **DataLoader** - API data fetching and transformation
|
||||
3. **EventHandlers** - Map interaction events
|
||||
4. **FilterManager** - Data filtering and searching
|
||||
5. **DateManager** - Date range calculations
|
||||
6. **SettingsManager** - User preferences persistence
|
||||
|
||||
**Benefits:**
|
||||
- Single Responsibility Principle
|
||||
- Easier testing
|
||||
- Improved code organization
|
||||
- Better reusability
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action
|
||||
↓
|
||||
Stimulus Controller Method
|
||||
↓
|
||||
Manager (e.g., DataLoader)
|
||||
↓
|
||||
Service (e.g., ApiClient)
|
||||
↓
|
||||
API Endpoint
|
||||
↓
|
||||
Transform to GeoJSON
|
||||
↓
|
||||
Update Layer
|
||||
↓
|
||||
MapLibre Renders
|
||||
```
|
||||
|
||||
### State Management
|
||||
|
||||
**Settings Persistence:**
|
||||
- Primary: Backend API (`/api/v1/settings`)
|
||||
- Fallback: localStorage
|
||||
- Sync on initialization
|
||||
- Save on every change (debounced)
|
||||
|
||||
**Layer State:**
|
||||
- Stored in layer instances (`this.visible`, `this.data`)
|
||||
- Synced with SettingsManager
|
||||
- Persisted across sessions
|
||||
|
||||
### Event System
|
||||
|
||||
**Custom Events:**
|
||||
```javascript
|
||||
// Dispatch
|
||||
document.dispatchEvent(new CustomEvent('visit:created', {
|
||||
detail: { visitId: 123 }
|
||||
}))
|
||||
|
||||
// Listen
|
||||
document.addEventListener('visit:created', (event) => {
|
||||
console.log(event.detail.visitId)
|
||||
})
|
||||
```
|
||||
|
||||
**Map Events:**
|
||||
```javascript
|
||||
map.on('click', 'layer-id', (e) => {
|
||||
const feature = e.features[0]
|
||||
// Handle click
|
||||
})
|
||||
```
|
||||
|
||||
## Maps (MapLibre) Architecture
|
||||
|
||||
### Layer Hierarchy
|
||||
|
||||
Layers are rendered in specific order (bottom to top):
|
||||
|
||||
1. **Scratch Layer** - Visited countries/regions overlay
|
||||
2. **Heatmap Layer** - Point density visualization
|
||||
3. **Areas Layer** - User-defined circular areas
|
||||
4. **Tracks Layer** - Imported GPS tracks
|
||||
5. **Routes Layer** - Generated routes from points
|
||||
6. **Visits Layer** - Detected visits to places
|
||||
7. **Places Layer** - Named locations
|
||||
8. **Photos Layer** - Photos with geolocation
|
||||
9. **Family Layer** - Real-time family member locations
|
||||
10. **Points Layer** - Individual location points
|
||||
11. **Fog Layer** - Canvas overlay showing unexplored areas
|
||||
|
||||
### BaseLayer Pattern
|
||||
|
||||
All layers extend `BaseLayer` which provides:
|
||||
|
||||
**Methods:**
|
||||
- `add(data)` - Add layer to map
|
||||
- `update(data)` - Update layer data
|
||||
- `remove()` - Remove layer from map
|
||||
- `show()` / `hide()` - Toggle visibility
|
||||
- `toggle(visible)` - Set visibility state
|
||||
|
||||
**Abstract Methods (must implement):**
|
||||
- `getSourceConfig()` - MapLibre source configuration
|
||||
- `getLayerConfigs()` - Array of MapLibre layer configurations
|
||||
|
||||
**Example Implementation:**
|
||||
```javascript
|
||||
export class PointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'points', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [{
|
||||
id: 'points',
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 4,
|
||||
'circle-color': '#3b82f6'
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
|
||||
Heavy layers are lazy-loaded to reduce initial bundle size:
|
||||
|
||||
```javascript
|
||||
// In lazy_loader.js
|
||||
const paths = {
|
||||
'fog': () => import('../layers/fog_layer.js'),
|
||||
'scratch': () => import('../layers/scratch_layer.js')
|
||||
}
|
||||
|
||||
// Usage
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
const layer = new ScratchLayer(map, options)
|
||||
```
|
||||
|
||||
**When to use:**
|
||||
- Large dependencies (e.g., canvas-based rendering)
|
||||
- Rarely-used features
|
||||
- Heavy computations
|
||||
|
||||
### GeoJSON Transformations
|
||||
|
||||
All data is transformed to GeoJSON before rendering:
|
||||
|
||||
```javascript
|
||||
// Points
|
||||
{
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [longitude, latitude]
|
||||
},
|
||||
properties: {
|
||||
id: 1,
|
||||
timestamp: '2024-01-01T12:00:00Z',
|
||||
// ... other properties
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Key Functions:**
|
||||
- `pointsToGeoJSON(points)` - Convert points array
|
||||
- `visitsToGeoJSON(visits)` - Convert visits
|
||||
- `photosToGeoJSON(photos)` - Convert photos
|
||||
- `placesToGeoJSON(places)` - Convert places
|
||||
- `areasToGeoJSON(areas)` - Convert circular areas to polygons
|
||||
|
||||
## Creating New Features
|
||||
|
||||
### Adding a New Layer
|
||||
|
||||
1. **Create layer class** in `app/javascript/maps_maplibre/layers/`:
|
||||
|
||||
```javascript
|
||||
import { BaseLayer } from './base_layer'
|
||||
|
||||
export class NewLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'new-layer', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [{
|
||||
id: this.id,
|
||||
type: 'symbol', // or 'circle', 'line', 'fill', 'heatmap'
|
||||
source: this.sourceId,
|
||||
paint: { /* styling */ },
|
||||
layout: { /* layout */ }
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Register in LayerManager** (`controllers/maps_maplibre/layer_manager.js`):
|
||||
|
||||
```javascript
|
||||
import { NewLayer } from 'maps_maplibre/layers/new_layer'
|
||||
|
||||
// In addAllLayers method
|
||||
_addNewLayer(dataGeoJSON) {
|
||||
if (!this.layers.newLayer) {
|
||||
this.layers.newLayer = new NewLayer(this.map, {
|
||||
visible: this.settings.newLayerEnabled || false
|
||||
})
|
||||
this.layers.newLayer.add(dataGeoJSON)
|
||||
} else {
|
||||
this.layers.newLayer.update(dataGeoJSON)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Add to settings** (`utils/settings_manager.js`):
|
||||
|
||||
```javascript
|
||||
const DEFAULT_SETTINGS = {
|
||||
// ...
|
||||
newLayerEnabled: false
|
||||
}
|
||||
|
||||
const LAYER_NAME_MAP = {
|
||||
// ...
|
||||
'New Layer': 'newLayerEnabled'
|
||||
}
|
||||
```
|
||||
|
||||
4. **Add UI controls** in view template.
|
||||
|
||||
### Adding a New API Endpoint
|
||||
|
||||
1. **Add method to ApiClient** (`services/api_client.js`):
|
||||
|
||||
```javascript
|
||||
async fetchNewData({ param1, param2 }) {
|
||||
const params = new URLSearchParams({ param1, param2 })
|
||||
|
||||
const response = await fetch(`${this.baseURL}/new-endpoint?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
```
|
||||
|
||||
2. **Add transformation** in DataLoader:
|
||||
|
||||
```javascript
|
||||
newDataToGeoJSON(data) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: data.map(item => ({
|
||||
type: 'Feature',
|
||||
geometry: { /* ... */ },
|
||||
properties: { /* ... */ }
|
||||
}))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use in controller:**
|
||||
|
||||
```javascript
|
||||
const data = await this.api.fetchNewData({ param1, param2 })
|
||||
const geojson = this.dataLoader.newDataToGeoJSON(data)
|
||||
this.layerManager.updateLayer('new-layer', geojson)
|
||||
```
|
||||
|
||||
### Adding a New Utility
|
||||
|
||||
1. **Create utility file** in `utils/`:
|
||||
|
||||
```javascript
|
||||
export class NewUtility {
|
||||
static calculate(input) {
|
||||
// Pure function - no side effects
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// Or singleton for stateful utilities
|
||||
class NewManager {
|
||||
constructor() {
|
||||
this.state = {}
|
||||
}
|
||||
|
||||
doSomething() {
|
||||
// Stateful operation
|
||||
}
|
||||
}
|
||||
|
||||
export const newManager = new NewManager()
|
||||
```
|
||||
|
||||
2. **Import and use:**
|
||||
|
||||
```javascript
|
||||
import { NewUtility } from 'maps_maplibre/utils/new_utility'
|
||||
|
||||
const result = NewUtility.calculate(input)
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Code Style
|
||||
|
||||
1. **Use ES6+ features:**
|
||||
- Arrow functions
|
||||
- Template literals
|
||||
- Destructuring
|
||||
- Async/await
|
||||
- Classes
|
||||
|
||||
2. **Naming conventions:**
|
||||
- Classes: `PascalCase`
|
||||
- Methods/variables: `camelCase`
|
||||
- Constants: `UPPER_SNAKE_CASE`
|
||||
- Files: `snake_case.js`
|
||||
|
||||
3. **Always use semicolons** for statement termination
|
||||
|
||||
4. **Prefer `const` over `let`**, avoid `var`
|
||||
|
||||
### Performance
|
||||
|
||||
1. **Lazy load heavy features:**
|
||||
```javascript
|
||||
const Layer = await lazyLoader.loadLayer('name')
|
||||
```
|
||||
|
||||
2. **Debounce frequent operations:**
|
||||
```javascript
|
||||
let timeout
|
||||
function onInput(e) {
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => actualWork(e), 300)
|
||||
}
|
||||
```
|
||||
|
||||
3. **Use performance monitoring:**
|
||||
```javascript
|
||||
performanceMonitor.mark('operation')
|
||||
// ... do work
|
||||
performanceMonitor.measure('operation')
|
||||
```
|
||||
|
||||
4. **Minimize DOM manipulations** - batch updates when possible
|
||||
|
||||
### Error Handling
|
||||
|
||||
1. **Always handle promise rejections:**
|
||||
```javascript
|
||||
try {
|
||||
const data = await fetchData()
|
||||
} catch (error) {
|
||||
console.error('Failed:', error)
|
||||
Toast.error('Operation failed')
|
||||
}
|
||||
```
|
||||
|
||||
2. **Provide user feedback:**
|
||||
```javascript
|
||||
Toast.success('Data loaded')
|
||||
Toast.error('Failed to load data')
|
||||
Toast.info('Click map to add point')
|
||||
```
|
||||
|
||||
3. **Log errors for debugging:**
|
||||
```javascript
|
||||
console.error('[Component] Error details:', error)
|
||||
```
|
||||
|
||||
### Memory Management
|
||||
|
||||
1. **Always cleanup in disconnect():**
|
||||
```javascript
|
||||
disconnect() {
|
||||
this.searchManager?.destroy()
|
||||
this.cleanup.cleanup()
|
||||
this.map?.remove()
|
||||
}
|
||||
```
|
||||
|
||||
2. **Use CleanupHelper for event listeners:**
|
||||
```javascript
|
||||
this.cleanup = new CleanupHelper()
|
||||
this.cleanup.addEventListener(element, 'click', handler)
|
||||
|
||||
// In disconnect():
|
||||
this.cleanup.cleanup() // Removes all listeners
|
||||
```
|
||||
|
||||
3. **Remove map layers and sources:**
|
||||
```javascript
|
||||
remove() {
|
||||
this.getLayerIds().forEach(id => {
|
||||
if (this.map.getLayer(id)) {
|
||||
this.map.removeLayer(id)
|
||||
}
|
||||
})
|
||||
if (this.map.getSource(this.sourceId)) {
|
||||
this.map.removeSource(this.sourceId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Testing Considerations
|
||||
|
||||
1. **Keep methods small and focused** - easier to test
|
||||
2. **Avoid tight coupling** - use dependency injection
|
||||
3. **Separate pure functions** from side effects
|
||||
4. **Use static methods** for stateless utilities
|
||||
|
||||
### State Management
|
||||
|
||||
1. **Single source of truth:**
|
||||
- Settings: `SettingsManager`
|
||||
- Layer data: Layer instances
|
||||
- UI state: Controller properties
|
||||
|
||||
2. **Sync state with backend:**
|
||||
```javascript
|
||||
SettingsManager.updateSetting('key', value)
|
||||
// Saves to both localStorage and backend
|
||||
```
|
||||
|
||||
3. **Restore state on load:**
|
||||
```javascript
|
||||
async connect() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
this.syncToggleStates()
|
||||
}
|
||||
```
|
||||
|
||||
### Documentation
|
||||
|
||||
1. **Add JSDoc comments for public APIs:**
|
||||
```javascript
|
||||
/**
|
||||
* Fetch all points for date range
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* @returns {Promise<Array>} All points
|
||||
*/
|
||||
async fetchAllPoints({ start_at, end_at, onProgress }) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
2. **Document complex logic with inline comments**
|
||||
|
||||
3. **Keep this README updated** when adding major features
|
||||
|
||||
### Code Organization
|
||||
|
||||
1. **One class per file** - easier to find and maintain
|
||||
2. **Group related functionality** in directories
|
||||
3. **Use index files** for barrel exports when needed
|
||||
4. **Avoid circular dependencies** - use dependency injection
|
||||
|
||||
### Migration from Maps V1 to V2
|
||||
|
||||
When updating features, follow this pattern:
|
||||
|
||||
1. **Keep V1 working** - V2 is opt-in
|
||||
2. **Share utilities** where possible (e.g., color calculations)
|
||||
3. **Use same API endpoints** - maintain compatibility
|
||||
4. **Document differences** in code comments
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### Complete Layer Implementation
|
||||
|
||||
See `app/javascript/maps_maplibre/layers/heatmap_layer.js` for a simple example.
|
||||
|
||||
### Complete Utility Implementation
|
||||
|
||||
See `app/javascript/maps_maplibre/utils/settings_manager.js` for state management.
|
||||
|
||||
### Complete Service Implementation
|
||||
|
||||
See `app/javascript/maps_maplibre/services/api_client.js` for API communication.
|
||||
|
||||
### Complete Controller Implementation
|
||||
|
||||
See `app/javascript/controllers/maps/maplibre_controller.js` for orchestration.
|
||||
|
||||
---
|
||||
|
||||
**Questions or need help?** Check the existing code for patterns or ask in Discord: https://discord.gg/pHsBjpt5J8
|
||||
167
app/javascript/controllers/area_creation_v2_controller.js
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Area creation controller
|
||||
* Handles the area creation modal and form submission
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
'modal',
|
||||
'form',
|
||||
'nameInput',
|
||||
'latitudeInput',
|
||||
'longitudeInput',
|
||||
'radiusInput',
|
||||
'radiusDisplay',
|
||||
'submitButton',
|
||||
'submitSpinner',
|
||||
'submitText'
|
||||
]
|
||||
|
||||
static values = {
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.area = null
|
||||
this.setupEventListeners()
|
||||
console.log('[Area Creation V2] Controller connected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event listeners for area drawing
|
||||
*/
|
||||
setupEventListeners() {
|
||||
document.addEventListener('area:drawn', (e) => {
|
||||
console.log('[Area Creation V2] area:drawn event received:', e.detail)
|
||||
this.open(e.detail.center, e.detail.radius)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal with area data
|
||||
*/
|
||||
open(center, radius) {
|
||||
console.log('[Area Creation V2] open() called with center:', center, 'radius:', radius)
|
||||
|
||||
// Store area data
|
||||
this.area = { center, radius }
|
||||
|
||||
// Update form fields
|
||||
this.latitudeInputTarget.value = center[1]
|
||||
this.longitudeInputTarget.value = center[0]
|
||||
this.radiusInputTarget.value = Math.round(radius)
|
||||
this.radiusDisplayTarget.textContent = Math.round(radius)
|
||||
|
||||
// Show modal
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
this.nameInputTarget.focus()
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
close() {
|
||||
this.modalTarget.classList.remove('modal-open')
|
||||
this.resetForm()
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit the form
|
||||
*/
|
||||
async submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
if (!this.area) {
|
||||
console.error('No area data available')
|
||||
return
|
||||
}
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
const name = formData.get('name')
|
||||
const latitude = parseFloat(formData.get('latitude'))
|
||||
const longitude = parseFloat(formData.get('longitude'))
|
||||
const radius = parseFloat(formData.get('radius'))
|
||||
|
||||
if (!name || !latitude || !longitude || !radius) {
|
||||
alert('Please fill in all required fields')
|
||||
return
|
||||
}
|
||||
|
||||
this.setLoading(true)
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/areas', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
latitude,
|
||||
longitude,
|
||||
radius
|
||||
})
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json()
|
||||
throw new Error(error.message || 'Failed to create area')
|
||||
}
|
||||
|
||||
const area = await response.json()
|
||||
|
||||
// Close modal
|
||||
this.close()
|
||||
|
||||
// Dispatch document event for area created
|
||||
document.dispatchEvent(new CustomEvent('area:created', {
|
||||
detail: { area }
|
||||
}))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating area:', error)
|
||||
alert(`Error creating area: ${error.message}`)
|
||||
} finally {
|
||||
this.setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set loading state
|
||||
*/
|
||||
setLoading(loading) {
|
||||
this.submitButtonTarget.disabled = loading
|
||||
|
||||
if (loading) {
|
||||
this.submitSpinnerTarget.classList.remove('hidden')
|
||||
this.submitTextTarget.textContent = 'Creating...'
|
||||
} else {
|
||||
this.submitSpinnerTarget.classList.add('hidden')
|
||||
this.submitTextTarget.textContent = 'Create Area'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset form
|
||||
*/
|
||||
resetForm() {
|
||||
this.formTarget.reset()
|
||||
this.area = null
|
||||
this.radiusDisplayTarget.textContent = '0'
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success message
|
||||
*/
|
||||
showSuccess(message) {
|
||||
// You can replace this with a toast notification if available
|
||||
console.log(message)
|
||||
|
||||
// Try to use the Toast component if available
|
||||
if (window.Toast) {
|
||||
window.Toast.show(message, 'success')
|
||||
}
|
||||
}
|
||||
}
|
||||
152
app/javascript/controllers/area_drawer_controller.js
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { createCircle, calculateDistance } from 'maps_maplibre/utils/geometry'
|
||||
|
||||
/**
|
||||
* Area drawer controller
|
||||
* Draw circular areas on map
|
||||
*/
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.isDrawing = false
|
||||
this.center = null
|
||||
this.radius = 0
|
||||
this.map = null
|
||||
|
||||
// Bind event handlers to maintain context
|
||||
this.onClick = this.onClick.bind(this)
|
||||
this.onMouseMove = this.onMouseMove.bind(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start drawing mode
|
||||
* @param {maplibregl.Map} map - The MapLibre map instance
|
||||
*/
|
||||
startDrawing(map) {
|
||||
console.log('[Area Drawer] startDrawing called with map:', map)
|
||||
if (!map) {
|
||||
console.error('[Area Drawer] Map instance not provided')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Area Drawer] Starting drawing mode')
|
||||
this.isDrawing = true
|
||||
this.map = map
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
|
||||
// Add temporary layer
|
||||
if (!map.getSource('draw-source')) {
|
||||
map.addSource('draw-source', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'draw-fill',
|
||||
type: 'fill',
|
||||
source: 'draw-source',
|
||||
paint: {
|
||||
'fill-color': '#22c55e',
|
||||
'fill-opacity': 0.2
|
||||
}
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'draw-outline',
|
||||
type: 'line',
|
||||
source: 'draw-source',
|
||||
paint: {
|
||||
'line-color': '#22c55e',
|
||||
'line-width': 2
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
map.on('click', this.onClick)
|
||||
map.on('mousemove', this.onMouseMove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel drawing mode
|
||||
*/
|
||||
cancelDrawing() {
|
||||
if (!this.map) return
|
||||
|
||||
this.isDrawing = false
|
||||
this.center = null
|
||||
this.radius = 0
|
||||
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
|
||||
// Clear drawing
|
||||
const source = this.map.getSource('draw-source')
|
||||
if (source) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
this.map.off('click', this.onClick)
|
||||
this.map.off('mousemove', this.onMouseMove)
|
||||
}
|
||||
|
||||
/**
|
||||
* Click handler
|
||||
*/
|
||||
onClick(e) {
|
||||
if (!this.isDrawing || !this.map) return
|
||||
|
||||
if (!this.center) {
|
||||
// First click - set center
|
||||
console.log('[Area Drawer] First click - setting center:', e.lngLat)
|
||||
this.center = [e.lngLat.lng, e.lngLat.lat]
|
||||
} else {
|
||||
// Second click - finish drawing
|
||||
console.log('[Area Drawer] Second click - finishing drawing')
|
||||
|
||||
console.log('[Area Drawer] Dispatching area:drawn event')
|
||||
document.dispatchEvent(new CustomEvent('area:drawn', {
|
||||
detail: {
|
||||
center: this.center,
|
||||
radius: this.radius
|
||||
}
|
||||
}))
|
||||
|
||||
this.cancelDrawing()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse move handler
|
||||
*/
|
||||
onMouseMove(e) {
|
||||
if (!this.isDrawing || !this.center || !this.map) return
|
||||
|
||||
const currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||
this.radius = calculateDistance(this.center, currentPoint)
|
||||
|
||||
this.updateDrawing()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update drawing visualization
|
||||
*/
|
||||
updateDrawing() {
|
||||
if (!this.center || this.radius === 0 || !this.map) return
|
||||
|
||||
const coordinates = createCircle(this.center, this.radius)
|
||||
|
||||
const source = this.map.getSource('draw-source')
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates]
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
161
app/javascript/controllers/area_selector_controller.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { createRectangle } from 'maps_maplibre/utils/geometry'
|
||||
|
||||
/**
|
||||
* Area selector controller
|
||||
* Draw rectangle selection on map
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static outlets = ['mapsV2']
|
||||
|
||||
connect() {
|
||||
this.isSelecting = false
|
||||
this.startPoint = null
|
||||
this.currentPoint = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Start rectangle selection mode
|
||||
*/
|
||||
startSelection() {
|
||||
if (!this.hasMapsV2Outlet) {
|
||||
console.error('Maps V2 outlet not found')
|
||||
return
|
||||
}
|
||||
|
||||
this.isSelecting = true
|
||||
const map = this.mapsV2Outlet.map
|
||||
map.getCanvas().style.cursor = 'crosshair'
|
||||
|
||||
// Add temporary layer for selection
|
||||
if (!map.getSource('selection-source')) {
|
||||
map.addSource('selection-source', {
|
||||
type: 'geojson',
|
||||
data: { type: 'FeatureCollection', features: [] }
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'selection-fill',
|
||||
type: 'fill',
|
||||
source: 'selection-source',
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.2
|
||||
}
|
||||
})
|
||||
|
||||
map.addLayer({
|
||||
id: 'selection-outline',
|
||||
type: 'line',
|
||||
source: 'selection-source',
|
||||
paint: {
|
||||
'line-color': '#3b82f6',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
map.on('mousedown', this.onMouseDown)
|
||||
map.on('mousemove', this.onMouseMove)
|
||||
map.on('mouseup', this.onMouseUp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel selection mode
|
||||
*/
|
||||
cancelSelection() {
|
||||
if (!this.hasMapsV2Outlet) return
|
||||
|
||||
this.isSelecting = false
|
||||
this.startPoint = null
|
||||
this.currentPoint = null
|
||||
|
||||
const map = this.mapsV2Outlet.map
|
||||
map.getCanvas().style.cursor = ''
|
||||
|
||||
// Clear selection
|
||||
const source = map.getSource('selection-source')
|
||||
if (source) {
|
||||
source.setData({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
map.off('mousedown', this.onMouseDown)
|
||||
map.off('mousemove', this.onMouseMove)
|
||||
map.off('mouseup', this.onMouseUp)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse down handler
|
||||
*/
|
||||
onMouseDown = (e) => {
|
||||
if (!this.isSelecting || !this.hasMapsV2Outlet) return
|
||||
|
||||
this.startPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||
this.mapsV2Outlet.map.dragPan.disable()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse move handler
|
||||
*/
|
||||
onMouseMove = (e) => {
|
||||
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
|
||||
|
||||
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||
this.updateSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Mouse up handler
|
||||
*/
|
||||
onMouseUp = (e) => {
|
||||
if (!this.isSelecting || !this.startPoint || !this.hasMapsV2Outlet) return
|
||||
|
||||
this.currentPoint = [e.lngLat.lng, e.lngLat.lat]
|
||||
this.mapsV2Outlet.map.dragPan.enable()
|
||||
|
||||
// Emit selection event
|
||||
const bounds = this.getSelectionBounds()
|
||||
this.dispatch('selected', { detail: { bounds } })
|
||||
|
||||
this.cancelSelection()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selection visualization
|
||||
*/
|
||||
updateSelection() {
|
||||
if (!this.startPoint || !this.currentPoint || !this.hasMapsV2Outlet) return
|
||||
|
||||
const bounds = this.getSelectionBounds()
|
||||
const rectangle = createRectangle(bounds)
|
||||
|
||||
const source = this.mapsV2Outlet.map.getSource('selection-source')
|
||||
if (source) {
|
||||
source.setData({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: rectangle
|
||||
}
|
||||
}]
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selection bounds
|
||||
*/
|
||||
getSelectionBounds() {
|
||||
return {
|
||||
minLng: Math.min(this.startPoint[0], this.currentPoint[0]),
|
||||
minLat: Math.min(this.startPoint[1], this.currentPoint[1]),
|
||||
maxLng: Math.max(this.startPoint[0], this.currentPoint[0]),
|
||||
maxLat: Math.max(this.startPoint[1], this.currentPoint[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
68
app/javascript/controllers/map_panel_controller.js
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Map Panel Controller
|
||||
* Handles tab switching in the map control panel
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['tabButton', 'tabContent', 'title']
|
||||
|
||||
// Tab title mappings
|
||||
static titles = {
|
||||
search: 'Search',
|
||||
layers: 'Map Layers',
|
||||
tools: 'Tools',
|
||||
links: 'Links',
|
||||
settings: 'Settings'
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log('[Map Panel] Connected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a different tab
|
||||
*/
|
||||
switchTab(event) {
|
||||
const button = event.currentTarget
|
||||
const tabName = button.dataset.tab
|
||||
|
||||
this.activateTab(tabName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Programmatically switch to a tab by name
|
||||
*/
|
||||
switchToTab(tabName) {
|
||||
this.activateTab(tabName)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal method to activate a tab
|
||||
*/
|
||||
activateTab(tabName) {
|
||||
// Find the button for this tab
|
||||
const button = this.tabButtonTargets.find(btn => btn.dataset.tab === tabName)
|
||||
|
||||
// Update active button
|
||||
this.tabButtonTargets.forEach(btn => {
|
||||
btn.classList.remove('active')
|
||||
})
|
||||
if (button) {
|
||||
button.classList.add('active')
|
||||
}
|
||||
|
||||
// Update tab content
|
||||
this.tabContentTargets.forEach(content => {
|
||||
const contentTab = content.dataset.tabContent
|
||||
if (contentTab === tabName) {
|
||||
content.classList.add('active')
|
||||
} else {
|
||||
content.classList.remove('active')
|
||||
}
|
||||
})
|
||||
|
||||
// Update title
|
||||
this.titleTarget.textContent = this.constructor.titles[tabName] || tabName
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,540 @@
|
|||
import { SelectionLayer } from 'maps_maplibre/layers/selection_layer'
|
||||
import { SelectedPointsLayer } from 'maps_maplibre/layers/selected_points_layer'
|
||||
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
|
||||
import { VisitCard } from 'maps_maplibre/components/visit_card'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Manages area selection and bulk operations for Maps V2
|
||||
* Handles selection mode, visit cards, and bulk actions (merge, confirm, decline)
|
||||
*/
|
||||
export class AreaSelectionManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.map = controller.map
|
||||
this.api = controller.api
|
||||
this.selectionLayer = null
|
||||
this.selectedPointsLayer = null
|
||||
this.selectedVisits = []
|
||||
this.selectedVisitIds = new Set()
|
||||
}
|
||||
|
||||
/**
|
||||
* Start area selection mode
|
||||
*/
|
||||
async startSelectArea() {
|
||||
console.log('[Maps V2] Starting area selection mode')
|
||||
|
||||
// Initialize selection layer if not exists
|
||||
if (!this.selectionLayer) {
|
||||
this.selectionLayer = new SelectionLayer(this.map, {
|
||||
visible: true,
|
||||
onSelectionComplete: this.handleAreaSelected.bind(this)
|
||||
})
|
||||
|
||||
this.selectionLayer.add({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selection layer initialized')
|
||||
}
|
||||
|
||||
// Initialize selected points layer if not exists
|
||||
if (!this.selectedPointsLayer) {
|
||||
this.selectedPointsLayer = new SelectedPointsLayer(this.map, {
|
||||
visible: true
|
||||
})
|
||||
|
||||
this.selectedPointsLayer.add({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Selected points layer initialized')
|
||||
}
|
||||
|
||||
// Enable selection mode
|
||||
this.selectionLayer.enableSelectionMode()
|
||||
|
||||
// Update UI - replace Select Area button with Cancel Selection button
|
||||
if (this.controller.hasSelectAreaButtonTarget) {
|
||||
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"></line>
|
||||
<line x1="6" y1="6" x2="18" y2="18"></line>
|
||||
</svg>
|
||||
Cancel Selection
|
||||
`
|
||||
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#cancelAreaSelection'
|
||||
}
|
||||
|
||||
Toast.info('Draw a rectangle on the map to select points')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle area selection completion
|
||||
*/
|
||||
async handleAreaSelected(bounds) {
|
||||
console.log('[Maps V2] Area selected:', bounds)
|
||||
|
||||
try {
|
||||
Toast.info('Fetching data in selected area...')
|
||||
|
||||
const [points, visits] = await Promise.all([
|
||||
this.api.fetchPointsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
min_longitude: bounds.minLng,
|
||||
max_longitude: bounds.maxLng,
|
||||
min_latitude: bounds.minLat,
|
||||
max_latitude: bounds.maxLat
|
||||
}),
|
||||
this.api.fetchVisitsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
sw_lat: bounds.minLat,
|
||||
sw_lng: bounds.minLng,
|
||||
ne_lat: bounds.maxLat,
|
||||
ne_lng: bounds.maxLng
|
||||
})
|
||||
])
|
||||
|
||||
console.log('[Maps V2] Found', points.length, 'points and', visits.length, 'visits in area')
|
||||
|
||||
if (points.length === 0 && visits.length === 0) {
|
||||
Toast.info('No data found in selected area')
|
||||
this.cancelAreaSelection()
|
||||
return
|
||||
}
|
||||
|
||||
// Convert points to GeoJSON and display
|
||||
if (points.length > 0) {
|
||||
const geojson = pointsToGeoJSON(points)
|
||||
this.selectedPointsLayer.updateSelectedPoints(geojson)
|
||||
this.selectedPointsLayer.show()
|
||||
}
|
||||
|
||||
// Display visits in side panel and on map
|
||||
if (visits.length > 0) {
|
||||
this.displaySelectedVisits(visits)
|
||||
}
|
||||
|
||||
// Update UI - show action buttons
|
||||
if (this.controller.hasSelectionActionsTarget) {
|
||||
this.controller.selectionActionsTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
// Update delete button text with count
|
||||
if (this.controller.hasDeleteButtonTextTarget) {
|
||||
this.controller.deleteButtonTextTarget.textContent = `Delete ${points.length} Point${points.length === 1 ? '' : 's'}`
|
||||
}
|
||||
|
||||
// Disable selection mode
|
||||
this.selectionLayer.disableSelectionMode()
|
||||
|
||||
const messages = []
|
||||
if (points.length > 0) messages.push(`${points.length} point${points.length === 1 ? '' : 's'}`)
|
||||
if (visits.length > 0) messages.push(`${visits.length} visit${visits.length === 1 ? '' : 's'}`)
|
||||
|
||||
Toast.success(`Selected ${messages.join(' and ')}`)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to fetch data in area:', error)
|
||||
Toast.error('Failed to fetch data in selected area')
|
||||
this.cancelAreaSelection()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Display selected visits in side panel
|
||||
*/
|
||||
displaySelectedVisits(visits) {
|
||||
if (!this.controller.hasSelectedVisitsContainerTarget) return
|
||||
|
||||
this.selectedVisits = visits
|
||||
this.selectedVisitIds = new Set()
|
||||
|
||||
const cardsHTML = visits.map(visit =>
|
||||
VisitCard.create(visit, { isSelected: false })
|
||||
).join('')
|
||||
|
||||
this.controller.selectedVisitsContainerTarget.innerHTML = `
|
||||
<div class="selected-visits-list">
|
||||
<div class="flex items-center gap-2 mb-3 pb-2 border-b border-base-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-primary" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
<h3 class="text-sm font-bold">Visits in Area (${visits.length})</h3>
|
||||
</div>
|
||||
${cardsHTML}
|
||||
</div>
|
||||
`
|
||||
|
||||
this.controller.selectedVisitsContainerTarget.classList.remove('hidden')
|
||||
this.attachVisitCardListeners()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.updateBulkActions()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to visit cards
|
||||
*/
|
||||
attachVisitCardListeners() {
|
||||
this.controller.element.querySelectorAll('[data-visit-select]').forEach(checkbox => {
|
||||
checkbox.addEventListener('change', (e) => {
|
||||
const visitId = parseInt(e.target.dataset.visitSelect)
|
||||
if (e.target.checked) {
|
||||
this.selectedVisitIds.add(visitId)
|
||||
} else {
|
||||
this.selectedVisitIds.delete(visitId)
|
||||
}
|
||||
this.updateBulkActions()
|
||||
})
|
||||
})
|
||||
|
||||
this.controller.element.querySelectorAll('[data-visit-confirm]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const visitId = parseInt(e.currentTarget.dataset.visitConfirm)
|
||||
await this.confirmVisit(visitId)
|
||||
})
|
||||
})
|
||||
|
||||
this.controller.element.querySelectorAll('[data-visit-decline]').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const visitId = parseInt(e.currentTarget.dataset.visitDecline)
|
||||
await this.declineVisit(visitId)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update bulk action buttons visibility and attach listeners
|
||||
*/
|
||||
updateBulkActions() {
|
||||
const selectedCount = this.selectedVisitIds.size
|
||||
|
||||
const existingBulkActions = this.controller.element.querySelectorAll('.bulk-actions-inline')
|
||||
existingBulkActions.forEach(el => el.remove())
|
||||
|
||||
if (selectedCount >= 2) {
|
||||
const selectedVisitCards = Array.from(this.controller.element.querySelectorAll('.visit-card'))
|
||||
.filter(card => {
|
||||
const visitId = parseInt(card.dataset.visitId)
|
||||
return this.selectedVisitIds.has(visitId)
|
||||
})
|
||||
|
||||
if (selectedVisitCards.length > 0) {
|
||||
const lastSelectedCard = selectedVisitCards[selectedVisitCards.length - 1]
|
||||
|
||||
const bulkActionsDiv = document.createElement('div')
|
||||
bulkActionsDiv.className = 'bulk-actions-inline mb-2'
|
||||
bulkActionsDiv.innerHTML = `
|
||||
<div class="bg-primary/10 border-2 border-primary border-dashed rounded-lg p-3">
|
||||
<div class="text-xs font-semibold mb-2 text-primary flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected</span>
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-1.5">
|
||||
<button class="btn btn-xs btn-outline normal-case" data-bulk-merge>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
Merge
|
||||
</button>
|
||||
<button class="btn btn-xs btn-primary normal-case" data-bulk-confirm>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-xs btn-outline btn-error normal-case" data-bulk-decline>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
lastSelectedCard.insertAdjacentElement('afterend', bulkActionsDiv)
|
||||
|
||||
const mergeBtn = bulkActionsDiv.querySelector('[data-bulk-merge]')
|
||||
const confirmBtn = bulkActionsDiv.querySelector('[data-bulk-confirm]')
|
||||
const declineBtn = bulkActionsDiv.querySelector('[data-bulk-decline]')
|
||||
|
||||
if (mergeBtn) mergeBtn.addEventListener('click', () => this.bulkMergeVisits())
|
||||
if (confirmBtn) confirmBtn.addEventListener('click', () => this.bulkConfirmVisits())
|
||||
if (declineBtn) declineBtn.addEventListener('click', () => this.bulkDeclineVisits())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm a single visit
|
||||
*/
|
||||
async confirmVisit(visitId) {
|
||||
try {
|
||||
await this.api.updateVisitStatus(visitId, 'confirmed')
|
||||
Toast.success('Visit confirmed')
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to confirm visit:', error)
|
||||
Toast.error('Failed to confirm visit')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decline a single visit
|
||||
*/
|
||||
async declineVisit(visitId) {
|
||||
try {
|
||||
await this.api.updateVisitStatus(visitId, 'declined')
|
||||
Toast.success('Visit declined')
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to decline visit:', error)
|
||||
Toast.error('Failed to decline visit')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk merge selected visits
|
||||
*/
|
||||
async bulkMergeVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
if (visitIds.length < 2) {
|
||||
Toast.error('Select at least 2 visits to merge')
|
||||
return
|
||||
}
|
||||
|
||||
if (!confirm(`Merge ${visitIds.length} visits into one?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.info('Merging visits...')
|
||||
const mergedVisit = await this.api.mergeVisits(visitIds)
|
||||
Toast.success('Visits merged successfully')
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
this.replaceVisitsWithMerged(visitIds, mergedVisit)
|
||||
this.updateBulkActions()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to merge visits:', error)
|
||||
Toast.error('Failed to merge visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk confirm selected visits
|
||||
*/
|
||||
async bulkConfirmVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
try {
|
||||
Toast.info('Confirming visits...')
|
||||
await this.api.bulkUpdateVisits(visitIds, 'confirmed')
|
||||
Toast.success(`Confirmed ${visitIds.length} visits`)
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to confirm visits:', error)
|
||||
Toast.error('Failed to confirm visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk decline selected visits
|
||||
*/
|
||||
async bulkDeclineVisits() {
|
||||
const visitIds = Array.from(this.selectedVisitIds)
|
||||
|
||||
if (!confirm(`Decline ${visitIds.length} visits?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
Toast.info('Declining visits...')
|
||||
await this.api.bulkUpdateVisits(visitIds, 'declined')
|
||||
Toast.success(`Declined ${visitIds.length} visits`)
|
||||
|
||||
this.selectedVisitIds.clear()
|
||||
await this.refreshSelectedVisits()
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to decline visits:', error)
|
||||
Toast.error('Failed to decline visits')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace merged visit cards with the new merged visit
|
||||
*/
|
||||
replaceVisitsWithMerged(oldVisitIds, mergedVisit) {
|
||||
const container = this.controller.element.querySelector('.selected-visits-list')
|
||||
if (!container) return
|
||||
|
||||
const mergedStartTime = new Date(mergedVisit.started_at).getTime()
|
||||
const allCards = Array.from(container.querySelectorAll('.visit-card'))
|
||||
|
||||
let insertBeforeCard = null
|
||||
for (const card of allCards) {
|
||||
const cardId = parseInt(card.dataset.visitId)
|
||||
if (oldVisitIds.includes(cardId)) continue
|
||||
|
||||
const cardVisit = this.selectedVisits.find(v => v.id === cardId)
|
||||
if (cardVisit) {
|
||||
const cardStartTime = new Date(cardVisit.started_at).getTime()
|
||||
if (cardStartTime > mergedStartTime) {
|
||||
insertBeforeCard = card
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldVisitIds.forEach(id => {
|
||||
const card = this.controller.element.querySelector(`.visit-card[data-visit-id="${id}"]`)
|
||||
if (card) card.remove()
|
||||
})
|
||||
|
||||
this.selectedVisits = this.selectedVisits.filter(v => !oldVisitIds.includes(v.id))
|
||||
this.selectedVisits.push(mergedVisit)
|
||||
this.selectedVisits.sort((a, b) => new Date(a.started_at) - new Date(b.started_at))
|
||||
|
||||
const newCardHTML = VisitCard.create(mergedVisit, { isSelected: false })
|
||||
|
||||
if (insertBeforeCard) {
|
||||
insertBeforeCard.insertAdjacentHTML('beforebegin', newCardHTML)
|
||||
} else {
|
||||
container.insertAdjacentHTML('beforeend', newCardHTML)
|
||||
}
|
||||
|
||||
const header = container.querySelector('h3')
|
||||
if (header) {
|
||||
header.textContent = `Visits in Area (${this.selectedVisits.length})`
|
||||
}
|
||||
|
||||
this.attachVisitCardListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh selected visits after changes
|
||||
*/
|
||||
async refreshSelectedVisits() {
|
||||
const bounds = this.selectionLayer.currentRect
|
||||
if (!bounds) return
|
||||
|
||||
try {
|
||||
const visits = await this.api.fetchVisitsInArea({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue,
|
||||
sw_lat: bounds.start.lat < bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||
sw_lng: bounds.start.lng < bounds.end.lng ? bounds.start.lng : bounds.end.lng,
|
||||
ne_lat: bounds.start.lat > bounds.end.lat ? bounds.start.lat : bounds.end.lat,
|
||||
ne_lng: bounds.start.lng > bounds.end.lng ? bounds.start.lng : bounds.end.lng
|
||||
})
|
||||
|
||||
this.displaySelectedVisits(visits)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to refresh visits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel area selection
|
||||
*/
|
||||
cancelAreaSelection() {
|
||||
console.log('[Maps V2] Cancelling area selection')
|
||||
|
||||
if (this.selectionLayer) {
|
||||
this.selectionLayer.disableSelectionMode()
|
||||
this.selectionLayer.clearSelection()
|
||||
}
|
||||
|
||||
if (this.selectedPointsLayer) {
|
||||
this.selectedPointsLayer.clearSelection()
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectedVisitsContainerTarget) {
|
||||
this.controller.selectedVisitsContainerTarget.classList.add('hidden')
|
||||
this.controller.selectedVisitsContainerTarget.innerHTML = ''
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectedVisitsBulkActionsTarget) {
|
||||
this.controller.selectedVisitsBulkActionsTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
this.selectedVisits = []
|
||||
this.selectedVisitIds = new Set()
|
||||
|
||||
if (this.controller.hasSelectAreaButtonTarget) {
|
||||
this.controller.selectAreaButtonTarget.innerHTML = `
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="w-5 h-5">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
||||
<path d="M9 3v18"></path>
|
||||
<path d="M15 3v18"></path>
|
||||
<path d="M3 9h18"></path>
|
||||
<path d="M3 15h18"></path>
|
||||
</svg>
|
||||
Select Area
|
||||
`
|
||||
this.controller.selectAreaButtonTarget.classList.remove('btn-error')
|
||||
this.controller.selectAreaButtonTarget.classList.add('btn', 'btn-outline')
|
||||
this.controller.selectAreaButtonTarget.dataset.action = 'click->maps--maplibre#startSelectArea'
|
||||
}
|
||||
|
||||
if (this.controller.hasSelectionActionsTarget) {
|
||||
this.controller.selectionActionsTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
Toast.info('Selection cancelled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete selected points
|
||||
*/
|
||||
async deleteSelectedPoints() {
|
||||
const pointCount = this.selectedPointsLayer.getCount()
|
||||
const pointIds = this.selectedPointsLayer.getSelectedPointIds()
|
||||
|
||||
if (pointIds.length === 0) {
|
||||
Toast.error('No points selected')
|
||||
return
|
||||
}
|
||||
|
||||
const confirmed = confirm(
|
||||
`Are you sure you want to delete ${pointCount} point${pointCount === 1 ? '' : 's'}? This action cannot be undone.`
|
||||
)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
console.log('[Maps V2] Deleting', pointIds.length, 'points')
|
||||
|
||||
try {
|
||||
Toast.info('Deleting points...')
|
||||
const result = await this.api.bulkDeletePoints(pointIds)
|
||||
|
||||
console.log('[Maps V2] Deleted', result.count, 'points')
|
||||
|
||||
this.cancelAreaSelection()
|
||||
|
||||
await this.controller.loadMapData({
|
||||
showLoading: false,
|
||||
fitBounds: false,
|
||||
showToast: false
|
||||
})
|
||||
|
||||
Toast.success(`Deleted ${result.count} point${result.count === 1 ? '' : 's'}`)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to delete points:', error)
|
||||
Toast.error('Failed to delete points. Please try again.')
|
||||
}
|
||||
}
|
||||
}
|
||||
225
app/javascript/controllers/maps/maplibre/data_loader.js
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import { pointsToGeoJSON } from 'maps_maplibre/utils/geojson_transformers'
|
||||
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
|
||||
import { createCircle } from 'maps_maplibre/utils/geometry'
|
||||
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||
|
||||
/**
|
||||
* Handles loading and transforming data from API
|
||||
*/
|
||||
export class DataLoader {
|
||||
constructor(api, apiKey) {
|
||||
this.api = api
|
||||
this.apiKey = apiKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all map data (points, visits, photos, areas, tracks)
|
||||
*/
|
||||
async fetchMapData(startDate, endDate, onProgress) {
|
||||
const data = {}
|
||||
|
||||
// Fetch points
|
||||
performanceMonitor.mark('fetch-points')
|
||||
data.points = await this.api.fetchAllPoints({
|
||||
start_at: startDate,
|
||||
end_at: endDate,
|
||||
onProgress: onProgress
|
||||
})
|
||||
performanceMonitor.measure('fetch-points')
|
||||
|
||||
// Transform points to GeoJSON
|
||||
performanceMonitor.mark('transform-geojson')
|
||||
data.pointsGeoJSON = pointsToGeoJSON(data.points)
|
||||
data.routesGeoJSON = RoutesLayer.pointsToRoutes(data.points)
|
||||
performanceMonitor.measure('transform-geojson')
|
||||
|
||||
// Fetch visits
|
||||
try {
|
||||
data.visits = await this.api.fetchVisits({
|
||||
start_at: startDate,
|
||||
end_at: endDate
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch visits:', error)
|
||||
data.visits = []
|
||||
}
|
||||
data.visitsGeoJSON = this.visitsToGeoJSON(data.visits)
|
||||
|
||||
// Fetch photos
|
||||
try {
|
||||
console.log('[Photos] Fetching photos from:', startDate, 'to', endDate)
|
||||
data.photos = await this.api.fetchPhotos({
|
||||
start_at: startDate,
|
||||
end_at: endDate
|
||||
})
|
||||
console.log('[Photos] Fetched photos:', data.photos.length, 'photos')
|
||||
console.log('[Photos] Sample photo:', data.photos[0])
|
||||
} catch (error) {
|
||||
console.error('[Photos] Failed to fetch photos:', error)
|
||||
data.photos = []
|
||||
}
|
||||
data.photosGeoJSON = this.photosToGeoJSON(data.photos)
|
||||
console.log('[Photos] Converted to GeoJSON:', data.photosGeoJSON.features.length, 'features')
|
||||
console.log('[Photos] Sample feature:', data.photosGeoJSON.features[0])
|
||||
|
||||
// Fetch areas
|
||||
try {
|
||||
data.areas = await this.api.fetchAreas()
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch areas:', error)
|
||||
data.areas = []
|
||||
}
|
||||
data.areasGeoJSON = this.areasToGeoJSON(data.areas)
|
||||
|
||||
// Fetch places (no date filtering)
|
||||
try {
|
||||
data.places = await this.api.fetchPlaces()
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch places:', error)
|
||||
data.places = []
|
||||
}
|
||||
data.placesGeoJSON = this.placesToGeoJSON(data.places)
|
||||
|
||||
// Tracks - DISABLED: Backend API not yet implemented
|
||||
// TODO: Re-enable when /api/v1/tracks endpoint is created
|
||||
data.tracks = []
|
||||
data.tracksGeoJSON = this.tracksToGeoJSON(data.tracks)
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert visits to GeoJSON
|
||||
*/
|
||||
visitsToGeoJSON(visits) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: visits.map(visit => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [visit.place.longitude, visit.place.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: visit.id,
|
||||
name: visit.name,
|
||||
place_name: visit.place?.name,
|
||||
status: visit.status,
|
||||
started_at: visit.started_at,
|
||||
ended_at: visit.ended_at,
|
||||
duration: visit.duration
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert photos to GeoJSON
|
||||
*/
|
||||
photosToGeoJSON(photos) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: photos.map(photo => {
|
||||
// Construct thumbnail URL
|
||||
const thumbnailUrl = `/api/v1/photos/${photo.id}/thumbnail.jpg?api_key=${this.apiKey}&source=${photo.source}`
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [photo.longitude, photo.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: photo.id,
|
||||
thumbnail_url: thumbnailUrl,
|
||||
taken_at: photo.localDateTime,
|
||||
filename: photo.originalFileName,
|
||||
city: photo.city,
|
||||
state: photo.state,
|
||||
country: photo.country,
|
||||
type: photo.type,
|
||||
source: photo.source
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert places to GeoJSON
|
||||
*/
|
||||
placesToGeoJSON(places) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: places.map(place => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [place.longitude, place.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: place.id,
|
||||
name: place.name,
|
||||
latitude: place.latitude,
|
||||
longitude: place.longitude,
|
||||
note: place.note,
|
||||
// Stringify tags for MapLibre GL JS compatibility
|
||||
tags: JSON.stringify(place.tags || []),
|
||||
// Use first tag's color if available
|
||||
color: place.tags?.[0]?.color || '#6366f1'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert areas to GeoJSON
|
||||
* Backend returns circular areas with latitude, longitude, radius
|
||||
*/
|
||||
areasToGeoJSON(areas) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: areas.map(area => {
|
||||
// Create circle polygon from center and radius
|
||||
// Parse as floats since API returns strings
|
||||
const center = [parseFloat(area.longitude), parseFloat(area.latitude)]
|
||||
const coordinates = createCircle(center, area.radius)
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [coordinates]
|
||||
},
|
||||
properties: {
|
||||
id: area.id,
|
||||
name: area.name,
|
||||
color: area.color || '#ef4444',
|
||||
radius: area.radius
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert tracks to GeoJSON
|
||||
*/
|
||||
tracksToGeoJSON(tracks) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: tracks.map(track => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: track.coordinates
|
||||
},
|
||||
properties: {
|
||||
id: track.id,
|
||||
name: track.name,
|
||||
color: track.color || '#8b5cf6'
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
35
app/javascript/controllers/maps/maplibre/date_manager.js
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* Manages date formatting and range calculations
|
||||
*/
|
||||
export class DateManager {
|
||||
/**
|
||||
* Format date for API requests (matching V1 format)
|
||||
* Format: "YYYY-MM-DDTHH:MM" (e.g., "2025-10-15T00:00", "2025-10-15T23:59")
|
||||
*/
|
||||
static formatDateForAPI(date) {
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
const year = date.getFullYear()
|
||||
const month = pad(date.getMonth() + 1)
|
||||
const day = pad(date.getDate())
|
||||
const hours = pad(date.getHours())
|
||||
const minutes = pad(date.getMinutes())
|
||||
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse month selector value to date range
|
||||
*/
|
||||
static parseMonthSelector(value) {
|
||||
const [year, month] = value.split('-')
|
||||
|
||||
const startDate = new Date(year, month - 1, 1, 0, 0, 0)
|
||||
const lastDay = new Date(year, month, 0).getDate()
|
||||
const endDate = new Date(year, month - 1, lastDay, 23, 59, 0)
|
||||
|
||||
return {
|
||||
startDate: this.formatDateForAPI(startDate),
|
||||
endDate: this.formatDateForAPI(endDate)
|
||||
}
|
||||
}
|
||||
}
|
||||
129
app/javascript/controllers/maps/maplibre/event_handlers.js
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import { formatTimestamp } from 'maps_maplibre/utils/geojson_transformers'
|
||||
|
||||
/**
|
||||
* Handles map interaction events (clicks, info display)
|
||||
*/
|
||||
export class EventHandlers {
|
||||
constructor(map, controller) {
|
||||
this.map = map
|
||||
this.controller = controller
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle point click
|
||||
*/
|
||||
handlePointClick(e) {
|
||||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
<div><span class="font-semibold">Time:</span> ${formatTimestamp(properties.timestamp)}</div>
|
||||
${properties.battery ? `<div><span class="font-semibold">Battery:</span> ${properties.battery}%</div>` : ''}
|
||||
${properties.altitude ? `<div><span class="font-semibold">Altitude:</span> ${Math.round(properties.altitude)}m</div>` : ''}
|
||||
${properties.velocity ? `<div><span class="font-semibold">Speed:</span> ${Math.round(properties.velocity)} km/h</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
|
||||
this.controller.showInfo('Location Point', content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit click
|
||||
*/
|
||||
handleVisitClick(e) {
|
||||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const startTime = formatTimestamp(properties.started_at)
|
||||
const endTime = formatTimestamp(properties.ended_at)
|
||||
const durationHours = Math.round(properties.duration / 3600)
|
||||
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(properties.duration / 60)}m`
|
||||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
<div class="badge badge-sm ${properties.status === 'confirmed' ? 'badge-success' : 'badge-warning'}">${properties.status}</div>
|
||||
<div><span class="font-semibold">Arrived:</span> ${startTime}</div>
|
||||
<div><span class="font-semibold">Left:</span> ${endTime}</div>
|
||||
<div><span class="font-semibold">Duration:</span> ${durationDisplay}</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
const actions = [{
|
||||
type: 'button',
|
||||
handler: 'handleEdit',
|
||||
id: properties.id,
|
||||
entityType: 'visit',
|
||||
label: 'Edit'
|
||||
}]
|
||||
|
||||
this.controller.showInfo(properties.name || properties.place_name || 'Visit', content, actions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle photo click
|
||||
*/
|
||||
handlePhotoClick(e) {
|
||||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
${properties.photo_url ? `<img src="${properties.photo_url}" alt="Photo" class="w-full rounded-lg mb-2" />` : ''}
|
||||
${properties.taken_at ? `<div><span class="font-semibold">Taken:</span> ${formatTimestamp(properties.taken_at)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
|
||||
this.controller.showInfo('Photo', content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle place click
|
||||
*/
|
||||
handlePlaceClick(e) {
|
||||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
${properties.tag ? `<div class="badge badge-sm badge-primary">${properties.tag}</div>` : ''}
|
||||
${properties.description ? `<div>${properties.description}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
|
||||
const actions = properties.id ? [{
|
||||
type: 'button',
|
||||
handler: 'handleEdit',
|
||||
id: properties.id,
|
||||
entityType: 'place',
|
||||
label: 'Edit'
|
||||
}] : []
|
||||
|
||||
this.controller.showInfo(properties.name || 'Place', content, actions)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle area click
|
||||
*/
|
||||
handleAreaClick(e) {
|
||||
const feature = e.features[0]
|
||||
const properties = feature.properties
|
||||
|
||||
const content = `
|
||||
<div class="space-y-2">
|
||||
${properties.radius ? `<div><span class="font-semibold">Radius:</span> ${Math.round(properties.radius)}m</div>` : ''}
|
||||
${properties.latitude && properties.longitude ? `<div><span class="font-semibold">Center:</span> ${properties.latitude.toFixed(6)}, ${properties.longitude.toFixed(6)}</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
|
||||
const actions = properties.id ? [{
|
||||
type: 'button',
|
||||
handler: 'handleDelete',
|
||||
id: properties.id,
|
||||
entityType: 'area',
|
||||
label: 'Delete'
|
||||
}] : []
|
||||
|
||||
this.controller.showInfo(properties.name || 'Area', content, actions)
|
||||
}
|
||||
}
|
||||
53
app/javascript/controllers/maps/maplibre/filter_manager.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
/**
|
||||
* Manages filtering and searching of map data
|
||||
*/
|
||||
export class FilterManager {
|
||||
constructor(dataLoader) {
|
||||
this.dataLoader = dataLoader
|
||||
this.currentVisitFilter = 'all'
|
||||
this.allVisits = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Store all visits for filtering
|
||||
*/
|
||||
setAllVisits(visits) {
|
||||
this.allVisits = visits
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter and update visits display
|
||||
*/
|
||||
filterAndUpdateVisits(searchTerm, statusFilter, visitsLayer) {
|
||||
if (!this.allVisits || !visitsLayer) return
|
||||
|
||||
const filtered = this.allVisits.filter(visit => {
|
||||
// Apply search
|
||||
const matchesSearch = !searchTerm ||
|
||||
visit.name?.toLowerCase().includes(searchTerm) ||
|
||||
visit.place?.name?.toLowerCase().includes(searchTerm)
|
||||
|
||||
// Apply status filter
|
||||
const matchesStatus = statusFilter === 'all' || visit.status === statusFilter
|
||||
|
||||
return matchesSearch && matchesStatus
|
||||
})
|
||||
|
||||
const geojson = this.dataLoader.visitsToGeoJSON(filtered)
|
||||
visitsLayer.update(geojson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current visit filter
|
||||
*/
|
||||
getCurrentVisitFilter() {
|
||||
return this.currentVisitFilter
|
||||
}
|
||||
|
||||
/**
|
||||
* Set current visit filter
|
||||
*/
|
||||
setCurrentVisitFilter(filter) {
|
||||
this.currentVisitFilter = filter
|
||||
}
|
||||
}
|
||||
279
app/javascript/controllers/maps/maplibre/layer_manager.js
Normal file
|
|
@ -0,0 +1,279 @@
|
|||
import { PointsLayer } from 'maps_maplibre/layers/points_layer'
|
||||
import { RoutesLayer } from 'maps_maplibre/layers/routes_layer'
|
||||
import { HeatmapLayer } from 'maps_maplibre/layers/heatmap_layer'
|
||||
import { VisitsLayer } from 'maps_maplibre/layers/visits_layer'
|
||||
import { PhotosLayer } from 'maps_maplibre/layers/photos_layer'
|
||||
import { AreasLayer } from 'maps_maplibre/layers/areas_layer'
|
||||
import { TracksLayer } from 'maps_maplibre/layers/tracks_layer'
|
||||
import { PlacesLayer } from 'maps_maplibre/layers/places_layer'
|
||||
import { FogLayer } from 'maps_maplibre/layers/fog_layer'
|
||||
import { FamilyLayer } from 'maps_maplibre/layers/family_layer'
|
||||
import { RecentPointLayer } from 'maps_maplibre/layers/recent_point_layer'
|
||||
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
|
||||
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||
|
||||
/**
|
||||
* Manages all map layers lifecycle and visibility
|
||||
*/
|
||||
export class LayerManager {
|
||||
constructor(map, settings, api) {
|
||||
this.map = map
|
||||
this.settings = settings
|
||||
this.api = api
|
||||
this.layers = {}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add or update all layers with provided data
|
||||
*/
|
||||
async addAllLayers(pointsGeoJSON, routesGeoJSON, visitsGeoJSON, photosGeoJSON, areasGeoJSON, tracksGeoJSON, placesGeoJSON) {
|
||||
performanceMonitor.mark('add-layers')
|
||||
|
||||
// Layer order matters - layers added first render below layers added later
|
||||
// Order: scratch (bottom) -> heatmap -> areas -> tracks -> routes -> visits -> places -> photos -> family -> points -> recent-point (top) -> fog (canvas overlay)
|
||||
|
||||
await this._addScratchLayer(pointsGeoJSON)
|
||||
this._addHeatmapLayer(pointsGeoJSON)
|
||||
this._addAreasLayer(areasGeoJSON)
|
||||
this._addTracksLayer(tracksGeoJSON)
|
||||
this._addRoutesLayer(routesGeoJSON)
|
||||
this._addVisitsLayer(visitsGeoJSON)
|
||||
this._addPlacesLayer(placesGeoJSON)
|
||||
|
||||
// Add photos layer with error handling (async, might fail loading images)
|
||||
try {
|
||||
await this._addPhotosLayer(photosGeoJSON)
|
||||
} catch (error) {
|
||||
console.warn('Failed to add photos layer:', error)
|
||||
}
|
||||
|
||||
this._addFamilyLayer()
|
||||
this._addPointsLayer(pointsGeoJSON)
|
||||
this._addRecentPointLayer()
|
||||
this._addFogLayer(pointsGeoJSON)
|
||||
|
||||
performanceMonitor.measure('add-layers')
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup event handlers for layer interactions
|
||||
*/
|
||||
setupLayerEventHandlers(handlers) {
|
||||
// Click handlers
|
||||
this.map.on('click', 'points', handlers.handlePointClick)
|
||||
this.map.on('click', 'visits', handlers.handleVisitClick)
|
||||
this.map.on('click', 'photos', handlers.handlePhotoClick)
|
||||
this.map.on('click', 'places', handlers.handlePlaceClick)
|
||||
// Areas have multiple layers (fill, outline, labels)
|
||||
this.map.on('click', 'areas-fill', handlers.handleAreaClick)
|
||||
this.map.on('click', 'areas-outline', handlers.handleAreaClick)
|
||||
this.map.on('click', 'areas-labels', handlers.handleAreaClick)
|
||||
|
||||
// Cursor change on hover
|
||||
this.map.on('mouseenter', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'points', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
this.map.on('mouseenter', 'visits', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'visits', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
this.map.on('mouseenter', 'photos', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'photos', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
this.map.on('mouseenter', 'places', () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', 'places', () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
// Areas hover handlers for all sub-layers
|
||||
const areaLayers = ['areas-fill', 'areas-outline', 'areas-labels']
|
||||
areaLayers.forEach(layerId => {
|
||||
// Only add handlers if layer exists
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.on('mouseenter', layerId, () => {
|
||||
this.map.getCanvas().style.cursor = 'pointer'
|
||||
})
|
||||
this.map.on('mouseleave', layerId, () => {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle layer visibility
|
||||
*/
|
||||
toggleLayer(layerName) {
|
||||
const layer = this.layers[`${layerName}Layer`]
|
||||
if (!layer) return null
|
||||
|
||||
layer.toggle()
|
||||
return layer.visible
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer instance
|
||||
*/
|
||||
getLayer(layerName) {
|
||||
return this.layers[`${layerName}Layer`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all layer references (for style changes)
|
||||
*/
|
||||
clearLayerReferences() {
|
||||
this.layers = {}
|
||||
}
|
||||
|
||||
// Private methods for individual layer management
|
||||
|
||||
async _addScratchLayer(pointsGeoJSON) {
|
||||
try {
|
||||
if (!this.layers.scratchLayer && this.settings.scratchEnabled) {
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
this.layers.scratchLayer = new ScratchLayer(this.map, {
|
||||
visible: true,
|
||||
apiClient: this.api
|
||||
})
|
||||
await this.layers.scratchLayer.add(pointsGeoJSON)
|
||||
} else if (this.layers.scratchLayer) {
|
||||
await this.layers.scratchLayer.update(pointsGeoJSON)
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load scratch layer:', error)
|
||||
}
|
||||
}
|
||||
|
||||
_addHeatmapLayer(pointsGeoJSON) {
|
||||
if (!this.layers.heatmapLayer) {
|
||||
this.layers.heatmapLayer = new HeatmapLayer(this.map, {
|
||||
visible: this.settings.heatmapEnabled
|
||||
})
|
||||
this.layers.heatmapLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.layers.heatmapLayer.update(pointsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addAreasLayer(areasGeoJSON) {
|
||||
if (!this.layers.areasLayer) {
|
||||
this.layers.areasLayer = new AreasLayer(this.map, {
|
||||
visible: this.settings.areasEnabled || false
|
||||
})
|
||||
this.layers.areasLayer.add(areasGeoJSON)
|
||||
} else {
|
||||
this.layers.areasLayer.update(areasGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addTracksLayer(tracksGeoJSON) {
|
||||
if (!this.layers.tracksLayer) {
|
||||
this.layers.tracksLayer = new TracksLayer(this.map, {
|
||||
visible: this.settings.tracksEnabled || false
|
||||
})
|
||||
this.layers.tracksLayer.add(tracksGeoJSON)
|
||||
} else {
|
||||
this.layers.tracksLayer.update(tracksGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addRoutesLayer(routesGeoJSON) {
|
||||
if (!this.layers.routesLayer) {
|
||||
this.layers.routesLayer = new RoutesLayer(this.map, {
|
||||
visible: this.settings.routesVisible !== false // Default true unless explicitly false
|
||||
})
|
||||
this.layers.routesLayer.add(routesGeoJSON)
|
||||
} else {
|
||||
this.layers.routesLayer.update(routesGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addVisitsLayer(visitsGeoJSON) {
|
||||
if (!this.layers.visitsLayer) {
|
||||
this.layers.visitsLayer = new VisitsLayer(this.map, {
|
||||
visible: this.settings.visitsEnabled || false
|
||||
})
|
||||
this.layers.visitsLayer.add(visitsGeoJSON)
|
||||
} else {
|
||||
this.layers.visitsLayer.update(visitsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addPlacesLayer(placesGeoJSON) {
|
||||
if (!this.layers.placesLayer) {
|
||||
this.layers.placesLayer = new PlacesLayer(this.map, {
|
||||
visible: this.settings.placesEnabled || false
|
||||
})
|
||||
this.layers.placesLayer.add(placesGeoJSON)
|
||||
} else {
|
||||
this.layers.placesLayer.update(placesGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
async _addPhotosLayer(photosGeoJSON) {
|
||||
console.log('[Photos] Adding photos layer, visible:', this.settings.photosEnabled)
|
||||
if (!this.layers.photosLayer) {
|
||||
this.layers.photosLayer = new PhotosLayer(this.map, {
|
||||
visible: this.settings.photosEnabled || false
|
||||
})
|
||||
console.log('[Photos] Created new PhotosLayer instance')
|
||||
await this.layers.photosLayer.add(photosGeoJSON)
|
||||
console.log('[Photos] Added photos to layer')
|
||||
} else {
|
||||
console.log('[Photos] Updating existing PhotosLayer')
|
||||
await this.layers.photosLayer.update(photosGeoJSON)
|
||||
console.log('[Photos] Updated photos layer')
|
||||
}
|
||||
}
|
||||
|
||||
_addFamilyLayer() {
|
||||
if (!this.layers.familyLayer) {
|
||||
this.layers.familyLayer = new FamilyLayer(this.map, {
|
||||
visible: false // Initially hidden, shown when family locations arrive via ActionCable
|
||||
})
|
||||
this.layers.familyLayer.add({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
_addPointsLayer(pointsGeoJSON) {
|
||||
if (!this.layers.pointsLayer) {
|
||||
this.layers.pointsLayer = new PointsLayer(this.map, {
|
||||
visible: this.settings.pointsVisible !== false // Default true unless explicitly false
|
||||
})
|
||||
this.layers.pointsLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.layers.pointsLayer.update(pointsGeoJSON)
|
||||
}
|
||||
}
|
||||
|
||||
_addRecentPointLayer() {
|
||||
if (!this.layers.recentPointLayer) {
|
||||
this.layers.recentPointLayer = new RecentPointLayer(this.map, {
|
||||
visible: false // Initially hidden, shown only when live mode is enabled
|
||||
})
|
||||
this.layers.recentPointLayer.add({ type: 'FeatureCollection', features: [] })
|
||||
}
|
||||
}
|
||||
|
||||
_addFogLayer(pointsGeoJSON) {
|
||||
// Always create fog layer for backward compatibility
|
||||
if (!this.layers.fogLayer) {
|
||||
this.layers.fogLayer = new FogLayer(this.map, {
|
||||
clearRadius: 1000,
|
||||
visible: this.settings.fogEnabled || false
|
||||
})
|
||||
this.layers.fogLayer.add(pointsGeoJSON)
|
||||
} else {
|
||||
this.layers.fogLayer.update(pointsGeoJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
131
app/javascript/controllers/maps/maplibre/map_data_manager.js
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import maplibregl from 'maplibre-gl'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||
|
||||
/**
|
||||
* Manages data loading and layer setup for the map
|
||||
*/
|
||||
export class MapDataManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.map = controller.map
|
||||
this.dataLoader = controller.dataLoader
|
||||
this.layerManager = controller.layerManager
|
||||
this.filterManager = controller.filterManager
|
||||
this.eventHandlers = controller.eventHandlers
|
||||
}
|
||||
|
||||
/**
|
||||
* Load map data from API and setup layers
|
||||
* @param {string} startDate - Start date for data range
|
||||
* @param {string} endDate - End date for data range
|
||||
* @param {Object} options - Loading options
|
||||
*/
|
||||
async loadMapData(startDate, endDate, options = {}) {
|
||||
const {
|
||||
showLoading = true,
|
||||
fitBounds = true,
|
||||
showToast = true,
|
||||
onProgress = null
|
||||
} = options
|
||||
|
||||
performanceMonitor.mark('load-map-data')
|
||||
|
||||
if (showLoading) {
|
||||
this.controller.showLoading()
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch data from API
|
||||
const data = await this.dataLoader.fetchMapData(
|
||||
startDate,
|
||||
endDate,
|
||||
showLoading ? onProgress : null
|
||||
)
|
||||
|
||||
// Store visits for filtering
|
||||
this.filterManager.setAllVisits(data.visits)
|
||||
|
||||
// Setup layers
|
||||
await this._setupLayers(data)
|
||||
|
||||
// Fit bounds if requested
|
||||
if (fitBounds && data.points.length > 0) {
|
||||
this._fitMapToBounds(data.pointsGeoJSON)
|
||||
}
|
||||
|
||||
// Show success message
|
||||
if (showToast) {
|
||||
const pointText = data.points.length === 1 ? 'point' : 'points'
|
||||
Toast.success(`Loaded ${data.points.length} location ${pointText}`)
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('[MapDataManager] Failed to load map data:', error)
|
||||
Toast.error('Failed to load location data. Please try again.')
|
||||
throw error
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
this.controller.hideLoading()
|
||||
}
|
||||
const duration = performanceMonitor.measure('load-map-data')
|
||||
console.log(`[Performance] Map data loaded in ${duration}ms`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup all map layers with loaded data
|
||||
* @private
|
||||
*/
|
||||
async _setupLayers(data) {
|
||||
const addAllLayers = async () => {
|
||||
await this.layerManager.addAllLayers(
|
||||
data.pointsGeoJSON,
|
||||
data.routesGeoJSON,
|
||||
data.visitsGeoJSON,
|
||||
data.photosGeoJSON,
|
||||
data.areasGeoJSON,
|
||||
data.tracksGeoJSON,
|
||||
data.placesGeoJSON
|
||||
)
|
||||
|
||||
this.layerManager.setupLayerEventHandlers({
|
||||
handlePointClick: this.eventHandlers.handlePointClick.bind(this.eventHandlers),
|
||||
handleVisitClick: this.eventHandlers.handleVisitClick.bind(this.eventHandlers),
|
||||
handlePhotoClick: this.eventHandlers.handlePhotoClick.bind(this.eventHandlers),
|
||||
handlePlaceClick: this.eventHandlers.handlePlaceClick.bind(this.eventHandlers),
|
||||
handleAreaClick: this.eventHandlers.handleAreaClick.bind(this.eventHandlers)
|
||||
})
|
||||
}
|
||||
|
||||
if (this.map.loaded()) {
|
||||
await addAllLayers()
|
||||
} else {
|
||||
this.map.once('load', async () => {
|
||||
await addAllLayers()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to data bounds
|
||||
* @private
|
||||
*/
|
||||
_fitMapToBounds(geojson) {
|
||||
if (!geojson?.features?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const coordinates = geojson.features.map(f => f.geometry.coordinates)
|
||||
|
||||
const bounds = coordinates.reduce((bounds, coord) => {
|
||||
return bounds.extend(coord)
|
||||
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
|
||||
|
||||
this.map.fitBounds(bounds, {
|
||||
padding: 50,
|
||||
maxZoom: 15
|
||||
})
|
||||
}
|
||||
}
|
||||
66
app/javascript/controllers/maps/maplibre/map_initializer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import maplibregl from 'maplibre-gl'
|
||||
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
|
||||
|
||||
/**
|
||||
* Handles map initialization for Maps V2
|
||||
*/
|
||||
export class MapInitializer {
|
||||
/**
|
||||
* Initialize MapLibre map instance
|
||||
* @param {HTMLElement} container - The container element for the map
|
||||
* @param {Object} settings - Map settings (style, center, zoom)
|
||||
* @returns {Promise<maplibregl.Map>} The initialized map instance
|
||||
*/
|
||||
static async initialize(container, settings = {}) {
|
||||
const {
|
||||
mapStyle = 'streets',
|
||||
center = [0, 0],
|
||||
zoom = 2,
|
||||
showControls = true
|
||||
} = settings
|
||||
|
||||
const style = await getMapStyle(mapStyle)
|
||||
|
||||
const map = new maplibregl.Map({
|
||||
container,
|
||||
style,
|
||||
center,
|
||||
zoom
|
||||
})
|
||||
|
||||
if (showControls) {
|
||||
map.addControl(new maplibregl.NavigationControl(), 'top-right')
|
||||
}
|
||||
|
||||
return map
|
||||
}
|
||||
|
||||
/**
|
||||
* Fit map to bounds of GeoJSON features
|
||||
* @param {maplibregl.Map} map - The map instance
|
||||
* @param {Object} geojson - GeoJSON FeatureCollection
|
||||
* @param {Object} options - Fit bounds options
|
||||
*/
|
||||
static fitToBounds(map, geojson, options = {}) {
|
||||
const {
|
||||
padding = 50,
|
||||
maxZoom = 15
|
||||
} = options
|
||||
|
||||
if (!geojson?.features?.length) {
|
||||
console.warn('[MapInitializer] No features to fit bounds to')
|
||||
return
|
||||
}
|
||||
|
||||
const coordinates = geojson.features.map(f => f.geometry.coordinates)
|
||||
|
||||
const bounds = coordinates.reduce((bounds, coord) => {
|
||||
return bounds.extend(coord)
|
||||
}, new maplibregl.LngLatBounds(coordinates[0], coordinates[0]))
|
||||
|
||||
map.fitBounds(bounds, {
|
||||
padding,
|
||||
maxZoom
|
||||
})
|
||||
}
|
||||
}
|
||||
281
app/javascript/controllers/maps/maplibre/places_manager.js
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Manages places-related operations for Maps V2
|
||||
* Including place creation, tag filtering, and layer management
|
||||
*/
|
||||
export class PlacesManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.layerManager = controller.layerManager
|
||||
this.api = controller.api
|
||||
this.dataLoader = controller.dataLoader
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle places layer
|
||||
*/
|
||||
togglePlaces(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('placesEnabled', enabled)
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
if (enabled) {
|
||||
placesLayer.show()
|
||||
if (this.controller.hasPlacesFiltersTarget) {
|
||||
this.controller.placesFiltersTarget.style.display = 'block'
|
||||
}
|
||||
this.initializePlaceTagFilters()
|
||||
} else {
|
||||
placesLayer.hide()
|
||||
if (this.controller.hasPlacesFiltersTarget) {
|
||||
this.controller.placesFiltersTarget.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize place tag filters (enable all by default or restore saved state)
|
||||
*/
|
||||
initializePlaceTagFilters() {
|
||||
const savedFilters = this.settings.placesTagFilters
|
||||
|
||||
if (savedFilters && savedFilters.length > 0) {
|
||||
this.restoreSavedTagFilters(savedFilters)
|
||||
} else {
|
||||
this.enableAllTagsInitial()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore saved tag filters
|
||||
*/
|
||||
restoreSavedTagFilters(savedFilters) {
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||
const shouldBeChecked = savedFilters.includes(value)
|
||||
|
||||
if (checkbox.checked !== shouldBeChecked) {
|
||||
checkbox.checked = shouldBeChecked
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (shouldBeChecked) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.syncEnableAllTagsToggle()
|
||||
this.loadPlacesWithTags(savedFilters)
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable all tags initially
|
||||
*/
|
||||
enableAllTagsInitial() {
|
||||
if (this.controller.hasEnableAllPlaceTagsToggleTarget) {
|
||||
this.controller.enableAllPlaceTagsToggleTarget.checked = true
|
||||
}
|
||||
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
const allTagIds = []
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
checkbox.checked = true
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
|
||||
const value = checkbox.value === 'untagged' ? checkbox.value : parseInt(checkbox.value)
|
||||
allTagIds.push(value)
|
||||
})
|
||||
|
||||
SettingsManager.updateSetting('placesTagFilters', allTagIds)
|
||||
this.loadPlacesWithTags(allTagIds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get selected place tag IDs
|
||||
*/
|
||||
getSelectedPlaceTags() {
|
||||
return Array.from(
|
||||
document.querySelectorAll('input[name="place_tag_ids[]"]:checked')
|
||||
).map(cb => {
|
||||
const value = cb.value
|
||||
return value === 'untagged' ? value : parseInt(value)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter places by selected tags
|
||||
*/
|
||||
filterPlacesByTags(event) {
|
||||
const badge = event.target.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (event.target.checked) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
|
||||
this.syncEnableAllTagsToggle()
|
||||
|
||||
const checkedTags = this.getSelectedPlaceTags()
|
||||
SettingsManager.updateSetting('placesTagFilters', checkedTags)
|
||||
this.loadPlacesWithTags(checkedTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync "Enable All Tags" toggle with individual tag states
|
||||
*/
|
||||
syncEnableAllTagsToggle() {
|
||||
if (!this.controller.hasEnableAllPlaceTagsToggleTarget) return
|
||||
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
const allChecked = Array.from(tagCheckboxes).every(cb => cb.checked)
|
||||
|
||||
this.controller.enableAllPlaceTagsToggleTarget.checked = allChecked
|
||||
}
|
||||
|
||||
/**
|
||||
* Load places filtered by tags
|
||||
*/
|
||||
async loadPlacesWithTags(tagIds = []) {
|
||||
try {
|
||||
let places = []
|
||||
|
||||
if (tagIds.length > 0) {
|
||||
places = await this.api.fetchPlaces({ tag_ids: tagIds })
|
||||
}
|
||||
|
||||
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
placesLayer.update(placesGeoJSON)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to load places:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle all place tags on/off
|
||||
*/
|
||||
toggleAllPlaceTags(event) {
|
||||
const enableAll = event.target.checked
|
||||
const tagCheckboxes = document.querySelectorAll('input[name="place_tag_ids[]"]')
|
||||
|
||||
tagCheckboxes.forEach(checkbox => {
|
||||
if (checkbox.checked !== enableAll) {
|
||||
checkbox.checked = enableAll
|
||||
|
||||
const badge = checkbox.nextElementSibling
|
||||
const color = badge.style.borderColor
|
||||
|
||||
if (enableAll) {
|
||||
badge.classList.remove('badge-outline')
|
||||
badge.style.backgroundColor = color
|
||||
badge.style.color = 'white'
|
||||
} else {
|
||||
badge.classList.add('badge-outline')
|
||||
badge.style.backgroundColor = 'transparent'
|
||||
badge.style.color = color
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const selectedTags = this.getSelectedPlaceTags()
|
||||
SettingsManager.updateSetting('placesTagFilters', selectedTags)
|
||||
this.loadPlacesWithTags(selectedTags)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start create place mode
|
||||
*/
|
||||
startCreatePlace() {
|
||||
console.log('[Maps V2] Starting create place mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||
Toast.info('Click on the map to place a place')
|
||||
|
||||
this.handleCreatePlaceClick = (e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
|
||||
document.dispatchEvent(new CustomEvent('place:create', {
|
||||
detail: { latitude: lat, longitude: lng }
|
||||
}))
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
this.controller.map.once('click', this.handleCreatePlaceClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle place creation event - reload places and update layer
|
||||
*/
|
||||
async handlePlaceCreated(event) {
|
||||
console.log('[Maps V2] Place created, reloading places...', event.detail)
|
||||
|
||||
try {
|
||||
const selectedTags = this.getSelectedPlaceTags()
|
||||
|
||||
const places = await this.api.fetchPlaces({
|
||||
tag_ids: selectedTags
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Fetched places:', places.length)
|
||||
|
||||
const placesGeoJSON = this.dataLoader.placesToGeoJSON(places)
|
||||
|
||||
console.log('[Maps V2] Converted to GeoJSON:', placesGeoJSON.features.length, 'features')
|
||||
|
||||
const placesLayer = this.layerManager.getLayer('places')
|
||||
if (placesLayer) {
|
||||
placesLayer.update(placesGeoJSON)
|
||||
console.log('[Maps V2] Places layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Places layer not found, cannot update')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload places:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle place update event - reload places and update layer
|
||||
*/
|
||||
async handlePlaceUpdated(event) {
|
||||
console.log('[Maps V2] Place updated, reloading places...', event.detail)
|
||||
|
||||
// Reuse the same logic as creation
|
||||
await this.handlePlaceCreated(event)
|
||||
}
|
||||
}
|
||||
360
app/javascript/controllers/maps/maplibre/routes_manager.js
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
import { lazyLoader } from 'maps_maplibre/utils/lazy_loader'
|
||||
|
||||
/**
|
||||
* Manages routes-related operations for Maps V2
|
||||
* Including speed-colored routes, route generation, and layer management
|
||||
*/
|
||||
export class RoutesManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.map = controller.map
|
||||
this.layerManager = controller.layerManager
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle routes layer visibility
|
||||
*/
|
||||
toggleRoutes(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer) {
|
||||
routesLayer.toggle(visible)
|
||||
}
|
||||
|
||||
if (this.controller.hasRoutesOptionsTarget) {
|
||||
this.controller.routesOptionsTarget.style.display = visible ? 'block' : 'none'
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('routesVisible', visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle speed-colored routes
|
||||
*/
|
||||
async toggleSpeedColoredRoutes(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('speedColoredRoutesEnabled', enabled)
|
||||
|
||||
if (this.controller.hasSpeedColorScaleContainerTarget) {
|
||||
this.controller.speedColorScaleContainerTarget.classList.toggle('hidden', !enabled)
|
||||
}
|
||||
|
||||
await this.reloadRoutes()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open speed color editor modal
|
||||
*/
|
||||
openSpeedColorEditor() {
|
||||
const currentScale = this.controller.speedColorScaleInputTarget.value ||
|
||||
'0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
|
||||
let modal = document.getElementById('speed-color-editor-modal')
|
||||
if (!modal) {
|
||||
modal = this.createSpeedColorEditorModal(currentScale)
|
||||
document.body.appendChild(modal)
|
||||
} else {
|
||||
const controller = this.controller.application.getControllerForElementAndIdentifier(modal, 'speed-color-editor')
|
||||
if (controller) {
|
||||
controller.colorStopsValue = currentScale
|
||||
controller.loadColorStops()
|
||||
}
|
||||
}
|
||||
|
||||
const checkbox = modal.querySelector('.modal-toggle')
|
||||
if (checkbox) {
|
||||
checkbox.checked = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create speed color editor modal element
|
||||
*/
|
||||
createSpeedColorEditorModal(currentScale) {
|
||||
const modal = document.createElement('div')
|
||||
modal.id = 'speed-color-editor-modal'
|
||||
modal.setAttribute('data-controller', 'speed-color-editor')
|
||||
modal.setAttribute('data-speed-color-editor-color-stops-value', currentScale)
|
||||
modal.setAttribute('data-action', 'speed-color-editor:save->maps--maplibre#handleSpeedColorSave')
|
||||
|
||||
modal.innerHTML = `
|
||||
<input type="checkbox" id="speed-color-editor-toggle" class="modal-toggle" />
|
||||
<div class="modal" role="dialog" data-speed-color-editor-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="text-lg font-bold mb-4">Edit Speed Color Gradient</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Gradient Preview -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Preview</span>
|
||||
</label>
|
||||
<div class="h-12 rounded-lg border-2 border-base-300"
|
||||
data-speed-color-editor-target="preview"></div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">This gradient will be applied to routes based on speed</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Color Stops List -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Color Stops</span>
|
||||
</label>
|
||||
<div class="space-y-2" data-speed-color-editor-target="stopsList"></div>
|
||||
</div>
|
||||
|
||||
<!-- Add Stop Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline w-full"
|
||||
data-action="click->speed-color-editor#addStop">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Color Stop
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button"
|
||||
class="btn btn-ghost"
|
||||
data-action="click->speed-color-editor#resetToDefault">
|
||||
Reset to Default
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn"
|
||||
data-action="click->speed-color-editor#close">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="button"
|
||||
class="btn btn-primary"
|
||||
data-action="click->speed-color-editor#save">
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="speed-color-editor-toggle"></label>
|
||||
</div>
|
||||
`
|
||||
|
||||
return modal
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle speed color save event from editor
|
||||
*/
|
||||
handleSpeedColorSave(event) {
|
||||
const newScale = event.detail.colorStops
|
||||
|
||||
this.controller.speedColorScaleInputTarget.value = newScale
|
||||
SettingsManager.updateSetting('speedColorScale', newScale)
|
||||
|
||||
if (this.controller.speedColoredToggleTarget.checked) {
|
||||
this.reloadRoutes()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload routes layer
|
||||
*/
|
||||
async reloadRoutes() {
|
||||
this.controller.showLoading('Reloading routes...')
|
||||
|
||||
try {
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
const points = pointsLayer?.data?.features?.map(f => ({
|
||||
latitude: f.geometry.coordinates[1],
|
||||
longitude: f.geometry.coordinates[0],
|
||||
timestamp: f.properties.timestamp
|
||||
})) || []
|
||||
|
||||
const distanceThresholdMeters = this.settings.metersBetweenRoutes || 500
|
||||
const timeThresholdMinutes = this.settings.minutesBetweenRoutes || 60
|
||||
|
||||
const { calculateSpeed, getSpeedColor } = await import('maps_maplibre/utils/speed_colors')
|
||||
|
||||
const routesGeoJSON = await this.generateRoutesWithSpeedColors(
|
||||
points,
|
||||
{ distanceThresholdMeters, timeThresholdMinutes },
|
||||
calculateSpeed,
|
||||
getSpeedColor
|
||||
)
|
||||
|
||||
this.layerManager.updateLayer('routes', routesGeoJSON)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to reload routes:', error)
|
||||
Toast.error('Failed to reload routes')
|
||||
} finally {
|
||||
this.controller.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate routes with speed coloring
|
||||
*/
|
||||
async generateRoutesWithSpeedColors(points, options, calculateSpeed, getSpeedColor) {
|
||||
const { RoutesLayer } = await import('maps_maplibre/layers/routes_layer')
|
||||
const useSpeedColors = this.settings.speedColoredRoutesEnabled || false
|
||||
const speedColorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
|
||||
const routesGeoJSON = RoutesLayer.pointsToRoutes(points, options)
|
||||
|
||||
if (!useSpeedColors) {
|
||||
return routesGeoJSON
|
||||
}
|
||||
|
||||
routesGeoJSON.features = routesGeoJSON.features.map((feature, index) => {
|
||||
const segment = points.slice(
|
||||
points.findIndex(p => p.timestamp === feature.properties.startTime),
|
||||
points.findIndex(p => p.timestamp === feature.properties.endTime) + 1
|
||||
)
|
||||
|
||||
if (segment.length >= 2) {
|
||||
const speed = calculateSpeed(segment[0], segment[segment.length - 1])
|
||||
const color = getSpeedColor(speed, useSpeedColors, speedColorScale)
|
||||
feature.properties.speed = speed
|
||||
feature.properties.color = color
|
||||
}
|
||||
|
||||
return feature
|
||||
})
|
||||
|
||||
return routesGeoJSON
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle heatmap visibility
|
||||
*/
|
||||
toggleHeatmap(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('heatmapEnabled', enabled)
|
||||
|
||||
const heatmapLayer = this.layerManager.getLayer('heatmap')
|
||||
if (heatmapLayer) {
|
||||
if (enabled) {
|
||||
heatmapLayer.show()
|
||||
} else {
|
||||
heatmapLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle fog of war layer
|
||||
*/
|
||||
toggleFog(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('fogEnabled', enabled)
|
||||
|
||||
const fogLayer = this.layerManager.getLayer('fog')
|
||||
if (fogLayer) {
|
||||
fogLayer.toggle(enabled)
|
||||
} else {
|
||||
console.warn('Fog layer not yet initialized')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle scratch map layer
|
||||
*/
|
||||
async toggleScratch(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('scratchEnabled', enabled)
|
||||
|
||||
try {
|
||||
const scratchLayer = this.layerManager.getLayer('scratch')
|
||||
if (!scratchLayer && enabled) {
|
||||
const ScratchLayer = await lazyLoader.loadLayer('scratch')
|
||||
const newScratchLayer = new ScratchLayer(this.map, {
|
||||
visible: true,
|
||||
apiClient: this.controller.api
|
||||
})
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
const pointsData = pointsLayer?.data || { type: 'FeatureCollection', features: [] }
|
||||
await newScratchLayer.add(pointsData)
|
||||
this.layerManager.layers.scratchLayer = newScratchLayer
|
||||
} else if (scratchLayer) {
|
||||
if (enabled) {
|
||||
scratchLayer.show()
|
||||
} else {
|
||||
scratchLayer.hide()
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to toggle scratch layer:', error)
|
||||
Toast.error('Failed to load scratch layer')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle photos layer
|
||||
*/
|
||||
togglePhotos(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('photosEnabled', enabled)
|
||||
|
||||
const photosLayer = this.layerManager.getLayer('photos')
|
||||
if (photosLayer) {
|
||||
if (enabled) {
|
||||
photosLayer.show()
|
||||
} else {
|
||||
photosLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle areas layer
|
||||
*/
|
||||
toggleAreas(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('areasEnabled', enabled)
|
||||
|
||||
const areasLayer = this.layerManager.getLayer('areas')
|
||||
if (areasLayer) {
|
||||
if (enabled) {
|
||||
areasLayer.show()
|
||||
} else {
|
||||
areasLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle tracks layer
|
||||
*/
|
||||
toggleTracks(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('tracksEnabled', enabled)
|
||||
|
||||
const tracksLayer = this.layerManager.getLayer('tracks')
|
||||
if (tracksLayer) {
|
||||
if (enabled) {
|
||||
tracksLayer.show()
|
||||
} else {
|
||||
tracksLayer.hide()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle points layer visibility
|
||||
*/
|
||||
togglePoints(event) {
|
||||
const element = event.currentTarget
|
||||
const visible = element.checked
|
||||
|
||||
const pointsLayer = this.layerManager.getLayer('points')
|
||||
if (pointsLayer) {
|
||||
pointsLayer.toggle(visible)
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('pointsVisible', visible)
|
||||
}
|
||||
}
|
||||
271
app/javascript/controllers/maps/maplibre/settings_manager.js
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||
import { getMapStyle } from 'maps_maplibre/utils/style_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Handles all settings-related operations for Maps V2
|
||||
* Including toggles, advanced settings, and UI synchronization
|
||||
*/
|
||||
export class SettingsController {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.settings = controller.settings
|
||||
}
|
||||
|
||||
// Lazy getters for properties that may not be initialized yet
|
||||
get map() {
|
||||
return this.controller.map
|
||||
}
|
||||
|
||||
get layerManager() {
|
||||
return this.controller.layerManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings (sync from backend and localStorage)
|
||||
*/
|
||||
async loadSettings() {
|
||||
this.settings = await SettingsManager.sync()
|
||||
this.controller.settings = this.settings
|
||||
console.log('[Maps V2] Settings loaded:', this.settings)
|
||||
return this.settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync UI controls with loaded settings
|
||||
*/
|
||||
syncToggleStates() {
|
||||
const controller = this.controller
|
||||
|
||||
// Sync layer toggles
|
||||
const toggleMap = {
|
||||
pointsToggle: 'pointsVisible',
|
||||
routesToggle: 'routesVisible',
|
||||
heatmapToggle: 'heatmapEnabled',
|
||||
visitsToggle: 'visitsEnabled',
|
||||
photosToggle: 'photosEnabled',
|
||||
areasToggle: 'areasEnabled',
|
||||
placesToggle: 'placesEnabled',
|
||||
fogToggle: 'fogEnabled',
|
||||
scratchToggle: 'scratchEnabled',
|
||||
speedColoredToggle: 'speedColoredRoutesEnabled'
|
||||
}
|
||||
|
||||
Object.entries(toggleMap).forEach(([targetName, settingKey]) => {
|
||||
const target = `${targetName}Target`
|
||||
if (controller[target]) {
|
||||
controller[target].checked = this.settings[settingKey]
|
||||
}
|
||||
})
|
||||
|
||||
// Show/hide visits search based on initial toggle state
|
||||
if (controller.hasVisitsToggleTarget && controller.hasVisitsSearchTarget) {
|
||||
controller.visitsSearchTarget.style.display = controller.visitsToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Show/hide places filters based on initial toggle state
|
||||
if (controller.hasPlacesToggleTarget && controller.hasPlacesFiltersTarget) {
|
||||
controller.placesFiltersTarget.style.display = controller.placesToggleTarget.checked ? 'block' : 'none'
|
||||
}
|
||||
|
||||
// Sync route opacity slider
|
||||
if (controller.hasRouteOpacityRangeTarget) {
|
||||
controller.routeOpacityRangeTarget.value = (this.settings.routeOpacity || 1.0) * 100
|
||||
}
|
||||
|
||||
// Sync map style dropdown
|
||||
const mapStyleSelect = controller.element.querySelector('select[name="mapStyle"]')
|
||||
if (mapStyleSelect) {
|
||||
mapStyleSelect.value = this.settings.mapStyle || 'light'
|
||||
}
|
||||
|
||||
// Sync fog of war settings
|
||||
const fogRadiusInput = controller.element.querySelector('input[name="fogOfWarRadius"]')
|
||||
if (fogRadiusInput) {
|
||||
fogRadiusInput.value = this.settings.fogOfWarRadius || 1000
|
||||
if (controller.hasFogRadiusValueTarget) {
|
||||
controller.fogRadiusValueTarget.textContent = `${fogRadiusInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const fogThresholdInput = controller.element.querySelector('input[name="fogOfWarThreshold"]')
|
||||
if (fogThresholdInput) {
|
||||
fogThresholdInput.value = this.settings.fogOfWarThreshold || 1
|
||||
if (controller.hasFogThresholdValueTarget) {
|
||||
controller.fogThresholdValueTarget.textContent = fogThresholdInput.value
|
||||
}
|
||||
}
|
||||
|
||||
// Sync route generation settings
|
||||
const metersBetweenInput = controller.element.querySelector('input[name="metersBetweenRoutes"]')
|
||||
if (metersBetweenInput) {
|
||||
metersBetweenInput.value = this.settings.metersBetweenRoutes || 500
|
||||
if (controller.hasMetersBetweenValueTarget) {
|
||||
controller.metersBetweenValueTarget.textContent = `${metersBetweenInput.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
const minutesBetweenInput = controller.element.querySelector('input[name="minutesBetweenRoutes"]')
|
||||
if (minutesBetweenInput) {
|
||||
minutesBetweenInput.value = this.settings.minutesBetweenRoutes || 60
|
||||
if (controller.hasMinutesBetweenValueTarget) {
|
||||
controller.minutesBetweenValueTarget.textContent = `${minutesBetweenInput.value}min`
|
||||
}
|
||||
}
|
||||
|
||||
// Sync speed-colored routes settings
|
||||
if (controller.hasSpeedColorScaleInputTarget) {
|
||||
const colorScale = this.settings.speedColorScale || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
controller.speedColorScaleInputTarget.value = colorScale
|
||||
}
|
||||
if (controller.hasSpeedColorScaleContainerTarget && controller.hasSpeedColoredToggleTarget) {
|
||||
const isEnabled = controller.speedColoredToggleTarget.checked
|
||||
controller.speedColorScaleContainerTarget.classList.toggle('hidden', !isEnabled)
|
||||
}
|
||||
|
||||
// Sync points rendering mode radio buttons
|
||||
const pointsRenderingRadios = controller.element.querySelectorAll('input[name="pointsRenderingMode"]')
|
||||
pointsRenderingRadios.forEach(radio => {
|
||||
radio.checked = radio.value === (this.settings.pointsRenderingMode || 'raw')
|
||||
})
|
||||
|
||||
// Sync speed-colored routes toggle
|
||||
const speedColoredRoutesToggle = controller.element.querySelector('input[name="speedColoredRoutes"]')
|
||||
if (speedColoredRoutesToggle) {
|
||||
speedColoredRoutesToggle.checked = this.settings.speedColoredRoutes || false
|
||||
}
|
||||
|
||||
console.log('[Maps V2] UI controls synced with settings')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update map style from settings
|
||||
*/
|
||||
async updateMapStyle(event) {
|
||||
const styleName = event.target.value
|
||||
SettingsManager.updateSetting('mapStyle', styleName)
|
||||
|
||||
const style = await getMapStyle(styleName)
|
||||
|
||||
// Clear layer references
|
||||
this.layerManager.clearLayerReferences()
|
||||
|
||||
this.map.setStyle(style)
|
||||
|
||||
// Reload layers after style change
|
||||
this.map.once('style.load', () => {
|
||||
console.log('Style loaded, reloading map data')
|
||||
this.controller.loadMapData()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset settings to defaults
|
||||
*/
|
||||
resetSettings() {
|
||||
if (confirm('Reset all settings to defaults? This will reload the page.')) {
|
||||
SettingsManager.resetToDefaults()
|
||||
window.location.reload()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update route opacity in real-time
|
||||
*/
|
||||
updateRouteOpacity(event) {
|
||||
const opacity = parseInt(event.target.value) / 100
|
||||
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', opacity)
|
||||
}
|
||||
|
||||
SettingsManager.updateSetting('routeOpacity', opacity)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update advanced settings from form submission
|
||||
*/
|
||||
async updateAdvancedSettings(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const formData = new FormData(event.target)
|
||||
const settings = {
|
||||
routeOpacity: parseFloat(formData.get('routeOpacity')) / 100,
|
||||
fogOfWarRadius: parseInt(formData.get('fogOfWarRadius')),
|
||||
fogOfWarThreshold: parseInt(formData.get('fogOfWarThreshold')),
|
||||
metersBetweenRoutes: parseInt(formData.get('metersBetweenRoutes')),
|
||||
minutesBetweenRoutes: parseInt(formData.get('minutesBetweenRoutes')),
|
||||
pointsRenderingMode: formData.get('pointsRenderingMode'),
|
||||
speedColoredRoutes: formData.get('speedColoredRoutes') === 'on'
|
||||
}
|
||||
|
||||
// Apply settings to current map
|
||||
await this.applySettingsToMap(settings)
|
||||
|
||||
// Save to backend and localStorage
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
await SettingsManager.updateSetting(key, value)
|
||||
}
|
||||
|
||||
Toast.success('Settings updated successfully')
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply settings to map without reload
|
||||
*/
|
||||
async applySettingsToMap(settings) {
|
||||
// Update route opacity
|
||||
if (settings.routeOpacity !== undefined) {
|
||||
const routesLayer = this.layerManager.getLayer('routes')
|
||||
if (routesLayer && this.map.getLayer('routes')) {
|
||||
this.map.setPaintProperty('routes', 'line-opacity', settings.routeOpacity)
|
||||
}
|
||||
}
|
||||
|
||||
// Update fog of war settings
|
||||
if (settings.fogOfWarRadius !== undefined || settings.fogOfWarThreshold !== undefined) {
|
||||
const fogLayer = this.layerManager.getLayer('fog')
|
||||
if (fogLayer) {
|
||||
if (settings.fogOfWarRadius) {
|
||||
fogLayer.clearRadius = settings.fogOfWarRadius
|
||||
}
|
||||
// Redraw fog layer
|
||||
if (fogLayer.visible) {
|
||||
await fogLayer.update(fogLayer.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For settings that require data reload
|
||||
if (settings.pointsRenderingMode || settings.speedColoredRoutes !== undefined) {
|
||||
Toast.info('Reloading map data with new settings...')
|
||||
await this.controller.loadMapData()
|
||||
}
|
||||
}
|
||||
|
||||
// Display value update methods
|
||||
updateFogRadiusDisplay(event) {
|
||||
if (this.controller.hasFogRadiusValueTarget) {
|
||||
this.controller.fogRadiusValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
updateFogThresholdDisplay(event) {
|
||||
if (this.controller.hasFogThresholdValueTarget) {
|
||||
this.controller.fogThresholdValueTarget.textContent = event.target.value
|
||||
}
|
||||
}
|
||||
|
||||
updateMetersBetweenDisplay(event) {
|
||||
if (this.controller.hasMetersBetweenValueTarget) {
|
||||
this.controller.metersBetweenValueTarget.textContent = `${event.target.value}m`
|
||||
}
|
||||
}
|
||||
|
||||
updateMinutesBetweenDisplay(event) {
|
||||
if (this.controller.hasMinutesBetweenValueTarget) {
|
||||
this.controller.minutesBetweenValueTarget.textContent = `${event.target.value}min`
|
||||
}
|
||||
}
|
||||
}
|
||||
153
app/javascript/controllers/maps/maplibre/visits_manager.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Manages visits-related operations for Maps V2
|
||||
* Including visit creation, filtering, and layer management
|
||||
*/
|
||||
export class VisitsManager {
|
||||
constructor(controller) {
|
||||
this.controller = controller
|
||||
this.layerManager = controller.layerManager
|
||||
this.filterManager = controller.filterManager
|
||||
this.api = controller.api
|
||||
this.dataLoader = controller.dataLoader
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle visits layer
|
||||
*/
|
||||
toggleVisits(event) {
|
||||
const enabled = event.target.checked
|
||||
SettingsManager.updateSetting('visitsEnabled', enabled)
|
||||
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
if (visitsLayer) {
|
||||
if (enabled) {
|
||||
visitsLayer.show()
|
||||
if (this.controller.hasVisitsSearchTarget) {
|
||||
this.controller.visitsSearchTarget.style.display = 'block'
|
||||
}
|
||||
} else {
|
||||
visitsLayer.hide()
|
||||
if (this.controller.hasVisitsSearchTarget) {
|
||||
this.controller.visitsSearchTarget.style.display = 'none'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search visits
|
||||
*/
|
||||
searchVisits(event) {
|
||||
const searchTerm = event.target.value.toLowerCase()
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
this.filterManager.filterAndUpdateVisits(
|
||||
searchTerm,
|
||||
this.filterManager.getCurrentVisitFilter(),
|
||||
visitsLayer
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter visits by status
|
||||
*/
|
||||
filterVisits(event) {
|
||||
const filter = event.target.value
|
||||
this.filterManager.setCurrentVisitFilter(filter)
|
||||
const searchTerm = document.getElementById('visits-search')?.value.toLowerCase() || ''
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
this.filterManager.filterAndUpdateVisits(searchTerm, filter, visitsLayer)
|
||||
}
|
||||
|
||||
/**
|
||||
* Start create visit mode
|
||||
*/
|
||||
startCreateVisit() {
|
||||
console.log('[Maps V2] Starting create visit mode')
|
||||
|
||||
if (this.controller.hasSettingsPanelTarget && this.controller.settingsPanelTarget.classList.contains('open')) {
|
||||
this.controller.toggleSettings()
|
||||
}
|
||||
|
||||
this.controller.map.getCanvas().style.cursor = 'crosshair'
|
||||
Toast.info('Click on the map to place a visit')
|
||||
|
||||
this.handleCreateVisitClick = (e) => {
|
||||
const { lng, lat } = e.lngLat
|
||||
this.openVisitCreationModal(lat, lng)
|
||||
this.controller.map.getCanvas().style.cursor = ''
|
||||
}
|
||||
|
||||
this.controller.map.once('click', this.handleCreateVisitClick)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open visit creation modal
|
||||
*/
|
||||
openVisitCreationModal(lat, lng) {
|
||||
console.log('[Maps V2] Opening visit creation modal', { lat, lng })
|
||||
|
||||
const modalElement = document.querySelector('[data-controller="visit-creation-v2"]')
|
||||
|
||||
if (!modalElement) {
|
||||
console.error('[Maps V2] Visit creation modal not found')
|
||||
Toast.error('Visit creation modal not available')
|
||||
return
|
||||
}
|
||||
|
||||
const controller = this.controller.application.getControllerForElementAndIdentifier(
|
||||
modalElement,
|
||||
'visit-creation-v2'
|
||||
)
|
||||
|
||||
if (controller) {
|
||||
controller.open(lat, lng, this.controller)
|
||||
} else {
|
||||
console.error('[Maps V2] Visit creation controller not found')
|
||||
Toast.error('Visit creation controller not available')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit creation event - reload visits and update layer
|
||||
*/
|
||||
async handleVisitCreated(event) {
|
||||
console.log('[Maps V2] Visit created, reloading visits...', event.detail)
|
||||
|
||||
try {
|
||||
const visits = await this.api.fetchVisits({
|
||||
start_at: this.controller.startDateValue,
|
||||
end_at: this.controller.endDateValue
|
||||
})
|
||||
|
||||
console.log('[Maps V2] Fetched visits:', visits.length)
|
||||
|
||||
this.filterManager.setAllVisits(visits)
|
||||
const visitsGeoJSON = this.dataLoader.visitsToGeoJSON(visits)
|
||||
|
||||
console.log('[Maps V2] Converted to GeoJSON:', visitsGeoJSON.features.length, 'features')
|
||||
|
||||
const visitsLayer = this.layerManager.getLayer('visits')
|
||||
if (visitsLayer) {
|
||||
visitsLayer.update(visitsGeoJSON)
|
||||
console.log('[Maps V2] Visits layer updated successfully')
|
||||
} else {
|
||||
console.warn('[Maps V2] Visits layer not found, cannot update')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload visits:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle visit update event - reload visits and update layer
|
||||
*/
|
||||
async handleVisitUpdated(event) {
|
||||
console.log('[Maps V2] Visit updated, reloading visits...', event.detail)
|
||||
|
||||
// Reuse the same logic as creation
|
||||
await this.handleVisitCreated(event)
|
||||
}
|
||||
}
|
||||
543
app/javascript/controllers/maps/maplibre_controller.js
Normal file
|
|
@ -0,0 +1,543 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { ApiClient } from 'maps_maplibre/services/api_client'
|
||||
import { SettingsManager } from 'maps_maplibre/utils/settings_manager'
|
||||
import { SearchManager } from 'maps_maplibre/utils/search_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
import { performanceMonitor } from 'maps_maplibre/utils/performance_monitor'
|
||||
import { CleanupHelper } from 'maps_maplibre/utils/cleanup_helper'
|
||||
import { MapInitializer } from './maplibre/map_initializer'
|
||||
import { MapDataManager } from './maplibre/map_data_manager'
|
||||
import { LayerManager } from './maplibre/layer_manager'
|
||||
import { DataLoader } from './maplibre/data_loader'
|
||||
import { EventHandlers } from './maplibre/event_handlers'
|
||||
import { FilterManager } from './maplibre/filter_manager'
|
||||
import { DateManager } from './maplibre/date_manager'
|
||||
import { SettingsController } from './maplibre/settings_manager'
|
||||
import { AreaSelectionManager } from './maplibre/area_selection_manager'
|
||||
import { VisitsManager } from './maplibre/visits_manager'
|
||||
import { PlacesManager } from './maplibre/places_manager'
|
||||
import { RoutesManager } from './maplibre/routes_manager'
|
||||
|
||||
/**
|
||||
* Main map controller for Maps V2
|
||||
* Coordinates between different managers and handles UI interactions
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static values = {
|
||||
apiKey: String,
|
||||
startDate: String,
|
||||
endDate: String
|
||||
}
|
||||
|
||||
static targets = [
|
||||
'container',
|
||||
'loading',
|
||||
'loadingText',
|
||||
'monthSelect',
|
||||
'clusterToggle',
|
||||
'settingsPanel',
|
||||
'visitsSearch',
|
||||
'routeOpacityRange',
|
||||
'placesFilters',
|
||||
'enableAllPlaceTagsToggle',
|
||||
'fogRadiusValue',
|
||||
'fogThresholdValue',
|
||||
'metersBetweenValue',
|
||||
'minutesBetweenValue',
|
||||
// Search
|
||||
'searchInput',
|
||||
'searchResults',
|
||||
// Layer toggles
|
||||
'pointsToggle',
|
||||
'routesToggle',
|
||||
'heatmapToggle',
|
||||
'visitsToggle',
|
||||
'photosToggle',
|
||||
'areasToggle',
|
||||
'placesToggle',
|
||||
'fogToggle',
|
||||
'scratchToggle',
|
||||
// Speed-colored routes
|
||||
'routesOptions',
|
||||
'speedColoredToggle',
|
||||
'speedColorScaleContainer',
|
||||
'speedColorScaleInput',
|
||||
// Area selection
|
||||
'selectAreaButton',
|
||||
'selectionActions',
|
||||
'deleteButtonText',
|
||||
'selectedVisitsContainer',
|
||||
'selectedVisitsBulkActions',
|
||||
// Info display
|
||||
'infoDisplay',
|
||||
'infoTitle',
|
||||
'infoContent',
|
||||
'infoActions'
|
||||
]
|
||||
|
||||
async connect() {
|
||||
this.cleanup = new CleanupHelper()
|
||||
|
||||
// Initialize API and settings
|
||||
SettingsManager.initialize(this.apiKeyValue)
|
||||
this.settingsController = new SettingsController(this)
|
||||
await this.settingsController.loadSettings()
|
||||
this.settings = this.settingsController.settings
|
||||
|
||||
// Sync toggle states with loaded settings
|
||||
this.settingsController.syncToggleStates()
|
||||
|
||||
await this.initializeMap()
|
||||
this.initializeAPI()
|
||||
|
||||
// Initialize managers
|
||||
this.layerManager = new LayerManager(this.map, this.settings, this.api)
|
||||
this.dataLoader = new DataLoader(this.api, this.apiKeyValue)
|
||||
this.eventHandlers = new EventHandlers(this.map, this)
|
||||
this.filterManager = new FilterManager(this.dataLoader)
|
||||
this.mapDataManager = new MapDataManager(this)
|
||||
|
||||
// Initialize feature managers
|
||||
this.areaSelectionManager = new AreaSelectionManager(this)
|
||||
this.visitsManager = new VisitsManager(this)
|
||||
this.placesManager = new PlacesManager(this)
|
||||
this.routesManager = new RoutesManager(this)
|
||||
|
||||
// Initialize search manager
|
||||
this.initializeSearch()
|
||||
|
||||
// Listen for visit and place creation/update events
|
||||
this.boundHandleVisitCreated = this.visitsManager.handleVisitCreated.bind(this.visitsManager)
|
||||
this.cleanup.addEventListener(document, 'visit:created', this.boundHandleVisitCreated)
|
||||
|
||||
this.boundHandleVisitUpdated = this.visitsManager.handleVisitUpdated.bind(this.visitsManager)
|
||||
this.cleanup.addEventListener(document, 'visit:updated', this.boundHandleVisitUpdated)
|
||||
|
||||
this.boundHandlePlaceCreated = this.placesManager.handlePlaceCreated.bind(this.placesManager)
|
||||
this.cleanup.addEventListener(document, 'place:created', this.boundHandlePlaceCreated)
|
||||
|
||||
this.boundHandlePlaceUpdated = this.placesManager.handlePlaceUpdated.bind(this.placesManager)
|
||||
this.cleanup.addEventListener(document, 'place:updated', this.boundHandlePlaceUpdated)
|
||||
|
||||
this.boundHandleAreaCreated = this.handleAreaCreated.bind(this)
|
||||
this.cleanup.addEventListener(document, 'area:created', this.boundHandleAreaCreated)
|
||||
|
||||
// Format initial dates
|
||||
this.startDateValue = DateManager.formatDateForAPI(new Date(this.startDateValue))
|
||||
this.endDateValue = DateManager.formatDateForAPI(new Date(this.endDateValue))
|
||||
console.log('[Maps V2] Initial dates:', this.startDateValue, 'to', this.endDateValue)
|
||||
|
||||
this.loadMapData()
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.searchManager?.destroy()
|
||||
this.cleanup.cleanup()
|
||||
this.map?.remove()
|
||||
performanceMonitor.logReport()
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize MapLibre map
|
||||
*/
|
||||
async initializeMap() {
|
||||
this.map = await MapInitializer.initialize(this.containerTarget, {
|
||||
mapStyle: this.settings.mapStyle
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize API client
|
||||
*/
|
||||
initializeAPI() {
|
||||
this.api = new ApiClient(this.apiKeyValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize location search
|
||||
*/
|
||||
initializeSearch() {
|
||||
if (!this.hasSearchInputTarget || !this.hasSearchResultsTarget) {
|
||||
console.warn('[Maps V2] Search targets not found, search functionality disabled')
|
||||
return
|
||||
}
|
||||
|
||||
this.searchManager = new SearchManager(this.map, this.apiKeyValue)
|
||||
this.searchManager.initialize(this.searchInputTarget, this.searchResultsTarget)
|
||||
|
||||
console.log('[Maps V2] Search manager initialized')
|
||||
}
|
||||
|
||||
/**
|
||||
* Load map data from API
|
||||
*/
|
||||
async loadMapData(options = {}) {
|
||||
return this.mapDataManager.loadMapData(
|
||||
this.startDateValue,
|
||||
this.endDateValue,
|
||||
{
|
||||
...options,
|
||||
onProgress: this.updateLoadingProgress.bind(this)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Month selector changed
|
||||
*/
|
||||
monthChanged(event) {
|
||||
const { startDate, endDate } = DateManager.parseMonthSelector(event.target.value)
|
||||
this.startDateValue = startDate
|
||||
this.endDateValue = endDate
|
||||
|
||||
console.log('[Maps V2] Date range changed:', this.startDateValue, 'to', this.endDateValue)
|
||||
this.loadMapData()
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading indicator
|
||||
*/
|
||||
showLoading() {
|
||||
this.loadingTarget.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide loading indicator
|
||||
*/
|
||||
hideLoading() {
|
||||
this.loadingTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update loading progress
|
||||
*/
|
||||
updateLoadingProgress({ loaded, totalPages, progress }) {
|
||||
if (this.hasLoadingTextTarget) {
|
||||
const percentage = Math.round(progress * 100)
|
||||
this.loadingTextTarget.textContent = `Loading... ${percentage}%`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle settings panel
|
||||
*/
|
||||
toggleSettings() {
|
||||
if (this.hasSettingsPanelTarget) {
|
||||
this.settingsPanelTarget.classList.toggle('open')
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Delegated Methods to Managers =====
|
||||
|
||||
// Settings Controller methods
|
||||
updateMapStyle(event) { return this.settingsController.updateMapStyle(event) }
|
||||
resetSettings() { return this.settingsController.resetSettings() }
|
||||
updateRouteOpacity(event) { return this.settingsController.updateRouteOpacity(event) }
|
||||
updateAdvancedSettings(event) { return this.settingsController.updateAdvancedSettings(event) }
|
||||
updateFogRadiusDisplay(event) { return this.settingsController.updateFogRadiusDisplay(event) }
|
||||
updateFogThresholdDisplay(event) { return this.settingsController.updateFogThresholdDisplay(event) }
|
||||
updateMetersBetweenDisplay(event) { return this.settingsController.updateMetersBetweenDisplay(event) }
|
||||
updateMinutesBetweenDisplay(event) { return this.settingsController.updateMinutesBetweenDisplay(event) }
|
||||
|
||||
// Area Selection Manager methods
|
||||
startSelectArea() { return this.areaSelectionManager.startSelectArea() }
|
||||
cancelAreaSelection() { return this.areaSelectionManager.cancelAreaSelection() }
|
||||
deleteSelectedPoints() { return this.areaSelectionManager.deleteSelectedPoints() }
|
||||
|
||||
// Visits Manager methods
|
||||
toggleVisits(event) { return this.visitsManager.toggleVisits(event) }
|
||||
searchVisits(event) { return this.visitsManager.searchVisits(event) }
|
||||
filterVisits(event) { return this.visitsManager.filterVisits(event) }
|
||||
startCreateVisit() { return this.visitsManager.startCreateVisit() }
|
||||
|
||||
// Places Manager methods
|
||||
togglePlaces(event) { return this.placesManager.togglePlaces(event) }
|
||||
filterPlacesByTags(event) { return this.placesManager.filterPlacesByTags(event) }
|
||||
toggleAllPlaceTags(event) { return this.placesManager.toggleAllPlaceTags(event) }
|
||||
startCreatePlace() { return this.placesManager.startCreatePlace() }
|
||||
|
||||
// Area creation
|
||||
startCreateArea() {
|
||||
console.log('[Maps V2] Starting create area mode')
|
||||
|
||||
if (this.hasSettingsPanelTarget && this.settingsPanelTarget.classList.contains('open')) {
|
||||
this.toggleSettings()
|
||||
}
|
||||
|
||||
// Find area drawer controller on the same element
|
||||
const drawerController = this.application.getControllerForElementAndIdentifier(
|
||||
this.element,
|
||||
'area-drawer'
|
||||
)
|
||||
|
||||
if (drawerController) {
|
||||
console.log('[Maps V2] Area drawer controller found, starting drawing with map:', this.map)
|
||||
drawerController.startDrawing(this.map)
|
||||
} else {
|
||||
console.error('[Maps V2] Area drawer controller not found')
|
||||
Toast.error('Area drawer controller not available')
|
||||
}
|
||||
}
|
||||
|
||||
async handleAreaCreated(event) {
|
||||
console.log('[Maps V2] Area created:', event.detail.area)
|
||||
|
||||
try {
|
||||
// Fetch all areas from API
|
||||
const areas = await this.api.fetchAreas()
|
||||
console.log('[Maps V2] Fetched areas:', areas.length)
|
||||
|
||||
// Convert to GeoJSON
|
||||
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||
console.log('[Maps V2] Converted to GeoJSON:', areasGeoJSON.features.length, 'features')
|
||||
if (areasGeoJSON.features.length > 0) {
|
||||
console.log('[Maps V2] First area GeoJSON:', JSON.stringify(areasGeoJSON.features[0], null, 2))
|
||||
}
|
||||
|
||||
// Get or create the areas layer
|
||||
let areasLayer = this.layerManager.getLayer('areas')
|
||||
console.log('[Maps V2] Areas layer exists?', !!areasLayer, 'visible?', areasLayer?.visible)
|
||||
|
||||
if (areasLayer) {
|
||||
// Update existing layer
|
||||
areasLayer.update(areasGeoJSON)
|
||||
console.log('[Maps V2] Areas layer updated')
|
||||
} else {
|
||||
// Create the layer if it doesn't exist yet
|
||||
console.log('[Maps V2] Creating areas layer')
|
||||
this.layerManager._addAreasLayer(areasGeoJSON)
|
||||
areasLayer = this.layerManager.getLayer('areas')
|
||||
console.log('[Maps V2] Areas layer created, visible?', areasLayer?.visible)
|
||||
}
|
||||
|
||||
// Enable the layer if it wasn't already
|
||||
if (areasLayer) {
|
||||
if (!areasLayer.visible) {
|
||||
console.log('[Maps V2] Showing areas layer')
|
||||
areasLayer.show()
|
||||
this.settings.layers.areas = true
|
||||
this.settingsController.saveSetting('layers.areas', true)
|
||||
|
||||
// Update toggle state
|
||||
if (this.hasAreasToggleTarget) {
|
||||
this.areasToggleTarget.checked = true
|
||||
}
|
||||
} else {
|
||||
console.log('[Maps V2] Areas layer already visible')
|
||||
}
|
||||
}
|
||||
|
||||
Toast.success('Area created successfully!')
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to reload areas:', error)
|
||||
Toast.error('Failed to reload areas')
|
||||
}
|
||||
}
|
||||
|
||||
// Routes Manager methods
|
||||
togglePoints(event) { return this.routesManager.togglePoints(event) }
|
||||
toggleRoutes(event) { return this.routesManager.toggleRoutes(event) }
|
||||
toggleHeatmap(event) { return this.routesManager.toggleHeatmap(event) }
|
||||
toggleFog(event) { return this.routesManager.toggleFog(event) }
|
||||
toggleScratch(event) { return this.routesManager.toggleScratch(event) }
|
||||
togglePhotos(event) { return this.routesManager.togglePhotos(event) }
|
||||
toggleAreas(event) { return this.routesManager.toggleAreas(event) }
|
||||
toggleTracks(event) { return this.routesManager.toggleTracks(event) }
|
||||
toggleSpeedColoredRoutes(event) { return this.routesManager.toggleSpeedColoredRoutes(event) }
|
||||
openSpeedColorEditor() { return this.routesManager.openSpeedColorEditor() }
|
||||
handleSpeedColorSave(event) { return this.routesManager.handleSpeedColorSave(event) }
|
||||
|
||||
// Info Display methods
|
||||
showInfo(title, content, actions = []) {
|
||||
if (!this.hasInfoDisplayTarget) return
|
||||
|
||||
// Set title
|
||||
this.infoTitleTarget.textContent = title
|
||||
|
||||
// Set content
|
||||
this.infoContentTarget.innerHTML = content
|
||||
|
||||
// Set actions
|
||||
if (actions.length > 0) {
|
||||
this.infoActionsTarget.innerHTML = actions.map(action => {
|
||||
if (action.type === 'button') {
|
||||
// For button actions (modals, etc.), create a button with data-action
|
||||
// Use error styling for delete buttons
|
||||
const buttonClass = action.label === 'Delete' ? 'btn btn-sm btn-error' : 'btn btn-sm btn-primary'
|
||||
return `<button class="${buttonClass}" data-action="click->maps--maplibre#${action.handler}" data-id="${action.id}" data-entity-type="${action.entityType}">${action.label}</button>`
|
||||
} else {
|
||||
// For link actions, keep the original behavior
|
||||
return `<a href="${action.url}" class="btn btn-sm btn-primary">${action.label}</a>`
|
||||
}
|
||||
}).join('')
|
||||
} else {
|
||||
this.infoActionsTarget.innerHTML = ''
|
||||
}
|
||||
|
||||
// Show info display
|
||||
this.infoDisplayTarget.classList.remove('hidden')
|
||||
|
||||
// Switch to tools tab and open panel
|
||||
this.switchToToolsTab()
|
||||
}
|
||||
|
||||
closeInfo() {
|
||||
if (!this.hasInfoDisplayTarget) return
|
||||
this.infoDisplayTarget.classList.add('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle edit action from info display
|
||||
*/
|
||||
handleEdit(event) {
|
||||
const button = event.currentTarget
|
||||
const id = button.dataset.id
|
||||
const entityType = button.dataset.entityType
|
||||
|
||||
console.log('[Maps V2] Opening edit for', entityType, id)
|
||||
|
||||
switch (entityType) {
|
||||
case 'visit':
|
||||
this.openVisitModal(id)
|
||||
break
|
||||
case 'place':
|
||||
this.openPlaceEditModal(id)
|
||||
break
|
||||
default:
|
||||
console.warn('[Maps V2] Unknown entity type:', entityType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle delete action from info display
|
||||
*/
|
||||
handleDelete(event) {
|
||||
const button = event.currentTarget
|
||||
const id = button.dataset.id
|
||||
const entityType = button.dataset.entityType
|
||||
|
||||
console.log('[Maps V2] Deleting', entityType, id)
|
||||
|
||||
switch (entityType) {
|
||||
case 'area':
|
||||
this.deleteArea(id)
|
||||
break
|
||||
default:
|
||||
console.warn('[Maps V2] Unknown entity type for delete:', entityType)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open visit edit modal
|
||||
*/
|
||||
async openVisitModal(visitId) {
|
||||
try {
|
||||
// Fetch visit details
|
||||
const response = await fetch(`/api/v1/visits/${visitId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch visit: ${response.status}`)
|
||||
}
|
||||
|
||||
const visit = await response.json()
|
||||
|
||||
// Trigger visit edit event
|
||||
const event = new CustomEvent('visit:edit', {
|
||||
detail: { visit },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to load visit:', error)
|
||||
Toast.error('Failed to load visit details')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete area with confirmation
|
||||
*/
|
||||
async deleteArea(areaId) {
|
||||
try {
|
||||
// Fetch area details
|
||||
const area = await this.api.fetchArea(areaId)
|
||||
|
||||
// Show delete confirmation
|
||||
const confirmed = confirm(`Delete area "${area.name}"?\n\nThis action cannot be undone.`)
|
||||
|
||||
if (!confirmed) return
|
||||
|
||||
Toast.info('Deleting area...')
|
||||
|
||||
// Delete the area
|
||||
await this.api.deleteArea(areaId)
|
||||
|
||||
// Reload areas
|
||||
const areas = await this.api.fetchAreas()
|
||||
const areasGeoJSON = this.dataLoader.areasToGeoJSON(areas)
|
||||
|
||||
const areasLayer = this.layerManager.getLayer('areas')
|
||||
if (areasLayer) {
|
||||
areasLayer.update(areasGeoJSON)
|
||||
}
|
||||
|
||||
// Close info display
|
||||
this.closeInfo()
|
||||
|
||||
Toast.success('Area deleted successfully')
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to delete area:', error)
|
||||
Toast.error('Failed to delete area')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open place edit modal
|
||||
*/
|
||||
async openPlaceEditModal(placeId) {
|
||||
try {
|
||||
// Fetch place details
|
||||
const response = await fetch(`/api/v1/places/${placeId}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch place: ${response.status}`)
|
||||
}
|
||||
|
||||
const place = await response.json()
|
||||
|
||||
// Trigger place edit event
|
||||
const event = new CustomEvent('place:edit', {
|
||||
detail: { place },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
} catch (error) {
|
||||
console.error('[Maps V2] Failed to load place:', error)
|
||||
Toast.error('Failed to load place details')
|
||||
}
|
||||
}
|
||||
|
||||
switchToToolsTab() {
|
||||
// Open the panel if it's not already open
|
||||
if (!this.settingsPanelTarget.classList.contains('open')) {
|
||||
this.toggleSettings()
|
||||
}
|
||||
|
||||
// Find the map-panel controller and switch to tools tab
|
||||
const panelElement = this.settingsPanelTarget
|
||||
const panelController = this.application.getControllerForElementAndIdentifier(panelElement, 'map-panel')
|
||||
|
||||
if (panelController && panelController.switchToTab) {
|
||||
panelController.switchToTab('tools')
|
||||
}
|
||||
}
|
||||
}
|
||||
323
app/javascript/controllers/maps/maplibre_realtime_controller.js
Normal file
|
|
@ -0,0 +1,323 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { createMapChannel } from 'maps_maplibre/channels/map_channel'
|
||||
import { WebSocketManager } from 'maps_maplibre/utils/websocket_manager'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Real-time controller
|
||||
* Manages ActionCable connection and real-time updates
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['liveModeToggle']
|
||||
|
||||
static values = {
|
||||
enabled: { type: Boolean, default: true },
|
||||
liveMode: { type: Boolean, default: false }
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log('[Realtime Controller] Connecting...')
|
||||
|
||||
if (!this.enabledValue) {
|
||||
console.log('[Realtime Controller] Disabled, skipping setup')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.connectedChannels = new Set()
|
||||
this.liveModeEnabled = false // Start with live mode disabled
|
||||
|
||||
// Delay channel setup to ensure ActionCable is ready
|
||||
// This prevents race condition with page initialization
|
||||
setTimeout(() => {
|
||||
try {
|
||||
this.setupChannels()
|
||||
} catch (error) {
|
||||
console.error('[Realtime Controller] Failed to setup channels in setTimeout:', error)
|
||||
this.updateConnectionIndicator(false)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// Initialize toggle state from settings
|
||||
if (this.hasLiveModeToggleTarget) {
|
||||
this.liveModeToggleTarget.checked = this.liveModeEnabled
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Realtime Controller] Failed to initialize:', error)
|
||||
// Don't throw - allow page to continue loading
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.channels?.unsubscribeAll()
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup ActionCable channels
|
||||
* Family channel is always enabled when family feature is on
|
||||
* Points channel (live mode) is controlled by user toggle
|
||||
*/
|
||||
setupChannels() {
|
||||
try {
|
||||
console.log('[Realtime Controller] Setting up channels...')
|
||||
this.channels = createMapChannel({
|
||||
connected: this.handleConnected.bind(this),
|
||||
disconnected: this.handleDisconnected.bind(this),
|
||||
received: this.handleReceived.bind(this),
|
||||
enableLiveMode: this.liveModeEnabled // Control points channel
|
||||
})
|
||||
console.log('[Realtime Controller] Channels setup complete')
|
||||
} catch (error) {
|
||||
console.error('[Realtime Controller] Failed to setup channels:', error)
|
||||
console.error('[Realtime Controller] Error stack:', error.stack)
|
||||
this.updateConnectionIndicator(false)
|
||||
// Don't throw - page should continue to work
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle live mode (new points appearing in real-time)
|
||||
*/
|
||||
toggleLiveMode(event) {
|
||||
this.liveModeEnabled = event.target.checked
|
||||
|
||||
// Update recent point layer visibility
|
||||
this.updateRecentPointLayerVisibility()
|
||||
|
||||
// Reconnect channels with new settings
|
||||
if (this.channels) {
|
||||
this.channels.unsubscribeAll()
|
||||
}
|
||||
this.setupChannels()
|
||||
|
||||
const message = this.liveModeEnabled ? 'Live mode enabled' : 'Live mode disabled'
|
||||
Toast.info(message)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update recent point layer visibility based on live mode state
|
||||
*/
|
||||
updateRecentPointLayerVisibility() {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) {
|
||||
return
|
||||
}
|
||||
|
||||
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||
if (!recentPointLayer) {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.liveModeEnabled) {
|
||||
recentPointLayer.show()
|
||||
} else {
|
||||
recentPointLayer.hide()
|
||||
recentPointLayer.clear()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle connection
|
||||
*/
|
||||
handleConnected(channelName) {
|
||||
this.connectedChannels.add(channelName)
|
||||
|
||||
// Only show toast when at least one channel is connected
|
||||
if (this.connectedChannels.size === 1) {
|
||||
Toast.success('Connected to real-time updates')
|
||||
this.updateConnectionIndicator(true)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle disconnection
|
||||
*/
|
||||
handleDisconnected(channelName) {
|
||||
this.connectedChannels.delete(channelName)
|
||||
|
||||
// Show warning only when all channels are disconnected
|
||||
if (this.connectedChannels.size === 0) {
|
||||
Toast.warning('Disconnected from real-time updates')
|
||||
this.updateConnectionIndicator(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle received data
|
||||
*/
|
||||
handleReceived(data) {
|
||||
switch (data.type) {
|
||||
case 'new_point':
|
||||
this.handleNewPoint(data.point)
|
||||
break
|
||||
|
||||
case 'family_location':
|
||||
this.handleFamilyLocation(data.member)
|
||||
break
|
||||
|
||||
case 'notification':
|
||||
this.handleNotification(data.notification)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the maps--maplibre controller (on same element)
|
||||
*/
|
||||
get mapsV2Controller() {
|
||||
const element = this.element
|
||||
const app = this.application
|
||||
return app.getControllerForElementAndIdentifier(element, 'maps--maplibre')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle new point
|
||||
* Point data is broadcast as: [lat, lon, battery, altitude, timestamp, velocity, id, country_name]
|
||||
*/
|
||||
handleNewPoint(pointData) {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) {
|
||||
console.warn('[Realtime Controller] Maps controller not found')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[Realtime Controller] Received point data:', pointData)
|
||||
|
||||
// Parse point data from array format
|
||||
const [lat, lon, battery, altitude, timestamp, velocity, id, countryName] = pointData
|
||||
|
||||
// Get points layer from layer manager
|
||||
const pointsLayer = mapsController.layerManager?.getLayer('points')
|
||||
if (!pointsLayer) {
|
||||
console.warn('[Realtime Controller] Points layer not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Get current data
|
||||
const currentData = pointsLayer.data || { type: 'FeatureCollection', features: [] }
|
||||
const features = [...(currentData.features || [])]
|
||||
|
||||
// Add new point
|
||||
features.push({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [parseFloat(lon), parseFloat(lat)]
|
||||
},
|
||||
properties: {
|
||||
id: parseInt(id),
|
||||
latitude: parseFloat(lat),
|
||||
longitude: parseFloat(lon),
|
||||
battery: parseFloat(battery) || null,
|
||||
altitude: parseFloat(altitude) || null,
|
||||
timestamp: timestamp,
|
||||
velocity: parseFloat(velocity) || null,
|
||||
country_name: countryName || null
|
||||
}
|
||||
})
|
||||
|
||||
// Update layer with new data
|
||||
pointsLayer.update({
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
})
|
||||
|
||||
console.log('[Realtime Controller] Added new point to map:', id)
|
||||
|
||||
// Update recent point marker (always visible in live mode)
|
||||
this.updateRecentPoint(parseFloat(lon), parseFloat(lat), {
|
||||
id: parseInt(id),
|
||||
battery: parseFloat(battery) || null,
|
||||
altitude: parseFloat(altitude) || null,
|
||||
timestamp: timestamp,
|
||||
velocity: parseFloat(velocity) || null,
|
||||
country_name: countryName || null
|
||||
})
|
||||
|
||||
// Zoom to the new point
|
||||
this.zoomToPoint(parseFloat(lon), parseFloat(lat))
|
||||
|
||||
Toast.info('New location recorded')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle family member location update
|
||||
*/
|
||||
handleFamilyLocation(member) {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) return
|
||||
|
||||
const familyLayer = mapsController.familyLayer
|
||||
if (familyLayer) {
|
||||
familyLayer.updateMember(member)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle notification
|
||||
*/
|
||||
handleNotification(notification) {
|
||||
Toast.info(notification.message || 'New notification')
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the recent point marker
|
||||
* This marker is always visible in live mode, independent of points layer visibility
|
||||
*/
|
||||
updateRecentPoint(longitude, latitude, properties = {}) {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController) {
|
||||
console.warn('[Realtime Controller] Maps controller not found')
|
||||
return
|
||||
}
|
||||
|
||||
const recentPointLayer = mapsController.layerManager?.getLayer('recentPoint')
|
||||
if (!recentPointLayer) {
|
||||
console.warn('[Realtime Controller] Recent point layer not found')
|
||||
return
|
||||
}
|
||||
|
||||
// Show the layer if live mode is enabled and update with new point
|
||||
if (this.liveModeEnabled) {
|
||||
recentPointLayer.show()
|
||||
recentPointLayer.updateRecentPoint(longitude, latitude, properties)
|
||||
console.log('[Realtime Controller] Updated recent point marker:', longitude, latitude)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoom map to a specific point
|
||||
*/
|
||||
zoomToPoint(longitude, latitude) {
|
||||
const mapsController = this.mapsV2Controller
|
||||
if (!mapsController || !mapsController.map) {
|
||||
console.warn('[Realtime Controller] Map not available for zooming')
|
||||
return
|
||||
}
|
||||
|
||||
const map = mapsController.map
|
||||
|
||||
// Fly to the new point with a smooth animation
|
||||
map.flyTo({
|
||||
center: [longitude, latitude],
|
||||
zoom: Math.max(map.getZoom(), 14), // Zoom to at least level 14, or keep current zoom if higher
|
||||
duration: 2000, // 2 second animation
|
||||
essential: true // This animation is considered essential with respect to prefers-reduced-motion
|
||||
})
|
||||
|
||||
console.log('[Realtime Controller] Zoomed to point:', longitude, latitude)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update connection indicator
|
||||
*/
|
||||
updateConnectionIndicator(connected) {
|
||||
const indicator = document.querySelector('.connection-indicator')
|
||||
if (indicator) {
|
||||
// Show the indicator when connection is attempted
|
||||
indicator.classList.add('active')
|
||||
indicator.classList.toggle('connected', connected)
|
||||
indicator.classList.toggle('disconnected', !connected)
|
||||
}
|
||||
}
|
||||
}
|
||||
184
app/javascript/controllers/speed_color_editor_controller.js
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
|
||||
/**
|
||||
* Speed Color Editor Controller
|
||||
* Manages the gradient editor modal for speed-colored routes
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = ['modal', 'stopsList', 'preview']
|
||||
static values = {
|
||||
colorStops: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
this.loadColorStops()
|
||||
}
|
||||
|
||||
loadColorStops() {
|
||||
const stopsString = this.colorStopsValue || '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
this.stops = this.parseColorStops(stopsString)
|
||||
this.renderStops()
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
parseColorStops(stopsString) {
|
||||
return stopsString.split('|').map(segment => {
|
||||
const [speed, color] = segment.split(':')
|
||||
return { speed: Number(speed), color }
|
||||
})
|
||||
}
|
||||
|
||||
serializeColorStops() {
|
||||
return this.stops.map(stop => `${stop.speed}:${stop.color}`).join('|')
|
||||
}
|
||||
|
||||
renderStops() {
|
||||
if (!this.hasStopsListTarget) return
|
||||
|
||||
this.stopsListTarget.innerHTML = this.stops.map((stop, index) => `
|
||||
<div class="flex items-center gap-3 p-3 bg-base-200 rounded-lg" data-index="${index}">
|
||||
<div class="flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Speed (km/h)</span>
|
||||
</label>
|
||||
<input type="number"
|
||||
class="input input-bordered input-sm w-full"
|
||||
value="${stop.speed}"
|
||||
min="0"
|
||||
max="200"
|
||||
data-action="input->speed-color-editor#updateSpeed"
|
||||
data-index="${index}" />
|
||||
</div>
|
||||
|
||||
<div class="flex-1">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Color</span>
|
||||
</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<input type="color"
|
||||
class="w-12 h-10 rounded cursor-pointer border-2 border-base-300"
|
||||
value="${stop.color}"
|
||||
data-action="input->speed-color-editor#updateColor"
|
||||
data-index="${index}" />
|
||||
<input type="text"
|
||||
class="input input-bordered input-sm w-24 font-mono text-xs"
|
||||
value="${stop.color}"
|
||||
pattern="^#[0-9A-Fa-f]{6}$"
|
||||
data-action="input->speed-color-editor#updateColorText"
|
||||
data-index="${index}" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-ghost btn-circle text-error mt-6"
|
||||
data-action="click->speed-color-editor#removeStop"
|
||||
data-index="${index}"
|
||||
${this.stops.length <= 2 ? 'disabled' : ''}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
`).join('')
|
||||
}
|
||||
|
||||
updateSpeed(event) {
|
||||
const index = parseInt(event.target.dataset.index)
|
||||
this.stops[index].speed = Number(event.target.value)
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
updateColor(event) {
|
||||
const index = parseInt(event.target.dataset.index)
|
||||
const color = event.target.value
|
||||
this.stops[index].color = color
|
||||
|
||||
// Update text input
|
||||
const textInput = event.target.parentElement.querySelector('input[type="text"]')
|
||||
if (textInput) {
|
||||
textInput.value = color
|
||||
}
|
||||
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
updateColorText(event) {
|
||||
const index = parseInt(event.target.dataset.index)
|
||||
const color = event.target.value
|
||||
|
||||
if (/^#[0-9A-Fa-f]{6}$/.test(color)) {
|
||||
this.stops[index].color = color
|
||||
|
||||
// Update color picker
|
||||
const colorInput = event.target.parentElement.querySelector('input[type="color"]')
|
||||
if (colorInput) {
|
||||
colorInput.value = color
|
||||
}
|
||||
|
||||
this.updatePreview()
|
||||
}
|
||||
}
|
||||
|
||||
addStop() {
|
||||
// Find a good speed value between existing stops
|
||||
const lastStop = this.stops[this.stops.length - 1]
|
||||
const newSpeed = lastStop.speed + 10
|
||||
|
||||
this.stops.push({
|
||||
speed: newSpeed,
|
||||
color: '#ff0000'
|
||||
})
|
||||
|
||||
// Sort by speed
|
||||
this.stops.sort((a, b) => a.speed - b.speed)
|
||||
|
||||
this.renderStops()
|
||||
this.updatePreview()
|
||||
}
|
||||
|
||||
removeStop(event) {
|
||||
const index = parseInt(event.target.dataset.index)
|
||||
|
||||
if (this.stops.length > 2) {
|
||||
this.stops.splice(index, 1)
|
||||
this.renderStops()
|
||||
this.updatePreview()
|
||||
}
|
||||
}
|
||||
|
||||
updatePreview() {
|
||||
if (!this.hasPreviewTarget) return
|
||||
|
||||
const gradient = this.stops.map((stop, index) => {
|
||||
const percentage = (index / (this.stops.length - 1)) * 100
|
||||
return `${stop.color} ${percentage}%`
|
||||
}).join(', ')
|
||||
|
||||
this.previewTarget.style.background = `linear-gradient(to right, ${gradient})`
|
||||
}
|
||||
|
||||
save() {
|
||||
const serialized = this.serializeColorStops()
|
||||
|
||||
// Dispatch event with the new color stops
|
||||
this.dispatch('save', {
|
||||
detail: { colorStops: serialized }
|
||||
})
|
||||
|
||||
this.close()
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.hasModalTarget) {
|
||||
const checkbox = this.modalTarget.querySelector('.modal-toggle')
|
||||
if (checkbox) {
|
||||
checkbox.checked = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resetToDefault() {
|
||||
this.colorStopsValue = '0:#00ff00|15:#00ffff|30:#ff00ff|50:#ffff00|100:#ff3300'
|
||||
this.loadColorStops()
|
||||
}
|
||||
}
|
||||
255
app/javascript/controllers/visit_creation_v2_controller.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
import { Controller } from '@hotwired/stimulus'
|
||||
import { Toast } from 'maps_maplibre/components/toast'
|
||||
|
||||
/**
|
||||
* Controller for visit creation modal in Maps V2
|
||||
*/
|
||||
export default class extends Controller {
|
||||
static targets = [
|
||||
'modal',
|
||||
'form',
|
||||
'modalTitle',
|
||||
'nameInput',
|
||||
'startTimeInput',
|
||||
'endTimeInput',
|
||||
'latitudeInput',
|
||||
'longitudeInput',
|
||||
'submitButton'
|
||||
]
|
||||
|
||||
static values = {
|
||||
apiKey: String
|
||||
}
|
||||
|
||||
connect() {
|
||||
console.log('[Visit Creation V2] Controller connected')
|
||||
this.marker = null
|
||||
this.mapController = null
|
||||
this.editingVisitId = null
|
||||
this.setupEventListeners()
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
document.addEventListener('visit:edit', (e) => {
|
||||
this.openForEdit(e.detail.visit)
|
||||
})
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.cleanup()
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal with coordinates
|
||||
*/
|
||||
open(lat, lng, mapController) {
|
||||
console.log('[Visit Creation V2] Opening modal', { lat, lng })
|
||||
|
||||
this.editingVisitId = null
|
||||
this.mapController = mapController
|
||||
this.latitudeInputTarget.value = lat
|
||||
this.longitudeInputTarget.value = lng
|
||||
|
||||
// Set modal title and button for creation
|
||||
if (this.hasModalTitleTarget) {
|
||||
this.modalTitleTarget.textContent = 'Create New Visit'
|
||||
}
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.textContent = 'Create Visit'
|
||||
}
|
||||
|
||||
// Set default times
|
||||
const now = new Date()
|
||||
const oneHourLater = new Date(now.getTime() + (60 * 60 * 1000))
|
||||
|
||||
this.startTimeInputTarget.value = this.formatDateTime(now)
|
||||
this.endTimeInputTarget.value = this.formatDateTime(oneHourLater)
|
||||
|
||||
// Show modal
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
|
||||
// Focus on name input
|
||||
setTimeout(() => this.nameInputTarget.focus(), 100)
|
||||
|
||||
// Add marker to map
|
||||
this.addMarker(lat, lng)
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the modal for editing an existing visit
|
||||
*/
|
||||
openForEdit(visit) {
|
||||
console.log('[Visit Creation V2] Opening modal for edit', visit)
|
||||
|
||||
this.editingVisitId = visit.id
|
||||
|
||||
// Set modal title and button for editing
|
||||
if (this.hasModalTitleTarget) {
|
||||
this.modalTitleTarget.textContent = 'Edit Visit'
|
||||
}
|
||||
if (this.hasSubmitButtonTarget) {
|
||||
this.submitButtonTarget.textContent = 'Update Visit'
|
||||
}
|
||||
|
||||
// Fill form with visit data
|
||||
this.nameInputTarget.value = visit.name || ''
|
||||
this.latitudeInputTarget.value = visit.latitude
|
||||
this.longitudeInputTarget.value = visit.longitude
|
||||
|
||||
// Convert timestamps to datetime-local format
|
||||
this.startTimeInputTarget.value = this.formatDateTime(new Date(visit.started_at))
|
||||
this.endTimeInputTarget.value = this.formatDateTime(new Date(visit.ended_at))
|
||||
|
||||
// Show modal
|
||||
this.modalTarget.classList.add('modal-open')
|
||||
|
||||
// Focus on name input
|
||||
setTimeout(() => this.nameInputTarget.focus(), 100)
|
||||
|
||||
// Try to get map controller from the maps--maplibre controller
|
||||
const mapElement = document.querySelector('[data-controller*="maps--maplibre"]')
|
||||
if (mapElement) {
|
||||
const app = window.Stimulus || window.Application
|
||||
this.mapController = app?.getControllerForElementAndIdentifier(mapElement, 'maps--maplibre')
|
||||
}
|
||||
|
||||
// Add marker to map
|
||||
this.addMarker(visit.latitude, visit.longitude)
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the modal
|
||||
*/
|
||||
close() {
|
||||
console.log('[Visit Creation V2] Closing modal')
|
||||
|
||||
// Hide modal
|
||||
this.modalTarget.classList.remove('modal-open')
|
||||
|
||||
// Reset form
|
||||
this.formTarget.reset()
|
||||
|
||||
// Reset editing state
|
||||
this.editingVisitId = null
|
||||
|
||||
// Remove marker
|
||||
this.removeMarker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle form submission
|
||||
*/
|
||||
async submit(event) {
|
||||
event.preventDefault()
|
||||
|
||||
const isEdit = this.editingVisitId !== null
|
||||
console.log(`[Visit Creation V2] Submitting form (${isEdit ? 'edit' : 'create'})`)
|
||||
|
||||
const formData = new FormData(this.formTarget)
|
||||
|
||||
const visitData = {
|
||||
visit: {
|
||||
name: formData.get('name'),
|
||||
started_at: formData.get('started_at'),
|
||||
ended_at: formData.get('ended_at'),
|
||||
latitude: parseFloat(formData.get('latitude')),
|
||||
longitude: parseFloat(formData.get('longitude')),
|
||||
status: 'confirmed'
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const url = isEdit ? `/api/v1/visits/${this.editingVisitId}` : '/api/v1/visits'
|
||||
const method = isEdit ? 'PATCH' : 'POST'
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${this.apiKeyValue}`,
|
||||
'X-CSRF-Token': document.querySelector('meta[name="csrf-token"]')?.content || ''
|
||||
},
|
||||
body: JSON.stringify(visitData)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
throw new Error(errorData.error || `Failed to ${isEdit ? 'update' : 'create'} visit`)
|
||||
}
|
||||
|
||||
const visit = await response.json()
|
||||
|
||||
console.log(`[Visit Creation V2] Visit ${isEdit ? 'updated' : 'created'} successfully`, visit)
|
||||
|
||||
// Show success message
|
||||
this.showToast(`Visit ${isEdit ? 'updated' : 'created'} successfully`, 'success')
|
||||
|
||||
// Close modal
|
||||
this.close()
|
||||
|
||||
// Dispatch event to notify map controller
|
||||
const eventName = isEdit ? 'visit:updated' : 'visit:created'
|
||||
document.dispatchEvent(new CustomEvent(eventName, {
|
||||
detail: { visit }
|
||||
}))
|
||||
} catch (error) {
|
||||
console.error(`[Visit Creation V2] Error ${isEdit ? 'updating' : 'creating'} visit:`, error)
|
||||
this.showToast(error.message || `Failed to ${isEdit ? 'update' : 'create'} visit`, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add marker to map
|
||||
*/
|
||||
addMarker(lat, lng) {
|
||||
if (!this.mapController) return
|
||||
|
||||
// Remove existing marker if any
|
||||
this.removeMarker()
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div')
|
||||
el.className = 'visit-creation-marker'
|
||||
el.innerHTML = '📍'
|
||||
el.style.fontSize = '30px'
|
||||
|
||||
// Use maplibregl if available (from mapController)
|
||||
const maplibregl = window.maplibregl
|
||||
if (maplibregl) {
|
||||
this.marker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(this.mapController.map)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove marker from map
|
||||
*/
|
||||
removeMarker() {
|
||||
if (this.marker) {
|
||||
this.marker.remove()
|
||||
this.marker = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources
|
||||
*/
|
||||
cleanup() {
|
||||
this.removeMarker()
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date for datetime-local input
|
||||
*/
|
||||
formatDateTime(date) {
|
||||
return date.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
*/
|
||||
showToast(message, type = 'info') {
|
||||
Toast[type](message)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +1,36 @@
|
|||
/**
|
||||
* Vector maps configuration for Maps V1 (legacy)
|
||||
* For Maps V2, use style_manager.js instead
|
||||
*/
|
||||
export const mapsConfig = {
|
||||
"Light": {
|
||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||
flavor: "light",
|
||||
maxZoom: 16,
|
||||
maxZoom: 14,
|
||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||
},
|
||||
"Dark": {
|
||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||
flavor: "dark",
|
||||
maxZoom: 16,
|
||||
maxZoom: 14,
|
||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||
},
|
||||
"White": {
|
||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||
flavor: "white",
|
||||
maxZoom: 16,
|
||||
maxZoom: 14,
|
||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||
},
|
||||
"Grayscale": {
|
||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||
flavor: "grayscale",
|
||||
maxZoom: 16,
|
||||
maxZoom: 14,
|
||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||
},
|
||||
"Black": {
|
||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||
flavor: "black",
|
||||
maxZoom: 16,
|
||||
maxZoom: 14,
|
||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||
},
|
||||
};
|
||||
|
|
|
|||
118
app/javascript/maps_maplibre/channels/map_channel.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
import consumer from '../../channels/consumer'
|
||||
|
||||
/**
|
||||
* Create map channel subscription for maps_maplibre
|
||||
* Wraps the existing FamilyLocationsChannel and other channels for real-time updates
|
||||
* @param {Object} options - { received, connected, disconnected, enableLiveMode }
|
||||
* @returns {Object} Subscriptions object with multiple channels
|
||||
*/
|
||||
export function createMapChannel(options = {}) {
|
||||
const { enableLiveMode = false, ...callbacks } = options
|
||||
const subscriptions = {
|
||||
family: null,
|
||||
points: null,
|
||||
notifications: null
|
||||
}
|
||||
|
||||
console.log('[MapChannel] Creating channels with enableLiveMode:', enableLiveMode)
|
||||
|
||||
// Defensive check - consumer might not be available
|
||||
if (!consumer) {
|
||||
console.warn('[MapChannel] ActionCable consumer not available')
|
||||
return {
|
||||
subscriptions,
|
||||
unsubscribeAll() {}
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to family locations if family feature is enabled
|
||||
try {
|
||||
const familyFeaturesElement = document.querySelector('[data-family-members-features-value]')
|
||||
const features = familyFeaturesElement ? JSON.parse(familyFeaturesElement.dataset.familyMembersFeaturesValue) : {}
|
||||
|
||||
if (features.family) {
|
||||
subscriptions.family = consumer.subscriptions.create('FamilyLocationsChannel', {
|
||||
connected() {
|
||||
console.log('FamilyLocationsChannel connected')
|
||||
callbacks.connected?.('family')
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
console.log('FamilyLocationsChannel disconnected')
|
||||
callbacks.disconnected?.('family')
|
||||
},
|
||||
|
||||
received(data) {
|
||||
console.log('FamilyLocationsChannel received:', data)
|
||||
callbacks.received?.({
|
||||
type: 'family_location',
|
||||
member: data
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[MapChannel] Failed to subscribe to family channel:', error)
|
||||
}
|
||||
|
||||
// Subscribe to points channel for real-time point updates (only if live mode is enabled)
|
||||
if (enableLiveMode) {
|
||||
try {
|
||||
subscriptions.points = consumer.subscriptions.create('PointsChannel', {
|
||||
connected() {
|
||||
console.log('PointsChannel connected')
|
||||
callbacks.connected?.('points')
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
console.log('PointsChannel disconnected')
|
||||
callbacks.disconnected?.('points')
|
||||
},
|
||||
|
||||
received(data) {
|
||||
console.log('PointsChannel received:', data)
|
||||
callbacks.received?.({
|
||||
type: 'new_point',
|
||||
point: data
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[MapChannel] Failed to subscribe to points channel:', error)
|
||||
}
|
||||
} else {
|
||||
console.log('[MapChannel] Live mode disabled, not subscribing to PointsChannel')
|
||||
}
|
||||
|
||||
// Subscribe to notifications channel
|
||||
try {
|
||||
subscriptions.notifications = consumer.subscriptions.create('NotificationsChannel', {
|
||||
connected() {
|
||||
console.log('NotificationsChannel connected')
|
||||
callbacks.connected?.('notifications')
|
||||
},
|
||||
|
||||
disconnected() {
|
||||
console.log('NotificationsChannel disconnected')
|
||||
callbacks.disconnected?.('notifications')
|
||||
},
|
||||
|
||||
received(data) {
|
||||
console.log('NotificationsChannel received:', data)
|
||||
callbacks.received?.({
|
||||
type: 'notification',
|
||||
notification: data
|
||||
})
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn('[MapChannel] Failed to subscribe to notifications channel:', error)
|
||||
}
|
||||
|
||||
return {
|
||||
subscriptions,
|
||||
unsubscribeAll() {
|
||||
Object.values(subscriptions).forEach(sub => sub?.unsubscribe())
|
||||
}
|
||||
}
|
||||
}
|
||||
100
app/javascript/maps_maplibre/components/photo_popup.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
/**
|
||||
* Factory for creating photo popups
|
||||
*/
|
||||
export class PhotoPopupFactory {
|
||||
/**
|
||||
* Create popup for a photo
|
||||
* @param {Object} properties - Photo properties
|
||||
* @returns {string} HTML for popup
|
||||
*/
|
||||
static createPhotoPopup(properties) {
|
||||
const {
|
||||
id,
|
||||
thumbnail_url,
|
||||
taken_at,
|
||||
filename,
|
||||
city,
|
||||
state,
|
||||
country,
|
||||
type,
|
||||
source
|
||||
} = properties
|
||||
|
||||
const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown'
|
||||
const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location'
|
||||
const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo'
|
||||
|
||||
return `
|
||||
<div class="photo-popup">
|
||||
<div class="photo-preview">
|
||||
<img src="${thumbnail_url}"
|
||||
alt="${filename}"
|
||||
loading="lazy">
|
||||
</div>
|
||||
<div class="photo-info">
|
||||
<div class="filename">${filename}</div>
|
||||
<div class="timestamp">Taken: ${takenDate}</div>
|
||||
<div class="location">Location: ${location}</div>
|
||||
<div class="source">Source: ${source}</div>
|
||||
<div class="media-type">${mediaType}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.photo-popup {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.photo-preview {
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 12px;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.photo-preview img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 300px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.photo-info {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.photo-info > div {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.photo-info .filename {
|
||||
font-weight: 600;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
.photo-info .timestamp {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.photo-info .location {
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.photo-info .source {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.photo-info .media-type {
|
||||
font-size: 14px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
</style>
|
||||
`
|
||||
}
|
||||
}
|
||||
114
app/javascript/maps_maplibre/components/popup_factory.js
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { formatTimestamp } from '../utils/geojson_transformers'
|
||||
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
|
||||
|
||||
/**
|
||||
* Factory for creating map popups
|
||||
*/
|
||||
export class PopupFactory {
|
||||
/**
|
||||
* Create popup for a point
|
||||
* @param {Object} properties - Point properties
|
||||
* @returns {string} HTML for popup
|
||||
*/
|
||||
static createPointPopup(properties) {
|
||||
const { id, timestamp, altitude, battery, accuracy, velocity } = properties
|
||||
|
||||
// Get theme colors
|
||||
const theme = getCurrentTheme()
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
return `
|
||||
<div class="point-popup" style="color: ${colors.textPrimary};">
|
||||
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
|
||||
<strong>Point #${id}</strong>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Time:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${formatTimestamp(timestamp)}</span>
|
||||
</div>
|
||||
${altitude ? `
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Altitude:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${Math.round(altitude)}m</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${battery ? `
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Battery:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${battery}%</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${accuracy ? `
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Accuracy:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${Math.round(accuracy)}m</span>
|
||||
</div>
|
||||
` : ''}
|
||||
${velocity ? `
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Speed:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${Math.round(velocity * 3.6)} km/h</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create popup for a place
|
||||
* @param {Object} properties - Place properties
|
||||
* @returns {string} HTML for popup
|
||||
*/
|
||||
static createPlacePopup(properties) {
|
||||
const { id, name, latitude, longitude, note, tags } = properties
|
||||
|
||||
// Get theme colors
|
||||
const theme = getCurrentTheme()
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
// Parse tags if they're stringified
|
||||
let parsedTags = tags
|
||||
if (typeof tags === 'string') {
|
||||
try {
|
||||
parsedTags = JSON.parse(tags)
|
||||
} catch (e) {
|
||||
parsedTags = []
|
||||
}
|
||||
}
|
||||
|
||||
// Format tags as badges
|
||||
const tagsHtml = parsedTags && Array.isArray(parsedTags) && parsedTags.length > 0
|
||||
? parsedTags.map(tag => `
|
||||
<span class="badge badge-sm" style="background-color: ${tag.color}; color: white;">
|
||||
${tag.icon} #${tag.name}
|
||||
</span>
|
||||
`).join(' ')
|
||||
: `<span class="badge badge-sm badge-outline" style="border-color: ${colors.border}; color: ${colors.textMuted};">Untagged</span>`
|
||||
|
||||
return `
|
||||
<div class="place-popup" style="color: ${colors.textPrimary};">
|
||||
<div class="popup-header" style="border-bottom: 1px solid ${colors.border};">
|
||||
<strong>${name || `Place #${id}`}</strong>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
${note ? `
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Note:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${note}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Tags:</span>
|
||||
<div class="value">${tagsHtml}</div>
|
||||
</div>
|
||||
<div class="popup-row">
|
||||
<span class="label" style="color: ${colors.textMuted};">Coordinates:</span>
|
||||
<span class="value" style="color: ${colors.textPrimary};">${latitude.toFixed(5)}, ${longitude.toFixed(5)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
183
app/javascript/maps_maplibre/components/toast.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
/**
|
||||
* Toast notification system
|
||||
* Displays temporary notifications in the top-right corner
|
||||
*/
|
||||
export class Toast {
|
||||
static container = null
|
||||
|
||||
/**
|
||||
* Initialize toast container
|
||||
*/
|
||||
static init() {
|
||||
if (this.container) return
|
||||
|
||||
this.container = document.createElement('div')
|
||||
this.container.className = 'toast-container'
|
||||
this.container.style.cssText = `
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 9999;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
`
|
||||
document.body.appendChild(this.container)
|
||||
|
||||
// Add CSS animations
|
||||
this.addStyles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Add CSS animations for toasts
|
||||
*/
|
||||
static addStyles() {
|
||||
if (document.getElementById('toast-styles')) return
|
||||
|
||||
const style = document.createElement('style')
|
||||
style.id = 'toast-styles'
|
||||
style.textContent = `
|
||||
@keyframes toast-slide-in {
|
||||
from {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toast-slide-out {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateX(400px);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.toast {
|
||||
pointer-events: auto;
|
||||
animation: toast-slide-in 0.3s ease-out;
|
||||
}
|
||||
|
||||
.toast.removing {
|
||||
animation: toast-slide-out 0.3s ease-out;
|
||||
}
|
||||
`
|
||||
document.head.appendChild(style)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show toast notification
|
||||
* @param {string} message - Message to display
|
||||
* @param {string} type - Toast type: 'success', 'error', 'info', 'warning'
|
||||
* @param {number} duration - Duration in milliseconds (default 3000)
|
||||
*/
|
||||
static show(message, type = 'info', duration = 3000) {
|
||||
this.init()
|
||||
|
||||
const toast = document.createElement('div')
|
||||
toast.className = `toast toast-${type}`
|
||||
toast.textContent = message
|
||||
|
||||
toast.style.cssText = `
|
||||
padding: 12px 20px;
|
||||
background: ${this.getBackgroundColor(type)};
|
||||
color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
max-width: 300px;
|
||||
line-height: 1.4;
|
||||
`
|
||||
|
||||
this.container.appendChild(toast)
|
||||
|
||||
// Auto dismiss after duration
|
||||
if (duration > 0) {
|
||||
setTimeout(() => {
|
||||
this.dismiss(toast)
|
||||
}, duration)
|
||||
}
|
||||
|
||||
return toast
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismiss a toast
|
||||
* @param {HTMLElement} toast - Toast element to dismiss
|
||||
*/
|
||||
static dismiss(toast) {
|
||||
toast.classList.add('removing')
|
||||
setTimeout(() => {
|
||||
toast.remove()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get background color for toast type
|
||||
* @param {string} type - Toast type
|
||||
* @returns {string} CSS color
|
||||
*/
|
||||
static getBackgroundColor(type) {
|
||||
const colors = {
|
||||
success: '#22c55e',
|
||||
error: '#ef4444',
|
||||
warning: '#f59e0b',
|
||||
info: '#3b82f6'
|
||||
}
|
||||
return colors[type] || colors.info
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
static success(message, duration = 3000) {
|
||||
return this.show(message, 'success', duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
static error(message, duration = 4000) {
|
||||
return this.show(message, 'error', duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show warning toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
static warning(message, duration = 3500) {
|
||||
return this.show(message, 'warning', duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show info toast
|
||||
* @param {string} message
|
||||
* @param {number} duration
|
||||
*/
|
||||
static info(message, duration = 3000) {
|
||||
return this.show(message, 'info', duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all toasts
|
||||
*/
|
||||
static clearAll() {
|
||||
if (!this.container) return
|
||||
|
||||
const toasts = this.container.querySelectorAll('.toast')
|
||||
toasts.forEach(toast => this.dismiss(toast))
|
||||
}
|
||||
}
|
||||
156
app/javascript/maps_maplibre/components/visit_card.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* Visit card component for rendering individual visit cards in the side panel
|
||||
*/
|
||||
export class VisitCard {
|
||||
/**
|
||||
* Create HTML for a visit card
|
||||
* @param {Object} visit - Visit object with id, name, status, started_at, ended_at, duration, place
|
||||
* @param {Object} options - { isSelected, onSelect, onConfirm, onDecline, onHover }
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
static create(visit, options = {}) {
|
||||
const { isSelected = false, onSelect, onConfirm, onDecline, onHover } = options
|
||||
const isSuggested = visit.status === 'suggested'
|
||||
const isConfirmed = visit.status === 'confirmed'
|
||||
const isDeclined = visit.status === 'declined'
|
||||
|
||||
// Format date and time
|
||||
const startDate = new Date(visit.started_at)
|
||||
const endDate = new Date(visit.ended_at)
|
||||
const dateStr = startDate.toLocaleDateString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
})
|
||||
const timeRange = `${startDate.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})} - ${endDate.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}`
|
||||
|
||||
// Format duration (duration is in minutes from the backend)
|
||||
const hours = Math.floor(visit.duration / 60)
|
||||
const minutes = visit.duration % 60
|
||||
const durationStr = hours > 0
|
||||
? `${hours}h ${minutes}m`
|
||||
: `${minutes}m`
|
||||
|
||||
// Border style based on status
|
||||
const borderClass = isSuggested ? 'border-dashed' : ''
|
||||
const bgClass = isDeclined ? 'bg-base-200 opacity-60' : 'bg-base-100'
|
||||
const selectedClass = isSelected ? 'ring-2 ring-primary' : ''
|
||||
|
||||
return `
|
||||
<div class="visit-card card ${bgClass} ${borderClass} ${selectedClass} border-2 border-base-content/20 mb-2 hover:shadow-md transition-all relative"
|
||||
data-visit-id="${visit.id}"
|
||||
data-visit-status="${visit.status}"
|
||||
onmouseenter="this.querySelector('.visit-checkbox').classList.remove('hidden')"
|
||||
onmouseleave="if(!this.querySelector('.visit-checkbox input').checked) this.querySelector('.visit-checkbox').classList.add('hidden')">
|
||||
|
||||
<!-- Checkbox (hidden by default, shown on hover) -->
|
||||
<div class="visit-checkbox absolute top-3 right-3 z-10 ${isSelected ? '' : 'hidden'}">
|
||||
<input type="checkbox"
|
||||
class="checkbox checkbox-primary checkbox-sm"
|
||||
${isSelected ? 'checked' : ''}
|
||||
data-visit-select="${visit.id}"
|
||||
onclick="event.stopPropagation()">
|
||||
</div>
|
||||
|
||||
<div class="card-body p-3">
|
||||
<!-- Visit Name -->
|
||||
<h3 class="card-title text-sm font-semibold mb-2">
|
||||
${visit.name || visit.place?.name || 'Unnamed Visit'}
|
||||
</h3>
|
||||
|
||||
<!-- Date and Time -->
|
||||
<div class="text-xs text-base-content/70 space-y-1">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<span class="truncate">${dateStr}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span class="truncate">${timeRange}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3.5 w-3.5 flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<span class="truncate">${durationStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action buttons for suggested visits -->
|
||||
${isSuggested ? `
|
||||
<div class="card-actions justify-end mt-3 gap-1.5">
|
||||
<button class="btn btn-xs btn-outline btn-error" data-visit-decline="${visit.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
<button class="btn btn-xs btn-primary" data-visit-confirm="${visit.id}">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<!-- Status badge for confirmed/declined visits -->
|
||||
${isConfirmed || isDeclined ? `
|
||||
<div class="mt-2">
|
||||
<span class="badge badge-xs ${isConfirmed ? 'badge-success' : 'badge-error'}">
|
||||
${visit.status}
|
||||
</span>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Create bulk action buttons HTML
|
||||
* @param {number} selectedCount - Number of selected visits
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
static createBulkActions(selectedCount) {
|
||||
if (selectedCount < 2) return ''
|
||||
|
||||
return `
|
||||
<div class="bulk-actions-panel sticky bottom-0 bg-base-100 border-t border-base-300 p-4 mt-4 space-y-2">
|
||||
<div class="text-sm font-medium mb-3">
|
||||
${selectedCount} visit${selectedCount === 1 ? '' : 's'} selected
|
||||
</div>
|
||||
<div class="grid grid-cols-3 gap-2">
|
||||
<button class="btn btn-sm btn-outline" data-bulk-merge>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4" />
|
||||
</svg>
|
||||
Merge
|
||||
</button>
|
||||
<button class="btn btn-sm btn-primary" data-bulk-confirm>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
Confirm
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline btn-error" data-bulk-decline>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
Decline
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
}
|
||||
138
app/javascript/maps_maplibre/components/visit_popup.js
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { formatTimestamp } from '../utils/geojson_transformers'
|
||||
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
|
||||
|
||||
/**
|
||||
* Factory for creating visit popups
|
||||
*/
|
||||
export class VisitPopupFactory {
|
||||
/**
|
||||
* Create popup for a visit
|
||||
* @param {Object} properties - Visit properties
|
||||
* @returns {string} HTML for popup
|
||||
*/
|
||||
static createVisitPopup(properties) {
|
||||
const { id, name, status, started_at, ended_at, duration, place_name } = properties
|
||||
|
||||
const startTime = formatTimestamp(started_at)
|
||||
const endTime = formatTimestamp(ended_at)
|
||||
const durationHours = Math.round(duration / 3600)
|
||||
const durationDisplay = durationHours >= 1 ? `${durationHours}h` : `${Math.round(duration / 60)}m`
|
||||
|
||||
// Get theme colors
|
||||
const theme = getCurrentTheme()
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
return `
|
||||
<div class="visit-popup">
|
||||
<div class="popup-header">
|
||||
<strong>${name || place_name || 'Unknown Place'}</strong>
|
||||
<span class="visit-badge ${status}">${status}</span>
|
||||
</div>
|
||||
<div class="popup-body">
|
||||
<div class="popup-row">
|
||||
<span class="label">Arrived:</span>
|
||||
<span class="value">${startTime}</span>
|
||||
</div>
|
||||
<div class="popup-row">
|
||||
<span class="label">Left:</span>
|
||||
<span class="value">${endTime}</span>
|
||||
</div>
|
||||
<div class="popup-row">
|
||||
<span class="label">Duration:</span>
|
||||
<span class="value">${durationDisplay}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="popup-footer">
|
||||
<a href="/visits/${id}" class="view-details-btn">View Details →</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.visit-popup {
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid ${colors.border};
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.popup-header strong {
|
||||
font-size: 15px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.visit-badge {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.visit-badge.suggested {
|
||||
background: ${colors.badgeSuggested.bg};
|
||||
color: ${colors.badgeSuggested.text};
|
||||
}
|
||||
|
||||
.visit-badge.confirmed {
|
||||
background: ${colors.badgeConfirmed.bg};
|
||||
color: ${colors.badgeConfirmed.text};
|
||||
}
|
||||
|
||||
.popup-body {
|
||||
font-size: 13px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.popup-row {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.popup-row .label {
|
||||
color: ${colors.textMuted};
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.popup-row .value {
|
||||
font-weight: 500;
|
||||
color: ${colors.textPrimary};
|
||||
display: block;
|
||||
}
|
||||
|
||||
.popup-footer {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid ${colors.border};
|
||||
}
|
||||
|
||||
.view-details-btn {
|
||||
display: block;
|
||||
text-align: center;
|
||||
padding: 10px 16px;
|
||||
background: ${colors.accent};
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.view-details-btn:hover {
|
||||
background: ${colors.accentHover};
|
||||
}
|
||||
</style>
|
||||
`
|
||||
}
|
||||
}
|
||||
67
app/javascript/maps_maplibre/layers/areas_layer.js
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Areas layer for user-defined regions
|
||||
*/
|
||||
export class AreasLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'areas', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Area fills
|
||||
{
|
||||
id: `${this.id}-fill`,
|
||||
type: 'fill',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'fill-color': '#ff0000',
|
||||
'fill-opacity': 0.4
|
||||
}
|
||||
},
|
||||
|
||||
// Area outlines
|
||||
{
|
||||
id: `${this.id}-outline`,
|
||||
type: 'line',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'line-color': '#ff0000',
|
||||
'line-width': 3
|
||||
}
|
||||
},
|
||||
|
||||
// Area labels
|
||||
{
|
||||
id: `${this.id}-labels`,
|
||||
type: 'symbol',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 14
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111827',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return [`${this.id}-fill`, `${this.id}-outline`, `${this.id}-labels`]
|
||||
}
|
||||
}
|
||||
136
app/javascript/maps_maplibre/layers/base_layer.js
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Base class for all map layers
|
||||
* Provides common functionality for layer management
|
||||
*/
|
||||
export class BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
this.map = map
|
||||
this.id = options.id || this.constructor.name.toLowerCase()
|
||||
this.sourceId = `${this.id}-source`
|
||||
this.visible = options.visible !== false
|
||||
this.data = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Add layer to map with data
|
||||
* @param {Object} data - GeoJSON or layer-specific data
|
||||
*/
|
||||
add(data) {
|
||||
console.log(`[BaseLayer:${this.id}] add() called, visible:`, this.visible, 'features:', data?.features?.length || 0)
|
||||
this.data = data
|
||||
|
||||
// Add source
|
||||
if (!this.map.getSource(this.sourceId)) {
|
||||
console.log(`[BaseLayer:${this.id}] Adding source:`, this.sourceId)
|
||||
this.map.addSource(this.sourceId, this.getSourceConfig())
|
||||
} else {
|
||||
console.log(`[BaseLayer:${this.id}] Source already exists:`, this.sourceId)
|
||||
}
|
||||
|
||||
// Add layers
|
||||
const layers = this.getLayerConfigs()
|
||||
console.log(`[BaseLayer:${this.id}] Adding ${layers.length} layer(s)`)
|
||||
layers.forEach(layerConfig => {
|
||||
if (!this.map.getLayer(layerConfig.id)) {
|
||||
console.log(`[BaseLayer:${this.id}] Adding layer:`, layerConfig.id, 'type:', layerConfig.type)
|
||||
this.map.addLayer(layerConfig)
|
||||
} else {
|
||||
console.log(`[BaseLayer:${this.id}] Layer already exists:`, layerConfig.id)
|
||||
}
|
||||
})
|
||||
|
||||
this.setVisibility(this.visible)
|
||||
console.log(`[BaseLayer:${this.id}] Layer added successfully`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layer data
|
||||
* @param {Object} data - New data
|
||||
*/
|
||||
update(data) {
|
||||
this.data = data
|
||||
const source = this.map.getSource(this.sourceId)
|
||||
if (source && source.setData) {
|
||||
source.setData(data)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove layer from map
|
||||
*/
|
||||
remove() {
|
||||
this.getLayerIds().forEach(layerId => {
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.removeLayer(layerId)
|
||||
}
|
||||
})
|
||||
|
||||
if (this.map.getSource(this.sourceId)) {
|
||||
this.map.removeSource(this.sourceId)
|
||||
}
|
||||
|
||||
this.data = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Show layer
|
||||
*/
|
||||
show() {
|
||||
this.visible = true
|
||||
this.setVisibility(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide layer
|
||||
*/
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.setVisibility(false)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle layer visibility
|
||||
* @param {boolean} visible - Show/hide layer
|
||||
*/
|
||||
toggle(visible = !this.visible) {
|
||||
this.visible = visible
|
||||
this.setVisibility(visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set visibility for all layer IDs
|
||||
* @param {boolean} visible
|
||||
*/
|
||||
setVisibility(visible) {
|
||||
const visibility = visible ? 'visible' : 'none'
|
||||
this.getLayerIds().forEach(layerId => {
|
||||
if (this.map.getLayer(layerId)) {
|
||||
this.map.setLayoutProperty(layerId, 'visibility', visibility)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source configuration (override in subclass)
|
||||
* @returns {Object} MapLibre source config
|
||||
*/
|
||||
getSourceConfig() {
|
||||
throw new Error('Must implement getSourceConfig()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer configurations (override in subclass)
|
||||
* @returns {Array<Object>} Array of MapLibre layer configs
|
||||
*/
|
||||
getLayerConfigs() {
|
||||
throw new Error('Must implement getLayerConfigs()')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all layer IDs for this layer
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getLayerIds() {
|
||||
return this.getLayerConfigs().map(config => config.id)
|
||||
}
|
||||
}
|
||||
151
app/javascript/maps_maplibre/layers/family_layer.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Family layer showing family member locations
|
||||
* Each member has unique color
|
||||
*/
|
||||
export class FamilyLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'family', ...options })
|
||||
this.memberColors = {}
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Member circles
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-opacity': 0.9
|
||||
}
|
||||
},
|
||||
|
||||
// Member labels
|
||||
{
|
||||
id: `${this.id}-labels`,
|
||||
type: 'symbol',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 12,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-anchor': 'top'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111827',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
},
|
||||
|
||||
// Pulse animation
|
||||
{
|
||||
id: `${this.id}-pulse`,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
10, 15,
|
||||
15, 25
|
||||
],
|
||||
'circle-color': ['get', 'color'],
|
||||
'circle-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'lastUpdate'],
|
||||
Date.now() - 10000, 0,
|
||||
Date.now(), 0.3
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return [this.id, `${this.id}-labels`, `${this.id}-pulse`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update single family member location
|
||||
* @param {Object} member - { id, name, latitude, longitude, color }
|
||||
*/
|
||||
updateMember(member) {
|
||||
const features = this.data?.features || []
|
||||
|
||||
// Find existing or add new
|
||||
const index = features.findIndex(f => f.properties.id === member.id)
|
||||
|
||||
const feature = {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [member.longitude, member.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
color: member.color || this.getMemberColor(member.id),
|
||||
lastUpdate: Date.now()
|
||||
}
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
features[index] = feature
|
||||
} else {
|
||||
features.push(feature)
|
||||
}
|
||||
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consistent color for member
|
||||
*/
|
||||
getMemberColor(memberId) {
|
||||
if (!this.memberColors[memberId]) {
|
||||
const colors = [
|
||||
'#3b82f6', '#10b981', '#f59e0b',
|
||||
'#ef4444', '#8b5cf6', '#ec4899'
|
||||
]
|
||||
const index = Object.keys(this.memberColors).length % colors.length
|
||||
this.memberColors[memberId] = colors[index]
|
||||
}
|
||||
return this.memberColors[memberId]
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove family member
|
||||
*/
|
||||
removeMember(memberId) {
|
||||
const features = this.data?.features || []
|
||||
const filtered = features.filter(f => f.properties.id !== memberId)
|
||||
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: filtered
|
||||
})
|
||||
}
|
||||
}
|
||||
140
app/javascript/maps_maplibre/layers/fog_layer.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Fog of war layer
|
||||
* Shows explored vs unexplored areas using canvas overlay
|
||||
* Does not extend BaseLayer as it uses canvas instead of MapLibre layers
|
||||
*/
|
||||
export class FogLayer {
|
||||
constructor(map, options = {}) {
|
||||
this.map = map
|
||||
this.id = 'fog'
|
||||
this.visible = options.visible !== undefined ? options.visible : false
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
this.clearRadius = options.clearRadius || 1000 // meters
|
||||
this.points = []
|
||||
}
|
||||
|
||||
add(data) {
|
||||
this.points = data.features || []
|
||||
this.createCanvas()
|
||||
if (this.visible) {
|
||||
this.show()
|
||||
}
|
||||
this.render()
|
||||
}
|
||||
|
||||
update(data) {
|
||||
this.points = data.features || []
|
||||
this.render()
|
||||
}
|
||||
|
||||
createCanvas() {
|
||||
if (this.canvas) return
|
||||
|
||||
// Create canvas overlay
|
||||
this.canvas = document.createElement('canvas')
|
||||
this.canvas.className = 'fog-canvas'
|
||||
this.canvas.style.position = 'absolute'
|
||||
this.canvas.style.top = '0'
|
||||
this.canvas.style.left = '0'
|
||||
this.canvas.style.pointerEvents = 'none'
|
||||
this.canvas.style.zIndex = '10'
|
||||
this.canvas.style.display = this.visible ? 'block' : 'none'
|
||||
|
||||
this.ctx = this.canvas.getContext('2d')
|
||||
|
||||
// Add to map container
|
||||
const mapContainer = this.map.getContainer()
|
||||
mapContainer.appendChild(this.canvas)
|
||||
|
||||
// Update on map move/zoom/resize
|
||||
this.map.on('move', () => this.render())
|
||||
this.map.on('zoom', () => this.render())
|
||||
this.map.on('resize', () => this.resizeCanvas())
|
||||
|
||||
this.resizeCanvas()
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
if (!this.canvas) return
|
||||
|
||||
const container = this.map.getContainer()
|
||||
this.canvas.width = container.offsetWidth
|
||||
this.canvas.height = container.offsetHeight
|
||||
this.render()
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.canvas || !this.ctx || !this.visible) return
|
||||
|
||||
const { width, height } = this.canvas
|
||||
|
||||
// Clear canvas
|
||||
this.ctx.clearRect(0, 0, width, height)
|
||||
|
||||
// Draw fog overlay
|
||||
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.6)'
|
||||
this.ctx.fillRect(0, 0, width, height)
|
||||
|
||||
// Clear circles around visited points
|
||||
this.ctx.globalCompositeOperation = 'destination-out'
|
||||
|
||||
this.points.forEach(feature => {
|
||||
const coords = feature.geometry.coordinates
|
||||
const point = this.map.project(coords)
|
||||
|
||||
// Calculate pixel radius based on zoom level
|
||||
const metersPerPixel = this.getMetersPerPixel(coords[1])
|
||||
const radiusPixels = this.clearRadius / metersPerPixel
|
||||
|
||||
this.ctx.beginPath()
|
||||
this.ctx.arc(point.x, point.y, radiusPixels, 0, Math.PI * 2)
|
||||
this.ctx.fill()
|
||||
})
|
||||
|
||||
this.ctx.globalCompositeOperation = 'source-over'
|
||||
}
|
||||
|
||||
getMetersPerPixel(latitude) {
|
||||
const earthCircumference = 40075017 // meters at equator
|
||||
const latitudeRadians = latitude * Math.PI / 180
|
||||
const zoom = this.map.getZoom()
|
||||
return earthCircumference * Math.cos(latitudeRadians) / (256 * Math.pow(2, zoom))
|
||||
}
|
||||
|
||||
show() {
|
||||
this.visible = true
|
||||
if (this.canvas) {
|
||||
this.canvas.style.display = 'block'
|
||||
this.render()
|
||||
}
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.visible = false
|
||||
if (this.canvas) {
|
||||
this.canvas.style.display = 'none'
|
||||
}
|
||||
}
|
||||
|
||||
toggle(visible = !this.visible) {
|
||||
if (visible) {
|
||||
this.show()
|
||||
} else {
|
||||
this.hide()
|
||||
}
|
||||
}
|
||||
|
||||
remove() {
|
||||
if (this.canvas) {
|
||||
this.canvas.remove()
|
||||
this.canvas = null
|
||||
this.ctx = null
|
||||
}
|
||||
|
||||
// Remove event listeners
|
||||
this.map.off('move', this.render)
|
||||
this.map.off('zoom', this.render)
|
||||
this.map.off('resize', this.resizeCanvas)
|
||||
}
|
||||
}
|
||||
86
app/javascript/maps_maplibre/layers/heatmap_layer.js
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Heatmap layer showing point density
|
||||
* Uses MapLibre's native heatmap for performance
|
||||
* Fixed radius: 20 pixels
|
||||
*/
|
||||
export class HeatmapLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'heatmap', ...options })
|
||||
this.radius = 20 // Fixed radius
|
||||
this.weight = options.weight || 1
|
||||
this.intensity = 1 // Fixed intensity
|
||||
this.opacity = options.opacity || 0.6
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
{
|
||||
id: this.id,
|
||||
type: 'heatmap',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
// Increase weight as diameter increases
|
||||
'heatmap-weight': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['get', 'weight'],
|
||||
0, 0,
|
||||
6, 1
|
||||
],
|
||||
|
||||
// Increase intensity as zoom increases
|
||||
'heatmap-intensity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, this.intensity,
|
||||
9, this.intensity * 3
|
||||
],
|
||||
|
||||
// Color ramp from blue to red
|
||||
'heatmap-color': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['heatmap-density'],
|
||||
0, 'rgba(33,102,172,0)',
|
||||
0.2, 'rgb(103,169,207)',
|
||||
0.4, 'rgb(209,229,240)',
|
||||
0.6, 'rgb(253,219,199)',
|
||||
0.8, 'rgb(239,138,98)',
|
||||
1, 'rgb(178,24,43)'
|
||||
],
|
||||
|
||||
// Fixed radius adjusted by zoom level
|
||||
'heatmap-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, this.radius,
|
||||
9, this.radius * 3
|
||||
],
|
||||
|
||||
// Transition from heatmap to circle layer by zoom level
|
||||
'heatmap-opacity': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
7, this.opacity,
|
||||
9, 0
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
220
app/javascript/maps_maplibre/layers/photos_layer.js
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { getCurrentTheme, getThemeColors } from '../utils/popup_theme'
|
||||
|
||||
/**
|
||||
* Photos layer with thumbnail markers
|
||||
* Uses HTML DOM markers with circular image thumbnails
|
||||
*/
|
||||
export class PhotosLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'photos', ...options })
|
||||
this.markers = [] // Store marker references for cleanup
|
||||
}
|
||||
|
||||
async add(data) {
|
||||
console.log('[PhotosLayer] add() called with data:', {
|
||||
featuresCount: data.features?.length || 0,
|
||||
sampleFeature: data.features?.[0],
|
||||
visible: this.visible
|
||||
})
|
||||
|
||||
// Store data
|
||||
this.data = data
|
||||
|
||||
// Create HTML markers for photos
|
||||
this.createPhotoMarkers(data)
|
||||
console.log('[PhotosLayer] Photo markers created')
|
||||
}
|
||||
|
||||
async update(data) {
|
||||
console.log('[PhotosLayer] update() called with data:', {
|
||||
featuresCount: data.features?.length || 0
|
||||
})
|
||||
|
||||
// Remove existing markers
|
||||
this.clearMarkers()
|
||||
|
||||
// Create new markers
|
||||
this.createPhotoMarkers(data)
|
||||
console.log('[PhotosLayer] Photo markers updated')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create HTML markers with photo thumbnails
|
||||
* @param {Object} geojson - GeoJSON with photo features
|
||||
*/
|
||||
createPhotoMarkers(geojson) {
|
||||
if (!geojson?.features) {
|
||||
console.log('[PhotosLayer] No features to create markers for')
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[PhotosLayer] Creating markers for', geojson.features.length, 'photos')
|
||||
console.log('[PhotosLayer] Sample feature:', geojson.features[0])
|
||||
|
||||
geojson.features.forEach((feature, index) => {
|
||||
const { id, thumbnail_url, photo_url, taken_at } = feature.properties
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
|
||||
if (index === 0) {
|
||||
console.log('[PhotosLayer] First marker thumbnail_url:', thumbnail_url)
|
||||
}
|
||||
|
||||
// Create marker container (MapLibre will position this)
|
||||
const container = document.createElement('div')
|
||||
container.style.cssText = `
|
||||
display: ${this.visible ? 'block' : 'none'};
|
||||
`
|
||||
|
||||
// Create inner element for the image (this is what we'll transform)
|
||||
const el = document.createElement('div')
|
||||
el.className = 'photo-marker'
|
||||
el.style.cssText = `
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-image: url('${thumbnail_url}');
|
||||
border: 3px solid white;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
`
|
||||
|
||||
// Add hover effect
|
||||
el.addEventListener('mouseenter', () => {
|
||||
el.style.transform = 'scale(1.2)'
|
||||
el.style.boxShadow = '0 4px 8px rgba(0,0,0,0.4)'
|
||||
el.style.zIndex = '1000'
|
||||
})
|
||||
|
||||
el.addEventListener('mouseleave', () => {
|
||||
el.style.transform = 'scale(1)'
|
||||
el.style.boxShadow = '0 2px 4px rgba(0,0,0,0.3)'
|
||||
el.style.zIndex = '1'
|
||||
})
|
||||
|
||||
// Add click handler to show popup
|
||||
el.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
this.showPhotoPopup(feature)
|
||||
})
|
||||
|
||||
// Add image element to container
|
||||
container.appendChild(el)
|
||||
|
||||
// Create MapLibre marker with container
|
||||
const marker = new maplibregl.Marker({ element: container })
|
||||
.setLngLat([lng, lat])
|
||||
.addTo(this.map)
|
||||
|
||||
this.markers.push(marker)
|
||||
|
||||
if (index === 0) {
|
||||
console.log('[PhotosLayer] First marker created at:', lng, lat)
|
||||
}
|
||||
})
|
||||
|
||||
console.log('[PhotosLayer] Created', this.markers.length, 'markers, visible:', this.visible)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show photo popup with image
|
||||
* @param {Object} feature - GeoJSON feature with photo properties
|
||||
*/
|
||||
showPhotoPopup(feature) {
|
||||
const { thumbnail_url, taken_at, filename, city, state, country, type, source } = feature.properties
|
||||
const [lng, lat] = feature.geometry.coordinates
|
||||
|
||||
const takenDate = taken_at ? new Date(taken_at).toLocaleString() : 'Unknown'
|
||||
const location = [city, state, country].filter(Boolean).join(', ') || 'Unknown location'
|
||||
const mediaType = type === 'VIDEO' ? '🎥 Video' : '📷 Photo'
|
||||
|
||||
// Get theme colors
|
||||
const theme = getCurrentTheme()
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
// Create popup HTML with theme-aware styling
|
||||
const popupHTML = `
|
||||
<div class="photo-popup" style="font-family: system-ui, -apple-system, sans-serif; max-width: 350px;">
|
||||
<div style="width: 100%; border-radius: 8px; overflow: hidden; margin-bottom: 12px; background: ${colors.backgroundAlt};">
|
||||
<img
|
||||
src="${thumbnail_url}"
|
||||
alt="${filename || 'Photo'}"
|
||||
style="width: 100%; height: auto; max-height: 350px; object-fit: contain; display: block;"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div style="font-size: 13px;">
|
||||
${filename ? `<div style="font-weight: 600; color: ${colors.textPrimary}; margin-bottom: 6px; word-wrap: break-word;">${filename}</div>` : ''}
|
||||
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">📅 ${takenDate}</div>
|
||||
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">📍 ${location}</div>
|
||||
<div style="color: ${colors.textMuted}; font-size: 12px; margin-bottom: 6px;">Coordinates: ${lat.toFixed(6)}, ${lng.toFixed(6)}</div>
|
||||
${source ? `<div style="color: ${colors.textSecondary}; font-size: 11px; margin-bottom: 6px;">Source: ${source}</div>` : ''}
|
||||
<div style="font-size: 14px; margin-top: 8px; color: ${colors.textPrimary};">${mediaType}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
// Create and show popup
|
||||
new maplibregl.Popup({
|
||||
closeButton: true,
|
||||
closeOnClick: true,
|
||||
maxWidth: '400px'
|
||||
})
|
||||
.setLngLat([lng, lat])
|
||||
.setHTML(popupHTML)
|
||||
.addTo(this.map)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all markers from map
|
||||
*/
|
||||
clearMarkers() {
|
||||
this.markers.forEach(marker => marker.remove())
|
||||
this.markers = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Override remove to clean up markers
|
||||
*/
|
||||
remove() {
|
||||
this.clearMarkers()
|
||||
super.remove()
|
||||
}
|
||||
|
||||
/**
|
||||
* Override show to display markers
|
||||
*/
|
||||
show() {
|
||||
this.visible = true
|
||||
this.markers.forEach(marker => {
|
||||
marker.getElement().style.display = 'block'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Override hide to hide markers
|
||||
*/
|
||||
hide() {
|
||||
this.visible = false
|
||||
this.markers.forEach(marker => {
|
||||
marker.getElement().style.display = 'none'
|
||||
})
|
||||
}
|
||||
|
||||
// Override these methods since we're not using source/layer approach
|
||||
getSourceConfig() {
|
||||
return null
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return []
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return []
|
||||
}
|
||||
}
|
||||
66
app/javascript/maps_maplibre/layers/places_layer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Places layer showing user-created places with tags
|
||||
* Different colors based on tags
|
||||
*/
|
||||
export class PlacesLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'places', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Place circles
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 10,
|
||||
'circle-color': [
|
||||
'coalesce',
|
||||
['get', 'color'], // Use tag color if available
|
||||
'#6366f1' // Default indigo color
|
||||
],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-opacity': 0.85
|
||||
}
|
||||
},
|
||||
|
||||
// Place labels
|
||||
{
|
||||
id: `${this.id}-labels`,
|
||||
type: 'symbol',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 11,
|
||||
'text-offset': [0, 1.3],
|
||||
'text-anchor': 'top'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111827',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return [this.id, `${this.id}-labels`]
|
||||
}
|
||||
}
|
||||
37
app/javascript/maps_maplibre/layers/points_layer.js
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Points layer for displaying individual location points
|
||||
*/
|
||||
export class PointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'points', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Individual points
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-color': '#3b82f6',
|
||||
'circle-radius': 6,
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
94
app/javascript/maps_maplibre/layers/recent_point_layer.js
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Recent point layer for displaying the most recent location in live mode
|
||||
* This layer is always visible when live mode is enabled, regardless of points layer visibility
|
||||
*/
|
||||
export class RecentPointLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'recent-point', visible: true, ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Pulsing outer circle (animation effect)
|
||||
{
|
||||
id: `${this.id}-pulse`,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-color': '#ef4444',
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 8,
|
||||
20, 40
|
||||
],
|
||||
'circle-opacity': 0.3
|
||||
}
|
||||
},
|
||||
// Main point circle
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-color': '#ef4444',
|
||||
'circle-radius': [
|
||||
'interpolate',
|
||||
['linear'],
|
||||
['zoom'],
|
||||
0, 6,
|
||||
20, 20
|
||||
],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update layer with a single recent point
|
||||
* @param {number} lon - Longitude
|
||||
* @param {number} lat - Latitude
|
||||
* @param {Object} properties - Additional point properties
|
||||
*/
|
||||
updateRecentPoint(lon, lat, properties = {}) {
|
||||
const data = {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [lon, lat]
|
||||
},
|
||||
properties
|
||||
}
|
||||
]
|
||||
}
|
||||
this.update(data)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the recent point
|
||||
*/
|
||||
clear() {
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
}
|
||||
}
|
||||
145
app/javascript/maps_maplibre/layers/routes_layer.js
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Routes layer showing travel paths
|
||||
* Connects points chronologically with solid color
|
||||
*/
|
||||
export class RoutesLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'routes', ...options })
|
||||
this.maxGapHours = options.maxGapHours || 5 // Max hours between points to connect
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
{
|
||||
id: this.id,
|
||||
type: 'line',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': '#f97316', // Solid orange color
|
||||
'line-width': 3,
|
||||
'line-opacity': 0.8
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate haversine distance between two points in kilometers
|
||||
* @param {number} lat1 - First point latitude
|
||||
* @param {number} lon1 - First point longitude
|
||||
* @param {number} lat2 - Second point latitude
|
||||
* @param {number} lon2 - Second point longitude
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
static haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371 // Earth's radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert points to route LineStrings with splitting
|
||||
* Matches V1's route splitting logic for consistency
|
||||
* @param {Array} points - Points from API
|
||||
* @param {Object} options - Splitting options
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
*/
|
||||
static pointsToRoutes(points, options = {}) {
|
||||
if (points.length < 2) {
|
||||
return { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
|
||||
// Default thresholds (matching V1 defaults from polylines.js)
|
||||
const distanceThresholdKm = (options.distanceThresholdMeters || 500) / 1000
|
||||
const timeThresholdMinutes = options.timeThresholdMinutes || 60
|
||||
|
||||
// Sort by timestamp
|
||||
const sorted = points.slice().sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
// Split into segments based on distance and time gaps (like V1)
|
||||
const segments = []
|
||||
let currentSegment = [sorted[0]]
|
||||
|
||||
for (let i = 1; i < sorted.length; i++) {
|
||||
const prev = sorted[i - 1]
|
||||
const curr = sorted[i]
|
||||
|
||||
// Calculate distance between consecutive points
|
||||
const distance = this.haversineDistance(
|
||||
prev.latitude, prev.longitude,
|
||||
curr.latitude, curr.longitude
|
||||
)
|
||||
|
||||
// Calculate time difference in minutes
|
||||
const timeDiff = (curr.timestamp - prev.timestamp) / 60
|
||||
|
||||
// Split if either threshold is exceeded (matching V1 logic)
|
||||
if (distance > distanceThresholdKm || timeDiff > timeThresholdMinutes) {
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
currentSegment = [curr]
|
||||
} else {
|
||||
currentSegment.push(curr)
|
||||
}
|
||||
}
|
||||
|
||||
if (currentSegment.length > 1) {
|
||||
segments.push(currentSegment)
|
||||
}
|
||||
|
||||
// Convert segments to LineStrings
|
||||
const features = segments.map(segment => {
|
||||
const coordinates = segment.map(p => [p.longitude, p.latitude])
|
||||
|
||||
// Calculate total distance for the segment
|
||||
let totalDistance = 0
|
||||
for (let i = 0; i < segment.length - 1; i++) {
|
||||
totalDistance += this.haversineDistance(
|
||||
segment[i].latitude, segment[i].longitude,
|
||||
segment[i + 1].latitude, segment[i + 1].longitude
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates
|
||||
},
|
||||
properties: {
|
||||
pointCount: segment.length,
|
||||
startTime: segment[0].timestamp,
|
||||
endTime: segment[segment.length - 1].timestamp,
|
||||
distance: totalDistance
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features
|
||||
}
|
||||
}
|
||||
}
|
||||
178
app/javascript/maps_maplibre/layers/scratch_layer.js
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Scratch map layer
|
||||
* Highlights countries that have been visited based on points' country_name attribute
|
||||
* Extracts country names from points (via database country relationship)
|
||||
* Matches country names to polygons in lib/assets/countries.geojson by name field
|
||||
* "Scratches off" visited countries by overlaying gold/amber polygons
|
||||
*/
|
||||
export class ScratchLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'scratch', ...options })
|
||||
this.visitedCountries = new Set()
|
||||
this.countriesData = null
|
||||
this.loadingCountries = null // Promise for loading countries
|
||||
this.apiClient = options.apiClient // For authenticated requests
|
||||
}
|
||||
|
||||
async add(data) {
|
||||
const points = data.features || []
|
||||
|
||||
// Load country boundaries
|
||||
await this.loadCountryBoundaries()
|
||||
|
||||
// Detect which countries have been visited
|
||||
this.visitedCountries = this.detectCountriesFromPoints(points)
|
||||
|
||||
// Create GeoJSON with visited countries
|
||||
const geojson = this.createCountriesGeoJSON()
|
||||
|
||||
super.add(geojson)
|
||||
}
|
||||
|
||||
async update(data) {
|
||||
const points = data.features || []
|
||||
|
||||
// Countries already loaded from add()
|
||||
this.visitedCountries = this.detectCountriesFromPoints(points)
|
||||
|
||||
const geojson = this.createCountriesGeoJSON()
|
||||
|
||||
super.update(geojson)
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract country names from points' country_name attribute
|
||||
* Points already have country association from database (country_id relationship)
|
||||
* @param {Array} points - Array of point features with properties.country_name
|
||||
* @returns {Set} Set of country names
|
||||
*/
|
||||
detectCountriesFromPoints(points) {
|
||||
const visitedCountries = new Set()
|
||||
|
||||
// Extract unique country names from points
|
||||
points.forEach(point => {
|
||||
const countryName = point.properties?.country_name
|
||||
|
||||
if (countryName && countryName !== 'Unknown') {
|
||||
visitedCountries.add(countryName)
|
||||
}
|
||||
})
|
||||
|
||||
return visitedCountries
|
||||
}
|
||||
|
||||
/**
|
||||
* Load country boundaries from internal API endpoint
|
||||
* Endpoint: GET /api/v1/countries/borders
|
||||
*/
|
||||
async loadCountryBoundaries() {
|
||||
// Return existing promise if already loading
|
||||
if (this.loadingCountries) {
|
||||
return this.loadingCountries
|
||||
}
|
||||
|
||||
// Return immediately if already loaded
|
||||
if (this.countriesData) {
|
||||
return
|
||||
}
|
||||
|
||||
this.loadingCountries = (async () => {
|
||||
try {
|
||||
// Use internal API endpoint with authentication
|
||||
const headers = {}
|
||||
if (this.apiClient) {
|
||||
headers['Authorization'] = `Bearer ${this.apiClient.apiKey}`
|
||||
}
|
||||
|
||||
const response = await fetch('/api/v1/countries/borders.json', {
|
||||
headers: headers
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load country borders: ${response.statusText}`)
|
||||
}
|
||||
|
||||
this.countriesData = await response.json()
|
||||
} catch (error) {
|
||||
console.error('[ScratchLayer] Failed to load country boundaries:', error)
|
||||
// Fallback to empty data
|
||||
this.countriesData = { type: 'FeatureCollection', features: [] }
|
||||
}
|
||||
})()
|
||||
|
||||
return this.loadingCountries
|
||||
}
|
||||
|
||||
/**
|
||||
* Create GeoJSON for visited countries
|
||||
* Matches visited country names from points to boundary polygons by name
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
*/
|
||||
createCountriesGeoJSON() {
|
||||
if (!this.countriesData || this.visitedCountries.size === 0) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
|
||||
// Filter country features by matching name field to visited country names
|
||||
const visitedFeatures = this.countriesData.features.filter(country => {
|
||||
const countryName = country.properties.name || country.properties.NAME
|
||||
|
||||
if (!countryName) return false
|
||||
|
||||
// Case-insensitive exact match
|
||||
return Array.from(this.visitedCountries).some(visitedName =>
|
||||
countryName.toLowerCase() === visitedName.toLowerCase()
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: visitedFeatures
|
||||
}
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Country fill
|
||||
{
|
||||
id: this.id,
|
||||
type: 'fill',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'fill-color': '#fbbf24', // Amber/gold color
|
||||
'fill-opacity': 0.3
|
||||
}
|
||||
},
|
||||
// Country outline
|
||||
{
|
||||
id: `${this.id}-outline`,
|
||||
type: 'line',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'line-color': '#f59e0b',
|
||||
'line-width': 1,
|
||||
'line-opacity': 0.6
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return [this.id, `${this.id}-outline`]
|
||||
}
|
||||
}
|
||||
96
app/javascript/maps_maplibre/layers/selected_points_layer.js
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Layer for displaying selected points with distinct styling
|
||||
*/
|
||||
export class SelectedPointsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'selected-points', ...options })
|
||||
this.pointIds = []
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Outer circle (highlight)
|
||||
{
|
||||
id: `${this.id}-highlight`,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 8,
|
||||
'circle-color': '#ef4444',
|
||||
'circle-opacity': 0.3
|
||||
}
|
||||
},
|
||||
// Inner circle (selected point)
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 5,
|
||||
'circle-color': '#ef4444',
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff'
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer IDs for this layer
|
||||
*/
|
||||
getLayerIds() {
|
||||
return [`${this.id}-highlight`, this.id]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update selected points and store their IDs
|
||||
*/
|
||||
updateSelectedPoints(geojson) {
|
||||
this.data = geojson
|
||||
|
||||
// Extract point IDs
|
||||
this.pointIds = geojson.features.map(f => f.properties.id)
|
||||
|
||||
// Update map source
|
||||
this.update(geojson)
|
||||
|
||||
console.log('[SelectedPointsLayer] Updated with', this.pointIds.length, 'points')
|
||||
}
|
||||
|
||||
/**
|
||||
* Get IDs of selected points
|
||||
*/
|
||||
getSelectedPointIds() {
|
||||
return this.pointIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear selected points
|
||||
*/
|
||||
clearSelection() {
|
||||
this.pointIds = []
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of selected points
|
||||
*/
|
||||
getCount() {
|
||||
return this.pointIds.length
|
||||
}
|
||||
}
|
||||
200
app/javascript/maps_maplibre/layers/selection_layer.js
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Selection layer for drawing selection rectangles on the map
|
||||
* Allows users to select areas by clicking and dragging
|
||||
*/
|
||||
export class SelectionLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'selection', ...options })
|
||||
this.isDrawing = false
|
||||
this.startPoint = null
|
||||
this.currentRect = null
|
||||
this.onSelectionComplete = options.onSelectionComplete || (() => {})
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Fill layer
|
||||
{
|
||||
id: `${this.id}-fill`,
|
||||
type: 'fill',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'fill-color': '#3b82f6',
|
||||
'fill-opacity': 0.1
|
||||
}
|
||||
},
|
||||
// Outline layer
|
||||
{
|
||||
id: `${this.id}-outline`,
|
||||
type: 'line',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'line-color': '#3b82f6',
|
||||
'line-width': 2,
|
||||
'line-dasharray': [2, 2]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get layer IDs for this layer
|
||||
*/
|
||||
getLayerIds() {
|
||||
return [`${this.id}-fill`, `${this.id}-outline`]
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable selection mode
|
||||
*/
|
||||
enableSelectionMode() {
|
||||
this.map.getCanvas().style.cursor = 'crosshair'
|
||||
|
||||
// Add mouse event listeners
|
||||
this.handleMouseDown = this.onMouseDown.bind(this)
|
||||
this.handleMouseMove = this.onMouseMove.bind(this)
|
||||
this.handleMouseUp = this.onMouseUp.bind(this)
|
||||
|
||||
this.map.on('mousedown', this.handleMouseDown)
|
||||
this.map.on('mousemove', this.handleMouseMove)
|
||||
this.map.on('mouseup', this.handleMouseUp)
|
||||
|
||||
console.log('[SelectionLayer] Selection mode enabled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable selection mode
|
||||
*/
|
||||
disableSelectionMode() {
|
||||
this.map.getCanvas().style.cursor = ''
|
||||
|
||||
// Remove mouse event listeners
|
||||
if (this.handleMouseDown) {
|
||||
this.map.off('mousedown', this.handleMouseDown)
|
||||
this.map.off('mousemove', this.handleMouseMove)
|
||||
this.map.off('mouseup', this.handleMouseUp)
|
||||
}
|
||||
|
||||
// Clear selection
|
||||
this.clearSelection()
|
||||
|
||||
console.log('[SelectionLayer] Selection mode disabled')
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down - start drawing
|
||||
*/
|
||||
onMouseDown(e) {
|
||||
// Prevent default to stop map panning during selection
|
||||
e.preventDefault()
|
||||
|
||||
this.isDrawing = true
|
||||
this.startPoint = e.lngLat
|
||||
|
||||
console.log('[SelectionLayer] Started drawing at:', this.startPoint)
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse move - update rectangle
|
||||
*/
|
||||
onMouseMove(e) {
|
||||
if (!this.isDrawing || !this.startPoint) return
|
||||
|
||||
const endPoint = e.lngLat
|
||||
|
||||
// Create rectangle from start and end points
|
||||
const rect = this.createRectangle(this.startPoint, endPoint)
|
||||
|
||||
// Update layer with rectangle
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [rect]
|
||||
}
|
||||
}]
|
||||
})
|
||||
|
||||
this.currentRect = { start: this.startPoint, end: endPoint }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse up - finish drawing
|
||||
*/
|
||||
onMouseUp(e) {
|
||||
if (!this.isDrawing || !this.startPoint) return
|
||||
|
||||
this.isDrawing = false
|
||||
const endPoint = e.lngLat
|
||||
|
||||
// Calculate bounds
|
||||
const bounds = this.calculateBounds(this.startPoint, endPoint)
|
||||
|
||||
console.log('[SelectionLayer] Selection completed:', bounds)
|
||||
|
||||
// Notify callback
|
||||
this.onSelectionComplete(bounds)
|
||||
|
||||
this.startPoint = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rectangle coordinates from two points
|
||||
*/
|
||||
createRectangle(start, end) {
|
||||
return [
|
||||
[start.lng, start.lat],
|
||||
[end.lng, start.lat],
|
||||
[end.lng, end.lat],
|
||||
[start.lng, end.lat],
|
||||
[start.lng, start.lat]
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate bounds from two points
|
||||
*/
|
||||
calculateBounds(start, end) {
|
||||
return {
|
||||
minLng: Math.min(start.lng, end.lng),
|
||||
maxLng: Math.max(start.lng, end.lng),
|
||||
minLat: Math.min(start.lat, end.lat),
|
||||
maxLat: Math.max(start.lat, end.lat)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear current selection
|
||||
*/
|
||||
clearSelection() {
|
||||
this.update({
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
})
|
||||
this.currentRect = null
|
||||
this.startPoint = null
|
||||
this.isDrawing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove layer and cleanup
|
||||
*/
|
||||
remove() {
|
||||
this.disableSelectionMode()
|
||||
super.remove()
|
||||
}
|
||||
}
|
||||
39
app/javascript/maps_maplibre/layers/tracks_layer.js
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Tracks layer for saved routes
|
||||
*/
|
||||
export class TracksLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'tracks', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
{
|
||||
id: this.id,
|
||||
type: 'line',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'line-join': 'round',
|
||||
'line-cap': 'round'
|
||||
},
|
||||
paint: {
|
||||
'line-color': ['get', 'color'],
|
||||
'line-width': 4,
|
||||
'line-opacity': 0.7
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
66
app/javascript/maps_maplibre/layers/visits_layer.js
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
import { BaseLayer } from './base_layer'
|
||||
|
||||
/**
|
||||
* Visits layer showing suggested and confirmed visits
|
||||
* Yellow = suggested, Green = confirmed
|
||||
*/
|
||||
export class VisitsLayer extends BaseLayer {
|
||||
constructor(map, options = {}) {
|
||||
super(map, { id: 'visits', ...options })
|
||||
}
|
||||
|
||||
getSourceConfig() {
|
||||
return {
|
||||
type: 'geojson',
|
||||
data: this.data || {
|
||||
type: 'FeatureCollection',
|
||||
features: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getLayerConfigs() {
|
||||
return [
|
||||
// Visit circles
|
||||
{
|
||||
id: this.id,
|
||||
type: 'circle',
|
||||
source: this.sourceId,
|
||||
paint: {
|
||||
'circle-radius': 12,
|
||||
'circle-color': [
|
||||
'case',
|
||||
['==', ['get', 'status'], 'confirmed'], '#22c55e', // Green for confirmed
|
||||
'#eab308' // Yellow for suggested
|
||||
],
|
||||
'circle-stroke-width': 2,
|
||||
'circle-stroke-color': '#ffffff',
|
||||
'circle-opacity': 0.9
|
||||
}
|
||||
},
|
||||
|
||||
// Visit labels
|
||||
{
|
||||
id: `${this.id}-labels`,
|
||||
type: 'symbol',
|
||||
source: this.sourceId,
|
||||
layout: {
|
||||
'text-field': ['get', 'name'],
|
||||
'text-font': ['Open Sans Bold', 'Arial Unicode MS Bold'],
|
||||
'text-size': 11,
|
||||
'text-offset': [0, 1.5],
|
||||
'text-anchor': 'top'
|
||||
},
|
||||
paint: {
|
||||
'text-color': '#111827',
|
||||
'text-halo-color': '#ffffff',
|
||||
'text-halo-width': 2
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
getLayerIds() {
|
||||
return [this.id, `${this.id}-labels`]
|
||||
}
|
||||
}
|
||||
361
app/javascript/maps_maplibre/services/api_client.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
/**
|
||||
* API client for Maps V2
|
||||
* Wraps all API endpoints with consistent error handling
|
||||
*/
|
||||
export class ApiClient {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.baseURL = '/api/v1'
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch points for date range (paginated)
|
||||
* @param {Object} options - { start_at, end_at, page, per_page }
|
||||
* @returns {Promise<Object>} { points, currentPage, totalPages }
|
||||
*/
|
||||
async fetchPoints({ start_at, end_at, page = 1, per_page = 1000 }) {
|
||||
const params = new URLSearchParams({
|
||||
start_at,
|
||||
end_at,
|
||||
page: page.toString(),
|
||||
per_page: per_page.toString()
|
||||
})
|
||||
|
||||
const response = await fetch(`${this.baseURL}/points?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch points: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const points = await response.json()
|
||||
|
||||
return {
|
||||
points,
|
||||
currentPage: parseInt(response.headers.get('X-Current-Page') || '1'),
|
||||
totalPages: parseInt(response.headers.get('X-Total-Pages') || '1')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all points for date range (handles pagination)
|
||||
* @param {Object} options - { start_at, end_at, onProgress }
|
||||
* @returns {Promise<Array>} All points
|
||||
*/
|
||||
async fetchAllPoints({ start_at, end_at, onProgress = null }) {
|
||||
const allPoints = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
|
||||
do {
|
||||
const { points, currentPage, totalPages: total } =
|
||||
await this.fetchPoints({ start_at, end_at, page, per_page: 1000 })
|
||||
|
||||
allPoints.push(...points)
|
||||
totalPages = total
|
||||
page++
|
||||
|
||||
if (onProgress) {
|
||||
// Avoid division by zero - if no pages, progress is 100%
|
||||
const progress = totalPages > 0 ? currentPage / totalPages : 1.0
|
||||
onProgress({
|
||||
loaded: allPoints.length,
|
||||
currentPage,
|
||||
totalPages,
|
||||
progress
|
||||
})
|
||||
}
|
||||
} while (page <= totalPages)
|
||||
|
||||
return allPoints
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch visits for date range
|
||||
*/
|
||||
async fetchVisits({ start_at, end_at }) {
|
||||
const params = new URLSearchParams({ start_at, end_at })
|
||||
|
||||
const response = await fetch(`${this.baseURL}/visits?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch visits: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch places optionally filtered by tags
|
||||
*/
|
||||
async fetchPlaces({ tag_ids = [] } = {}) {
|
||||
const params = new URLSearchParams()
|
||||
|
||||
if (tag_ids && tag_ids.length > 0) {
|
||||
tag_ids.forEach(id => params.append('tag_ids[]', id))
|
||||
}
|
||||
|
||||
const url = `${this.baseURL}/places${params.toString() ? '?' + params.toString() : ''}`
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch places: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch photos for date range
|
||||
*/
|
||||
async fetchPhotos({ start_at, end_at }) {
|
||||
// Photos API uses start_date/end_date parameters
|
||||
// Pass dates as-is (matching V1 behavior)
|
||||
const params = new URLSearchParams({
|
||||
start_date: start_at,
|
||||
end_date: end_at
|
||||
})
|
||||
|
||||
const url = `${this.baseURL}/photos?${params}`
|
||||
console.log('[ApiClient] Fetching photos from:', url)
|
||||
console.log('[ApiClient] With headers:', this.getHeaders())
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
console.log('[ApiClient] Photos response status:', response.status)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch photos: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch areas
|
||||
*/
|
||||
async fetchAreas() {
|
||||
const response = await fetch(`${this.baseURL}/areas`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch areas: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch single area by ID
|
||||
* @param {number} areaId - Area ID
|
||||
*/
|
||||
async fetchArea(areaId) {
|
||||
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch tracks
|
||||
*/
|
||||
async fetchTracks() {
|
||||
const response = await fetch(`${this.baseURL}/tracks`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch tracks: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Create area
|
||||
* @param {Object} area - Area data
|
||||
*/
|
||||
async createArea(area) {
|
||||
const response = await fetch(`${this.baseURL}/areas`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ area })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete area by ID
|
||||
* @param {number} areaId - Area ID
|
||||
*/
|
||||
async deleteArea(areaId) {
|
||||
const response = await fetch(`${this.baseURL}/areas/${areaId}`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch points within a geographic area
|
||||
* @param {Object} options - { start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }
|
||||
* @returns {Promise<Array>} Points within the area
|
||||
*/
|
||||
async fetchPointsInArea({ start_at, end_at, min_longitude, max_longitude, min_latitude, max_latitude }) {
|
||||
const params = new URLSearchParams({
|
||||
start_at,
|
||||
end_at,
|
||||
min_longitude: min_longitude.toString(),
|
||||
max_longitude: max_longitude.toString(),
|
||||
min_latitude: min_latitude.toString(),
|
||||
max_latitude: max_latitude.toString(),
|
||||
per_page: '10000' // Get all points in area (up to 10k)
|
||||
})
|
||||
|
||||
const response = await fetch(`${this.baseURL}/points?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch points in area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch visits within a geographic area
|
||||
* @param {Object} options - { start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }
|
||||
* @returns {Promise<Array>} Visits within the area
|
||||
*/
|
||||
async fetchVisitsInArea({ start_at, end_at, sw_lat, sw_lng, ne_lat, ne_lng }) {
|
||||
const params = new URLSearchParams({
|
||||
start_at,
|
||||
end_at,
|
||||
selection: 'true',
|
||||
sw_lat: sw_lat.toString(),
|
||||
sw_lng: sw_lng.toString(),
|
||||
ne_lat: ne_lat.toString(),
|
||||
ne_lng: ne_lng.toString()
|
||||
})
|
||||
|
||||
const response = await fetch(`${this.baseURL}/visits?${params}`, {
|
||||
headers: this.getHeaders()
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch visits in area: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete points
|
||||
* @param {Array<number>} pointIds - Array of point IDs to delete
|
||||
* @returns {Promise<Object>} { message, count }
|
||||
*/
|
||||
async bulkDeletePoints(pointIds) {
|
||||
const response = await fetch(`${this.baseURL}/points/bulk_destroy`, {
|
||||
method: 'DELETE',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ point_ids: pointIds })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete points: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Update visit status (confirm/decline)
|
||||
* @param {number} visitId - Visit ID
|
||||
* @param {string} status - 'confirmed' or 'declined'
|
||||
* @returns {Promise<Object>} Updated visit
|
||||
*/
|
||||
async updateVisitStatus(visitId, status) {
|
||||
const response = await fetch(`${this.baseURL}/visits/${visitId}`, {
|
||||
method: 'PATCH',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ visit: { status } })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to update visit status: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge multiple visits
|
||||
* @param {Array<number>} visitIds - Array of visit IDs to merge
|
||||
* @returns {Promise<Object>} Merged visit
|
||||
*/
|
||||
async mergeVisits(visitIds) {
|
||||
const response = await fetch(`${this.baseURL}/visits/merge`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ visit_ids: visitIds })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to merge visits: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk update visit status
|
||||
* @param {Array<number>} visitIds - Array of visit IDs to update
|
||||
* @param {string} status - 'confirmed' or 'declined'
|
||||
* @returns {Promise<Object>} Update result
|
||||
*/
|
||||
async bulkUpdateVisits(visitIds, status) {
|
||||
const response = await fetch(`${this.baseURL}/visits/bulk_update`, {
|
||||
method: 'POST',
|
||||
headers: this.getHeaders(),
|
||||
body: JSON.stringify({ visit_ids: visitIds, status })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to bulk update visits: ${response.statusText}`)
|
||||
}
|
||||
|
||||
return response.json()
|
||||
}
|
||||
|
||||
getHeaders() {
|
||||
return {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
}
|
||||
117
app/javascript/maps_maplibre/services/location_search_service.js
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
/**
|
||||
* Location Search Service
|
||||
* Handles API calls for location search (suggestions and visits)
|
||||
*/
|
||||
|
||||
export class LocationSearchService {
|
||||
constructor(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.baseHeaders = {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch location suggestions based on query
|
||||
* @param {string} query - Search query
|
||||
* @returns {Promise<Array>} Array of location suggestions
|
||||
*/
|
||||
async fetchSuggestions(query) {
|
||||
if (!query || query.length < 2) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/v1/locations/suggestions?q=${encodeURIComponent(query)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: this.baseHeaders
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Suggestions API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
// Transform suggestions to expected format
|
||||
// API returns coordinates as [lat, lon], we need { lat, lon }
|
||||
const suggestions = (data.suggestions || []).map(suggestion => ({
|
||||
name: suggestion.name,
|
||||
address: suggestion.address,
|
||||
lat: suggestion.coordinates?.[0],
|
||||
lon: suggestion.coordinates?.[1],
|
||||
type: suggestion.type
|
||||
}))
|
||||
|
||||
return suggestions
|
||||
} catch (error) {
|
||||
console.error('LocationSearchService: Suggestion fetch error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Search for visits at a specific location
|
||||
* @param {Object} params - Search parameters
|
||||
* @param {number} params.lat - Latitude
|
||||
* @param {number} params.lon - Longitude
|
||||
* @param {string} params.name - Location name
|
||||
* @param {string} params.address - Location address
|
||||
* @returns {Promise<Object>} Search results with locations and visits
|
||||
*/
|
||||
async searchVisits({ lat, lon, name, address = '' }) {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
lat: lat.toString(),
|
||||
lon: lon.toString(),
|
||||
name,
|
||||
address
|
||||
})
|
||||
|
||||
const response = await fetch(`/api/v1/locations?${params}`, {
|
||||
method: 'GET',
|
||||
headers: this.baseHeaders
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Location search API error: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('LocationSearchService: Visit search error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new visit
|
||||
* @param {Object} visitData - Visit data
|
||||
* @returns {Promise<Object>} Created visit
|
||||
*/
|
||||
async createVisit(visitData) {
|
||||
try {
|
||||
const response = await fetch('/api/v1/visits', {
|
||||
method: 'POST',
|
||||
headers: this.baseHeaders,
|
||||
body: JSON.stringify({ visit: visitData })
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || data.message || 'Failed to create visit')
|
||||
}
|
||||
|
||||
return data
|
||||
} catch (error) {
|
||||
console.error('LocationSearchService: Create visit error:', error)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
49
app/javascript/maps_maplibre/utils/cleanup_helper.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* Helper for tracking and cleaning up resources
|
||||
* Prevents memory leaks by tracking event listeners, intervals, timeouts, and observers
|
||||
*/
|
||||
export class CleanupHelper {
|
||||
constructor() {
|
||||
this.listeners = []
|
||||
this.intervals = []
|
||||
this.timeouts = []
|
||||
this.observers = []
|
||||
}
|
||||
|
||||
addEventListener(target, event, handler, options) {
|
||||
target.addEventListener(event, handler, options)
|
||||
this.listeners.push({ target, event, handler, options })
|
||||
}
|
||||
|
||||
setInterval(callback, delay) {
|
||||
const id = setInterval(callback, delay)
|
||||
this.intervals.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
setTimeout(callback, delay) {
|
||||
const id = setTimeout(callback, delay)
|
||||
this.timeouts.push(id)
|
||||
return id
|
||||
}
|
||||
|
||||
addObserver(observer) {
|
||||
this.observers.push(observer)
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
this.listeners.forEach(({ target, event, handler, options }) => {
|
||||
target.removeEventListener(event, handler, options)
|
||||
})
|
||||
this.listeners = []
|
||||
|
||||
this.intervals.forEach(id => clearInterval(id))
|
||||
this.intervals = []
|
||||
|
||||
this.timeouts.forEach(id => clearTimeout(id))
|
||||
this.timeouts = []
|
||||
|
||||
this.observers.forEach(observer => observer.disconnect())
|
||||
this.observers = []
|
||||
}
|
||||
}
|
||||
49
app/javascript/maps_maplibre/utils/fps_monitor.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
/**
|
||||
* FPS (Frames Per Second) monitor
|
||||
* Tracks rendering performance
|
||||
*/
|
||||
export class FPSMonitor {
|
||||
constructor(sampleSize = 60) {
|
||||
this.sampleSize = sampleSize
|
||||
this.frames = []
|
||||
this.lastTime = performance.now()
|
||||
this.isRunning = false
|
||||
this.rafId = null
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRunning) return
|
||||
this.isRunning = true
|
||||
this.#tick()
|
||||
}
|
||||
|
||||
stop() {
|
||||
this.isRunning = false
|
||||
if (this.rafId) {
|
||||
cancelAnimationFrame(this.rafId)
|
||||
this.rafId = null
|
||||
}
|
||||
}
|
||||
|
||||
getFPS() {
|
||||
if (this.frames.length === 0) return 0
|
||||
const avg = this.frames.reduce((a, b) => a + b, 0) / this.frames.length
|
||||
return Math.round(avg)
|
||||
}
|
||||
|
||||
#tick = () => {
|
||||
if (!this.isRunning) return
|
||||
|
||||
const now = performance.now()
|
||||
const delta = now - this.lastTime
|
||||
const fps = 1000 / delta
|
||||
|
||||
this.frames.push(fps)
|
||||
if (this.frames.length > this.sampleSize) {
|
||||
this.frames.shift()
|
||||
}
|
||||
|
||||
this.lastTime = now
|
||||
this.rafId = requestAnimationFrame(this.#tick)
|
||||
}
|
||||
}
|
||||
54
app/javascript/maps_maplibre/utils/geojson_transformers.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
/**
|
||||
* Transform points array to GeoJSON FeatureCollection
|
||||
* @param {Array} points - Array of point objects from API
|
||||
* @returns {Object} GeoJSON FeatureCollection
|
||||
*/
|
||||
export function pointsToGeoJSON(points) {
|
||||
return {
|
||||
type: 'FeatureCollection',
|
||||
features: points.map(point => ({
|
||||
type: 'Feature',
|
||||
geometry: {
|
||||
type: 'Point',
|
||||
coordinates: [point.longitude, point.latitude]
|
||||
},
|
||||
properties: {
|
||||
id: point.id,
|
||||
timestamp: point.timestamp,
|
||||
altitude: point.altitude,
|
||||
battery: point.battery,
|
||||
accuracy: point.accuracy,
|
||||
velocity: point.velocity,
|
||||
country_name: point.country_name
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format timestamp for display
|
||||
* @param {number|string} timestamp - Unix timestamp (seconds) or ISO 8601 string
|
||||
* @returns {string} Formatted date/time
|
||||
*/
|
||||
export function formatTimestamp(timestamp) {
|
||||
// Handle different timestamp formats
|
||||
let date
|
||||
if (typeof timestamp === 'string') {
|
||||
// ISO 8601 string
|
||||
date = new Date(timestamp)
|
||||
} else if (timestamp < 10000000000) {
|
||||
// Unix timestamp in seconds
|
||||
date = new Date(timestamp * 1000)
|
||||
} else {
|
||||
// Unix timestamp in milliseconds
|
||||
date = new Date(timestamp)
|
||||
}
|
||||
|
||||
return date.toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
69
app/javascript/maps_maplibre/utils/geometry.js
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Calculate distance between two points in meters
|
||||
* @param {Array} point1 - [lng, lat]
|
||||
* @param {Array} point2 - [lng, lat]
|
||||
* @returns {number} Distance in meters
|
||||
*/
|
||||
export function calculateDistance(point1, point2) {
|
||||
const [lng1, lat1] = point1
|
||||
const [lng2, lat2] = point2
|
||||
|
||||
const R = 6371000 // Earth radius in meters
|
||||
const φ1 = lat1 * Math.PI / 180
|
||||
const φ2 = lat2 * Math.PI / 180
|
||||
const Δφ = (lat2 - lat1) * Math.PI / 180
|
||||
const Δλ = (lng2 - lng1) * Math.PI / 180
|
||||
|
||||
const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) +
|
||||
Math.cos(φ1) * Math.cos(φ2) *
|
||||
Math.sin(Δλ / 2) * Math.sin(Δλ / 2)
|
||||
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
|
||||
return R * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Create circle polygon
|
||||
* @param {Array} center - [lng, lat]
|
||||
* @param {number} radiusInMeters
|
||||
* @param {number} points - Number of points in polygon
|
||||
* @returns {Array} Coordinates array
|
||||
*/
|
||||
export function createCircle(center, radiusInMeters, points = 64) {
|
||||
const [lng, lat] = center
|
||||
const coords = []
|
||||
|
||||
const distanceX = radiusInMeters / (111320 * Math.cos(lat * Math.PI / 180))
|
||||
const distanceY = radiusInMeters / 110540
|
||||
|
||||
for (let i = 0; i < points; i++) {
|
||||
const theta = (i / points) * (2 * Math.PI)
|
||||
const x = distanceX * Math.cos(theta)
|
||||
const y = distanceY * Math.sin(theta)
|
||||
coords.push([lng + x, lat + y])
|
||||
}
|
||||
|
||||
coords.push(coords[0]) // Close the circle
|
||||
|
||||
return coords
|
||||
}
|
||||
|
||||
/**
|
||||
* Create rectangle from bounds
|
||||
* @param {Object} bounds - { minLng, minLat, maxLng, maxLat }
|
||||
* @returns {Array} Coordinates array
|
||||
*/
|
||||
export function createRectangle(bounds) {
|
||||
const { minLng, minLat, maxLng, maxLat } = bounds
|
||||
|
||||
return [
|
||||
[
|
||||
[minLng, minLat],
|
||||
[maxLng, minLat],
|
||||
[maxLng, maxLat],
|
||||
[minLng, maxLat],
|
||||
[minLng, minLat]
|
||||
]
|
||||
]
|
||||
}
|
||||
76
app/javascript/maps_maplibre/utils/lazy_loader.js
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
/**
|
||||
* Lazy loader for heavy map layers
|
||||
* Reduces initial bundle size by loading layers on demand
|
||||
*/
|
||||
export class LazyLoader {
|
||||
constructor() {
|
||||
this.cache = new Map()
|
||||
this.loading = new Map()
|
||||
}
|
||||
|
||||
/**
|
||||
* Load layer class dynamically
|
||||
* @param {string} name - Layer name (e.g., 'fog', 'scratch')
|
||||
* @returns {Promise<Class>}
|
||||
*/
|
||||
async loadLayer(name) {
|
||||
// Return cached
|
||||
if (this.cache.has(name)) {
|
||||
return this.cache.get(name)
|
||||
}
|
||||
|
||||
// Wait for loading
|
||||
if (this.loading.has(name)) {
|
||||
return this.loading.get(name)
|
||||
}
|
||||
|
||||
// Start loading
|
||||
const loadPromise = this.#load(name)
|
||||
this.loading.set(name, loadPromise)
|
||||
|
||||
try {
|
||||
const LayerClass = await loadPromise
|
||||
this.cache.set(name, LayerClass)
|
||||
this.loading.delete(name)
|
||||
return LayerClass
|
||||
} catch (error) {
|
||||
this.loading.delete(name)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async #load(name) {
|
||||
const paths = {
|
||||
'fog': () => import('../layers/fog_layer.js'),
|
||||
'scratch': () => import('../layers/scratch_layer.js')
|
||||
}
|
||||
|
||||
const loader = paths[name]
|
||||
if (!loader) {
|
||||
throw new Error(`Unknown layer: ${name}`)
|
||||
}
|
||||
|
||||
const module = await loader()
|
||||
return module[this.#getClassName(name)]
|
||||
}
|
||||
|
||||
#getClassName(name) {
|
||||
// fog -> FogLayer, scratch -> ScratchLayer
|
||||
return name.charAt(0).toUpperCase() + name.slice(1) + 'Layer'
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload layers
|
||||
* @param {string[]} names
|
||||
*/
|
||||
async preload(names) {
|
||||
return Promise.all(names.map(name => this.loadLayer(name)))
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.cache.clear()
|
||||
this.loading.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export const lazyLoader = new LazyLoader()
|
||||
108
app/javascript/maps_maplibre/utils/performance_monitor.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
/**
|
||||
* Performance monitoring utility
|
||||
* Tracks timing metrics and memory usage
|
||||
*/
|
||||
export class PerformanceMonitor {
|
||||
constructor() {
|
||||
this.marks = new Map()
|
||||
this.metrics = []
|
||||
}
|
||||
|
||||
/**
|
||||
* Start timing
|
||||
* @param {string} name
|
||||
*/
|
||||
mark(name) {
|
||||
this.marks.set(name, performance.now())
|
||||
}
|
||||
|
||||
/**
|
||||
* End timing and record
|
||||
* @param {string} name
|
||||
* @returns {number} Duration in ms
|
||||
*/
|
||||
measure(name) {
|
||||
const startTime = this.marks.get(name)
|
||||
if (!startTime) {
|
||||
console.warn(`No mark found for: ${name}`)
|
||||
return 0
|
||||
}
|
||||
|
||||
const duration = performance.now() - startTime
|
||||
this.marks.delete(name)
|
||||
|
||||
this.metrics.push({
|
||||
name,
|
||||
duration,
|
||||
timestamp: Date.now()
|
||||
})
|
||||
|
||||
return duration
|
||||
}
|
||||
|
||||
/**
|
||||
* Get performance report
|
||||
* @returns {Object}
|
||||
*/
|
||||
getReport() {
|
||||
const grouped = this.metrics.reduce((acc, metric) => {
|
||||
if (!acc[metric.name]) {
|
||||
acc[metric.name] = []
|
||||
}
|
||||
acc[metric.name].push(metric.duration)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const report = {}
|
||||
for (const [name, durations] of Object.entries(grouped)) {
|
||||
const avg = durations.reduce((a, b) => a + b, 0) / durations.length
|
||||
const min = Math.min(...durations)
|
||||
const max = Math.max(...durations)
|
||||
|
||||
report[name] = {
|
||||
count: durations.length,
|
||||
avg: Math.round(avg),
|
||||
min: Math.round(min),
|
||||
max: Math.round(max)
|
||||
}
|
||||
}
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
/**
|
||||
* Get memory usage
|
||||
* @returns {Object|null}
|
||||
*/
|
||||
getMemoryUsage() {
|
||||
if (!performance.memory) return null
|
||||
|
||||
return {
|
||||
used: Math.round(performance.memory.usedJSHeapSize / 1048576),
|
||||
total: Math.round(performance.memory.totalJSHeapSize / 1048576),
|
||||
limit: Math.round(performance.memory.jsHeapSizeLimit / 1048576)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log report to console
|
||||
*/
|
||||
logReport() {
|
||||
console.group('Performance Report')
|
||||
console.table(this.getReport())
|
||||
|
||||
const memory = this.getMemoryUsage()
|
||||
if (memory) {
|
||||
console.log(`Memory: ${memory.used}MB / ${memory.total}MB (limit: ${memory.limit}MB)`)
|
||||
}
|
||||
|
||||
console.groupEnd()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.marks.clear()
|
||||
this.metrics = []
|
||||
}
|
||||
}
|
||||
|
||||
export const performanceMonitor = new PerformanceMonitor()
|
||||
120
app/javascript/maps_maplibre/utils/popup_theme.js
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/**
|
||||
* Theme utilities for MapLibre popups
|
||||
* Provides consistent theming across all popup types
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get current theme from document
|
||||
* @returns {string} 'dark' or 'light'
|
||||
*/
|
||||
export function getCurrentTheme() {
|
||||
if (document.documentElement.getAttribute('data-theme') === 'dark' ||
|
||||
document.documentElement.classList.contains('dark')) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
/**
|
||||
* Get theme-aware color values
|
||||
* @param {string} theme - 'dark' or 'light'
|
||||
* @returns {Object} Color values for the theme
|
||||
*/
|
||||
export function getThemeColors(theme = getCurrentTheme()) {
|
||||
if (theme === 'dark') {
|
||||
return {
|
||||
// Background colors
|
||||
background: '#1f2937',
|
||||
backgroundAlt: '#374151',
|
||||
|
||||
// Text colors
|
||||
textPrimary: '#f9fafb',
|
||||
textSecondary: '#d1d5db',
|
||||
textMuted: '#9ca3af',
|
||||
|
||||
// Border colors
|
||||
border: '#4b5563',
|
||||
borderLight: '#374151',
|
||||
|
||||
// Accent colors
|
||||
accent: '#3b82f6',
|
||||
accentHover: '#2563eb',
|
||||
|
||||
// Badge colors
|
||||
badgeSuggested: { bg: '#713f12', text: '#fef3c7' },
|
||||
badgeConfirmed: { bg: '#065f46', text: '#d1fae5' }
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
// Background colors
|
||||
background: '#ffffff',
|
||||
backgroundAlt: '#f9fafb',
|
||||
|
||||
// Text colors
|
||||
textPrimary: '#111827',
|
||||
textSecondary: '#374151',
|
||||
textMuted: '#6b7280',
|
||||
|
||||
// Border colors
|
||||
border: '#e5e7eb',
|
||||
borderLight: '#f3f4f6',
|
||||
|
||||
// Accent colors
|
||||
accent: '#3b82f6',
|
||||
accentHover: '#2563eb',
|
||||
|
||||
// Badge colors
|
||||
badgeSuggested: { bg: '#fef3c7', text: '#92400e' },
|
||||
badgeConfirmed: { bg: '#d1fae5', text: '#065f46' }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base popup styles as inline CSS
|
||||
* @param {string} theme - 'dark' or 'light'
|
||||
* @returns {string} CSS string for inline styles
|
||||
*/
|
||||
export function getPopupBaseStyles(theme = getCurrentTheme()) {
|
||||
const colors = getThemeColors(theme)
|
||||
|
||||
return `
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
background-color: ${colors.background};
|
||||
color: ${colors.textPrimary};
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get popup container class with theme
|
||||
* @param {string} baseClass - Base CSS class name
|
||||
* @param {string} theme - 'dark' or 'light'
|
||||
* @returns {string} Class name with theme
|
||||
*/
|
||||
export function getPopupClass(baseClass, theme = getCurrentTheme()) {
|
||||
return `${baseClass} ${baseClass}--${theme}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen for theme changes and update popup if needed
|
||||
* @param {Function} callback - Callback to execute on theme change
|
||||
* @returns {Function} Cleanup function to remove listener
|
||||
*/
|
||||
export function onThemeChange(callback) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.type === 'attributes' &&
|
||||
(mutation.attributeName === 'data-theme' ||
|
||||
mutation.attributeName === 'class')) {
|
||||
callback(getCurrentTheme())
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
observer.observe(document.documentElement, {
|
||||
attributes: true,
|
||||
attributeFilter: ['data-theme', 'class']
|
||||
})
|
||||
|
||||
return () => observer.disconnect()
|
||||
}
|
||||
101
app/javascript/maps_maplibre/utils/progressive_loader.js
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
/**
|
||||
* Progressive loader for large datasets
|
||||
* Loads data in chunks with progress feedback and abort capability
|
||||
*/
|
||||
export class ProgressiveLoader {
|
||||
constructor(options = {}) {
|
||||
this.onProgress = options.onProgress || null
|
||||
this.onComplete = options.onComplete || null
|
||||
this.abortController = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data progressively
|
||||
* @param {Function} fetchFn - Function that fetches one page
|
||||
* @param {Object} options - { batchSize, maxConcurrent, maxPoints }
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
async load(fetchFn, options = {}) {
|
||||
const {
|
||||
batchSize = 1000,
|
||||
maxConcurrent = 3,
|
||||
maxPoints = 100000 // Limit for safety
|
||||
} = options
|
||||
|
||||
this.abortController = new AbortController()
|
||||
const allData = []
|
||||
let page = 1
|
||||
let totalPages = 1
|
||||
const activeRequests = []
|
||||
|
||||
try {
|
||||
do {
|
||||
// Check abort
|
||||
if (this.abortController.signal.aborted) {
|
||||
throw new Error('Load cancelled')
|
||||
}
|
||||
|
||||
// Check max points limit
|
||||
if (allData.length >= maxPoints) {
|
||||
console.warn(`Reached max points limit: ${maxPoints}`)
|
||||
break
|
||||
}
|
||||
|
||||
// Limit concurrent requests
|
||||
while (activeRequests.length >= maxConcurrent) {
|
||||
await Promise.race(activeRequests)
|
||||
}
|
||||
|
||||
const requestPromise = fetchFn({
|
||||
page,
|
||||
per_page: batchSize,
|
||||
signal: this.abortController.signal
|
||||
}).then(result => {
|
||||
allData.push(...result.data)
|
||||
|
||||
if (result.totalPages) {
|
||||
totalPages = result.totalPages
|
||||
}
|
||||
|
||||
this.onProgress?.({
|
||||
loaded: allData.length,
|
||||
total: Math.min(totalPages * batchSize, maxPoints),
|
||||
currentPage: page,
|
||||
totalPages,
|
||||
progress: page / totalPages
|
||||
})
|
||||
|
||||
// Remove from active
|
||||
const idx = activeRequests.indexOf(requestPromise)
|
||||
if (idx > -1) activeRequests.splice(idx, 1)
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
activeRequests.push(requestPromise)
|
||||
page++
|
||||
|
||||
} while (page <= totalPages && allData.length < maxPoints)
|
||||
|
||||
// Wait for remaining
|
||||
await Promise.all(activeRequests)
|
||||
|
||||
this.onComplete?.(allData)
|
||||
return allData
|
||||
|
||||
} catch (error) {
|
||||
if (error.name === 'AbortError' || error.message === 'Load cancelled') {
|
||||
console.log('Progressive load cancelled')
|
||||
return allData // Return partial data
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel loading
|
||||
*/
|
||||
cancel() {
|
||||
this.abortController?.abort()
|
||||
}
|
||||
}
|
||||
729
app/javascript/maps_maplibre/utils/search_manager.js
Normal file
|
|
@ -0,0 +1,729 @@
|
|||
/**
|
||||
* Search Manager
|
||||
* Manages location search functionality for Maps V2
|
||||
*/
|
||||
|
||||
import { LocationSearchService } from '../services/location_search_service.js'
|
||||
|
||||
export class SearchManager {
|
||||
constructor(map, apiKey) {
|
||||
this.map = map
|
||||
this.service = new LocationSearchService(apiKey)
|
||||
this.searchInput = null
|
||||
this.resultsContainer = null
|
||||
this.debounceTimer = null
|
||||
this.debounceDelay = 300 // ms
|
||||
this.currentMarker = null
|
||||
this.currentVisitsData = null // Store visits data for click handling
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize search manager with DOM elements
|
||||
* @param {HTMLInputElement} searchInput - Search input element
|
||||
* @param {HTMLElement} resultsContainer - Container for search results
|
||||
*/
|
||||
initialize(searchInput, resultsContainer) {
|
||||
this.searchInput = searchInput
|
||||
this.resultsContainer = resultsContainer
|
||||
|
||||
if (!this.searchInput || !this.resultsContainer) {
|
||||
console.warn('SearchManager: Missing required DOM elements')
|
||||
return
|
||||
}
|
||||
|
||||
this.attachEventListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to search input
|
||||
*/
|
||||
attachEventListeners() {
|
||||
// Input event with debouncing
|
||||
this.searchInput.addEventListener('input', (e) => {
|
||||
this.handleSearchInput(e.target.value)
|
||||
})
|
||||
|
||||
// Prevent results from hiding when clicking inside results container
|
||||
this.resultsContainer.addEventListener('mousedown', (e) => {
|
||||
e.preventDefault() // Prevent blur event on search input
|
||||
})
|
||||
|
||||
// Clear results when clicking outside
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!this.searchInput.contains(e.target) && !this.resultsContainer.contains(e.target)) {
|
||||
// Delay to allow animations to complete
|
||||
setTimeout(() => {
|
||||
this.clearResults()
|
||||
}, 100)
|
||||
}
|
||||
})
|
||||
|
||||
// Handle Enter key
|
||||
this.searchInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
const firstResult = this.resultsContainer.querySelector('.search-result-item')
|
||||
if (firstResult) {
|
||||
firstResult.click()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle search input with debouncing
|
||||
* @param {string} query - Search query
|
||||
*/
|
||||
handleSearchInput(query) {
|
||||
clearTimeout(this.debounceTimer)
|
||||
|
||||
if (!query || query.length < 2) {
|
||||
this.clearResults()
|
||||
return
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
this.showLoading()
|
||||
const suggestions = await this.service.fetchSuggestions(query)
|
||||
this.displayResults(suggestions)
|
||||
} catch (error) {
|
||||
this.showError('Failed to fetch suggestions')
|
||||
console.error('SearchManager: Search error:', error)
|
||||
}
|
||||
}, this.debounceDelay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Display search results
|
||||
* @param {Array} suggestions - Array of location suggestions
|
||||
*/
|
||||
displayResults(suggestions) {
|
||||
this.clearResults()
|
||||
|
||||
if (!suggestions || suggestions.length === 0) {
|
||||
this.showNoResults()
|
||||
return
|
||||
}
|
||||
|
||||
suggestions.forEach(suggestion => {
|
||||
const resultItem = this.createResultItem(suggestion)
|
||||
this.resultsContainer.appendChild(resultItem)
|
||||
})
|
||||
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a result item element
|
||||
* @param {Object} suggestion - Location suggestion
|
||||
* @returns {HTMLElement} Result item element
|
||||
*/
|
||||
createResultItem(suggestion) {
|
||||
const item = document.createElement('div')
|
||||
item.className = 'search-result-item p-3 hover:bg-base-200 cursor-pointer rounded-lg transition-colors'
|
||||
item.setAttribute('data-lat', suggestion.lat)
|
||||
item.setAttribute('data-lon', suggestion.lon)
|
||||
|
||||
const name = document.createElement('div')
|
||||
name.className = 'font-medium text-sm'
|
||||
name.textContent = suggestion.name || 'Unknown location'
|
||||
|
||||
if (suggestion.address) {
|
||||
const address = document.createElement('div')
|
||||
address.className = 'text-xs text-base-content/60 mt-1'
|
||||
address.textContent = suggestion.address
|
||||
item.appendChild(name)
|
||||
item.appendChild(address)
|
||||
} else {
|
||||
item.appendChild(name)
|
||||
}
|
||||
|
||||
item.addEventListener('click', () => {
|
||||
this.handleResultClick(suggestion)
|
||||
})
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on search result
|
||||
* @param {Object} location - Selected location
|
||||
*/
|
||||
async handleResultClick(location) {
|
||||
// Fly to location on map
|
||||
this.map.flyTo({
|
||||
center: [location.lon, location.lat],
|
||||
zoom: 15,
|
||||
duration: 1000
|
||||
})
|
||||
|
||||
// Add temporary marker
|
||||
this.addSearchMarker(location.lon, location.lat)
|
||||
|
||||
// Update search input
|
||||
if (this.searchInput) {
|
||||
this.searchInput.value = location.name || ''
|
||||
}
|
||||
|
||||
// Show loading state in results
|
||||
this.showVisitsLoading(location.name)
|
||||
|
||||
// Search for visits at this location
|
||||
try {
|
||||
const visitsData = await this.service.searchVisits({
|
||||
lat: location.lat,
|
||||
lon: location.lon,
|
||||
name: location.name,
|
||||
address: location.address || ''
|
||||
})
|
||||
|
||||
// Display visits results
|
||||
this.displayVisitsResults(visitsData, location)
|
||||
} catch (error) {
|
||||
console.error('SearchManager: Failed to fetch visits:', error)
|
||||
this.showError('Failed to load visits for this location')
|
||||
}
|
||||
|
||||
// Dispatch custom event for other components
|
||||
this.dispatchSearchEvent(location)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a temporary marker at search location
|
||||
* @param {number} lon - Longitude
|
||||
* @param {number} lat - Latitude
|
||||
*/
|
||||
addSearchMarker(lon, lat) {
|
||||
// Remove existing marker
|
||||
if (this.currentMarker) {
|
||||
this.currentMarker.remove()
|
||||
}
|
||||
|
||||
// Create marker element
|
||||
const el = document.createElement('div')
|
||||
el.className = 'search-marker'
|
||||
el.style.cssText = `
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background-color: #3b82f6;
|
||||
border: 3px solid white;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||
cursor: pointer;
|
||||
`
|
||||
|
||||
// Add marker to map (MapLibre GL style)
|
||||
if (this.map.getSource) {
|
||||
// Use MapLibre marker
|
||||
const maplibregl = window.maplibregl
|
||||
if (maplibregl) {
|
||||
this.currentMarker = new maplibregl.Marker({ element: el })
|
||||
.setLngLat([lon, lat])
|
||||
.addTo(this.map)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch custom search event
|
||||
* @param {Object} location - Selected location
|
||||
*/
|
||||
dispatchSearchEvent(location) {
|
||||
const event = new CustomEvent('location-search:selected', {
|
||||
detail: { location },
|
||||
bubbles: true
|
||||
})
|
||||
document.dispatchEvent(event)
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading indicator
|
||||
*/
|
||||
showLoading() {
|
||||
this.clearResults()
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="p-3 text-sm text-base-content/60 flex items-center gap-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
Searching...
|
||||
</div>
|
||||
`
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Show no results message
|
||||
*/
|
||||
showNoResults() {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="p-3 text-sm text-base-content/60">
|
||||
No locations found
|
||||
</div>
|
||||
`
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Show error message
|
||||
* @param {string} message - Error message
|
||||
*/
|
||||
showError(message) {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="p-3 text-sm text-error">
|
||||
${message}
|
||||
</div>
|
||||
`
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Show loading state while fetching visits
|
||||
* @param {string} locationName - Name of the location being searched
|
||||
*/
|
||||
showVisitsLoading(locationName) {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="p-4 text-sm text-base-content/60">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="loading loading-spinner loading-sm"></span>
|
||||
<span class="font-medium">Searching for visits...</span>
|
||||
</div>
|
||||
<div class="text-xs">${this.escapeHtml(locationName)}</div>
|
||||
</div>
|
||||
`
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
}
|
||||
|
||||
/**
|
||||
* Display visits results
|
||||
* @param {Object} visitsData - Visits data from API
|
||||
* @param {Object} location - Selected location
|
||||
*/
|
||||
displayVisitsResults(visitsData, location) {
|
||||
// Store visits data for click handling
|
||||
this.currentVisitsData = visitsData
|
||||
|
||||
if (!visitsData.locations || visitsData.locations.length === 0) {
|
||||
this.resultsContainer.innerHTML = `
|
||||
<div class="p-6 text-center text-base-content/60">
|
||||
<div class="text-3xl mb-3">📍</div>
|
||||
<div class="text-sm font-medium">No visits found</div>
|
||||
<div class="text-xs mt-1">No visits found for "${this.escapeHtml(location.name)}"</div>
|
||||
</div>
|
||||
`
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
return
|
||||
}
|
||||
|
||||
// Display visits grouped by location
|
||||
let html = `
|
||||
<div class="p-4 border-b bg-base-200">
|
||||
<div class="text-sm font-medium">Found ${visitsData.total_locations} location(s)</div>
|
||||
<div class="text-xs text-base-content/60 mt-1">for "${this.escapeHtml(location.name)}"</div>
|
||||
</div>
|
||||
`
|
||||
|
||||
visitsData.locations.forEach((loc, index) => {
|
||||
html += this.buildLocationVisitsHtml(loc, index)
|
||||
})
|
||||
|
||||
this.resultsContainer.innerHTML = html
|
||||
this.resultsContainer.classList.remove('hidden')
|
||||
|
||||
// Attach event listeners to year toggles and visit items
|
||||
this.attachYearToggleListeners()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HTML for a location with its visits
|
||||
* @param {Object} location - Location with visits
|
||||
* @param {number} index - Location index
|
||||
* @returns {string} HTML string
|
||||
*/
|
||||
buildLocationVisitsHtml(location, index) {
|
||||
const visits = location.visits || []
|
||||
if (visits.length === 0) return ''
|
||||
|
||||
// Handle case where visits are sorted newest first
|
||||
const sortedVisits = [...visits].sort((a, b) => new Date(a.date) - new Date(b.date))
|
||||
const firstVisit = sortedVisits[0]
|
||||
const lastVisit = sortedVisits[sortedVisits.length - 1]
|
||||
const visitsByYear = this.groupVisitsByYear(visits)
|
||||
|
||||
// Use place_name, address, or coordinates as fallback
|
||||
const displayName = location.place_name || location.address ||
|
||||
`Location (${location.coordinates?.[0]?.toFixed(4)}, ${location.coordinates?.[1]?.toFixed(4)})`
|
||||
|
||||
return `
|
||||
<div class="location-result border-b" data-location-index="${index}">
|
||||
<div class="p-4">
|
||||
<div class="font-medium text-sm">${this.escapeHtml(displayName)}</div>
|
||||
${location.address && location.place_name !== location.address ?
|
||||
`<div class="text-xs text-base-content/60 mt-1">${this.escapeHtml(location.address)}</div>` : ''}
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div class="text-xs text-primary">${location.total_visits} visit(s)</div>
|
||||
<div class="text-xs text-base-content/60">
|
||||
first ${this.formatDateShort(firstVisit.date)}, last ${this.formatDateShort(lastVisit.date)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Years Section -->
|
||||
<div class="border-t bg-base-200">
|
||||
${Object.entries(visitsByYear).map(([year, yearVisits]) => `
|
||||
<div class="year-section">
|
||||
<div class="year-toggle p-3 hover:bg-base-300 cursor-pointer border-b flex justify-between items-center"
|
||||
data-location-index="${index}" data-year="${year}">
|
||||
<span class="text-sm font-medium">${year}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-primary">${yearVisits.length} visits</span>
|
||||
<span class="year-arrow text-base-content/40 transition-transform">▶</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="year-visits hidden" id="year-${index}-${year}">
|
||||
${yearVisits.map((visit) => `
|
||||
<div class="visit-item text-xs py-2 px-4 border-b hover:bg-base-300 cursor-pointer"
|
||||
data-location-index="${index}" data-visit-index="${visits.indexOf(visit)}">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>📍 ${this.formatDateTime(visit.date)}</div>
|
||||
<div class="text-xs text-base-content/60">${visit.duration_estimate || 'N/A'}</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Group visits by year
|
||||
* @param {Array} visits - Array of visits
|
||||
* @returns {Object} Visits grouped by year
|
||||
*/
|
||||
groupVisitsByYear(visits) {
|
||||
const groups = {}
|
||||
visits.forEach(visit => {
|
||||
const year = new Date(visit.date).getFullYear().toString()
|
||||
if (!groups[year]) {
|
||||
groups[year] = []
|
||||
}
|
||||
groups[year].push(visit)
|
||||
})
|
||||
return groups
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach event listeners to year toggle elements
|
||||
*/
|
||||
attachYearToggleListeners() {
|
||||
const toggles = this.resultsContainer.querySelectorAll('.year-toggle')
|
||||
toggles.forEach(toggle => {
|
||||
toggle.addEventListener('click', (e) => {
|
||||
const locationIndex = e.currentTarget.dataset.locationIndex
|
||||
const year = e.currentTarget.dataset.year
|
||||
const visitsContainer = document.getElementById(`year-${locationIndex}-${year}`)
|
||||
const arrow = e.currentTarget.querySelector('.year-arrow')
|
||||
|
||||
if (visitsContainer) {
|
||||
visitsContainer.classList.toggle('hidden')
|
||||
arrow.style.transform = visitsContainer.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(90deg)'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
// Attach event listeners to individual visit items
|
||||
const visitItems = this.resultsContainer.querySelectorAll('.visit-item')
|
||||
visitItems.forEach(item => {
|
||||
item.addEventListener('click', (e) => {
|
||||
e.stopPropagation()
|
||||
const locationIndex = parseInt(item.dataset.locationIndex)
|
||||
const visitIndex = parseInt(item.dataset.visitIndex)
|
||||
this.handleVisitClick(locationIndex, visitIndex)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle click on individual visit item
|
||||
* @param {number} locationIndex - Index of location in results
|
||||
* @param {number} visitIndex - Index of visit within location
|
||||
*/
|
||||
handleVisitClick(locationIndex, visitIndex) {
|
||||
if (!this.currentVisitsData || !this.currentVisitsData.locations) return
|
||||
|
||||
const location = this.currentVisitsData.locations[locationIndex]
|
||||
if (!location || !location.visits) return
|
||||
|
||||
const visit = location.visits[visitIndex]
|
||||
if (!visit) return
|
||||
|
||||
// Fly to visit coordinates (more precise than location coordinates)
|
||||
const [lat, lon] = visit.coordinates || location.coordinates
|
||||
this.map.flyTo({
|
||||
center: [lon, lat],
|
||||
zoom: 18,
|
||||
duration: 1000
|
||||
})
|
||||
|
||||
// Extract visit details
|
||||
const visitDetails = visit.visit_details || {}
|
||||
const startTime = visitDetails.start_time || visit.date
|
||||
const endTime = visitDetails.end_time || visit.date
|
||||
const placeName = location.place_name || location.address || 'Unnamed Location'
|
||||
|
||||
// Open create visit modal
|
||||
this.openCreateVisitModal({
|
||||
name: placeName,
|
||||
latitude: lat,
|
||||
longitude: lon,
|
||||
started_at: startTime,
|
||||
ended_at: endTime
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open modal to create a visit with prefilled data
|
||||
* @param {Object} visitData - Visit data to prefill
|
||||
*/
|
||||
openCreateVisitModal(visitData) {
|
||||
// Create modal HTML
|
||||
const modalId = 'create-visit-modal'
|
||||
|
||||
// Remove existing modal if present
|
||||
const existingModal = document.getElementById(modalId)
|
||||
if (existingModal) {
|
||||
existingModal.remove()
|
||||
}
|
||||
|
||||
const modal = document.createElement('div')
|
||||
modal.id = modalId
|
||||
modal.innerHTML = `
|
||||
<input type="checkbox" id="${modalId}-toggle" class="modal-toggle" checked />
|
||||
<div class="modal" role="dialog">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold mb-4">Create Visit</h3>
|
||||
|
||||
<form id="${modalId}-form">
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Name</span>
|
||||
</label>
|
||||
<input type="text" name="name" class="input input-bordered w-full"
|
||||
value="${this.escapeHtml(visitData.name)}" required />
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">Start Time</span>
|
||||
</label>
|
||||
<input type="datetime-local" name="started_at" class="input input-bordered w-full"
|
||||
value="${this.formatDateTimeForInput(visitData.started_at)}" required />
|
||||
</div>
|
||||
|
||||
<div class="form-control mb-4">
|
||||
<label class="label">
|
||||
<span class="label-text">End Time</span>
|
||||
</label>
|
||||
<input type="datetime-local" name="ended_at" class="input input-bordered w-full"
|
||||
value="${this.formatDateTimeForInput(visitData.ended_at)}" required />
|
||||
</div>
|
||||
|
||||
<input type="hidden" name="latitude" value="${visitData.latitude}" />
|
||||
<input type="hidden" name="longitude" value="${visitData.longitude}" />
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn" data-action="close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
<span class="submit-text">Create Visit</span>
|
||||
<span class="loading loading-spinner loading-sm hidden"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<label class="modal-backdrop" for="${modalId}-toggle"></label>
|
||||
</div>
|
||||
`
|
||||
|
||||
document.body.appendChild(modal)
|
||||
|
||||
// Attach event listeners
|
||||
const form = modal.querySelector('form')
|
||||
const closeBtn = modal.querySelector('[data-action="close"]')
|
||||
const modalToggle = modal.querySelector(`#${modalId}-toggle`)
|
||||
const backdrop = modal.querySelector('.modal-backdrop')
|
||||
|
||||
form.addEventListener('submit', (e) => {
|
||||
e.preventDefault()
|
||||
this.submitCreateVisit(form, modal)
|
||||
})
|
||||
|
||||
closeBtn.addEventListener('click', () => {
|
||||
modalToggle.checked = false
|
||||
setTimeout(() => modal.remove(), 300)
|
||||
})
|
||||
|
||||
backdrop.addEventListener('click', () => {
|
||||
modalToggle.checked = false
|
||||
setTimeout(() => modal.remove(), 300)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Submit create visit form
|
||||
* @param {HTMLFormElement} form - Form element
|
||||
* @param {HTMLElement} modal - Modal element
|
||||
*/
|
||||
async submitCreateVisit(form, modal) {
|
||||
const submitBtn = form.querySelector('button[type="submit"]')
|
||||
const submitText = submitBtn.querySelector('.submit-text')
|
||||
const spinner = submitBtn.querySelector('.loading')
|
||||
|
||||
// Disable submit button and show loading
|
||||
submitBtn.disabled = true
|
||||
submitText.classList.add('hidden')
|
||||
spinner.classList.remove('hidden')
|
||||
|
||||
try {
|
||||
const formData = new FormData(form)
|
||||
const visitData = {
|
||||
name: formData.get('name'),
|
||||
latitude: parseFloat(formData.get('latitude')),
|
||||
longitude: parseFloat(formData.get('longitude')),
|
||||
started_at: formData.get('started_at'),
|
||||
ended_at: formData.get('ended_at'),
|
||||
status: 'confirmed'
|
||||
}
|
||||
|
||||
const response = await this.service.createVisit(visitData)
|
||||
|
||||
if (response.error) {
|
||||
throw new Error(response.error)
|
||||
}
|
||||
|
||||
// Success - close modal and show success message
|
||||
const modalToggle = modal.querySelector('.modal-toggle')
|
||||
modalToggle.checked = false
|
||||
setTimeout(() => modal.remove(), 300)
|
||||
|
||||
// Show success notification
|
||||
this.showSuccessNotification('Visit created successfully!')
|
||||
|
||||
// Dispatch custom event for other components to react
|
||||
document.dispatchEvent(new CustomEvent('visit:created', {
|
||||
detail: { visit: response, coordinates: [visitData.longitude, visitData.latitude] }
|
||||
}))
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to create visit:', error)
|
||||
alert(`Failed to create visit: ${error.message}`)
|
||||
|
||||
// Re-enable submit button
|
||||
submitBtn.disabled = false
|
||||
submitText.classList.remove('hidden')
|
||||
spinner.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show success notification
|
||||
* @param {string} message - Success message
|
||||
*/
|
||||
showSuccessNotification(message) {
|
||||
const notification = document.createElement('div')
|
||||
notification.className = 'toast toast-top toast-end z-[9999]'
|
||||
notification.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<span>✓ ${this.escapeHtml(message)}</span>
|
||||
</div>
|
||||
`
|
||||
document.body.appendChild(notification)
|
||||
|
||||
setTimeout(() => {
|
||||
notification.remove()
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format datetime for input field (YYYY-MM-DDTHH:MM)
|
||||
* @param {string} dateString - Date string
|
||||
* @returns {string} Formatted datetime
|
||||
*/
|
||||
formatDateTimeForInput(dateString) {
|
||||
const date = new Date(dateString)
|
||||
const year = date.getFullYear()
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(date.getDate()).padStart(2, '0')
|
||||
const hours = String(date.getHours()).padStart(2, '0')
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date in short format
|
||||
* @param {string} dateString - Date string
|
||||
* @returns {string} Formatted date
|
||||
*/
|
||||
formatDateShort(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date and time
|
||||
* @param {string} dateString - Date string
|
||||
* @returns {string} Formatted date and time
|
||||
*/
|
||||
formatDateTime(dateString) {
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape HTML to prevent XSS
|
||||
* @param {string} str - String to escape
|
||||
* @returns {string} Escaped string
|
||||
*/
|
||||
escapeHtml(str) {
|
||||
if (!str) return ''
|
||||
const div = document.createElement('div')
|
||||
div.textContent = str
|
||||
return div.innerHTML
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search results
|
||||
*/
|
||||
clearResults() {
|
||||
if (this.resultsContainer) {
|
||||
this.resultsContainer.innerHTML = ''
|
||||
this.resultsContainer.classList.add('hidden')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear search marker
|
||||
*/
|
||||
clearMarker() {
|
||||
if (this.currentMarker) {
|
||||
this.currentMarker.remove()
|
||||
this.currentMarker = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup
|
||||
*/
|
||||
destroy() {
|
||||
clearTimeout(this.debounceTimer)
|
||||
this.clearMarker()
|
||||
this.clearResults()
|
||||
}
|
||||
}
|
||||
296
app/javascript/maps_maplibre/utils/settings_manager.js
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
/**
|
||||
* Settings manager for persisting user preferences
|
||||
* Supports both localStorage (fallback) and backend API (primary)
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'dawarich-maps-maplibre-settings'
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
mapStyle: 'light',
|
||||
enabledMapLayers: ['Points', 'Routes'], // Compatible with v1 map
|
||||
// Advanced settings
|
||||
routeOpacity: 1.0,
|
||||
fogOfWarRadius: 1000,
|
||||
fogOfWarThreshold: 1,
|
||||
metersBetweenRoutes: 500,
|
||||
minutesBetweenRoutes: 60,
|
||||
pointsRenderingMode: 'raw',
|
||||
speedColoredRoutes: false
|
||||
}
|
||||
|
||||
// Mapping between v2 layer names and v1 layer names in enabled_map_layers array
|
||||
const LAYER_NAME_MAP = {
|
||||
'Points': 'pointsVisible',
|
||||
'Routes': 'routesVisible',
|
||||
'Heatmap': 'heatmapEnabled',
|
||||
'Visits': 'visitsEnabled',
|
||||
'Photos': 'photosEnabled',
|
||||
'Areas': 'areasEnabled',
|
||||
'Tracks': 'tracksEnabled',
|
||||
'Fog of War': 'fogEnabled',
|
||||
'Scratch map': 'scratchEnabled'
|
||||
}
|
||||
|
||||
// Mapping between frontend settings and backend API keys
|
||||
const BACKEND_SETTINGS_MAP = {
|
||||
mapStyle: 'maps_maplibre_style',
|
||||
enabledMapLayers: 'enabled_map_layers'
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
static apiKey = null
|
||||
static cachedSettings = null
|
||||
|
||||
/**
|
||||
* Initialize settings manager with API key
|
||||
* @param {string} apiKey - User's API key for backend requests
|
||||
*/
|
||||
static initialize(apiKey) {
|
||||
this.apiKey = apiKey
|
||||
this.cachedSettings = null // Clear cache on initialization
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all settings (localStorage first, then merge with defaults)
|
||||
* Converts enabled_map_layers array to individual boolean flags
|
||||
* Uses cached settings if available to avoid race conditions
|
||||
* @returns {Object} Settings object
|
||||
*/
|
||||
static getSettings() {
|
||||
// Return cached settings if available
|
||||
if (this.cachedSettings) {
|
||||
return { ...this.cachedSettings }
|
||||
}
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
const settings = stored ? { ...DEFAULT_SETTINGS, ...JSON.parse(stored) } : DEFAULT_SETTINGS
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(settings)
|
||||
|
||||
// Cache the settings
|
||||
this.cachedSettings = expandedSettings
|
||||
|
||||
return { ...expandedSettings }
|
||||
} catch (error) {
|
||||
console.error('Failed to load settings:', error)
|
||||
return DEFAULT_SETTINGS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert enabled_map_layers array to individual boolean flags
|
||||
* @param {Object} settings - Settings with enabledMapLayers array
|
||||
* @returns {Object} Settings with individual layer booleans
|
||||
*/
|
||||
static _expandLayerSettings(settings) {
|
||||
const enabledLayers = settings.enabledMapLayers || []
|
||||
|
||||
// Set boolean flags based on array contents
|
||||
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
|
||||
settings[settingKey] = enabledLayers.includes(layerName)
|
||||
})
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert individual boolean flags to enabled_map_layers array
|
||||
* @param {Object} settings - Settings with individual layer booleans
|
||||
* @returns {Array} Array of enabled layer names
|
||||
*/
|
||||
static _collapseLayerSettings(settings) {
|
||||
const enabledLayers = []
|
||||
|
||||
Object.entries(LAYER_NAME_MAP).forEach(([layerName, settingKey]) => {
|
||||
if (settings[settingKey] === true) {
|
||||
enabledLayers.push(layerName)
|
||||
}
|
||||
})
|
||||
|
||||
return enabledLayers
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings from backend API
|
||||
* @returns {Promise<Object>} Settings object from backend
|
||||
*/
|
||||
static async loadFromBackend() {
|
||||
if (!this.apiKey) {
|
||||
console.warn('[Settings] API key not set, cannot load from backend')
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load settings: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const backendSettings = data.settings
|
||||
|
||||
// Convert backend settings to frontend format
|
||||
const frontendSettings = {}
|
||||
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
|
||||
if (backendKey in backendSettings) {
|
||||
frontendSettings[frontendKey] = backendSettings[backendKey]
|
||||
}
|
||||
})
|
||||
|
||||
// Merge with defaults, but prioritize backend's enabled_map_layers completely
|
||||
const mergedSettings = { ...DEFAULT_SETTINGS, ...frontendSettings }
|
||||
|
||||
// If backend has enabled_map_layers, use it as-is (don't merge with defaults)
|
||||
if (backendSettings.enabled_map_layers) {
|
||||
mergedSettings.enabledMapLayers = backendSettings.enabled_map_layers
|
||||
}
|
||||
|
||||
// Convert enabled_map_layers array to individual boolean flags
|
||||
const expandedSettings = this._expandLayerSettings(mergedSettings)
|
||||
|
||||
// Save to localStorage and cache
|
||||
this.saveToLocalStorage(expandedSettings)
|
||||
|
||||
return expandedSettings
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to load from backend:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save all settings to localStorage and update cache
|
||||
* @param {Object} settings - Settings object
|
||||
*/
|
||||
static saveToLocalStorage(settings) {
|
||||
try {
|
||||
// Update cache first
|
||||
this.cachedSettings = { ...settings }
|
||||
// Then save to localStorage
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))
|
||||
} catch (error) {
|
||||
console.error('Failed to save settings to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Save settings to backend API
|
||||
* @param {Object} settings - Settings to save
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
static async saveToBackend(settings) {
|
||||
if (!this.apiKey) {
|
||||
console.warn('[Settings] API key not set, cannot save to backend')
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
// Convert individual layer booleans to enabled_map_layers array
|
||||
const enabledMapLayers = this._collapseLayerSettings(settings)
|
||||
|
||||
// Convert frontend settings to backend format
|
||||
const backendSettings = {}
|
||||
Object.entries(BACKEND_SETTINGS_MAP).forEach(([frontendKey, backendKey]) => {
|
||||
if (frontendKey === 'enabledMapLayers') {
|
||||
// Use the collapsed array
|
||||
backendSettings[backendKey] = enabledMapLayers
|
||||
} else if (frontendKey in settings) {
|
||||
backendSettings[backendKey] = settings[frontendKey]
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch('/api/v1/settings', {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ settings: backendSettings })
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to save settings: ${response.status}`)
|
||||
}
|
||||
|
||||
console.log('[Settings] Saved to backend successfully:', backendSettings)
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('[Settings] Failed to save to backend:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a specific setting
|
||||
* @param {string} key - Setting key
|
||||
* @returns {*} Setting value
|
||||
*/
|
||||
static getSetting(key) {
|
||||
return this.getSettings()[key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a specific setting (saves to both localStorage and backend)
|
||||
* @param {string} key - Setting key
|
||||
* @param {*} value - New value
|
||||
*/
|
||||
static async updateSetting(key, value) {
|
||||
const settings = this.getSettings()
|
||||
settings[key] = value
|
||||
|
||||
// If this is a layer visibility setting, also update the enabledMapLayers array
|
||||
// This ensures the array is in sync before backend save
|
||||
const isLayerSetting = Object.values(LAYER_NAME_MAP).includes(key)
|
||||
if (isLayerSetting) {
|
||||
settings.enabledMapLayers = this._collapseLayerSettings(settings)
|
||||
}
|
||||
|
||||
// Save to localStorage immediately
|
||||
this.saveToLocalStorage(settings)
|
||||
|
||||
// Save to backend (non-blocking)
|
||||
this.saveToBackend(settings).catch(error => {
|
||||
console.warn('[Settings] Backend save failed, but localStorage updated:', error)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset to defaults
|
||||
*/
|
||||
static resetToDefaults() {
|
||||
try {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
this.cachedSettings = null // Clear cache
|
||||
|
||||
// Also reset on backend
|
||||
if (this.apiKey) {
|
||||
this.saveToBackend(DEFAULT_SETTINGS).catch(error => {
|
||||
console.warn('[Settings] Failed to reset backend settings:', error)
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to reset settings:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync settings: load from backend and merge with localStorage
|
||||
* Call this on app initialization
|
||||
* @returns {Promise<Object>} Merged settings
|
||||
*/
|
||||
static async sync() {
|
||||
const backendSettings = await this.loadFromBackend()
|
||||
if (backendSettings) {
|
||||
return backendSettings
|
||||
}
|
||||
return this.getSettings()
|
||||
}
|
||||
}
|
||||
140
app/javascript/maps_maplibre/utils/speed_colors.js
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
/**
|
||||
* Speed color utilities for route visualization
|
||||
* Provides speed calculation and color interpolation for route segments
|
||||
*/
|
||||
|
||||
// Default color stops for speed visualization
|
||||
export const colorStopsFallback = [
|
||||
{ speed: 0, color: '#00ff00' }, // Stationary/very slow (green)
|
||||
{ speed: 15, color: '#00ffff' }, // Walking/jogging (cyan)
|
||||
{ speed: 30, color: '#ff00ff' }, // Cycling/slow driving (magenta)
|
||||
{ speed: 50, color: '#ffff00' }, // Urban driving (yellow)
|
||||
{ speed: 100, color: '#ff3300' } // Highway driving (red)
|
||||
]
|
||||
|
||||
/**
|
||||
* Encode color stops array to string format for storage
|
||||
* @param {Array} arr - Array of {speed, color} objects
|
||||
* @returns {string} Encoded string (e.g., "0:#00ff00|15:#00ffff")
|
||||
*/
|
||||
export function colorFormatEncode(arr) {
|
||||
return arr.map(item => `${item.speed}:${item.color}`).join('|')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode color stops string to array format
|
||||
* @param {string} str - Encoded color stops string
|
||||
* @returns {Array} Array of {speed, color} objects
|
||||
*/
|
||||
export function colorFormatDecode(str) {
|
||||
return str.split('|').map(segment => {
|
||||
const [speed, color] = segment.split(':')
|
||||
return { speed: Number(speed), color }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert hex color to RGB object
|
||||
* @param {string} hex - Hex color (e.g., "#ff0000")
|
||||
* @returns {Object} RGB object {r, g, b}
|
||||
*/
|
||||
function hexToRGB(hex) {
|
||||
const r = parseInt(hex.slice(1, 3), 16)
|
||||
const g = parseInt(hex.slice(3, 5), 16)
|
||||
const b = parseInt(hex.slice(5, 7), 16)
|
||||
return { r, g, b }
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate speed between two points
|
||||
* @param {Object} point1 - First point with lat, lon, timestamp
|
||||
* @param {Object} point2 - Second point with lat, lon, timestamp
|
||||
* @returns {number} Speed in km/h
|
||||
*/
|
||||
export function calculateSpeed(point1, point2) {
|
||||
if (!point1 || !point2 || !point1.timestamp || !point2.timestamp) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const distanceKm = haversineDistance(
|
||||
point1.latitude, point1.longitude,
|
||||
point2.latitude, point2.longitude
|
||||
)
|
||||
const timeDiffSeconds = point2.timestamp - point1.timestamp
|
||||
|
||||
// Handle edge cases
|
||||
if (timeDiffSeconds <= 0 || distanceKm <= 0) {
|
||||
return 0
|
||||
}
|
||||
|
||||
const speedKmh = (distanceKm / timeDiffSeconds) * 3600
|
||||
|
||||
// Cap speed at reasonable maximum (150 km/h)
|
||||
const MAX_SPEED = 150
|
||||
return Math.min(speedKmh, MAX_SPEED)
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate haversine distance between two points
|
||||
* @param {number} lat1 - First point latitude
|
||||
* @param {number} lon1 - First point longitude
|
||||
* @param {number} lat2 - Second point latitude
|
||||
* @param {number} lon2 - Second point longitude
|
||||
* @returns {number} Distance in kilometers
|
||||
*/
|
||||
function haversineDistance(lat1, lon1, lat2, lon2) {
|
||||
const R = 6371 // Earth's radius in kilometers
|
||||
const dLat = (lat2 - lat1) * Math.PI / 180
|
||||
const dLon = (lon2 - lon1) * Math.PI / 180
|
||||
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
||||
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
||||
Math.sin(dLon / 2) * Math.sin(dLon / 2)
|
||||
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
|
||||
return R * c
|
||||
}
|
||||
|
||||
/**
|
||||
* Get color for a given speed with interpolation
|
||||
* @param {number} speedKmh - Speed in km/h
|
||||
* @param {boolean} useSpeedColors - Whether to use speed-based coloring
|
||||
* @param {string} speedColorScale - Encoded color scale string
|
||||
* @returns {string} RGB color string (e.g., "rgb(255, 0, 0)")
|
||||
*/
|
||||
export function getSpeedColor(speedKmh, useSpeedColors, speedColorScale) {
|
||||
if (!useSpeedColors) {
|
||||
return '#f97316' // Default orange color
|
||||
}
|
||||
|
||||
let colorStops
|
||||
|
||||
try {
|
||||
colorStops = colorFormatDecode(speedColorScale).map(stop => ({
|
||||
...stop,
|
||||
rgb: hexToRGB(stop.color)
|
||||
}))
|
||||
} catch (error) {
|
||||
// If user has given invalid values, use fallback
|
||||
colorStops = colorStopsFallback.map(stop => ({
|
||||
...stop,
|
||||
rgb: hexToRGB(stop.color)
|
||||
}))
|
||||
}
|
||||
|
||||
// Find the appropriate color segment and interpolate
|
||||
for (let i = 1; i < colorStops.length; i++) {
|
||||
if (speedKmh <= colorStops[i].speed) {
|
||||
const ratio = (speedKmh - colorStops[i-1].speed) / (colorStops[i].speed - colorStops[i-1].speed)
|
||||
const color1 = colorStops[i-1].rgb
|
||||
const color2 = colorStops[i].rgb
|
||||
|
||||
const r = Math.round(color1.r + (color2.r - color1.r) * ratio)
|
||||
const g = Math.round(color1.g + (color2.g - color1.g) * ratio)
|
||||
const b = Math.round(color1.b + (color2.b - color1.b) * ratio)
|
||||
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
}
|
||||
|
||||
// If speed exceeds all stops, return the last color
|
||||
return colorStops[colorStops.length - 1].color
|
||||
}
|
||||
113
app/javascript/maps_maplibre/utils/style_manager.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
/**
|
||||
* Style Manager for MapLibre GL styles
|
||||
* Loads and configures local map styles with dynamic tile source
|
||||
*/
|
||||
|
||||
const TILE_SOURCE_URL = 'https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt'
|
||||
|
||||
// Cache for loaded styles
|
||||
const styleCache = {}
|
||||
|
||||
/**
|
||||
* Available map styles
|
||||
*/
|
||||
export const MAP_STYLES = {
|
||||
dark: 'dark',
|
||||
light: 'light',
|
||||
white: 'white',
|
||||
black: 'black',
|
||||
grayscale: 'grayscale'
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a style JSON file via fetch
|
||||
* @param {string} styleName - Name of the style
|
||||
* @returns {Promise<Object>} Style object
|
||||
*/
|
||||
async function loadStyleFile(styleName) {
|
||||
// Check cache first
|
||||
if (styleCache[styleName]) {
|
||||
return styleCache[styleName]
|
||||
}
|
||||
|
||||
// Fetch the style file from the public assets
|
||||
const response = await fetch(`/maps_maplibre/styles/${styleName}.json`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load style: ${styleName} (${response.status})`)
|
||||
}
|
||||
|
||||
const style = await response.json()
|
||||
styleCache[styleName] = style
|
||||
return style
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a map style with configured tile source
|
||||
* @param {string} styleName - Name of the style (dark, light, white, black, grayscale)
|
||||
* @returns {Promise<Object>} MapLibre style object
|
||||
*/
|
||||
export async function getMapStyle(styleName = 'light') {
|
||||
try {
|
||||
// Load the style file
|
||||
const style = await loadStyleFile(styleName)
|
||||
|
||||
// Clone the style to avoid mutating the cached object
|
||||
const clonedStyle = JSON.parse(JSON.stringify(style))
|
||||
|
||||
// Update the tile source URL
|
||||
if (clonedStyle.sources && clonedStyle.sources.protomaps) {
|
||||
clonedStyle.sources.protomaps = {
|
||||
type: 'vector',
|
||||
tiles: [TILE_SOURCE_URL],
|
||||
minzoom: 0,
|
||||
maxzoom: 14,
|
||||
attribution: clonedStyle.sources.protomaps.attribution ||
|
||||
'<a href="https://github.com/protomaps/basemaps">Protomaps</a> © <a href="https://openstreetmap.org">OpenStreetMap</a>'
|
||||
}
|
||||
}
|
||||
|
||||
return clonedStyle
|
||||
} catch (error) {
|
||||
console.error(`Error loading style '${styleName}':`, error)
|
||||
// Fall back to light style if the requested style fails
|
||||
if (styleName !== 'light') {
|
||||
console.warn(`Falling back to 'light' style`)
|
||||
return getMapStyle('light')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of available style names
|
||||
* @returns {string[]} Array of style names
|
||||
*/
|
||||
export function getAvailableStyles() {
|
||||
return Object.keys(MAP_STYLES)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get style display name
|
||||
* @param {string} styleName - Style identifier
|
||||
* @returns {string} Human-readable style name
|
||||
*/
|
||||
export function getStyleDisplayName(styleName) {
|
||||
const displayNames = {
|
||||
dark: 'Dark',
|
||||
light: 'Light',
|
||||
white: 'White',
|
||||
black: 'Black',
|
||||
grayscale: 'Grayscale'
|
||||
}
|
||||
return displayNames[styleName] || styleName.charAt(0).toUpperCase() + styleName.slice(1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Preload all styles into cache for faster switching
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function preloadAllStyles() {
|
||||
const styleNames = getAvailableStyles()
|
||||
await Promise.all(styleNames.map(name => loadStyleFile(name)))
|
||||
console.log('All map styles preloaded')
|
||||
}
|
||||
82
app/javascript/maps_maplibre/utils/websocket_manager.js
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
/**
|
||||
* WebSocket connection manager
|
||||
* Handles reconnection logic and connection state
|
||||
*/
|
||||
export class WebSocketManager {
|
||||
constructor(options = {}) {
|
||||
this.maxReconnectAttempts = options.maxReconnectAttempts || 5
|
||||
this.reconnectDelay = options.reconnectDelay || 1000
|
||||
this.reconnectAttempts = 0
|
||||
this.isConnected = false
|
||||
this.subscription = null
|
||||
this.onConnect = options.onConnect || null
|
||||
this.onDisconnect = options.onDisconnect || null
|
||||
this.onError = options.onError || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect to channel
|
||||
* @param {Object} subscription - ActionCable subscription
|
||||
*/
|
||||
connect(subscription) {
|
||||
this.subscription = subscription
|
||||
|
||||
// Monitor connection state
|
||||
this.subscription.connected = () => {
|
||||
this.isConnected = true
|
||||
this.reconnectAttempts = 0
|
||||
this.onConnect?.()
|
||||
}
|
||||
|
||||
this.subscription.disconnected = () => {
|
||||
this.isConnected = false
|
||||
this.onDisconnect?.()
|
||||
this.attemptReconnect()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to reconnect
|
||||
*/
|
||||
attemptReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
this.onError?.(new Error('Max reconnect attempts reached'))
|
||||
return
|
||||
}
|
||||
|
||||
this.reconnectAttempts++
|
||||
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts - 1)
|
||||
|
||||
console.log(`Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts}/${this.maxReconnectAttempts})`)
|
||||
|
||||
setTimeout(() => {
|
||||
if (!this.isConnected) {
|
||||
this.subscription?.perform('reconnect')
|
||||
}
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect
|
||||
*/
|
||||
disconnect() {
|
||||
if (this.subscription) {
|
||||
this.subscription.unsubscribe()
|
||||
this.subscription = null
|
||||
}
|
||||
this.isConnected = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message
|
||||
*/
|
||||
send(action, data = {}) {
|
||||
if (!this.isConnected) {
|
||||
console.warn('Cannot send message: not connected')
|
||||
return
|
||||
}
|
||||
|
||||
this.subscription?.perform(action, data)
|
||||
}
|
||||
}
|
||||
|
|
@ -8,6 +8,16 @@ module Taggable
|
|||
has_many :tags, through: :taggings
|
||||
|
||||
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
|
||||
scope :with_all_tags, ->(tag_ids) {
|
||||
tag_ids = Array(tag_ids)
|
||||
return none if tag_ids.empty?
|
||||
|
||||
# For each tag, join and filter, then use HAVING to ensure all tags are present
|
||||
joins(:taggings)
|
||||
.where(taggings: { tag_id: tag_ids })
|
||||
.group("#{table_name}.id")
|
||||
.having("COUNT(DISTINCT taggings.tag_id) = ?", tag_ids.length)
|
||||
}
|
||||
scope :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
|
||||
scope :tagged_with, ->(tag_name, user) {
|
||||
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ class Api::PointSerializer
|
|||
|
||||
attributes['latitude'] = lat&.to_s
|
||||
attributes['longitude'] = lon&.to_s
|
||||
attributes['country_name'] = point.country_name
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ class Users::SafeSettings
|
|||
'photoprism_api_key' => nil,
|
||||
'maps' => { 'distance_unit' => 'km' },
|
||||
'visits_suggestions_enabled' => 'true',
|
||||
'enabled_map_layers' => ['Routes', 'Heatmap']
|
||||
'enabled_map_layers' => ['Routes', 'Heatmap'],
|
||||
'maps_maplibre_style' => 'light'
|
||||
}.freeze
|
||||
|
||||
def initialize(settings = {})
|
||||
|
|
@ -28,7 +29,7 @@ class Users::SafeSettings
|
|||
end
|
||||
|
||||
# rubocop:disable Metrics/MethodLength
|
||||
def default_settings
|
||||
def config
|
||||
{
|
||||
fog_of_war_meters: fog_of_war_meters,
|
||||
meters_between_routes: meters_between_routes,
|
||||
|
|
@ -49,7 +50,8 @@ class Users::SafeSettings
|
|||
visits_suggestions_enabled: visits_suggestions_enabled?,
|
||||
speed_color_scale: speed_color_scale,
|
||||
fog_of_war_threshold: fog_of_war_threshold,
|
||||
enabled_map_layers: enabled_map_layers
|
||||
enabled_map_layers: enabled_map_layers,
|
||||
maps_maplibre_style: maps_maplibre_style
|
||||
}
|
||||
end
|
||||
# rubocop:enable Metrics/MethodLength
|
||||
|
|
@ -133,4 +135,8 @@ class Users::SafeSettings
|
|||
def enabled_map_layers
|
||||
settings['enabled_map_layers']
|
||||
end
|
||||
|
||||
def maps_maplibre_style
|
||||
settings['maps_maplibre_style']
|
||||
end
|
||||
end
|
||||
|
|
|
|||
35
app/views/map/leaflet/index.html.erb
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<%= render 'shared/map/date_navigation', start_at: @start_at, end_at: @end_at %>
|
||||
|
||||
<!-- Map Container - Fills remaining space -->
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full"
|
||||
data-controller="maps points add-visit family-members"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'
|
||||
data-user_tags='<%= current_user.tags.ordered.select(:id, :name, :icon, :color).as_json.to_json.html_safe %>'
|
||||
data-home_coordinates='<%= @home_coordinates.to_json.html_safe %>'
|
||||
data-family-members-features-value='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
|
||||
<div data-maps-target="container" class="w-full h-full">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render 'map/leaflet/settings_modals' %>
|
||||
|
||||
<!-- Include Place Creation Modal -->
|
||||
<%= render 'shared/place_creation_modal' %>
|
||||
67
app/views/map/maplibre/_area_creation_modal.html.erb
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
<div data-controller="area-creation-v2"
|
||||
data-area-creation-v2-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal z-[10000]" data-area-creation-v2-target="modal">
|
||||
<div class="modal-box max-w-xl">
|
||||
<h3 class="font-bold text-lg mb-4">Create New Area</h3>
|
||||
|
||||
<form data-area-creation-v2-target="form" data-action="submit->area-creation-v2#submit">
|
||||
<input type="hidden" name="latitude" data-area-creation-v2-target="latitudeInput">
|
||||
<input type="hidden" name="longitude" data-area-creation-v2-target="longitudeInput">
|
||||
<input type="hidden" name="radius" data-area-creation-v2-target="radiusInput">
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- Area Name -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Area Name *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="e.g. Home, Office, Gym..."
|
||||
class="input input-bordered w-full"
|
||||
data-area-creation-v2-target="nameInput"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<!-- Radius Display -->
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Radius</span>
|
||||
</label>
|
||||
<div class="text-lg font-semibold">
|
||||
<span data-area-creation-v2-target="radiusDisplay">0</span> meters
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Draw on the map to set the radius</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Drawing Instructions -->
|
||||
<div class="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="stroke-current shrink-0 w-6 h-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||
</svg>
|
||||
<div class="text-sm">
|
||||
<strong>How to draw:</strong>
|
||||
<ol class="list-decimal list-inside mt-1">
|
||||
<li>Click once to set the center point</li>
|
||||
<li>Move mouse to adjust radius</li>
|
||||
<li>Click again to finish drawing</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" data-action="click->area-creation-v2#close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-area-creation-v2-target="submitButton">
|
||||
<span class="loading loading-sm hidden" data-area-creation-v2-target="submitSpinner"></span>
|
||||
<span data-area-creation-v2-target="submitText">Create Area</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-backdrop" data-action="click->area-creation-v2#close"></div>
|
||||
</div>
|
||||
</div>
|
||||
655
app/views/map/maplibre/_settings_panel.html.erb
Normal file
|
|
@ -0,0 +1,655 @@
|
|||
<div class="map-control-panel" data-maps--maplibre-target="settingsPanel" data-controller="map-panel">
|
||||
<!-- Vertical Icon Tabs (Left Side) -->
|
||||
<div class="panel-tabs">
|
||||
<button class="tab-btn active"
|
||||
data-action="click->map-panel#switchTab"
|
||||
data-tab="layers"
|
||||
data-map-panel-target="tabButton"
|
||||
title="Map Layers">
|
||||
<%= icon 'layer' %>
|
||||
</button>
|
||||
|
||||
<button class="tab-btn"
|
||||
data-action="click->map-panel#switchTab"
|
||||
data-tab="search"
|
||||
data-map-panel-target="tabButton"
|
||||
title="Search">
|
||||
<%= icon 'search' %>
|
||||
</button>
|
||||
|
||||
|
||||
<button class="tab-btn"
|
||||
data-action="click->map-panel#switchTab"
|
||||
data-tab="tools"
|
||||
data-map-panel-target="tabButton"
|
||||
title="Tools">
|
||||
<%= icon 'pocket-knife' %>
|
||||
</button>
|
||||
|
||||
<button class="tab-btn"
|
||||
data-action="click->map-panel#switchTab"
|
||||
data-tab="settings"
|
||||
data-map-panel-target="tabButton"
|
||||
title="Settings">
|
||||
<%= icon 'settings' %>
|
||||
</button>
|
||||
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<button class="tab-btn"
|
||||
data-action="click->map-panel#switchTab"
|
||||
data-tab="links"
|
||||
data-map-panel-target="tabButton"
|
||||
title="Links">
|
||||
<%= icon 'info' %>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<!-- Panel Content -->
|
||||
<div class="panel-content">
|
||||
<!-- Panel Header -->
|
||||
<div class="panel-header">
|
||||
<h3 class="panel-title" data-map-panel-target="title">Layers</h3>
|
||||
<button class="btn btn-ghost btn-sm btn-circle"
|
||||
data-action="click->maps--maplibre#toggleSettings"
|
||||
title="Close panel">
|
||||
<%= icon 'x' %>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Panel Body -->
|
||||
<div class="panel-body">
|
||||
<!-- Search Tab -->
|
||||
<div class="tab-content" data-tab-content="search" data-map-panel-target="tabContent">
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text">Search for a place</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input type="text"
|
||||
placeholder="Enter name of a place"
|
||||
class="input input-bordered w-full"
|
||||
data-maps--maplibre-target="searchInput"
|
||||
autocomplete="off" />
|
||||
<!-- Search Results -->
|
||||
<div class="absolute z-50 w-full mt-1 bg-base-100 rounded-lg shadow-lg border border-base-300 hidden max-h-full overflow-y-auto"
|
||||
data-maps--maplibre-target="searchResults">
|
||||
<!-- Results will be populated by SearchManager -->
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-2">
|
||||
Search for a location to find places you visited
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Layers Tab -->
|
||||
<div class="tab-content active" data-tab-content="layers" data-map-panel-target="tabContent">
|
||||
<div class="space-y-4">
|
||||
<!-- Points Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="pointsToggle"
|
||||
data-action="change->maps--maplibre#togglePoints" />
|
||||
<span class="label-text font-medium">Points</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show individual location points</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Routes Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="routesToggle"
|
||||
data-action="change->maps--maplibre#toggleRoutes" />
|
||||
<span class="label-text font-medium">Routes</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show connected route lines</p>
|
||||
</div>
|
||||
|
||||
<!-- Speed-Colored Routes Options (conditionally shown) -->
|
||||
<div class="ml-14 space-y-3" data-maps--maplibre-target="routesOptions" style="display: none;">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer py-2">
|
||||
<span class="label-text text-sm">Color by speed</span>
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-sm toggle-primary"
|
||||
data-maps--maplibre-target="speedColoredToggle"
|
||||
data-action="change->maps--maplibre#toggleSpeedColoredRoutes" />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Speed Color Scale Editor (shown when speed colors enabled) -->
|
||||
<div class="hidden" data-maps--maplibre-target="speedColorScaleContainer">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline w-full"
|
||||
data-action="click->maps--maplibre#openSpeedColorEditor">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21a4 4 0 01-4-4V5a2 2 0 012-2h4a2 2 0 012 2v12a4 4 0 01-4 4zm0 0h12a2 2 0 002-2v-4a2 2 0 00-2-2h-2.343M11 7.343l1.657-1.657a2 2 0 012.828 0l2.829 2.829a2 2 0 010 2.828l-8.486 8.485M7 17h.01" />
|
||||
</svg>
|
||||
Edit Color Gradient
|
||||
</button>
|
||||
<input type="hidden" data-maps--maplibre-target="speedColorScaleInput" value="" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Heatmap Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="heatmapToggle"
|
||||
data-action="change->maps--maplibre#toggleHeatmap" />
|
||||
<span class="label-text font-medium">Heatmap</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show density heatmap</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Visits Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="visitsToggle"
|
||||
data-action="change->maps--maplibre#toggleVisits" />
|
||||
<span class="label-text font-medium">Visits</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show detected area visits</p>
|
||||
</div>
|
||||
|
||||
<!-- Visits Search (conditionally shown) -->
|
||||
<div class="ml-14 space-y-2" data-maps--maplibre-target="visitsSearch" style="display: none;">
|
||||
<input type="text"
|
||||
id="visits-search"
|
||||
placeholder="Filter by name..."
|
||||
class="input input-sm input-bordered w-full"
|
||||
data-action="input->maps--maplibre#searchVisits" />
|
||||
|
||||
<select class="select select-bordered w-full"
|
||||
data-action="change->maps--maplibre#filterVisits">
|
||||
<option value="all">All Visits</option>
|
||||
<option value="confirmed">Confirmed Only</option>
|
||||
<option value="suggested">Suggested Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Places Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="placesToggle"
|
||||
data-action="change->maps--maplibre#togglePlaces" />
|
||||
<span class="label-text font-medium">Places</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show your saved places</p>
|
||||
</div>
|
||||
|
||||
<!-- Places Tags (conditionally shown) -->
|
||||
<div class="ml-14 space-y-2" data-maps--maplibre-target="placesFilters" style="display: none;">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-2">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-sm"
|
||||
data-maps--maplibre-target="enableAllPlaceTagsToggle"
|
||||
data-action="change->maps--maplibre#toggleAllPlaceTags">
|
||||
<span class="label-text text-sm">Enable All Tags</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text text-sm">Filter by Tags</span>
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<!-- Untagged option -->
|
||||
<label class="cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="place_tag_ids[]"
|
||||
value="untagged"
|
||||
class="checkbox checkbox-xs hidden peer"
|
||||
data-action="change->maps--maplibre#filterPlacesByTags">
|
||||
<span class="badge badge-sm badge-outline transition-all peer-checked:scale-105"
|
||||
style="border-color: #94a3b8; color: #94a3b8;"
|
||||
data-checked-style="background-color: #94a3b8; color: white;">
|
||||
🏷️ Untagged
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<% current_user.tags.ordered.each do |tag| %>
|
||||
<label class="cursor-pointer">
|
||||
<input type="checkbox"
|
||||
name="place_tag_ids[]"
|
||||
value="<%= tag.id %>"
|
||||
class="checkbox checkbox-xs hidden peer"
|
||||
data-action="change->maps--maplibre#filterPlacesByTags">
|
||||
<span class="badge badge-sm badge-outline transition-all peer-checked:scale-105"
|
||||
style="border-color: <%= tag.color %>; color: <%= tag.color %>;"
|
||||
data-checked-style="background-color: <%= tag.color %>; color: white;">
|
||||
<%= tag.icon %> #<%= tag.name %>
|
||||
</span>
|
||||
</label>
|
||||
<% end %>
|
||||
</div>
|
||||
<label class="label">
|
||||
<span class="label-text-alt">Click tags to filter places</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Photos Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="photosToggle"
|
||||
data-action="change->maps--maplibre#togglePhotos" />
|
||||
<span class="label-text font-medium">Photos</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show geotagged photos</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Areas Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="areasToggle"
|
||||
data-action="change->maps--maplibre#toggleAreas" />
|
||||
<span class="label-text font-medium">Areas</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show defined areas</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Tracks Layer -->
|
||||
<%# <div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="tracksToggle"
|
||||
data-action="change->maps--maplibre#toggleTracks" />
|
||||
<span class="label-text font-medium">Tracks</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show saved tracks</p>
|
||||
</div> %>
|
||||
|
||||
<%# <div class="divider"></div> %>
|
||||
|
||||
<!-- Fog of War Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="fogToggle"
|
||||
data-action="change->maps--maplibre#toggleFog" />
|
||||
<span class="label-text font-medium">Fog of War</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show explored areas</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Scratch Map Layer -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-maps--maplibre-target="scratchToggle"
|
||||
data-action="change->maps--maplibre#toggleScratch" />
|
||||
<span class="label-text font-medium">Scratch Map</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 ml-14">Show scratched countries</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Tab -->
|
||||
<div class="tab-content" data-tab-content="settings" data-map-panel-target="tabContent">
|
||||
<form data-action="submit->maps--maplibre#updateAdvancedSettings" class="space-y-4">
|
||||
<!-- Map Style -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Map Style</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full"
|
||||
name="mapStyle"
|
||||
data-action="change->maps--maplibre#updateMapStyle">
|
||||
<option value="light" selected>Light</option>
|
||||
<option value="dark">Dark</option>
|
||||
<option value="white">White</option>
|
||||
<option value="black">Black</option>
|
||||
<option value="grayscale">Grayscale</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Route Opacity -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Route Opacity</span>
|
||||
<span class="label-text-alt">%</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="routeOpacity"
|
||||
min="10"
|
||||
max="100"
|
||||
step="10"
|
||||
value="100"
|
||||
class="range range-sm"
|
||||
data-maps--maplibre-target="routeOpacityRange"
|
||||
data-action="input->maps--maplibre#updateRouteOpacity" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>10%</span>
|
||||
<span>50%</span>
|
||||
<span>100%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Fog of War Settings -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Fog of War Radius</span>
|
||||
<span class="label-text-alt" data-maps--maplibre-target="fogRadiusValue">1000m</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="fogOfWarRadius"
|
||||
min="5"
|
||||
max="2000"
|
||||
step="5"
|
||||
value="1000"
|
||||
class="range range-sm"
|
||||
data-action="input->maps--maplibre#updateFogRadiusDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>5m</span>
|
||||
<span>1000m</span>
|
||||
<span>2000m</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Clear radius around visited points</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Fog of War Threshold</span>
|
||||
<span class="label-text-alt" data-maps--maplibre-target="fogThresholdValue">1</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="fogOfWarThreshold"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
value="1"
|
||||
class="range range-sm"
|
||||
data-action="input->maps--maplibre#updateFogThresholdDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1</span>
|
||||
<span>5</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Minimum points to clear fog</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Route Generation Settings -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Meters Between Routes</span>
|
||||
<span class="label-text-alt" data-maps--maplibre-target="metersBetweenValue">500m</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="metersBetweenRoutes"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
value="500"
|
||||
class="range range-sm"
|
||||
data-action="input->maps--maplibre#updateMetersBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>100m</span>
|
||||
<span>2500m</span>
|
||||
<span>5000m</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Distance threshold for route splitting</p>
|
||||
</div>
|
||||
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Minutes Between Routes</span>
|
||||
<span class="label-text-alt" data-maps--maplibre-target="minutesBetweenValue">60min</span>
|
||||
</label>
|
||||
<input type="range"
|
||||
name="minutesBetweenRoutes"
|
||||
min="1"
|
||||
max="180"
|
||||
step="1"
|
||||
value="60"
|
||||
class="range range-sm"
|
||||
data-action="input->maps--maplibre#updateMinutesBetweenDisplay" />
|
||||
<div class="w-full flex justify-between text-xs px-2 mt-1">
|
||||
<span>1min</span>
|
||||
<span>90min</span>
|
||||
<span>180min</span>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 mt-1">Time threshold for route splitting</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Points Rendering Mode -->
|
||||
<div class="form-control w-full">
|
||||
<label class="label">
|
||||
<span class="label-text font-medium">Points Rendering Mode</span>
|
||||
</label>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="label cursor-pointer justify-start gap-3 py-1">
|
||||
<input type="radio"
|
||||
name="pointsRenderingMode"
|
||||
value="raw"
|
||||
class="radio radio-primary radio-sm"
|
||||
checked />
|
||||
<span class="label-text">Raw (all points)</span>
|
||||
</label>
|
||||
<label class="label cursor-pointer justify-start gap-3 py-1">
|
||||
<input type="radio"
|
||||
name="pointsRenderingMode"
|
||||
value="simplified"
|
||||
class="radio radio-primary radio-sm" />
|
||||
<span class="label-text">Simplified (reduced points)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Speed-Colored Routes -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
name="speedColoredRoutes"
|
||||
class="toggle toggle-primary" />
|
||||
<span class="label-text font-medium">Speed-Colored Routes</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 mt-1">Color routes by speed</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Live Mode -->
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input type="checkbox"
|
||||
class="toggle toggle-primary"
|
||||
data-action="change->maps--maplibre-realtime#toggleLiveMode"
|
||||
data-maps--maplibre-realtime-target="liveModeToggle" />
|
||||
<span class="label-text font-medium">Live Mode</span>
|
||||
</label>
|
||||
<p class="text-sm text-base-content/60 mt-1">Show new points in real-time</p>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Update Button -->
|
||||
<button type="submit" class="btn btn-sm btn-primary btn-block">
|
||||
<%= icon 'save' %>
|
||||
Apply Settings
|
||||
</button>
|
||||
|
||||
<!-- Reset Settings -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline btn-block"
|
||||
data-action="click->maps--maplibre#resetSettings">
|
||||
<%= icon 'rotate-ccw' %>
|
||||
Reset to Defaults
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Tools Tab -->
|
||||
<div class="tab-content" data-tab-content="tools" data-map-panel-target="tabContent">
|
||||
<div class="space-y-4">
|
||||
<!-- Tools Grid: Full width on mobile/tablet, 2 columns on large screens -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||
<!-- Create a Visit Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-action="click->maps--maplibre#startCreateVisit">
|
||||
<%= icon 'map-pin-check' %>
|
||||
Create a Visit
|
||||
</button>
|
||||
|
||||
<!-- Create a Place Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-action="click->maps--maplibre#startCreatePlace">
|
||||
<%= icon 'map-pin-plus' %>
|
||||
Create a Place
|
||||
</button>
|
||||
|
||||
<!-- Select Area Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-maps--maplibre-target="selectAreaButton"
|
||||
data-action="click->maps--maplibre#startSelectArea">
|
||||
<%= icon 'square-dashed-mouse-pointer' %>
|
||||
Select Area
|
||||
</button>
|
||||
|
||||
<!-- Create Area Button -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline"
|
||||
data-action="click->maps--maplibre#startCreateArea">
|
||||
<%= icon 'circle-plus' %>
|
||||
Create an Area
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Info Display (shown when clicking on visit/area/place) -->
|
||||
<div class="hidden mt-4" data-maps--maplibre-target="infoDisplay">
|
||||
<div class="card bg-base-200 shadow-md">
|
||||
<div class="card-body p-4">
|
||||
<div class="flex justify-between items-start mb-2">
|
||||
<h4 class="card-title text-base" data-maps--maplibre-target="infoTitle"></h4>
|
||||
<button class="btn btn-ghost btn-xs btn-circle" data-action="click->maps--maplibre#closeInfo" title="Close">✕</button>
|
||||
</div>
|
||||
<div class="space-y-2 text-sm" data-maps--maplibre-target="infoContent">
|
||||
<!-- Content will be dynamically inserted -->
|
||||
</div>
|
||||
<div class="card-actions justify-end mt-3" data-maps--maplibre-target="infoActions">
|
||||
<!-- Action buttons will be dynamically inserted -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selection Actions (shown after area is selected) -->
|
||||
<div class="hidden mt-4 space-y-2" data-maps--maplibre-target="selectionActions">
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline btn-error btn-block"
|
||||
data-action="click->maps--maplibre#deleteSelectedPoints"
|
||||
data-maps--maplibre-target="deletePointsButton">
|
||||
<%= icon 'trash-2' %>
|
||||
<span data-maps--maplibre-target="deleteButtonText">Delete Selected Points</span>
|
||||
</button>
|
||||
|
||||
<!-- Selected Visits Container -->
|
||||
<div class="hidden mt-4 max-h-full overflow-y-auto" data-maps--maplibre-target="selectedVisitsContainer">
|
||||
<!-- Visit cards will be dynamically inserted here -->
|
||||
</div>
|
||||
|
||||
<!-- Bulk Actions for Visits -->
|
||||
<div class="hidden" data-maps--maplibre-target="selectedVisitsBulkActions">
|
||||
<!-- Bulk action buttons will be dynamically inserted here -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<% if !DawarichSettings.self_hosted? %>
|
||||
<!-- Links Tab -->
|
||||
<div class="tab-content" data-tab-content="links" data-map-panel-target="tabContent">
|
||||
<div class="space-y-6">
|
||||
<!-- Community Section -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-base mb-3">Community</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a href="https://discord.gg/pHsBjpt5J8" target="_blank" class="link-hover text-sm">Discord</a>
|
||||
<a href="https://x.com/freymakesstuff" target="_blank" class="link-hover text-sm">X</a>
|
||||
<a href="https://github.com/Freika/dawarich" target="_blank" class="link-hover text-sm">Github</a>
|
||||
<a href="https://mastodon.social/@dawarich" target="_blank" class="link-hover text-sm">Mastodon</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Docs Section -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-base mb-3">Docs</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a href="https://dawarich.app/docs/intro" target="_blank" class="link-hover text-sm">Tutorial</a>
|
||||
<a href="https://dawarich.app/docs/tutorials/import-existing-data" target="_blank" class="link-hover text-sm">Import existing data</a>
|
||||
<a href="https://dawarich.app/docs/tutorials/export-your-data" target="_blank" class="link-hover text-sm">Exporting data</a>
|
||||
<a href="https://dawarich.app/docs/FAQ" target="_blank" class="link-hover text-sm">FAQ</a>
|
||||
<a href="https://dawarich.app/contact" target="_blank" class="link-hover text-sm">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- More Section -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-base mb-3">More</h4>
|
||||
<div class="flex flex-col gap-2">
|
||||
<a href="https://dawarich.app/privacy-policy" target="_blank" class="link-hover text-sm">Privacy policy</a>
|
||||
<a href="https://dawarich.app/terms-and-conditions" target="_blank" class="link-hover text-sm">Terms and Conditions</a>
|
||||
<a href="https://dawarich.app/refund-policy" target="_blank" class="link-hover text-sm">Refund policy</a>
|
||||
<a href="https://dawarich.app/impressum" target="_blank" class="link-hover text-sm">Impressum</a>
|
||||
<a href="https://dawarich.app/blog" target="_blank" class="link-hover text-sm">Blog</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
60
app/views/map/maplibre/_visit_creation_modal.html.erb
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<div data-controller="visit-creation-v2" data-visit-creation-v2-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal z-[10000]" data-visit-creation-v2-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4" data-visit-creation-v2-target="modalTitle">Create New Visit</h3>
|
||||
|
||||
<form data-visit-creation-v2-target="form" data-action="submit->visit-creation-v2#submit">
|
||||
<input type="hidden" name="latitude" data-visit-creation-v2-target="latitudeInput">
|
||||
<input type="hidden" name="longitude" data-visit-creation-v2-target="longitudeInput">
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Visit Name *</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
placeholder="Enter visit name..."
|
||||
class="input input-bordered w-full"
|
||||
data-visit-creation-v2-target="nameInput"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">Start Time *</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="started_at"
|
||||
class="input input-bordered w-full"
|
||||
data-visit-creation-v2-target="startTimeInput"
|
||||
required>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">End Time *</span>
|
||||
</label>
|
||||
<input
|
||||
type="datetime-local"
|
||||
name="ended_at"
|
||||
class="input input-bordered w-full"
|
||||
data-visit-creation-v2-target="endTimeInput"
|
||||
required>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button type="button" class="btn btn-ghost" data-action="click->visit-creation-v2#close">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" data-visit-creation-v2-target="submitButton">Create Visit</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-backdrop" data-action="click->visit-creation-v2#close"></div>
|
||||
</div>
|
||||
</div>
|
||||
49
app/views/map/maplibre/index.html.erb
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<%= render 'shared/map/date_navigation_v2', start_at: @start_at, end_at: @end_at %>
|
||||
|
||||
<div id="maps-maplibre-container"
|
||||
data-controller="maps--maplibre area-drawer maps--maplibre-realtime"
|
||||
data-maps--maplibre-api-key-value="<%= current_user.api_key %>"
|
||||
data-maps--maplibre-start-date-value="<%= @start_at.to_s %>"
|
||||
data-maps--maplibre-end-date-value="<%= @end_at.to_s %>"
|
||||
data-maps--maplibre-realtime-enabled-value="true"
|
||||
style="width: 100%; height: 100%; position: relative;">
|
||||
|
||||
<!-- Map container takes full width and height -->
|
||||
<div data-maps--maplibre-target="container" class="maps-maplibre-container" style="width: 100%; height: 100%;"></div>
|
||||
|
||||
<!-- Connection indicator -->
|
||||
<div class="connection-indicator disconnected">
|
||||
<span class="indicator-dot"></span>
|
||||
<span class="indicator-text"></span>
|
||||
</div>
|
||||
|
||||
<!-- Settings button (top-left corner) -->
|
||||
<div class="absolute top-4 left-4 z-10">
|
||||
<button data-action="click->maps--maplibre#toggleSettings"
|
||||
class="btn btn-sm btn-primary"
|
||||
title="Open map settings">
|
||||
<%= icon 'square-pen' %>
|
||||
<span class="ml-1">Settings</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Loading overlay -->
|
||||
<div data-maps--maplibre-target="loading" class="loading-overlay hidden">
|
||||
<div class="loading-spinner"></div>
|
||||
<div class="loading-text" data-maps--maplibre-target="loadingText">Loading points...</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings panel -->
|
||||
<%= render 'map/maplibre/settings_panel' %>
|
||||
|
||||
<!-- Visit creation modal -->
|
||||
<%= render 'map/maplibre/visit_creation_modal' %>
|
||||
|
||||
<!-- Area creation modal -->
|
||||
<%= render 'map/maplibre/area_creation_modal' %>
|
||||
|
||||
<!-- Place creation modal (shared) -->
|
||||
<%= render 'shared/place_creation_modal' %>
|
||||
</div>
|
||||
|
|
@ -80,6 +80,23 @@
|
|||
</div>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-start">
|
||||
<span class="label-text mr-4 flex items-center">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-map mr-2 w-4 h-4">
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21"></polygon>
|
||||
<line x1="9" x2="9" y1="3" y2="18"></line>
|
||||
<line x1="15" x2="15" y1="6" y2="21"></line>
|
||||
</svg>Preferred Map Version </span>
|
||||
<div class="flex items-center space-x-2">
|
||||
<%= f.label :preferred_version_v1, 'V1 (Leaflet)', class: 'cursor-pointer' %>
|
||||
<%= f.radio_button :preferred_version, 'v1', id: 'maps_preferred_version_v1', class: 'radio radio-primary ml-1 mr-4', checked: @maps['preferred_version'] != 'v2' %>
|
||||
<%= f.label :preferred_version_v2, 'V2 (MapLibre)', class: 'cursor-pointer' %>
|
||||
<%= f.radio_button :preferred_version, 'v2', id: 'maps_preferred_version_v2', class: 'radio radio-primary ml-1', checked: @maps['preferred_version'] == 'v2' %>
|
||||
</div>
|
||||
</label>
|
||||
<span class="label-text-alt mt-1">Choose which map version to use by default. V1 uses Leaflet, V2 uses MapLibre with enhanced features.</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
|
||||
<h3 class="font-semibold mb-2">Map Preview</h3>
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h8m-8 6h16" /></svg>
|
||||
</label>
|
||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
||||
<li><%= link_to 'Map', map_url, class: "#{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Map', preferred_map_path, class: "#{active_class?(preferred_map_path)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "#{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||
|
|
@ -44,7 +44,7 @@
|
|||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
<%= link_to 'Dawarich<sup>α</sup>'.html_safe, root_path, class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<%= link_to 'Dawarich<sup>α</sup>'.html_safe, (user_signed_in? ? preferred_map_path : root_path), class: 'btn btn-ghost normal-case text-xl'%>
|
||||
<div class="badge mx-4 <%= 'badge-outline' if new_version_available? %>">
|
||||
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
|
||||
<% if new_version_available? %>
|
||||
|
|
@ -71,7 +71,7 @@
|
|||
</div>
|
||||
<div class="navbar-center hidden lg:flex">
|
||||
<ul class="menu menu-horizontal px-1">
|
||||
<li><%= link_to 'Map', map_url, class: "mx-1 #{active_class?(map_url)}" %></li>
|
||||
<li><%= link_to 'Map', preferred_map_path, class: "mx-1 #{active_class?(preferred_map_path)}" %></li>
|
||||
<li><%= link_to 'Trips<sup>α</sup>'.html_safe, trips_url, class: "mx-1 #{active_class?(trips_url)}" %></li>
|
||||
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
<div data-controller="place-creation" data-place-creation-api-key-value="<%= current_user.api_key %>">
|
||||
<div class="modal" data-place-creation-target="modal">
|
||||
<div class="modal z-[10000]" data-place-creation-target="modal">
|
||||
<div class="modal-box max-w-2xl">
|
||||
<h3 class="font-bold text-lg mb-4" data-place-creation-target="modalTitle">Create New Place</h3>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
<% content_for :title, 'Map' %>
|
||||
|
||||
<!-- Date Navigation Controls - Native Page Element -->
|
||||
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
|
||||
<!-- Mobile: Compact Toggle Button -->
|
||||
|
|
@ -11,7 +9,7 @@
|
|||
<span data-map-controls-target="toggleIcon">
|
||||
<%= icon 'chevron-down' %>
|
||||
</span>
|
||||
<span class="ml-2"><%= human_date(@start_at) %></span>
|
||||
<span class="ml-2"><%= human_date(start_at) %></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
|
@ -23,23 +21,23 @@
|
|||
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at - 1.day, end_at: @end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
|
||||
<%= link_to map_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-left' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: @start_at %>
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: start_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: @end_at %>
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: end_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: @start_at + 1.day, end_at: @end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
|
||||
<%= link_to map_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-right' %>
|
||||
<% end %>
|
||||
</span>
|
||||
|
|
@ -71,35 +69,3 @@
|
|||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Container - Fills remaining space -->
|
||||
<div class="w-full h-full">
|
||||
<div
|
||||
id='map'
|
||||
class="w-full h-full"
|
||||
data-controller="maps points add-visit family-members"
|
||||
data-points-target="map"
|
||||
data-api_key="<%= current_user.api_key %>"
|
||||
data-self_hosted="<%= @self_hosted %>"
|
||||
data-user_settings='<%= current_user.safe_settings.settings.to_json %>'
|
||||
data-user_theme="<%= current_user&.theme || 'dark' %>"
|
||||
data-coordinates='<%= @coordinates.to_json.html_safe %>'
|
||||
data-tracks='<%= @tracks.to_json.html_safe %>'
|
||||
data-distance="<%= @distance %>"
|
||||
data-points_number="<%= @points_number %>"
|
||||
data-timezone="<%= Rails.configuration.time_zone %>"
|
||||
data-features='<%= @features.to_json.html_safe %>'
|
||||
data-user_tags='<%= current_user.tags.ordered.select(:id, :name, :icon, :color).as_json.to_json.html_safe %>'
|
||||
data-home_coordinates='<%= @home_coordinates.to_json.html_safe %>'
|
||||
data-family-members-features-value='<%= @features.to_json.html_safe %>'
|
||||
data-family-members-user-theme-value="<%= current_user&.theme || 'dark' %>">
|
||||
<div data-maps-target="container" class="w-full h-full">
|
||||
<div id="fog" class="fog"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%= render 'map/settings_modals' %>
|
||||
|
||||
<!-- Include Place Creation Modal -->
|
||||
<%= render 'shared/place_creation_modal' %>
|
||||
71
app/views/shared/map/_date_navigation_v2.html.erb
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
<!-- Date Navigation Controls - Native Page Element -->
|
||||
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
|
||||
<!-- Mobile: Compact Toggle Button -->
|
||||
<div class="lg:hidden flex justify-center">
|
||||
<button
|
||||
type="button"
|
||||
data-action="click->map-controls#toggle"
|
||||
class="btn btn-primary w-96 shadow-lg">
|
||||
<span data-map-controls-target="toggleIcon">
|
||||
<%= icon 'chevron-down' %>
|
||||
</span>
|
||||
<span class="ml-2"><%= human_date(start_at) %></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expandable Panel (hidden on mobile by default, always visible on desktop) -->
|
||||
<div
|
||||
data-map-controls-target="panel"
|
||||
class="hidden lg:!block bg-base-100 rounded-lg shadow-lg p-4 mt-2 lg:mt-0">
|
||||
<%= form_with url: map_v2_path(import_id: params[:import_id]), method: :get do |f| %>
|
||||
<div class="flex flex-col space-y-4 lg:flex-row lg:space-y-0 lg:space-x-4 lg:items-end">
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(start_at - 1.day) %>">
|
||||
<%= link_to map_v2_path(start_at: start_at - 1.day, end_at: end_at - 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-left' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
|
||||
<%= f.datetime_local_field :start_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: start_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
|
||||
<%= f.datetime_local_field :end_at, include_seconds: false, class: "input input-sm input-bordered hover:cursor-pointer hover:input-primary w-full", value: end_at %>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<span class="tooltip" data-tip="<%= human_date(start_at + 1.day) %>">
|
||||
<%= link_to map_v2_path(start_at: start_at + 1.day, end_at: end_at + 1.day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" do %>
|
||||
<%= icon 'chevron-right' %>
|
||||
<% end %>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<%= f.submit "Search", class: "btn btn-sm btn-primary hover:btn-info w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-1/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Today",
|
||||
map_v2_path(start_at: Time.current.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]),
|
||||
class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last 7 days", map_v2_path(start_at: 1.week.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-full lg:w-2/12">
|
||||
<div class="flex flex-col space-y-2 text-center">
|
||||
<%= link_to "Last month", map_v2_path(start_at: 1.month.ago.beginning_of_day, end_at: Time.current.end_of_day, import_id: params[:import_id]), class: "btn btn-sm border border-base-300 hover:btn-ghost w-full" %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -4,6 +4,7 @@
|
|||
|
||||
pin_all_from 'app/javascript/channels', under: 'channels'
|
||||
pin_all_from 'app/javascript/maps', under: 'maps'
|
||||
pin_all_from 'app/javascript/maps_maplibre', under: 'maps_maplibre'
|
||||
|
||||
pin 'application', preload: true
|
||||
pin '@rails/actioncable', to: 'actioncable.esm.js'
|
||||
|
|
@ -14,7 +15,7 @@ pin '@hotwired/stimulus', to: 'stimulus.min.js', preload: true
|
|||
pin '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
|
||||
pin_all_from 'app/javascript/controllers', under: 'controllers'
|
||||
|
||||
pin "leaflet" # @1.9.4
|
||||
pin 'leaflet' # @1.9.4
|
||||
pin 'leaflet-providers' # @2.0.0
|
||||
pin 'chartkick', to: 'chartkick.js'
|
||||
pin 'Chart.bundle', to: 'Chart.bundle.js'
|
||||
|
|
@ -26,5 +27,6 @@ pin 'imports_channel', to: 'channels/imports_channel.js'
|
|||
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
|
||||
pin 'trix'
|
||||
pin '@rails/actiontext', to: 'actiontext.esm.js'
|
||||
pin "leaflet.control.layers.tree" # @1.2.0
|
||||
pin "emoji-mart" # @5.6.0
|
||||
pin 'leaflet.control.layers.tree' # @1.2.0
|
||||
pin 'emoji-mart' # @5.6.0
|
||||
pin 'maplibre-gl' # @5.12.0
|
||||
|
|
|
|||
|
|
@ -110,7 +110,15 @@ Rails.application.routes.draw do
|
|||
|
||||
resources :metrics, only: [:index]
|
||||
|
||||
get 'map', to: 'map#index'
|
||||
# Map namespace with versioning
|
||||
namespace :map do
|
||||
get '/v1', to: 'leaflet#index', as: :v1
|
||||
get '/v2', to: 'maplibre#index', as: :v2
|
||||
end
|
||||
|
||||
# Backward compatibility redirects
|
||||
get '/map', to: 'map/leaflet#index'
|
||||
get '/maps/v2', to: redirect('/map/v2')
|
||||
|
||||
namespace :api do
|
||||
namespace :v1 do
|
||||
|
|
@ -120,7 +128,7 @@ Rails.application.routes.draw do
|
|||
get 'settings', to: 'settings#index'
|
||||
get 'users/me', to: 'users#me'
|
||||
|
||||
resources :areas, only: %i[index create update destroy]
|
||||
resources :areas, only: %i[index show create update destroy]
|
||||
resources :places, only: %i[index show create update destroy] do
|
||||
collection do
|
||||
get 'nearby'
|
||||
|
|
@ -136,7 +144,7 @@ Rails.application.routes.draw do
|
|||
delete :bulk_destroy
|
||||
end
|
||||
end
|
||||
resources :visits, only: %i[index create update destroy] do
|
||||
resources :visits, only: %i[index show create update destroy] do
|
||||
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||
collection do
|
||||
post 'merge', to: 'visits#merge'
|
||||
|
|
|
|||