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/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
[0.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
|
## Fixed
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ Simply install one of the supported apps on your device and configure it to send
|
||||||
1. Clone the repository.
|
1. Clone the repository.
|
||||||
2. Run the following command to start the app:
|
2. Run the following command to start the app:
|
||||||
```bash
|
```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`.
|
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
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class Api::V1::AreasController < ApiController
|
class Api::V1::AreasController < ApiController
|
||||||
before_action :set_area, only: %i[update destroy]
|
before_action :set_area, only: %i[show update destroy]
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@areas = current_api_user.areas
|
@areas = current_api_user.areas
|
||||||
|
|
@ -9,6 +9,10 @@ class Api::V1::AreasController < ApiController
|
||||||
render json: @areas, status: :ok
|
render json: @areas, status: :ok
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
render json: @area, status: :ok
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
@area = current_api_user.areas.build(area_params)
|
@area = current_api_user.areas.build(area_params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,28 @@ module Api
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@places = current_api_user.places.includes(:tags, :visits)
|
@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) }
|
render json: @places.map { |place| serialize_place(place) }
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,23 @@ class Api::V1::PointsController < ApiController
|
||||||
points = current_api_user
|
points = current_api_user
|
||||||
.points
|
.points
|
||||||
.where(timestamp: start_at..end_at)
|
.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)
|
.order(timestamp: order)
|
||||||
.page(params[:page])
|
.page(params[:page])
|
||||||
.per(params[:per_page] || 100)
|
.per(params[:per_page] || 100)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ class Api::V1::SettingsController < ApiController
|
||||||
|
|
||||||
def index
|
def index
|
||||||
render json: {
|
render json: {
|
||||||
settings: current_api_user.safe_settings,
|
settings: current_api_user.safe_settings.config,
|
||||||
status: 'success'
|
status: 'success'
|
||||||
}, status: :ok
|
}, status: :ok
|
||||||
end
|
end
|
||||||
|
|
@ -14,7 +14,7 @@ class Api::V1::SettingsController < ApiController
|
||||||
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
settings_params.each { |key, value| current_api_user.settings[key] = value }
|
||||||
|
|
||||||
if current_api_user.save
|
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
|
status: :ok
|
||||||
else
|
else
|
||||||
render json: { message: 'Something went wrong', errors: current_api_user.errors.full_messages },
|
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,
|
:preferred_map_layer, :points_rendering_mode, :live_map_enabled,
|
||||||
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
:immich_url, :immich_api_key, :photoprism_url, :photoprism_api_key,
|
||||||
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
:speed_colored_routes, :speed_color_scale, :fog_of_war_threshold,
|
||||||
|
:maps_v2_style, :maps_maplibre_style,
|
||||||
enabled_map_layers: []
|
enabled_map_layers: []
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,11 @@ class Api::V1::VisitsController < ApiController
|
||||||
render json: serialized_visits
|
render json: serialized_visits
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def show
|
||||||
|
visit = current_api_user.visits.find(params[:id])
|
||||||
|
render json: Api::VisitSerializer.new(visit).call
|
||||||
|
end
|
||||||
|
|
||||||
def create
|
def create
|
||||||
service = Visits::Create.new(current_api_user, visit_params)
|
service = Visits::Create.new(current_api_user, visit_params)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class HomeController < ApplicationController
|
class HomeController < ApplicationController
|
||||||
|
include ApplicationHelper
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# redirect_to 'https://dawarich.app', allow_other_host: true and return unless SELF_HOSTED
|
# 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
|
@points = current_user.points.without_raw_data if current_user
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# frozen_string_literal: true
|
# frozen_string_literal: true
|
||||||
|
|
||||||
class MapController < ApplicationController
|
class Map::LeafletController < ApplicationController
|
||||||
before_action :authenticate_user!
|
before_action :authenticate_user!
|
||||||
layout 'map', only: :index
|
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
|
private
|
||||||
|
|
||||||
def settings_params
|
def settings_params
|
||||||
params.require(:maps).permit(:name, :url, :distance_unit)
|
params.require(:maps).permit(:name, :url, :distance_unit, :preferred_version)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -142,4 +142,11 @@ module ApplicationHelper
|
||||||
|
|
||||||
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
ALLOW_EMAIL_PASSWORD_REGISTRATION
|
||||||
end
|
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
|
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 = {
|
export const mapsConfig = {
|
||||||
"Light": {
|
"Light": {
|
||||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||||
flavor: "light",
|
flavor: "light",
|
||||||
maxZoom: 16,
|
maxZoom: 14,
|
||||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||||
},
|
},
|
||||||
"Dark": {
|
"Dark": {
|
||||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||||
flavor: "dark",
|
flavor: "dark",
|
||||||
maxZoom: 16,
|
maxZoom: 14,
|
||||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||||
},
|
},
|
||||||
"White": {
|
"White": {
|
||||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||||
flavor: "white",
|
flavor: "white",
|
||||||
maxZoom: 16,
|
maxZoom: 14,
|
||||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||||
},
|
},
|
||||||
"Grayscale": {
|
"Grayscale": {
|
||||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||||
flavor: "grayscale",
|
flavor: "grayscale",
|
||||||
maxZoom: 16,
|
maxZoom: 14,
|
||||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
||||||
},
|
},
|
||||||
"Black": {
|
"Black": {
|
||||||
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
url: "https://tyles.dwri.xyz/planet/{z}/{x}/{y}.mvt",
|
||||||
flavor: "black",
|
flavor: "black",
|
||||||
maxZoom: 16,
|
maxZoom: 14,
|
||||||
attribution: "<a href='https://github.com/protomaps/basemaps'>Protomaps</a>, © <a href='https://openstreetmap.org'>OpenStreetMap</a>"
|
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
|
has_many :tags, through: :taggings
|
||||||
|
|
||||||
scope :with_tags, ->(tag_ids) { joins(:taggings).where(taggings: { tag_id: tag_ids }).distinct }
|
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 :without_tags, -> { left_joins(:taggings).where(taggings: { id: nil }) }
|
||||||
scope :tagged_with, ->(tag_name, user) {
|
scope :tagged_with, ->(tag_name, user) {
|
||||||
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
joins(:tags).where(tags: { name: tag_name, user: user }).distinct
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ class Api::PointSerializer
|
||||||
|
|
||||||
attributes['latitude'] = lat&.to_s
|
attributes['latitude'] = lat&.to_s
|
||||||
attributes['longitude'] = lon&.to_s
|
attributes['longitude'] = lon&.to_s
|
||||||
|
attributes['country_name'] = point.country_name
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,8 @@ class Users::SafeSettings
|
||||||
'photoprism_api_key' => nil,
|
'photoprism_api_key' => nil,
|
||||||
'maps' => { 'distance_unit' => 'km' },
|
'maps' => { 'distance_unit' => 'km' },
|
||||||
'visits_suggestions_enabled' => 'true',
|
'visits_suggestions_enabled' => 'true',
|
||||||
'enabled_map_layers' => ['Routes', 'Heatmap']
|
'enabled_map_layers' => ['Routes', 'Heatmap'],
|
||||||
|
'maps_maplibre_style' => 'light'
|
||||||
}.freeze
|
}.freeze
|
||||||
|
|
||||||
def initialize(settings = {})
|
def initialize(settings = {})
|
||||||
|
|
@ -28,7 +29,7 @@ class Users::SafeSettings
|
||||||
end
|
end
|
||||||
|
|
||||||
# rubocop:disable Metrics/MethodLength
|
# rubocop:disable Metrics/MethodLength
|
||||||
def default_settings
|
def config
|
||||||
{
|
{
|
||||||
fog_of_war_meters: fog_of_war_meters,
|
fog_of_war_meters: fog_of_war_meters,
|
||||||
meters_between_routes: meters_between_routes,
|
meters_between_routes: meters_between_routes,
|
||||||
|
|
@ -49,7 +50,8 @@ class Users::SafeSettings
|
||||||
visits_suggestions_enabled: visits_suggestions_enabled?,
|
visits_suggestions_enabled: visits_suggestions_enabled?,
|
||||||
speed_color_scale: speed_color_scale,
|
speed_color_scale: speed_color_scale,
|
||||||
fog_of_war_threshold: fog_of_war_threshold,
|
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
|
end
|
||||||
# rubocop:enable Metrics/MethodLength
|
# rubocop:enable Metrics/MethodLength
|
||||||
|
|
@ -133,4 +135,8 @@ class Users::SafeSettings
|
||||||
def enabled_map_layers
|
def enabled_map_layers
|
||||||
settings['enabled_map_layers']
|
settings['enabled_map_layers']
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def maps_maplibre_style
|
||||||
|
settings['maps_maplibre_style']
|
||||||
|
end
|
||||||
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>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
|
<div class="bg-base-100 p-5 rounded-lg shadow-sm">
|
||||||
<h3 class="font-semibold mb-2">Map Preview</h3>
|
<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>
|
<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>
|
</label>
|
||||||
<ul tabindex="0" class="menu menu-sm dropdown-content mt-3 z-[1] p-2 shadow bg-base-100 rounded-box w-52">
|
<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 '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>
|
<li><%= link_to 'Stats', stats_url, class: "#{active_class?(stats_url)}" %></li>
|
||||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
||||||
|
|
@ -44,7 +44,7 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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? %>">
|
<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">
|
<a href="https://github.com/Freika/dawarich/releases/latest" target="_blank" class="inline-flex items-center">
|
||||||
<% if new_version_available? %>
|
<% if new_version_available? %>
|
||||||
|
|
@ -71,7 +71,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="navbar-center hidden lg:flex">
|
<div class="navbar-center hidden lg:flex">
|
||||||
<ul class="menu menu-horizontal px-1">
|
<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 '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>
|
<li><%= link_to 'Stats', stats_url, class: "mx-1 #{active_class?(stats_url)}" %></li>
|
||||||
<% if user_signed_in? && DawarichSettings.family_feature_enabled? %>
|
<% 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 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">
|
<div class="modal-box max-w-2xl">
|
||||||
<h3 class="font-bold text-lg mb-4" data-place-creation-target="modalTitle">Create New Place</h3>
|
<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 -->
|
<!-- Date Navigation Controls - Native Page Element -->
|
||||||
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
|
<div class="w-full px-4 bg-base-100" data-controller="map-controls">
|
||||||
<!-- Mobile: Compact Toggle Button -->
|
<!-- Mobile: Compact Toggle Button -->
|
||||||
|
|
@ -11,7 +9,7 @@
|
||||||
<span data-map-controls-target="toggleIcon">
|
<span data-map-controls-target="toggleIcon">
|
||||||
<%= icon 'chevron-down' %>
|
<%= icon 'chevron-down' %>
|
||||||
</span>
|
</span>
|
||||||
<span class="ml-2"><%= human_date(@start_at) %></span>
|
<span class="ml-2"><%= human_date(start_at) %></span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="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="w-full lg:w-1/12">
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<span class="tooltip" data-tip="<%= human_date(@start_at - 1.day) %>">
|
<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 %>
|
<%= 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' %>
|
<%= icon 'chevron-left' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="Start date and time">
|
<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>
|
||||||
<div class="w-full lg:w-2/12 tooltip tooltip-bottom" data-tip="End date and time">
|
<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>
|
||||||
<div class="w-full lg:w-1/12">
|
<div class="w-full lg:w-1/12">
|
||||||
<div class="flex flex-col space-y-2">
|
<div class="flex flex-col space-y-2">
|
||||||
<span class="tooltip" data-tip="<%= human_date(@start_at + 1.day) %>">
|
<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 %>
|
<%= 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' %>
|
<%= icon 'chevron-right' %>
|
||||||
<% end %>
|
<% end %>
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -71,35 +69,3 @@
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
</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/channels', under: 'channels'
|
||||||
pin_all_from 'app/javascript/maps', under: 'maps'
|
pin_all_from 'app/javascript/maps', under: 'maps'
|
||||||
|
pin_all_from 'app/javascript/maps_maplibre', under: 'maps_maplibre'
|
||||||
|
|
||||||
pin 'application', preload: true
|
pin 'application', preload: true
|
||||||
pin '@rails/actioncable', to: 'actioncable.esm.js'
|
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 '@hotwired/stimulus-loading', to: 'stimulus-loading.js', preload: true
|
||||||
pin_all_from 'app/javascript/controllers', under: 'controllers'
|
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 'leaflet-providers' # @2.0.0
|
||||||
pin 'chartkick', to: 'chartkick.js'
|
pin 'chartkick', to: 'chartkick.js'
|
||||||
pin 'Chart.bundle', to: 'Chart.bundle.js'
|
pin 'Chart.bundle', to: 'Chart.bundle.js'
|
||||||
|
|
@ -26,5 +27,6 @@ pin 'imports_channel', to: 'channels/imports_channel.js'
|
||||||
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
|
pin 'family_locations_channel', to: 'channels/family_locations_channel.js'
|
||||||
pin 'trix'
|
pin 'trix'
|
||||||
pin '@rails/actiontext', to: 'actiontext.esm.js'
|
pin '@rails/actiontext', to: 'actiontext.esm.js'
|
||||||
pin "leaflet.control.layers.tree" # @1.2.0
|
pin 'leaflet.control.layers.tree' # @1.2.0
|
||||||
pin "emoji-mart" # @5.6.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]
|
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 :api do
|
||||||
namespace :v1 do
|
namespace :v1 do
|
||||||
|
|
@ -120,7 +128,7 @@ Rails.application.routes.draw do
|
||||||
get 'settings', to: 'settings#index'
|
get 'settings', to: 'settings#index'
|
||||||
get 'users/me', to: 'users#me'
|
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
|
resources :places, only: %i[index show create update destroy] do
|
||||||
collection do
|
collection do
|
||||||
get 'nearby'
|
get 'nearby'
|
||||||
|
|
@ -136,7 +144,7 @@ Rails.application.routes.draw do
|
||||||
delete :bulk_destroy
|
delete :bulk_destroy
|
||||||
end
|
end
|
||||||
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
|
get 'possible_places', to: 'visits/possible_places#index', on: :member
|
||||||
collection do
|
collection do
|
||||||
post 'merge', to: 'visits#merge'
|
post 'merge', to: 'visits#merge'
|
||||||
|
|
|
||||||