You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
6496 lines
254 KiB
6496 lines
254 KiB
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>HikeMap Trail Navigator</title>
|
|
|
|
<!-- PWA Meta Tags -->
|
|
<link rel="manifest" href="/manifest.json">
|
|
<meta name="theme-color" content="#4CAF50">
|
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
|
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
|
<meta name="apple-mobile-web-app-title" content="HikeMap">
|
|
<link rel="apple-touch-icon" href="/icon-192x192.png">
|
|
|
|
<!-- Additional PWA Meta Tags -->
|
|
<meta name="description" content="GPS trail navigation, tracking, and geocaching app for hikers">
|
|
<meta name="mobile-web-app-capable" content="yes">
|
|
|
|
<!-- Icons for different platforms -->
|
|
<link rel="icon" type="image/png" sizes="32x32" href="/icon-72x72.png">
|
|
<link rel="icon" type="image/png" sizes="16x16" href="/icon-72x72.png">
|
|
|
|
<!-- Open Graph Meta Tags for sharing -->
|
|
<meta property="og:title" content="HikeMap Trail Navigator">
|
|
<meta property="og:description" content="GPS trail navigation, tracking, and geocaching app">
|
|
<meta property="og:type" content="website">
|
|
<meta property="og:image" content="/icon-512x512.png">
|
|
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@mdi/font@7.4.47/css/materialdesignicons.min.css" />
|
|
<style>
|
|
* {
|
|
margin: 0;
|
|
padding: 0;
|
|
box-sizing: border-box;
|
|
}
|
|
body {
|
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
overflow: visible;
|
|
position: relative;
|
|
}
|
|
#map {
|
|
height: 100vh;
|
|
width: 100%;
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
/* Ensure Leaflet doesn't block our popups */
|
|
.leaflet-container {
|
|
z-index: 1 !important;
|
|
}
|
|
.leaflet-pane {
|
|
z-index: auto !important;
|
|
}
|
|
.controls {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 1000;
|
|
background: white;
|
|
padding: 15px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
width: 280px;
|
|
}
|
|
.panel-toggle {
|
|
position: absolute;
|
|
top: 10px;
|
|
right: 10px;
|
|
z-index: 1001;
|
|
background: white;
|
|
padding: 10px 15px;
|
|
border-radius: 8px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
cursor: pointer;
|
|
font-size: 18px;
|
|
border: none;
|
|
}
|
|
.panel-toggle:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
.controls h3 {
|
|
margin-bottom: 10px;
|
|
font-size: 14px;
|
|
color: #333;
|
|
}
|
|
.controls input[type="file"] {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border: 2px dashed #ccc;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
margin-bottom: 10px;
|
|
}
|
|
.controls input[type="file"]:hover {
|
|
border-color: #007bff;
|
|
}
|
|
.section {
|
|
margin-bottom: 15px;
|
|
padding-bottom: 15px;
|
|
border-bottom: 1px solid #eee;
|
|
}
|
|
.section:last-child {
|
|
border-bottom: none;
|
|
margin-bottom: 0;
|
|
padding-bottom: 0;
|
|
}
|
|
.section-title {
|
|
font-size: 12px;
|
|
font-weight: bold;
|
|
color: #666;
|
|
margin-bottom: 8px;
|
|
text-transform: uppercase;
|
|
}
|
|
.tab-bar {
|
|
display: flex;
|
|
margin-bottom: 15px;
|
|
border-bottom: 2px solid #ddd;
|
|
}
|
|
.tab-btn {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: none;
|
|
background: #f0f0f0;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
font-weight: bold;
|
|
transition: all 0.2s;
|
|
}
|
|
.tab-btn:first-child {
|
|
border-radius: 4px 0 0 0;
|
|
}
|
|
.tab-btn:last-child {
|
|
border-radius: 0 4px 0 0;
|
|
}
|
|
.tab-btn:hover {
|
|
background: #e0e0e0;
|
|
}
|
|
.tab-btn.active {
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
.tab-content {
|
|
display: none;
|
|
}
|
|
.tab-content.active {
|
|
display: block;
|
|
}
|
|
.nav-info {
|
|
background: #f8f9fa;
|
|
padding: 12px;
|
|
border-radius: 4px;
|
|
margin-top: 10px;
|
|
}
|
|
.nav-distance {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
color: #007bff;
|
|
margin-bottom: 5px;
|
|
}
|
|
.route-overlay {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.5);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 2000;
|
|
}
|
|
.route-overlay-content {
|
|
background: white;
|
|
padding: 30px 40px;
|
|
border-radius: 12px;
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
|
text-align: center;
|
|
}
|
|
.route-overlay .spinner {
|
|
width: 50px;
|
|
height: 50px;
|
|
border: 5px solid #e0e0e0;
|
|
border-top: 5px solid #007bff;
|
|
border-radius: 50%;
|
|
animation: spin 1s linear infinite;
|
|
margin: 0 auto 15px;
|
|
}
|
|
.route-overlay-text {
|
|
font-size: 18px;
|
|
font-weight: 500;
|
|
color: #333;
|
|
}
|
|
.compass-indicator {
|
|
position: fixed;
|
|
top: 10px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: rgba(255,255,255,0.9);
|
|
padding: 5px 15px;
|
|
border-radius: 20px;
|
|
font-weight: bold;
|
|
z-index: 1000;
|
|
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
|
|
display: none;
|
|
}
|
|
.compass-indicator.active {
|
|
display: block;
|
|
}
|
|
@keyframes spin {
|
|
0% { transform: rotate(0deg); }
|
|
100% { transform: rotate(360deg); }
|
|
}
|
|
.nav-instruction {
|
|
color: #666;
|
|
font-size: 12px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.destination-pin {
|
|
font-size: 24px;
|
|
margin-left: -12px;
|
|
margin-top: -24px;
|
|
}
|
|
.tool-buttons {
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 6px;
|
|
}
|
|
.tool-btn {
|
|
padding: 8px 12px;
|
|
border: 2px solid #ddd;
|
|
background: white;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 12px;
|
|
transition: all 0.2s;
|
|
}
|
|
.tool-btn:hover {
|
|
border-color: #007bff;
|
|
background: #f8f9fa;
|
|
}
|
|
.tool-btn.active {
|
|
border-color: #007bff;
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
.tool-btn.danger {
|
|
border-color: #dc3545;
|
|
}
|
|
.tool-btn.danger:hover, .tool-btn.danger.active {
|
|
background: #dc3545;
|
|
border-color: #dc3545;
|
|
color: white;
|
|
}
|
|
.action-btn {
|
|
width: 100%;
|
|
padding: 10px;
|
|
background: #28a745;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 13px;
|
|
margin-bottom: 6px;
|
|
}
|
|
.action-btn:hover {
|
|
background: #218838;
|
|
}
|
|
.action-btn.secondary {
|
|
background: #6c757d;
|
|
}
|
|
.action-btn.secondary:hover {
|
|
background: #5a6268;
|
|
}
|
|
.action-btn.danger {
|
|
background: #dc3545;
|
|
}
|
|
.action-btn.danger:hover {
|
|
background: #c82333;
|
|
}
|
|
.status {
|
|
margin-top: 10px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
padding: 8px;
|
|
background: #f8f9fa;
|
|
border-radius: 4px;
|
|
}
|
|
.status.success {
|
|
color: #155724;
|
|
background: #d4edda;
|
|
}
|
|
.status.error {
|
|
color: #721c24;
|
|
background: #f8d7da;
|
|
}
|
|
.status.info {
|
|
color: #0c5460;
|
|
background: #d1ecf1;
|
|
}
|
|
.slider-group {
|
|
margin: 10px 0;
|
|
}
|
|
.slider-group label {
|
|
display: block;
|
|
font-size: 11px;
|
|
color: #666;
|
|
margin-bottom: 4px;
|
|
}
|
|
.slider-group input[type="range"] {
|
|
width: 100%;
|
|
}
|
|
.slider-value {
|
|
font-size: 11px;
|
|
color: #333;
|
|
text-align: right;
|
|
}
|
|
.track-list {
|
|
max-height: 150px;
|
|
overflow-y: auto;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.track-item {
|
|
padding: 6px 10px;
|
|
font-size: 12px;
|
|
border-bottom: 1px solid #eee;
|
|
cursor: pointer;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
}
|
|
.track-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.track-item:hover {
|
|
background: #f8f9fa;
|
|
}
|
|
.track-item.selected {
|
|
background: #cce5ff;
|
|
}
|
|
.track-item .delete-btn {
|
|
background: none;
|
|
border: none;
|
|
color: #dc3545;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
padding: 2px 6px;
|
|
}
|
|
.track-item .delete-btn:hover {
|
|
background: #dc3545;
|
|
color: white;
|
|
border-radius: 3px;
|
|
}
|
|
.slider-group input[type="range"].preview-active {
|
|
outline: 3px solid #00cc00;
|
|
border-radius: 4px;
|
|
}
|
|
#applyMergeBtn {
|
|
background: #00cc00 !important;
|
|
}
|
|
#applyMergeBtn:hover {
|
|
background: #00aa00 !important;
|
|
}
|
|
.custom-div-icon {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
.custom-div-icon i {
|
|
display: block;
|
|
text-align: center;
|
|
line-height: 1;
|
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.3);
|
|
}
|
|
.icon-selector-overlay {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
background: rgba(0, 0, 0, 0.7) !important;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 999999 !important;
|
|
pointer-events: auto !important;
|
|
}
|
|
.icon-selector-modal {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
max-width: 500px;
|
|
width: 90%;
|
|
max-height: 80vh;
|
|
overflow-y: auto;
|
|
}
|
|
.icon-selector-title {
|
|
font-size: 24px;
|
|
margin-bottom: 10px;
|
|
text-align: center;
|
|
}
|
|
.icon-selector-subtitle {
|
|
color: #666;
|
|
text-align: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
.icon-grid {
|
|
display: grid;
|
|
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
|
|
gap: 15px;
|
|
margin-bottom: 20px;
|
|
}
|
|
.icon-option {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
padding: 15px 10px;
|
|
border: 2px solid #ddd;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
.icon-option:hover {
|
|
border-color: #007bff;
|
|
transform: scale(1.05);
|
|
background: #f8f9fa;
|
|
}
|
|
.icon-option i {
|
|
font-size: 36px;
|
|
margin-bottom: 5px;
|
|
}
|
|
.icon-option span {
|
|
font-size: 11px;
|
|
text-align: center;
|
|
}
|
|
.password-dialog {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
background: rgba(0, 0, 0, 0.5) !important;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 999998 !important;
|
|
pointer-events: auto !important;
|
|
}
|
|
.password-dialog-content {
|
|
background: white;
|
|
padding: 30px;
|
|
border-radius: 12px;
|
|
width: 90%;
|
|
max-width: 400px;
|
|
}
|
|
.password-dialog h3 {
|
|
margin-bottom: 20px;
|
|
text-align: center;
|
|
}
|
|
.password-dialog input {
|
|
width: 100%;
|
|
padding: 10px;
|
|
font-size: 16px;
|
|
border: 2px solid #ddd;
|
|
border-radius: 4px;
|
|
margin-bottom: 15px;
|
|
}
|
|
.password-dialog-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.password-dialog button {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
.password-dialog .submit-btn {
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
.password-dialog .cancel-btn {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
.password-error {
|
|
color: #dc3545;
|
|
font-size: 14px;
|
|
margin-top: -10px;
|
|
margin-bottom: 10px;
|
|
display: none;
|
|
}
|
|
/* Geocache styles */
|
|
.geocache-marker {
|
|
background: transparent;
|
|
border: none;
|
|
font-size: 20px;
|
|
cursor: pointer;
|
|
}
|
|
.geocache-marker:hover {
|
|
transform: scale(1.2);
|
|
}
|
|
.geocache-marker.in-range {
|
|
/* Removed pulse animation - was causing disappearing */
|
|
box-shadow: 0 0 20px rgba(255, 167, 38, 0.8);
|
|
border-radius: 50%;
|
|
}
|
|
.geocache-dialog {
|
|
position: fixed !important;
|
|
top: 0 !important;
|
|
left: 0 !important;
|
|
right: 0 !important;
|
|
bottom: 0 !important;
|
|
width: 100vw !important;
|
|
height: 100vh !important;
|
|
background: rgba(0, 0, 0, 0.6) !important;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 999997 !important;
|
|
pointer-events: auto !important;
|
|
}
|
|
.geocache-dialog-content {
|
|
background: white;
|
|
padding: 25px;
|
|
border-radius: 12px;
|
|
width: 90%;
|
|
max-width: 500px;
|
|
max-height: 80vh;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
.geocache-dialog h3 {
|
|
margin-bottom: 15px;
|
|
text-align: center;
|
|
color: #333;
|
|
}
|
|
.geocache-messages {
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
max-height: 300px;
|
|
margin-bottom: 15px;
|
|
padding: 10px;
|
|
background: #f8f9fa;
|
|
border-radius: 8px;
|
|
}
|
|
.geocache-message {
|
|
margin-bottom: 12px;
|
|
padding: 10px;
|
|
background: white;
|
|
border-radius: 6px;
|
|
border-left: 3px solid #007bff;
|
|
}
|
|
.geocache-message-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
margin-bottom: 5px;
|
|
font-size: 12px;
|
|
color: #666;
|
|
}
|
|
.geocache-message-author {
|
|
font-weight: bold;
|
|
}
|
|
.geocache-message-text {
|
|
font-size: 14px;
|
|
color: #333;
|
|
word-wrap: break-word;
|
|
}
|
|
.geocache-input-group {
|
|
margin-bottom: 15px;
|
|
}
|
|
.geocache-input-group label {
|
|
display: block;
|
|
margin-bottom: 5px;
|
|
font-size: 13px;
|
|
color: #666;
|
|
}
|
|
.geocache-input-group input,
|
|
.geocache-input-group textarea {
|
|
width: 100%;
|
|
padding: 8px;
|
|
border: 1px solid #ddd;
|
|
border-radius: 4px;
|
|
font-size: 14px;
|
|
}
|
|
.geocache-input-group textarea {
|
|
resize: vertical;
|
|
min-height: 80px;
|
|
}
|
|
.geocache-dialog-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.geocache-dialog button {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
.geocache-submit-btn {
|
|
background: #28a745;
|
|
color: white;
|
|
}
|
|
.geocache-cancel-btn {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
.geocache-delete-btn {
|
|
background: #dc3545;
|
|
color: white;
|
|
}
|
|
.geocache-alert {
|
|
position: fixed;
|
|
bottom: 20px;
|
|
left: 50%;
|
|
transform: translateX(-50%);
|
|
background: #007bff;
|
|
color: white;
|
|
padding: 12px 20px;
|
|
border-radius: 20px;
|
|
box-shadow: 0 2px 10px rgba(0,0,0,0.2);
|
|
z-index: 1001;
|
|
display: none;
|
|
}
|
|
.geocache-alert.show {
|
|
display: block;
|
|
animation: slideUp 0.3s ease;
|
|
}
|
|
|
|
/* Geocache List Sidebar */
|
|
.geocache-list-sidebar {
|
|
position: fixed;
|
|
right: -350px;
|
|
top: 60px;
|
|
bottom: 0;
|
|
width: 350px;
|
|
background: rgba(30, 30, 30, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
transition: right 0.3s ease;
|
|
z-index: 999;
|
|
overflow-y: auto;
|
|
padding: 20px;
|
|
box-shadow: -2px 0 10px rgba(0, 0, 0, 0.5);
|
|
}
|
|
.geocache-list-sidebar.open {
|
|
right: 0;
|
|
}
|
|
.geocache-list-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
padding-bottom: 10px;
|
|
border-bottom: 1px solid #444;
|
|
}
|
|
.geocache-list-item {
|
|
background: rgba(40, 40, 40, 0.8);
|
|
border-radius: 8px;
|
|
padding: 12px;
|
|
margin-bottom: 10px;
|
|
cursor: pointer;
|
|
transition: background 0.2s;
|
|
}
|
|
.geocache-list-item:hover {
|
|
background: rgba(60, 60, 60, 0.9);
|
|
}
|
|
.geocache-list-item-title {
|
|
font-weight: bold;
|
|
color: #FFA726;
|
|
margin-bottom: 5px;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
}
|
|
.geocache-list-item-info {
|
|
font-size: 0.9em;
|
|
color: #aaa;
|
|
}
|
|
.geocache-list-item-secret {
|
|
display: inline-block;
|
|
background: #9C27B0;
|
|
color: white;
|
|
padding: 2px 6px;
|
|
border-radius: 4px;
|
|
font-size: 0.75em;
|
|
margin-left: 5px;
|
|
}
|
|
.geocache-list-toggle {
|
|
position: fixed;
|
|
right: 20px;
|
|
bottom: 140px;
|
|
width: 50px;
|
|
height: 50px;
|
|
background: #FFA726;
|
|
border-radius: 50%;
|
|
display: none;
|
|
align-items: center;
|
|
justify-content: center;
|
|
cursor: pointer;
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
|
z-index: 998;
|
|
}
|
|
|
|
/* Admin Panel Overlay - Half-Height Bottom Sheet */
|
|
.admin-panel-overlay {
|
|
position: fixed;
|
|
bottom: 0;
|
|
left: 0;
|
|
right: 0;
|
|
height: 50vh;
|
|
background: rgba(30, 30, 30, 0.95);
|
|
backdrop-filter: blur(10px);
|
|
-webkit-backdrop-filter: blur(10px);
|
|
transform: translateY(100%);
|
|
transition: transform 0.3s ease;
|
|
z-index: 1002;
|
|
border-radius: 20px 20px 0 0;
|
|
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.admin-panel-overlay.active {
|
|
transform: translateY(0);
|
|
}
|
|
|
|
.admin-panel-header {
|
|
padding: 20px;
|
|
border-bottom: 1px solid #444;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.admin-panel-header h2 {
|
|
color: #FFA726;
|
|
margin: 0;
|
|
font-size: 20px;
|
|
}
|
|
|
|
.admin-panel-close {
|
|
background: none;
|
|
border: none;
|
|
color: #aaa;
|
|
font-size: 28px;
|
|
cursor: pointer;
|
|
padding: 0;
|
|
width: 32px;
|
|
height: 32px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
}
|
|
|
|
.admin-panel-close:hover {
|
|
color: white;
|
|
}
|
|
|
|
.admin-panel-content {
|
|
padding: 20px;
|
|
overflow-y: auto;
|
|
flex: 1;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
/* Admin Panel Styles - Simple and Working */
|
|
.admin-setting-group {
|
|
padding: 15px;
|
|
}
|
|
.admin-input-row {
|
|
display: flex;
|
|
flex-direction: column;
|
|
margin-bottom: 20px;
|
|
}
|
|
.admin-input-row label {
|
|
color: #FFA726;
|
|
font-size: 14px;
|
|
font-weight: 600;
|
|
margin-bottom: 8px;
|
|
display: block;
|
|
}
|
|
.admin-input-container {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 10px;
|
|
}
|
|
.admin-input-row input[type="number"] {
|
|
width: 100%;
|
|
max-width: 200px;
|
|
height: 44px;
|
|
padding: 8px 12px;
|
|
background: #2a2a2a;
|
|
color: white;
|
|
border: 2px solid #555;
|
|
border-radius: 8px;
|
|
font-size: 16px;
|
|
text-align: center;
|
|
-webkit-appearance: none;
|
|
-moz-appearance: textfield;
|
|
box-sizing: border-box;
|
|
}
|
|
.admin-input-row input[type="checkbox"] {
|
|
width: 24px;
|
|
height: 24px;
|
|
cursor: pointer;
|
|
margin-right: 10px;
|
|
}
|
|
.admin-input-row input[type="number"]:focus {
|
|
outline: none;
|
|
border-color: #4CAF50;
|
|
background: #333;
|
|
}
|
|
.admin-input-row .unit {
|
|
color: #aaa;
|
|
font-size: 14px;
|
|
white-space: nowrap;
|
|
}
|
|
.admin-checkbox-row {
|
|
display: flex;
|
|
align-items: center;
|
|
margin-bottom: 20px;
|
|
}
|
|
/* Responsive adjustments */
|
|
@media (min-width: 768px) {
|
|
.admin-input-row {
|
|
flex-direction: row;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
}
|
|
.admin-input-row label {
|
|
margin-bottom: 0;
|
|
flex: 0 0 auto;
|
|
min-width: 200px;
|
|
}
|
|
.admin-input-container {
|
|
flex: 0 0 auto;
|
|
}
|
|
}
|
|
.admin-button-group {
|
|
padding: 20px 10px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 12px;
|
|
}
|
|
.admin-button-group button {
|
|
min-height: 48px;
|
|
padding: 12px 20px;
|
|
background: #4CAF50;
|
|
color: white;
|
|
border: none;
|
|
border-radius: 8px;
|
|
cursor: pointer;
|
|
font-size: 15px;
|
|
font-weight: 600;
|
|
transition: all 0.2s;
|
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
|
letter-spacing: 0.5px;
|
|
}
|
|
.admin-button-group button:active {
|
|
transform: translateY(1px);
|
|
box-shadow: 0 1px 2px rgba(0,0,0,0.2);
|
|
}
|
|
.admin-button-group button:hover {
|
|
background: #45a049;
|
|
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
|
}
|
|
.admin-button-group button.danger {
|
|
background: #f44336;
|
|
}
|
|
.admin-button-group button.danger:hover {
|
|
background: #da190b;
|
|
}
|
|
@media (min-width: 768px) {
|
|
.admin-button-group {
|
|
flex-direction: row;
|
|
flex-wrap: wrap;
|
|
}
|
|
.admin-button-group button {
|
|
min-height: 40px;
|
|
flex: 1;
|
|
min-width: 140px;
|
|
}
|
|
}
|
|
/* Save indicator */
|
|
.admin-save-indicator {
|
|
position: fixed;
|
|
top: 70px;
|
|
right: 20px;
|
|
background: #4CAF50;
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border-radius: 20px;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
|
opacity: 0;
|
|
transform: translateY(-10px);
|
|
transition: all 0.3s;
|
|
z-index: 10000;
|
|
pointer-events: none;
|
|
}
|
|
.admin-save-indicator.show {
|
|
opacity: 1;
|
|
transform: translateY(0);
|
|
}
|
|
/* Collapsible sections */
|
|
.section.collapsible .section-title {
|
|
cursor: pointer;
|
|
user-select: none;
|
|
position: relative;
|
|
padding-right: 30px;
|
|
}
|
|
.section.collapsible .section-title:after {
|
|
content: '▼';
|
|
position: absolute;
|
|
right: 10px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
transition: transform 0.3s;
|
|
color: #FFA726;
|
|
}
|
|
.section.collapsed .section-title:after {
|
|
transform: translateY(-50%) rotate(-90deg);
|
|
}
|
|
.section-content {
|
|
max-height: 1000px;
|
|
overflow: hidden;
|
|
transition: max-height 0.3s ease;
|
|
}
|
|
.section.collapsed .section-content {
|
|
max-height: 0;
|
|
}
|
|
@keyframes slideUp {
|
|
from { transform: translateX(-50%) translateY(100%); }
|
|
to { transform: translateX(-50%) translateY(0); }
|
|
}
|
|
/* Navigation confirmation dialogs */
|
|
.nav-confirm-dialog {
|
|
position: fixed;
|
|
top: 0;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
background: rgba(0, 0, 0, 0.6);
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 3003;
|
|
}
|
|
.nav-confirm-content {
|
|
background: white;
|
|
padding: 20px;
|
|
border-radius: 8px;
|
|
max-width: 350px;
|
|
text-align: center;
|
|
}
|
|
.nav-confirm-content h3 {
|
|
margin: 0 0 15px 0;
|
|
color: #333;
|
|
}
|
|
.nav-confirm-content p {
|
|
margin: 0 0 20px 0;
|
|
color: #666;
|
|
}
|
|
.nav-confirm-buttons {
|
|
display: flex;
|
|
gap: 10px;
|
|
}
|
|
.nav-confirm-buttons button {
|
|
flex: 1;
|
|
padding: 10px;
|
|
border: none;
|
|
border-radius: 4px;
|
|
cursor: pointer;
|
|
font-size: 14px;
|
|
}
|
|
.nav-confirm-yes {
|
|
background: #007bff;
|
|
color: white;
|
|
}
|
|
.nav-confirm-no {
|
|
background: #6c757d;
|
|
color: white;
|
|
}
|
|
.press-hold-indicator {
|
|
position: fixed;
|
|
top: 50%;
|
|
left: 50%;
|
|
transform: translate(-50%, -50%);
|
|
background: rgba(0, 123, 255, 0.9);
|
|
color: white;
|
|
padding: 10px 20px;
|
|
border-radius: 20px;
|
|
font-size: 14px;
|
|
pointer-events: none;
|
|
z-index: 1000;
|
|
display: none;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="map"></div>
|
|
<div id="compassIndicator" class="compass-indicator">N</div>
|
|
<button id="panelToggle" class="panel-toggle" style="right: 10px;">☰</button>
|
|
<div class="controls" id="controlPanel" style="display: none;">
|
|
<div class="tab-bar">
|
|
<button class="tab-btn" id="editTab">Edit</button>
|
|
<button class="tab-btn active" id="navTab">Navigate</button>
|
|
<button class="tab-btn" id="adminTab">Admin</button>
|
|
</div>
|
|
|
|
<div class="tab-content" id="editContent">
|
|
<div class="section">
|
|
<div class="section-title">File</div>
|
|
<input type="file" id="kmlFile" accept=".kml">
|
|
<button class="action-btn" id="reloadBtn" style="background: #17a2b8;">Reload Tracks</button>
|
|
<button class="action-btn" id="exportBtn">Export to File</button>
|
|
<button class="action-btn" id="saveServerBtn" style="background: #28a745;">Save to Server</button>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Track Tools</div>
|
|
<div class="tool-buttons">
|
|
<button class="tool-btn" id="selectTool" title="Select track, then press Delete key to remove">Select</button>
|
|
<button class="tool-btn" id="splitTool" title="Click on a track to split it">Split</button>
|
|
<button class="tool-btn" id="drawTool" title="Draw a new track">Draw</button>
|
|
<button class="tool-btn" id="reshapeTool" title="Drag points to reshape track">Reshape</button>
|
|
<button class="tool-btn" id="smoothTool" title="Brush over track to smooth it">Smooth</button>
|
|
<button class="action-btn" id="remeshBtn" title="Remesh selected tracks with even spacing">Remesh Selected</button>
|
|
<button class="tool-btn" id="geocacheTool" title="Place a geocache">📍 Cache</button>
|
|
</div>
|
|
<div class="slider-group" id="reshapeControls" style="display: none;">
|
|
<label>Anchor Distance (points beyond stay fixed)</label>
|
|
<input type="range" id="anchorDistance" min="3" max="50" value="10">
|
|
<div class="slider-value"><span id="anchorValue">10</span> points</div>
|
|
<label style="margin-top: 8px;">Falloff (how sharply effect fades)</label>
|
|
<input type="range" id="reshapeFalloff" min="0.5" max="3" value="1" step="0.1">
|
|
<div class="slider-value"><span id="falloffValue">1.0</span></div>
|
|
</div>
|
|
<div class="slider-group" id="smoothControls" style="display: none;">
|
|
<label>Brush Size (pixels)</label>
|
|
<input type="range" id="smoothBrushSize" min="10" max="100" value="30">
|
|
<div class="slider-value"><span id="brushSizeValue">30</span> px</div>
|
|
<label style="margin-top: 8px;">Strength</label>
|
|
<input type="range" id="smoothStrength" min="0.1" max="1" value="0.5" step="0.1">
|
|
<div class="slider-value"><span id="strengthValue">0.5</span></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Merge / Simplify</div>
|
|
<div class="slider-group">
|
|
<label>Merge Threshold</label>
|
|
<input type="range" id="mergeThreshold" min="1" max="10" value="5" step="0.5">
|
|
<div class="slider-value"><span id="thresholdValue">5</span> meters</div>
|
|
</div>
|
|
<button class="action-btn" id="previewBtn" style="background: #17a2b8;">Preview Merge</button>
|
|
<button class="action-btn" id="applyMergeBtn" style="display: none;">Apply Merge</button>
|
|
<button class="action-btn secondary" id="cancelPreviewBtn" style="display: none;">Cancel Preview</button>
|
|
<div style="margin-top: 8px;">
|
|
<button class="action-btn secondary" id="mergeConnectBtn">Connect End-to-End</button>
|
|
</div>
|
|
<div style="border-top: 1px solid #eee; margin-top: 10px; padding-top: 10px;">
|
|
<button class="action-btn" id="selectAllBtn" style="background: #6c757d;">Select All</button>
|
|
<button class="action-btn secondary" id="clearSelectionBtn">Clear Selection</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Tracks (<span id="trackCount">0</span>)</div>
|
|
<div class="track-list" id="trackList"></div>
|
|
<button class="action-btn" id="undoBtn" style="background: #6c757d;">Undo</button>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="tab-content active" id="navContent">
|
|
<div class="section">
|
|
<div class="section-title">GPS</div>
|
|
<button class="action-btn" id="gpsBtn" style="width: 100%;">Show My Location</button>
|
|
<button class="action-btn secondary" id="rotateMapBtn" style="width: 100%; margin-top: 5px;">Rotate Map: OFF</button>
|
|
<button class="action-btn secondary" id="autoCenterBtn" style="width: 100%; margin-top: 5px;">Auto-Center: OFF</button>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Navigation</div>
|
|
<p class="nav-instruction">Enable GPS, then click on a track to set your destination.</p>
|
|
<div id="navInfo" class="nav-info" style="display: none;">
|
|
<div class="nav-distance"><span id="navDistance">--</span></div>
|
|
<div style="color: #666; font-size: 12px; margin-bottom: 10px;">remaining along track</div>
|
|
<button class="action-btn secondary" id="clearNavBtn">Clear Destination</button>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div class="tab-content" id="adminContent">
|
|
<div class="section collapsible" data-section="geocache">
|
|
<div class="section-title">Geocache Settings</div>
|
|
<div class="section-content">
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label for="geocacheRange">Interaction Range:</label>
|
|
<div class="admin-input-container">
|
|
<input type="number" id="geocacheRange" min="1" max="50" value="5">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label for="geocacheAlertRange">Alert Distance:</label>
|
|
<div class="admin-input-container">
|
|
<input type="number" id="geocacheAlertRange" min="1" max="50" value="5">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
<div class="admin-checkbox-row">
|
|
<input type="checkbox" id="geocacheSoundEnabled" checked>
|
|
<label for="geocacheSoundEnabled">Enable alert sound</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Navigation Settings</div>
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label>Track Proximity:</label>
|
|
<input type="number" id="trackProximity" min="10" max="200" value="50">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Intersection Threshold:</label>
|
|
<input type="number" id="intersectionThreshold" min="5" max="30" value="15">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>On-Trail Threshold:</label>
|
|
<input type="number" id="onTrailThreshold" min="20" max="200" value="100">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Node Spacing:</label>
|
|
<input type="number" id="nodeSpacing" min="10" max="100" value="50">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Performance Settings</div>
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label>GPS Poll Interval:</label>
|
|
<input type="number" id="gpsPollInterval" min="1000" max="10000" value="3000" step="500">
|
|
<span class="unit">ms</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Proximity Check Interval:</label>
|
|
<input type="number" id="proximityCheckInterval" min="1000" max="10000" value="3000" step="500">
|
|
<span class="unit">ms</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Push Notifications</div>
|
|
<div class="admin-setting-group">
|
|
<div id="notificationStatus" style="margin-bottom: 10px; color: #666;">
|
|
Status: <span id="notificationStatusText">Not configured</span>
|
|
</div>
|
|
<button class="action-btn" id="enableNotifications" onclick="setupPushNotifications()">
|
|
Enable Push Notifications
|
|
</button>
|
|
<button class="action-btn secondary" id="disableNotifications" onclick="disablePushNotifications()" style="display:none;">
|
|
Disable Notifications
|
|
</button>
|
|
<button class="action-btn" id="testNotification" onclick="sendTestNotification()" style="display:none; margin-top: 10px; background: #9c27b0;">
|
|
📨 Send Test Notification to All Users
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="admin-button-group">
|
|
<button class="action-btn" onclick="resetAdminSettings()">Reset to Defaults</button>
|
|
<button class="action-btn" onclick="exportAdminSettings()">Export Settings</button>
|
|
<button class="action-btn" onclick="importAdminSettings()">Import Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="status" class="status">Load a KML file or draw tracks</div>
|
|
</div>
|
|
|
|
<!-- Route calculation overlay -->
|
|
<div id="routeOverlay" class="route-overlay" style="display: none;">
|
|
<div class="route-overlay-content">
|
|
<div class="spinner"></div>
|
|
<div class="route-overlay-text">Finding route...</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Icon selector overlay -->
|
|
<div id="iconSelector" class="icon-selector-overlay" style="display: none;">
|
|
<div class="icon-selector-modal">
|
|
<div class="icon-selector-title">Choose Your Icon</div>
|
|
<div class="icon-selector-subtitle">Select how you want to appear to other users</div>
|
|
<div class="icon-grid" id="iconGrid">
|
|
<!-- Icons will be added dynamically -->
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Password dialog -->
|
|
<div id="passwordDialog" class="password-dialog" style="display: none;">
|
|
<div class="password-dialog-content">
|
|
<h3>Enter Password for Edit Mode</h3>
|
|
<input type="password" id="passwordInput" placeholder="Enter password" autofocus>
|
|
<div class="password-error" id="passwordError">Incorrect password</div>
|
|
<div class="password-dialog-buttons">
|
|
<button class="cancel-btn" id="passwordCancel">Cancel</button>
|
|
<button class="submit-btn" id="passwordSubmit">Submit</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Admin Save Indicator -->
|
|
<div id="adminSaveIndicator" class="admin-save-indicator">Settings Saved ✓</div>
|
|
|
|
<!-- Geocache dialog -->
|
|
<div id="geocacheDialog" class="geocache-dialog" style="display: none;">
|
|
<div class="geocache-dialog-content">
|
|
<h3 id="geocacheTitle">📍 Geocache</h3>
|
|
<div class="geocache-messages" id="geocacheMessages">
|
|
<!-- Messages will be added dynamically -->
|
|
</div>
|
|
<div id="geocacheForm">
|
|
<div class="geocache-input-group" id="geocacheTitleGroup" style="display: none;">
|
|
<label for="geocacheTitleInput">Geocache Title</label>
|
|
<input type="text" id="geocacheTitleInput" placeholder="Name this geocache..." maxlength="100">
|
|
</div>
|
|
<div class="geocache-input-group" id="geocacheIconGroup" style="display: none;">
|
|
<label for="geocacheIconInput">Icon (MDI name, e.g. 'treasure-chest', 'map-marker')</label>
|
|
<input type="text" id="geocacheIconInput" placeholder="package-variant" value="package-variant" maxlength="50">
|
|
<small style="color: #888; display: block; margin-top: 4px;">Browse icons at <a href="https://pictogrammers.com/library/mdi/" target="_blank" style="color: #FFA726;">Material Design Icons</a></small>
|
|
</div>
|
|
<div class="geocache-input-group" id="geocacheColorGroup" style="display: none;">
|
|
<label for="geocacheColorInput">Icon Color</label>
|
|
<div style="display: flex; align-items: center; gap: 10px;">
|
|
<input type="color" id="geocacheColorInput" value="#FFA726" style="width: 60px; height: 35px; border: 1px solid #555; border-radius: 4px; cursor: pointer;">
|
|
<span id="geocacheColorPreview" style="font-size: 28px;"><i class="mdi mdi-package-variant" style="color: #FFA726;"></i></span>
|
|
<button type="button" id="geocacheColorReset" style="padding: 5px 10px; background: #555; color: white; border: none; border-radius: 4px; cursor: pointer;">Reset</button>
|
|
</div>
|
|
</div>
|
|
<div class="geocache-input-group" id="geocacheVisibilityGroup" style="display: none;">
|
|
<label for="geocacheVisibilityInput">Visibility Distance (meters, 0 = always visible)</label>
|
|
<input type="number" id="geocacheVisibilityInput" placeholder="0" value="0" min="0" max="10000" step="10">
|
|
<small style="color: #888; display: block; margin-top: 4px;">Secret caches are only visible when users are within this distance</small>
|
|
</div>
|
|
<div class="geocache-input-group">
|
|
<label for="geocacheName">Your Name</label>
|
|
<input type="text" id="geocacheName" placeholder="Enter your name" maxlength="50">
|
|
</div>
|
|
<div class="geocache-input-group">
|
|
<label for="geocacheMessage">Leave a Message</label>
|
|
<textarea id="geocacheMessage" placeholder="Write your message..." maxlength="500"></textarea>
|
|
</div>
|
|
</div>
|
|
<div class="geocache-dialog-buttons">
|
|
<button class="geocache-cancel-btn" id="geocacheCancel">Close</button>
|
|
<button class="geocache-submit-btn" id="geocacheSubmit">Add Message</button>
|
|
<button class="geocache-edit-btn" id="geocacheEdit" style="display: none; background: #4CAF50;">Edit Cache</button>
|
|
<button class="geocache-delete-btn" id="geocacheDelete" style="display: none;">Delete Geocache</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Geocache alert notification -->
|
|
<div id="geocacheAlert" class="geocache-alert">
|
|
📍 Geocache nearby! Click to view messages.
|
|
</div>
|
|
|
|
<!-- Geocache List Sidebar -->
|
|
<div id="geocacheListSidebar" class="geocache-list-sidebar">
|
|
<div class="geocache-list-header">
|
|
<h3 style="color: #FFA726; margin: 0;">📍 Geocaches</h3>
|
|
<button id="geocacheListClose" style="background: none; border: none; color: #aaa; font-size: 24px; cursor: pointer;">×</button>
|
|
</div>
|
|
<div id="geocacheListContent">
|
|
<!-- Will be populated dynamically -->
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Geocache List Toggle Button -->
|
|
<div id="geocacheListToggle" class="geocache-list-toggle">
|
|
<i class="mdi mdi-map-marker-multiple" style="font-size: 24px; color: white;"></i>
|
|
</div>
|
|
|
|
<!-- Navigation confirmation dialog -->
|
|
<div id="navConfirmDialog" class="nav-confirm-dialog" style="display: none;">
|
|
<div class="nav-confirm-content">
|
|
<h3>Set Navigation Destination</h3>
|
|
<p id="navConfirmMessage">Navigate to this location?</p>
|
|
<div class="nav-confirm-buttons">
|
|
<button class="nav-confirm-no" id="navConfirmNo">Cancel</button>
|
|
<button class="nav-confirm-yes" id="navConfirmYes">Navigate</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Resume navigation dialog -->
|
|
<div id="resumeNavDialog" class="nav-confirm-dialog" style="display: none;">
|
|
<div class="nav-confirm-content">
|
|
<h3>Resume Navigation?</h3>
|
|
<p>You have a previous navigation destination. Would you like to resume?</p>
|
|
<div class="nav-confirm-buttons">
|
|
<button class="nav-confirm-no" id="resumeNavNo">Start Fresh</button>
|
|
<button class="nav-confirm-yes" id="resumeNavYes">Resume</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Press and hold indicator -->
|
|
<div id="pressHoldIndicator" class="press-hold-indicator">Hold to set destination...</div>
|
|
|
|
<!-- Audio element for geocache alert sound - using Web Audio API instead -->
|
|
<script>
|
|
// Create a simple notification sound using Web Audio API
|
|
function playGeocacheSound() {
|
|
if (!adminSettings.geocacheSoundEnabled) return;
|
|
|
|
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
// Create oscillator for beep sound
|
|
const oscillator = audioContext.createOscillator();
|
|
const gainNode = audioContext.createGain();
|
|
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(audioContext.destination);
|
|
|
|
// Configure the sound (two quick beeps)
|
|
oscillator.frequency.value = 800; // Frequency in Hz
|
|
gainNode.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
|
|
|
oscillator.start(audioContext.currentTime);
|
|
oscillator.stop(audioContext.currentTime + 0.1);
|
|
|
|
// Second beep
|
|
setTimeout(() => {
|
|
const osc2 = audioContext.createOscillator();
|
|
const gain2 = audioContext.createGain();
|
|
|
|
osc2.connect(gain2);
|
|
gain2.connect(audioContext.destination);
|
|
|
|
osc2.frequency.value = 1000;
|
|
gain2.gain.setValueAtTime(0.3, audioContext.currentTime);
|
|
gain2.gain.exponentialRampToValueAtTime(0.01, audioContext.currentTime + 0.1);
|
|
|
|
osc2.start(audioContext.currentTime);
|
|
osc2.stop(audioContext.currentTime + 0.1);
|
|
}, 150);
|
|
}
|
|
</script>
|
|
|
|
<!-- Remesh confirmation dialog -->
|
|
<div id="remeshDialog" class="nav-confirm-dialog" style="display: none;">
|
|
<div class="nav-confirm-content">
|
|
<h3>Remesh Track?</h3>
|
|
<p id="remeshMessage">This will redistribute points to be evenly spaced.</p>
|
|
<div class="slider-group" style="margin: 15px 0;">
|
|
<label>Point Spacing (meters)</label>
|
|
<input type="range" id="remeshSpacing" min="5" max="20" value="5" step="1">
|
|
<div class="slider-value"><span id="remeshSpacingValue">5</span> meters</div>
|
|
</div>
|
|
<p id="remeshDetails" style="font-size: 12px; color: #666; margin-top: 10px;"></p>
|
|
<div class="nav-confirm-buttons">
|
|
<button class="nav-confirm-no" id="remeshNo">Cancel</button>
|
|
<button class="nav-confirm-yes" id="remeshYes">Remesh</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
<script src="https://unpkg.com/leaflet-rotate@0.2.8/dist/leaflet-rotate-src.js"></script>
|
|
<script>
|
|
// Initialize the map with higher max zoom and rotation support
|
|
const map = L.map('map', {
|
|
maxZoom: 22,
|
|
rotate: true,
|
|
rotateControl: false,
|
|
touchRotate: false,
|
|
bearing: 0
|
|
}).setView([30.49, -97.84], 13);
|
|
|
|
// Base layers
|
|
const streetMap = L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
maxZoom: 22,
|
|
maxNativeZoom: 19
|
|
});
|
|
|
|
const satellite = L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', {
|
|
attribution: '© Esri — Esri, DeLorme, NAVTEQ',
|
|
maxZoom: 22,
|
|
maxNativeZoom: 19
|
|
});
|
|
|
|
// Add street map by default
|
|
streetMap.addTo(map);
|
|
|
|
// Layer control
|
|
const baseMaps = {
|
|
"Street Map": streetMap,
|
|
"Satellite": satellite
|
|
};
|
|
L.control.layers(baseMaps, null, { position: 'bottomleft' }).addTo(map);
|
|
|
|
// Admin Settings (loaded from localStorage or defaults)
|
|
let adminSettings = {
|
|
geocacheRange: 5,
|
|
geocacheAlertRange: 5,
|
|
geocacheSoundEnabled: true,
|
|
trackProximity: 50,
|
|
intersectionThreshold: 15,
|
|
onTrailThreshold: 100,
|
|
nodeSpacing: 50,
|
|
gpsPollInterval: 3000,
|
|
proximityCheckInterval: 3000,
|
|
snapDistancePx: 15
|
|
};
|
|
|
|
// Store all tracks
|
|
const tracks = [];
|
|
let selectedTracks = []; // Now supports multiple selection
|
|
let currentTool = 'select';
|
|
let isDrawing = false;
|
|
let drawingPoints = [];
|
|
let drawingLine = null;
|
|
|
|
// Geocache variables
|
|
let geocaches = []; // Array of { id, lat, lng, messages: [{author, text, timestamp}] }
|
|
let currentGeocache = null; // Currently selected/nearby geocache
|
|
let currentGeocacheEditMode = false; // Whether we're editing an existing geocache
|
|
let geocacheMarkers = {}; // Map of geocache id to marker
|
|
let lastGeocacheProximityCheck = 0;
|
|
let readGeocaches = JSON.parse(localStorage.getItem('readGeocaches') || '[]'); // Track which caches user has read
|
|
|
|
// Preview state
|
|
let previewMode = false;
|
|
let previewLayers = [];
|
|
let previewData = null;
|
|
|
|
// Undo stack
|
|
const undoStack = [];
|
|
const maxUndoSteps = 20;
|
|
|
|
// Reshape/Dragging state
|
|
let isDragging = false;
|
|
let dragTrack = null;
|
|
let dragPointIndex = -1;
|
|
let originalCoords = null;
|
|
let dragMarker = null;
|
|
let affectedMarkers = [];
|
|
|
|
// Snap state for endpoint merging
|
|
let snapTarget = null; // {track, index, latlng}
|
|
let snapMarker = null;
|
|
const SNAP_DISTANCE_PX = adminSettings.snapDistancePx; // from admin settings
|
|
|
|
// Smooth brush state
|
|
let isSmoothing = false;
|
|
let smoothBrushCircle = null;
|
|
let smoothedPoints = new Set(); // Track which points have been smoothed this stroke
|
|
|
|
// GPS state
|
|
let gpsWatchId = null;
|
|
let gpsBackupInterval = null;
|
|
let gpsMarker = null;
|
|
let gpsAccuracyCircle = null;
|
|
let gpsFirstFix = true;
|
|
let currentHeading = null;
|
|
|
|
// Navigation state
|
|
let navMode = false;
|
|
let destinationPin = null;
|
|
|
|
// Multi-user tracking
|
|
let ws = null;
|
|
let userId = null;
|
|
let otherUsers = new Map();
|
|
|
|
// Notification cooldown tracking
|
|
let notificationCooldowns = {
|
|
nearbyCache: {}, // cacheId -> lastNotificationTime
|
|
destinationArrival: 0 // lastNotificationTime
|
|
};
|
|
const CACHE_COOLDOWN = 10 * 60 * 1000; // 10 minutes
|
|
const CACHE_NOTIFY_DISTANCE = 200; // meters
|
|
const CACHE_RESET_DISTANCE = 200; // meters to reset cooldown
|
|
const DESTINATION_ARRIVAL_DISTANCE = 10; // meters
|
|
let wsReconnectTimer = null;
|
|
let myIcon = null;
|
|
let myColor = null;
|
|
let isNearTrack = false;
|
|
// Use settings value instead of const
|
|
// const TRACK_PROXIMITY_THRESHOLD = adminSettings.trackProximity;
|
|
|
|
// Authentication state
|
|
let isAuthenticated = false;
|
|
const DEFAULT_PASSWORD_HASH = '8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918'; // SHA-256 of 'admin'
|
|
let destinationTrack = null;
|
|
let destinationIndex = null;
|
|
let directionArrow = null;
|
|
let currentClosestIndex = null;
|
|
let currentRoute = null; // Array of {track, fromIndex, toIndex} segments
|
|
let routeHighlight = null; // Polyline showing the full route
|
|
let rotateMapMode = false;
|
|
let currentBearing = 0;
|
|
let autoCenterMode = false; // Start with auto-center off (will enable on first GPS fix)
|
|
|
|
// Trail graph for pathfinding
|
|
// Use settings value instead of const
|
|
// const adminSettings.intersectionThreshold = adminSettings.intersectionThreshold;
|
|
let trailGraph = null; // Cached graph, rebuilt when tracks change
|
|
|
|
// Save current state for undo
|
|
function saveStateForUndo() {
|
|
const state = {
|
|
tracks: tracks.map(t => ({
|
|
coords: [...t.coords.map(c => [...c])],
|
|
name: t.name,
|
|
description: t.description
|
|
})),
|
|
splitMarkers: splitMarkers.map(m => ({
|
|
latlng: [m.getLatLng().lat, m.getLatLng().lng],
|
|
popupContent: m.getPopup()?.getContent() || ''
|
|
}))
|
|
};
|
|
undoStack.push(state);
|
|
if (undoStack.length > maxUndoSteps) {
|
|
undoStack.shift(); // Remove oldest
|
|
}
|
|
updateUndoButton();
|
|
}
|
|
|
|
// Restore previous state
|
|
function undo() {
|
|
if (undoStack.length === 0) {
|
|
updateStatus('Nothing to undo', 'info');
|
|
return;
|
|
}
|
|
|
|
const state = undoStack.pop();
|
|
|
|
// Clear current tracks
|
|
tracks.forEach(t => t.remove());
|
|
tracks.length = 0;
|
|
selectedTracks = [];
|
|
|
|
// Clear current split markers
|
|
splitMarkers.forEach(m => map.removeLayer(m));
|
|
splitMarkers.length = 0;
|
|
|
|
// Restore tracks
|
|
for (const trackData of state.tracks) {
|
|
const track = new Track(trackData.coords, trackData.name, trackData.description);
|
|
tracks.push(track);
|
|
}
|
|
|
|
// Restore split markers
|
|
for (const markerData of state.splitMarkers) {
|
|
const marker = L.marker(markerData.latlng, {
|
|
icon: L.divIcon({
|
|
className: 'split-marker',
|
|
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6]
|
|
})
|
|
}).addTo(map);
|
|
if (markerData.popupContent) {
|
|
marker.bindPopup(markerData.popupContent);
|
|
}
|
|
splitMarkers.push(marker);
|
|
}
|
|
|
|
updateTrackList();
|
|
updateUndoButton();
|
|
updateStatus('Undone', 'success');
|
|
}
|
|
|
|
function updateUndoButton() {
|
|
const btn = document.getElementById('undoBtn');
|
|
btn.textContent = undoStack.length > 0 ? `Undo (${undoStack.length})` : 'Undo';
|
|
btn.disabled = undoStack.length === 0;
|
|
btn.style.opacity = undoStack.length === 0 ? '0.5' : '1';
|
|
}
|
|
|
|
// GPS functions
|
|
function toggleGPS() {
|
|
const btn = document.getElementById('gpsBtn');
|
|
|
|
if (gpsWatchId !== null) {
|
|
// Stop tracking
|
|
navigator.geolocation.clearWatch(gpsWatchId);
|
|
gpsWatchId = null;
|
|
|
|
if (gpsBackupInterval) {
|
|
clearInterval(gpsBackupInterval);
|
|
gpsBackupInterval = null;
|
|
}
|
|
|
|
if (gpsMarker) {
|
|
map.removeLayer(gpsMarker);
|
|
gpsMarker = null;
|
|
}
|
|
if (gpsAccuracyCircle) {
|
|
map.removeLayer(gpsAccuracyCircle);
|
|
gpsAccuracyCircle = null;
|
|
}
|
|
|
|
gpsFirstFix = true;
|
|
btn.textContent = 'Show My Location';
|
|
btn.classList.remove('active');
|
|
updateStatus('GPS tracking stopped', 'info');
|
|
} else {
|
|
// Start tracking
|
|
if (!navigator.geolocation) {
|
|
updateStatus('GPS not supported by this browser', 'error');
|
|
return;
|
|
}
|
|
|
|
btn.textContent = 'Locating...';
|
|
updateStatus('Requesting GPS location...', 'info');
|
|
console.log('Starting GPS tracking...');
|
|
|
|
gpsWatchId = navigator.geolocation.watchPosition(
|
|
(position) => {
|
|
console.log('GPS success:', position);
|
|
onGPSSuccess(position);
|
|
},
|
|
(error) => {
|
|
console.error('GPS error:', error);
|
|
onGPSError(error);
|
|
},
|
|
{
|
|
enableHighAccuracy: true,
|
|
maximumAge: 1000,
|
|
timeout: 15000
|
|
}
|
|
);
|
|
console.log('Watch ID:', gpsWatchId);
|
|
|
|
// Backup: poll for position every 3 seconds in case watchPosition doesn't fire
|
|
gpsBackupInterval = setInterval(() => {
|
|
console.log('Backup GPS poll...');
|
|
navigator.geolocation.getCurrentPosition(
|
|
(position) => {
|
|
console.log('Backup GPS success:', position);
|
|
onGPSSuccess(position);
|
|
},
|
|
(error) => {
|
|
console.log('Backup GPS error (ignored):', error);
|
|
}, // Ignore errors on backup poll
|
|
{
|
|
enableHighAccuracy: true,
|
|
maximumAge: 0,
|
|
timeout: 5000
|
|
}
|
|
);
|
|
}, adminSettings.gpsPollInterval);
|
|
}
|
|
}
|
|
|
|
function onGPSSuccess(position) {
|
|
const lat = position.coords.latitude;
|
|
const lng = position.coords.longitude;
|
|
const accuracy = position.coords.accuracy;
|
|
|
|
const btn = document.getElementById('gpsBtn');
|
|
btn.textContent = 'Stop Tracking';
|
|
btn.classList.add('active');
|
|
|
|
// Store user location for geocache proximity checks
|
|
userLocation = { lat, lng, accuracy };
|
|
|
|
// Update geocache visibility based on new location
|
|
if (navMode) {
|
|
updateGeocacheVisibility();
|
|
}
|
|
|
|
// Update or create marker
|
|
if (!gpsMarker) {
|
|
const myIcon = L.divIcon({
|
|
html: '<i class="mdi mdi-navigation" style="color: #4285f4; font-size: 36px; transform: rotate(' + (currentHeading || 0) + 'deg);"></i>',
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 18],
|
|
className: 'custom-div-icon'
|
|
});
|
|
gpsMarker = L.marker([lat, lng], { icon: myIcon }).addTo(map);
|
|
} else {
|
|
gpsMarker.setLatLng([lat, lng]);
|
|
// Update rotation if we have heading
|
|
if (currentHeading !== null) {
|
|
const myIcon = L.divIcon({
|
|
html: '<i class="mdi mdi-navigation" style="color: #4285f4; font-size: 36px; transform: rotate(' + currentHeading + 'deg);"></i>',
|
|
iconSize: [36, 36],
|
|
iconAnchor: [18, 18],
|
|
className: 'custom-div-icon'
|
|
});
|
|
gpsMarker.setIcon(myIcon);
|
|
}
|
|
}
|
|
|
|
// Update or create accuracy circle
|
|
if (!gpsAccuracyCircle) {
|
|
gpsAccuracyCircle = L.circle([lat, lng], {
|
|
radius: accuracy,
|
|
color: '#4285f4',
|
|
fillColor: '#4285f4',
|
|
fillOpacity: 0.15,
|
|
weight: 1
|
|
}).addTo(map);
|
|
} else {
|
|
gpsAccuracyCircle.setLatLng([lat, lng]);
|
|
gpsAccuracyCircle.setRadius(accuracy);
|
|
}
|
|
|
|
// Check if we're near a track
|
|
const distanceToTrack = getDistanceToNearestTrack(lat, lng);
|
|
const wasNearTrack = isNearTrack;
|
|
isNearTrack = distanceToTrack <= adminSettings.trackProximity;
|
|
|
|
// Send location to other users with visibility info
|
|
sendLocationToServer(lat, lng, accuracy, isNearTrack);
|
|
|
|
// Check for notification triggers
|
|
checkLocationNotifications(lat, lng);
|
|
|
|
// Check geocache proximity
|
|
checkGeocacheProximity();
|
|
|
|
// Log proximity status change
|
|
if (wasNearTrack !== isNearTrack) {
|
|
if (isNearTrack) {
|
|
console.log('Now visible to other users (near track)');
|
|
updateStatus('On track - visible to others', 'info');
|
|
} else {
|
|
console.log('Now hidden from other users (off track)');
|
|
updateStatus('Off track - hidden from others', 'info');
|
|
}
|
|
}
|
|
|
|
// Center map on first fix or when auto-center is enabled
|
|
if (gpsFirstFix) {
|
|
map.setView([lat, lng], 17);
|
|
gpsFirstFix = false;
|
|
// After first fix, turn off auto-center (user must manually enable)
|
|
autoCenterMode = false;
|
|
const btn = document.getElementById('autoCenterBtn');
|
|
btn.textContent = 'Auto-Center: OFF';
|
|
btn.classList.remove('active');
|
|
} else if (autoCenterMode) {
|
|
map.setView([lat, lng], map.getZoom());
|
|
}
|
|
|
|
// Update navigation if in nav mode
|
|
if (navMode && destinationTrack) {
|
|
try {
|
|
// Check if we're still on the current route or need to recalculate
|
|
if (currentRoute && currentRoute.length > 0) {
|
|
// Check distance to current route
|
|
const currentPos = L.latLng(lat, lng);
|
|
const distToRoute = getDistanceToRoute(currentPos, currentRoute);
|
|
|
|
if (distToRoute > 50) {
|
|
// More than 50m off route - recalculate
|
|
updateStatus('Recalculating route...', 'info');
|
|
updateNavigation();
|
|
} else {
|
|
// Still on route - just update arrow and distance (lightweight)
|
|
updateNavigationLight(currentPos);
|
|
}
|
|
} else {
|
|
// No route yet - calculate it
|
|
updateNavigation();
|
|
}
|
|
} catch (e) {
|
|
console.error('Navigation update error:', e);
|
|
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)}`, 'success');
|
|
}
|
|
} else {
|
|
updateStatus(`GPS: ${lat.toFixed(6)}, ${lng.toFixed(6)} (±${Math.round(accuracy)}m)`, 'success');
|
|
}
|
|
}
|
|
|
|
function onGPSError(error) {
|
|
const btn = document.getElementById('gpsBtn');
|
|
|
|
let message;
|
|
switch (error.code) {
|
|
case error.PERMISSION_DENIED:
|
|
message = 'GPS permission denied';
|
|
break;
|
|
case error.POSITION_UNAVAILABLE:
|
|
message = 'GPS position unavailable';
|
|
break;
|
|
case error.TIMEOUT:
|
|
message = 'GPS request timed out';
|
|
break;
|
|
default:
|
|
message = 'GPS error: ' + error.message;
|
|
}
|
|
|
|
updateStatus(message, 'error');
|
|
|
|
// Reset if we haven't got a fix yet
|
|
if (gpsFirstFix) {
|
|
btn.textContent = 'Show My Location';
|
|
btn.classList.remove('active');
|
|
if (gpsWatchId !== null) {
|
|
navigator.geolocation.clearWatch(gpsWatchId);
|
|
gpsWatchId = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Navigation functions
|
|
function setDestination(track, index) {
|
|
// Remove old pin if exists
|
|
if (destinationPin) {
|
|
map.removeLayer(destinationPin);
|
|
}
|
|
|
|
destinationTrack = track;
|
|
destinationIndex = index;
|
|
|
|
// Create destination pin marker
|
|
destinationPin = L.marker(track.coords[index], {
|
|
icon: L.divIcon({
|
|
className: 'destination-pin',
|
|
html: '📍',
|
|
iconSize: [24, 24],
|
|
iconAnchor: [12, 24]
|
|
})
|
|
}).addTo(map);
|
|
|
|
// Show nav info panel
|
|
document.getElementById('navInfo').style.display = 'block';
|
|
|
|
// Save destination to localStorage
|
|
saveDestination();
|
|
|
|
updateNavigation();
|
|
updateStatus(`Destination set on "${track.name}"`, 'success');
|
|
}
|
|
|
|
function saveDestination() {
|
|
if (destinationTrack && destinationIndex !== null) {
|
|
localStorage.setItem('navDestination', JSON.stringify({
|
|
trackId: destinationTrack.id,
|
|
trackName: destinationTrack.name,
|
|
index: destinationIndex,
|
|
coord: destinationTrack.coords[destinationIndex]
|
|
}));
|
|
}
|
|
}
|
|
|
|
function restoreDestination() {
|
|
const saved = localStorage.getItem('navDestination');
|
|
if (!saved) return;
|
|
|
|
try {
|
|
const data = JSON.parse(saved);
|
|
|
|
// Find the track by ID or name
|
|
let track = tracks.find(t => t.id === data.trackId);
|
|
if (!track) {
|
|
track = tracks.find(t => t.name === data.trackName);
|
|
}
|
|
|
|
if (track) {
|
|
// Find closest point to saved coordinate (in case track was edited)
|
|
let bestIndex = data.index;
|
|
if (bestIndex >= track.coords.length) {
|
|
bestIndex = track.coords.length - 1;
|
|
}
|
|
|
|
// Try to find exact or nearest point
|
|
if (data.coord) {
|
|
let minDist = Infinity;
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const dist = Math.abs(track.coords[i][0] - data.coord[0]) +
|
|
Math.abs(track.coords[i][1] - data.coord[1]);
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
bestIndex = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
setDestination(track, bestIndex);
|
|
updateStatus(`Restored destination on "${track.name}"`, 'info');
|
|
}
|
|
} catch (e) {
|
|
console.log('Could not restore destination:', e);
|
|
localStorage.removeItem('navDestination');
|
|
}
|
|
}
|
|
|
|
function clearDestination() {
|
|
if (destinationPin) {
|
|
map.removeLayer(destinationPin);
|
|
destinationPin = null;
|
|
}
|
|
if (directionArrow) {
|
|
map.removeLayer(directionArrow);
|
|
directionArrow = null;
|
|
}
|
|
clearRouteHighlight();
|
|
destinationTrack = null;
|
|
|
|
// Clear from localStorage
|
|
localStorage.removeItem('navDestination');
|
|
destinationIndex = null;
|
|
currentClosestIndex = null;
|
|
currentRoute = null;
|
|
|
|
// Clear saved destination
|
|
localStorage.removeItem('navDestination');
|
|
|
|
document.getElementById('navInfo').style.display = 'none';
|
|
document.getElementById('navDistance').textContent = '--';
|
|
|
|
updateStatus('Destination cleared', 'info');
|
|
}
|
|
|
|
function findClosestPointOnTrack(track, latlng) {
|
|
let minDist = Infinity;
|
|
let closestIndex = 0;
|
|
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const dist = map.distance(latlng, L.latLng(track.coords[i]));
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
closestIndex = i;
|
|
}
|
|
}
|
|
|
|
return closestIndex;
|
|
}
|
|
|
|
function calculateTrackDistance(track, fromIndex, toIndex) {
|
|
let distance = 0;
|
|
const start = Math.min(fromIndex, toIndex);
|
|
const end = Math.max(fromIndex, toIndex);
|
|
|
|
for (let i = start; i < end; i++) {
|
|
distance += map.distance(
|
|
L.latLng(track.coords[i]),
|
|
L.latLng(track.coords[i + 1])
|
|
);
|
|
}
|
|
return distance;
|
|
}
|
|
|
|
function updateNavigation() {
|
|
if (!destinationTrack || !gpsMarker) return;
|
|
|
|
const currentPos = gpsMarker.getLatLng();
|
|
|
|
// Check if arrived (within 10 meters of destination)
|
|
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
|
|
if (distToDestination < 10) {
|
|
clearRouteHighlight();
|
|
updateDirectionArrow(currentPos, []);
|
|
document.getElementById('routeOverlay').style.display = 'none';
|
|
updateStatus('You have arrived!', 'success');
|
|
return;
|
|
}
|
|
|
|
// Find the closest point on ANY track to current position
|
|
let closestTrack = null;
|
|
let closestIndex = 0;
|
|
let minDist = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const dist = map.distance(currentPos, L.latLng(track.coords[i]));
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
closestTrack = track;
|
|
closestIndex = i;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!closestTrack) {
|
|
document.getElementById('routeOverlay').style.display = 'none';
|
|
updateStatus('No trails nearby', 'error');
|
|
return;
|
|
}
|
|
|
|
// If far from any trail, show direct arrow to nearest trail
|
|
const ON_TRAIL_THRESHOLD = adminSettings.onTrailThreshold; // from admin settings
|
|
if (minDist > ON_TRAIL_THRESHOLD) {
|
|
clearRouteHighlight();
|
|
updateDirectionArrow(currentPos, [closestTrack.coords[closestIndex]], '#3388ff');
|
|
document.getElementById('routeOverlay').style.display = 'none';
|
|
updateStatus(`${Math.round(minDist)}m to nearest trail`, 'info');
|
|
return;
|
|
}
|
|
|
|
// Show loading indicator and defer pathfinding to allow UI update
|
|
document.getElementById('routeOverlay').style.display = 'flex';
|
|
document.getElementById('navInfo').style.display = 'none';
|
|
|
|
setTimeout(() => {
|
|
// Find path from current position to destination through trail network
|
|
const pathResult = findShortestPath(closestTrack, closestIndex, destinationTrack, destinationIndex);
|
|
|
|
document.getElementById('routeOverlay').style.display = 'none';
|
|
document.getElementById('navInfo').style.display = 'block';
|
|
|
|
finishNavUpdate(currentPos, pathResult);
|
|
}, 10);
|
|
}
|
|
|
|
function finishNavUpdate(currentPos, pathResult) {
|
|
|
|
if (!pathResult) {
|
|
// No path found - trails not connected
|
|
clearRouteHighlight();
|
|
updateDirectionArrow(currentPos, [destinationTrack.coords[destinationIndex]], '#ff0000');
|
|
updateStatus('No connected path to destination', 'error');
|
|
return;
|
|
}
|
|
|
|
// Convert path to route segments
|
|
currentRoute = pathToRouteSegments(pathResult.path);
|
|
|
|
// Update distance display
|
|
const distanceText = pathResult.totalDistance < 1000
|
|
? `${Math.round(pathResult.totalDistance)}m`
|
|
: `${(pathResult.totalDistance / 1000).toFixed(2)}km`;
|
|
document.getElementById('navDistance').textContent = distanceText;
|
|
|
|
// Get route coordinates and display
|
|
const routeCoords = getRouteCoordinates(currentRoute);
|
|
updateRouteHighlight(routeCoords);
|
|
|
|
// Show direction arrow for next few points
|
|
const arrowPoints = routeCoords.slice(0, Math.min(6, routeCoords.length));
|
|
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
|
|
|
|
// Status message
|
|
if (currentRoute.length > 1) {
|
|
const nextTrack = currentRoute[1] ? currentRoute[1].track.name : '';
|
|
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
|
|
} else {
|
|
updateStatus(`${distanceText} remaining`, 'info');
|
|
}
|
|
}
|
|
|
|
// Check how far user is from the current route
|
|
function getDistanceToRoute(currentPos, route) {
|
|
if (!route || route.length === 0) return Infinity;
|
|
|
|
let minDist = Infinity;
|
|
for (const segment of route) {
|
|
const start = Math.min(segment.fromIndex, segment.toIndex);
|
|
const end = Math.max(segment.fromIndex, segment.toIndex);
|
|
|
|
for (let i = start; i <= end; i++) {
|
|
const dist = map.distance(currentPos, L.latLng(segment.track.coords[i]));
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
}
|
|
}
|
|
}
|
|
return minDist;
|
|
}
|
|
|
|
// Lightweight navigation update - just update arrow and remaining distance
|
|
function updateNavigationLight(currentPos) {
|
|
if (!currentRoute || currentRoute.length === 0) return;
|
|
|
|
// Check if arrived
|
|
const distToDestination = map.distance(currentPos, L.latLng(destinationTrack.coords[destinationIndex]));
|
|
if (distToDestination < 10) {
|
|
clearRouteHighlight();
|
|
updateDirectionArrow(currentPos, []);
|
|
updateStatus('You have arrived!', 'success');
|
|
return;
|
|
}
|
|
|
|
// Find where we are on the route and calculate remaining distance
|
|
const routeCoords = getRouteCoordinates(currentRoute);
|
|
let closestIdx = 0;
|
|
let minDist = Infinity;
|
|
|
|
for (let i = 0; i < routeCoords.length; i++) {
|
|
const dist = map.distance(currentPos, L.latLng(routeCoords[i]));
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
closestIdx = i;
|
|
}
|
|
}
|
|
|
|
// Calculate remaining distance from current position
|
|
let remainingDist = minDist; // Distance to route
|
|
for (let i = closestIdx; i < routeCoords.length - 1; i++) {
|
|
remainingDist += map.distance(L.latLng(routeCoords[i]), L.latLng(routeCoords[i + 1]));
|
|
}
|
|
|
|
// Update distance display
|
|
const distanceText = remainingDist < 1000
|
|
? `${Math.round(remainingDist)}m`
|
|
: `${(remainingDist / 1000).toFixed(2)}km`;
|
|
document.getElementById('navDistance').textContent = distanceText;
|
|
|
|
// Update route highlight to only show remaining path (trim behind user)
|
|
const remainingRoute = routeCoords.slice(closestIdx);
|
|
updateRouteHighlight(remainingRoute);
|
|
|
|
// Update direction arrow to point along route from current position
|
|
const arrowPoints = routeCoords.slice(closestIdx, Math.min(closestIdx + 6, routeCoords.length));
|
|
updateDirectionArrow(currentPos, arrowPoints, '#ff6600');
|
|
|
|
// Status message
|
|
if (currentRoute.length > 1) {
|
|
updateStatus(`${distanceText} via ${currentRoute.length} trails`, 'info');
|
|
} else {
|
|
updateStatus(`${distanceText} remaining`, 'info');
|
|
}
|
|
|
|
// Update map rotation if enabled
|
|
if (rotateMapMode && arrowPoints.length >= 2) {
|
|
const bearing = calculateBearing(
|
|
arrowPoints[0][0], arrowPoints[0][1],
|
|
arrowPoints[1][0], arrowPoints[1][1]
|
|
);
|
|
rotateMap(bearing);
|
|
}
|
|
}
|
|
|
|
// Calculate bearing between two points (in degrees, 0 = North)
|
|
function calculateBearing(lat1, lng1, lat2, lng2) {
|
|
const dLng = (lng2 - lng1) * Math.PI / 180;
|
|
const lat1Rad = lat1 * Math.PI / 180;
|
|
const lat2Rad = lat2 * Math.PI / 180;
|
|
|
|
const y = Math.sin(dLng) * Math.cos(lat2Rad);
|
|
const x = Math.cos(lat1Rad) * Math.sin(lat2Rad) -
|
|
Math.sin(lat1Rad) * Math.cos(lat2Rad) * Math.cos(dLng);
|
|
|
|
let bearing = Math.atan2(y, x) * 180 / Math.PI;
|
|
return (bearing + 360) % 360; // Normalize to 0-360
|
|
}
|
|
|
|
// Update GPS marker to show direction of travel
|
|
function rotateMap(bearing) {
|
|
currentBearing = bearing;
|
|
|
|
// Rotate the map using leaflet-rotate plugin
|
|
// Negate bearing so direction of travel points UP
|
|
map.setBearing(-bearing);
|
|
|
|
// Update compass indicator
|
|
const compass = document.getElementById('compassIndicator');
|
|
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
|
|
const index = Math.round(bearing / 45) % 8;
|
|
compass.textContent = `Heading: ${directions[index]} ${Math.round(bearing)}°`;
|
|
}
|
|
|
|
// Reset map rotation
|
|
function resetMapRotation() {
|
|
currentBearing = 0;
|
|
map.setBearing(0);
|
|
document.getElementById('compassIndicator').classList.remove('active');
|
|
}
|
|
|
|
// Toggle rotate mode
|
|
function toggleRotateMap() {
|
|
rotateMapMode = !rotateMapMode;
|
|
const btn = document.getElementById('rotateMapBtn');
|
|
|
|
if (rotateMapMode) {
|
|
btn.textContent = 'Rotate Map: ON';
|
|
btn.classList.add('active');
|
|
document.getElementById('compassIndicator').classList.add('active');
|
|
} else {
|
|
btn.textContent = 'Rotate Map: OFF';
|
|
btn.classList.remove('active');
|
|
resetMapRotation();
|
|
}
|
|
}
|
|
|
|
function toggleAutoCenter() {
|
|
autoCenterMode = !autoCenterMode;
|
|
const btn = document.getElementById('autoCenterBtn');
|
|
|
|
if (autoCenterMode) {
|
|
btn.textContent = 'Auto-Center: ON';
|
|
btn.classList.add('active');
|
|
// Immediately center on current GPS position if available
|
|
if (gpsMarker) {
|
|
map.setView(gpsMarker.getLatLng(), map.getZoom());
|
|
}
|
|
} else {
|
|
btn.textContent = 'Auto-Center: OFF';
|
|
btn.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Admin Settings Functions
|
|
function loadAdminSettings() {
|
|
const saved = localStorage.getItem('adminSettings');
|
|
if (saved) {
|
|
try {
|
|
adminSettings = { ...adminSettings, ...JSON.parse(saved) };
|
|
} catch (e) {
|
|
console.error('Error loading admin settings:', e);
|
|
}
|
|
}
|
|
applyAdminSettings();
|
|
}
|
|
|
|
function saveAdminSettings() {
|
|
localStorage.setItem('adminSettings', JSON.stringify(adminSettings));
|
|
}
|
|
|
|
function applyAdminSettings() {
|
|
// Update UI elements if they exist
|
|
if (document.getElementById('geocacheRange')) {
|
|
document.getElementById('geocacheRange').value = adminSettings.geocacheRange;
|
|
document.getElementById('geocacheAlertRange').value = adminSettings.geocacheAlertRange;
|
|
document.getElementById('geocacheSoundEnabled').checked = adminSettings.geocacheSoundEnabled !== false;
|
|
document.getElementById('trackProximity').value = adminSettings.trackProximity;
|
|
document.getElementById('intersectionThreshold').value = adminSettings.intersectionThreshold;
|
|
document.getElementById('onTrailThreshold').value = adminSettings.onTrailThreshold;
|
|
document.getElementById('nodeSpacing').value = adminSettings.nodeSpacing;
|
|
document.getElementById('gpsPollInterval').value = adminSettings.gpsPollInterval;
|
|
document.getElementById('proximityCheckInterval').value = adminSettings.proximityCheckInterval;
|
|
}
|
|
}
|
|
|
|
// Debounce function for saving
|
|
function debounce(func, wait) {
|
|
let timeout;
|
|
return function executedFunction(...args) {
|
|
const later = () => {
|
|
clearTimeout(timeout);
|
|
func(...args);
|
|
};
|
|
clearTimeout(timeout);
|
|
timeout = setTimeout(later, wait);
|
|
};
|
|
}
|
|
|
|
// Show save indicator
|
|
function showSaveIndicator(message = 'Settings Saved ✓') {
|
|
const indicator = document.getElementById('adminSaveIndicator');
|
|
if (indicator) {
|
|
indicator.textContent = message;
|
|
indicator.classList.add('show');
|
|
setTimeout(() => {
|
|
indicator.classList.remove('show');
|
|
}, 2000);
|
|
}
|
|
}
|
|
|
|
// Debounced save function
|
|
const debouncedSave = debounce(() => {
|
|
saveAdminSettings();
|
|
showSaveIndicator();
|
|
}, 500);
|
|
|
|
function setupAdminInputListeners() {
|
|
// Add change and input listeners to all admin inputs
|
|
const adminInputs = [
|
|
{ id: 'geocacheRange', setting: 'geocacheRange', type: 'number' },
|
|
{ id: 'geocacheAlertRange', setting: 'geocacheAlertRange', type: 'number' },
|
|
{ id: 'geocacheSoundEnabled', setting: 'geocacheSoundEnabled', type: 'checkbox' },
|
|
{ id: 'trackProximity', setting: 'trackProximity', type: 'number' },
|
|
{ id: 'intersectionThreshold', setting: 'intersectionThreshold', type: 'number' },
|
|
{ id: 'onTrailThreshold', setting: 'onTrailThreshold', type: 'number' },
|
|
{ id: 'nodeSpacing', setting: 'nodeSpacing', type: 'number' },
|
|
{ id: 'gpsPollInterval', setting: 'gpsPollInterval', type: 'number' },
|
|
{ id: 'proximityCheckInterval', setting: 'proximityCheckInterval', type: 'number' }
|
|
];
|
|
|
|
|
|
adminInputs.forEach(input => {
|
|
const element = document.getElementById(input.id);
|
|
if (element) {
|
|
// Use both input and change events for better responsiveness
|
|
const updateHandler = function() {
|
|
let value;
|
|
if (input.type === 'checkbox') {
|
|
value = this.checked;
|
|
} else if (input.type === 'number') {
|
|
value = parseFloat(this.value);
|
|
} else {
|
|
value = this.value;
|
|
}
|
|
adminSettings[input.setting] = value;
|
|
debouncedSave();
|
|
};
|
|
|
|
if (input.type === 'checkbox') {
|
|
element.addEventListener('change', updateHandler);
|
|
} else {
|
|
element.addEventListener('input', updateHandler);
|
|
element.addEventListener('change', updateHandler);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Setup collapsible sections
|
|
document.querySelectorAll('.section.collapsible').forEach(section => {
|
|
const title = section.querySelector('.section-title');
|
|
if (title) {
|
|
title.addEventListener('click', () => {
|
|
section.classList.toggle('collapsed');
|
|
// Save collapsed state
|
|
const sectionName = section.dataset.section;
|
|
if (sectionName) {
|
|
const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}');
|
|
collapsedSections[sectionName] = section.classList.contains('collapsed');
|
|
localStorage.setItem('collapsedSections', JSON.stringify(collapsedSections));
|
|
}
|
|
});
|
|
|
|
// Restore collapsed state
|
|
const sectionName = section.dataset.section;
|
|
if (sectionName) {
|
|
const collapsedSections = JSON.parse(localStorage.getItem('collapsedSections') || '{}');
|
|
if (collapsedSections[sectionName]) {
|
|
section.classList.add('collapsed');
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function resetAdminSettings() {
|
|
if (confirm('Reset all settings to defaults?')) {
|
|
adminSettings = {
|
|
geocacheRange: 5,
|
|
geocacheAlertRange: 5,
|
|
geocacheSoundEnabled: true,
|
|
trackProximity: 50,
|
|
intersectionThreshold: 15,
|
|
onTrailThreshold: 100,
|
|
nodeSpacing: 50,
|
|
gpsPollInterval: 3000,
|
|
proximityCheckInterval: 3000,
|
|
snapDistancePx: 15
|
|
};
|
|
saveAdminSettings();
|
|
applyAdminSettings();
|
|
}
|
|
}
|
|
|
|
function exportAdminSettings() {
|
|
const dataStr = JSON.stringify(adminSettings, null, 2);
|
|
const dataUri = 'data:application/json;charset=utf-8,'+ encodeURIComponent(dataStr);
|
|
const exportFileDefaultName = 'hikemap-settings.json';
|
|
const linkElement = document.createElement('a');
|
|
linkElement.setAttribute('href', dataUri);
|
|
linkElement.setAttribute('download', exportFileDefaultName);
|
|
linkElement.click();
|
|
}
|
|
|
|
function importAdminSettings() {
|
|
const input = document.createElement('input');
|
|
input.type = 'file';
|
|
input.accept = 'application/json';
|
|
input.onchange = e => {
|
|
const file = e.target.files[0];
|
|
const reader = new FileReader();
|
|
reader.onload = event => {
|
|
try {
|
|
const imported = JSON.parse(event.target.result);
|
|
adminSettings = { ...adminSettings, ...imported };
|
|
saveAdminSettings();
|
|
applyAdminSettings();
|
|
alert('Settings imported successfully');
|
|
} catch (error) {
|
|
alert('Error importing settings: ' + error.message);
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
input.click();
|
|
}
|
|
|
|
// Icon selection
|
|
const availableIcons = [
|
|
{ icon: 'mdi-walk', name: 'Walking', color: '#ff6b6b' },
|
|
{ icon: 'mdi-bike', name: 'Biking', color: '#4ecdc4' },
|
|
{ icon: 'mdi-run', name: 'Running', color: '#45b7d1' },
|
|
{ icon: 'mdi-hiking', name: 'Hiking', color: '#f7b731' },
|
|
{ icon: 'mdi-ski', name: 'Skiing', color: '#5f27cd' },
|
|
{ icon: 'mdi-skateboard', name: 'Skateboarding', color: '#00d2d3' },
|
|
{ icon: 'mdi-wheelchair', name: 'Wheelchair', color: '#fd79a8' },
|
|
{ icon: 'mdi-car', name: 'Driving', color: '#636e72' },
|
|
{ icon: 'mdi-motorbike', name: 'Motorcycling', color: '#e17055' },
|
|
{ icon: 'mdi-horse', name: 'Horseback', color: '#a29bfe' }
|
|
];
|
|
|
|
function showIconSelector() {
|
|
const selector = document.getElementById('iconSelector');
|
|
if (!selector) {
|
|
console.error('Icon selector element not found!');
|
|
return;
|
|
}
|
|
|
|
const iconGrid = document.getElementById('iconGrid');
|
|
iconGrid.innerHTML = '';
|
|
|
|
availableIcons.forEach((iconConfig, index) => {
|
|
const iconDiv = document.createElement('div');
|
|
iconDiv.className = 'icon-option';
|
|
iconDiv.innerHTML = `
|
|
<i class="mdi ${iconConfig.icon}" style="color: ${iconConfig.color};"></i>
|
|
<span>${iconConfig.name}</span>
|
|
`;
|
|
iconDiv.onclick = () => selectIcon(iconConfig);
|
|
iconGrid.appendChild(iconDiv);
|
|
});
|
|
|
|
// Force display and ensure it's visible
|
|
selector.style.display = 'flex';
|
|
selector.style.visibility = 'visible';
|
|
selector.style.opacity = '1';
|
|
selector.style.zIndex = '10000';
|
|
document.body.style.overflow = 'visible';
|
|
|
|
// Force a reflow to ensure rendering
|
|
selector.offsetHeight;
|
|
|
|
console.log('Icon selector shown:', selector.style.display, 'Computed display:', window.getComputedStyle(selector).display);
|
|
}
|
|
|
|
function selectIcon(iconConfig) {
|
|
myIcon = iconConfig.icon;
|
|
myColor = iconConfig.color;
|
|
|
|
// Store in localStorage
|
|
localStorage.setItem('userIcon', myIcon);
|
|
localStorage.setItem('userColor', myColor);
|
|
|
|
document.getElementById('iconSelector').style.display = 'none';
|
|
|
|
// Send icon info to server if connected
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'iconUpdate',
|
|
icon: myIcon,
|
|
color: myColor
|
|
}));
|
|
}
|
|
}
|
|
|
|
function loadUserIcon() {
|
|
myIcon = localStorage.getItem('userIcon');
|
|
myColor = localStorage.getItem('userColor');
|
|
|
|
if (!myIcon || !myColor) {
|
|
// Show selector if no icon chosen yet - delay to ensure DOM is ready
|
|
setTimeout(() => {
|
|
showIconSelector();
|
|
}, 100);
|
|
}
|
|
}
|
|
|
|
// Password authentication functions
|
|
async function hashPassword(password) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(password);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
function showPasswordDialog() {
|
|
const dialog = document.getElementById('passwordDialog');
|
|
if (!dialog) {
|
|
console.error('Password dialog not found!');
|
|
return;
|
|
}
|
|
|
|
dialog.style.display = 'flex';
|
|
dialog.style.visibility = 'visible';
|
|
dialog.style.opacity = '1';
|
|
dialog.style.zIndex = '10001';
|
|
document.body.style.overflow = 'visible';
|
|
|
|
document.getElementById('passwordInput').value = '';
|
|
document.getElementById('passwordError').style.display = 'none';
|
|
document.getElementById('passwordInput').focus();
|
|
|
|
// Force a reflow
|
|
dialog.offsetHeight;
|
|
console.log('Password dialog shown');
|
|
}
|
|
|
|
function hidePasswordDialog() {
|
|
document.getElementById('passwordDialog').style.display = 'none';
|
|
}
|
|
|
|
async function checkPassword() {
|
|
const password = document.getElementById('passwordInput').value;
|
|
const hashedPassword = await hashPassword(password);
|
|
|
|
// Check against stored password hash or default
|
|
const storedHash = localStorage.getItem('editPasswordHash') || DEFAULT_PASSWORD_HASH;
|
|
|
|
if (hashedPassword === storedHash) {
|
|
isAuthenticated = true;
|
|
hidePasswordDialog();
|
|
switchTab('edit');
|
|
updateStatus('Edit mode unlocked', 'success');
|
|
} else {
|
|
document.getElementById('passwordError').style.display = 'block';
|
|
document.getElementById('passwordInput').select();
|
|
}
|
|
}
|
|
|
|
// Calculate minimum distance from a point to any track
|
|
function getDistanceToNearestTrack(lat, lng) {
|
|
if (tracks.length === 0) return Infinity;
|
|
|
|
let minDistance = Infinity;
|
|
const point = L.latLng(lat, lng);
|
|
|
|
for (const track of tracks) {
|
|
for (let i = 0; i < track.coords.length - 1; i++) {
|
|
const p1 = L.latLng(track.coords[i]);
|
|
const p2 = L.latLng(track.coords[i + 1]);
|
|
|
|
// Calculate distance from point to line segment
|
|
const distance = distanceToLineSegment(point, p1, p2);
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
}
|
|
}
|
|
}
|
|
|
|
return minDistance;
|
|
}
|
|
|
|
// Calculate distance from point to line segment
|
|
function distanceToLineSegment(point, lineStart, lineEnd) {
|
|
const x = point.lat;
|
|
const y = point.lng;
|
|
const x1 = lineStart.lat;
|
|
const y1 = lineStart.lng;
|
|
const x2 = lineEnd.lat;
|
|
const y2 = lineEnd.lng;
|
|
|
|
const A = x - x1;
|
|
const B = y - y1;
|
|
const C = x2 - x1;
|
|
const D = y2 - y1;
|
|
|
|
const dot = A * C + B * D;
|
|
const lenSq = C * C + D * D;
|
|
let param = -1;
|
|
|
|
if (lenSq !== 0) {
|
|
param = dot / lenSq;
|
|
}
|
|
|
|
let xx, yy;
|
|
|
|
if (param < 0) {
|
|
xx = x1;
|
|
yy = y1;
|
|
} else if (param > 1) {
|
|
xx = x2;
|
|
yy = y2;
|
|
} else {
|
|
xx = x1 + param * C;
|
|
yy = y1 + param * D;
|
|
}
|
|
|
|
const dx = x - xx;
|
|
const dy = y - yy;
|
|
|
|
// Return distance in meters
|
|
return L.latLng(x, y).distanceTo(L.latLng(xx, yy));
|
|
}
|
|
|
|
// Geocache Functions
|
|
function generateGeocacheId() {
|
|
return 'gc_' + Date.now() + '_' + Math.random().toString(36).substr(2, 9);
|
|
}
|
|
|
|
function shouldShowGeocache(geocache) {
|
|
// In edit mode, always show all geocaches
|
|
if (!navMode) return true;
|
|
|
|
// If no visibility restriction, always show
|
|
if (!geocache.visibilityDistance || geocache.visibilityDistance === 0) return true;
|
|
|
|
// In nav mode, only show if user is within visibility distance
|
|
if (!userLocation) return false;
|
|
|
|
const distance = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(geocache.lat, geocache.lng));
|
|
|
|
return distance <= geocache.visibilityDistance;
|
|
}
|
|
|
|
function placeGeocache(latlng) {
|
|
const id = generateGeocacheId();
|
|
const geocache = {
|
|
id: id,
|
|
lat: latlng.lat,
|
|
lng: latlng.lng,
|
|
title: '', // Will be set when user submits
|
|
icon: 'package-variant', // Default icon
|
|
color: '#FFA726', // Default orange color
|
|
visibilityDistance: 0, // 0 = always visible, >0 = only visible within that distance
|
|
messages: [],
|
|
createdAt: Date.now()
|
|
};
|
|
|
|
geocaches.push(geocache);
|
|
createGeocacheMarker(geocache);
|
|
showGeocacheDialog(geocache, true); // true = can add message immediately
|
|
}
|
|
|
|
function createGeocacheMarker(geocache) {
|
|
// Check visibility based on mode and distance
|
|
if (!shouldShowGeocache(geocache)) {
|
|
console.log(`Geocache ${geocache.id} not visible due to distance restriction`);
|
|
return;
|
|
}
|
|
|
|
console.log(`Creating geocache marker for ${geocache.id} at ${geocache.lat}, ${geocache.lng}`);
|
|
// Use geocache's custom icon and color
|
|
const iconClass = `mdi-${geocache.icon || 'package-variant'}`;
|
|
const color = geocache.color || '#FFA726';
|
|
|
|
// In edit mode, make secret caches slightly transparent
|
|
const opacity = (!navMode && geocache.visibilityDistance > 0) ? 0.7 : 1.0;
|
|
|
|
const marker = L.marker([geocache.lat, geocache.lng], {
|
|
icon: L.divIcon({
|
|
className: 'geocache-marker',
|
|
html: `<i class="mdi ${iconClass}" style="font-size: 28px; color: ${color}; opacity: ${opacity};"></i>`,
|
|
iconSize: [28, 28],
|
|
iconAnchor: [14, 28]
|
|
}),
|
|
zIndexOffset: 1000 // Ensure geocaches appear above GPS markers
|
|
});
|
|
|
|
marker.geocacheId = geocache.id;
|
|
marker._isInRange = false; // Initialize animation state tracking
|
|
marker.on('click', function(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
L.DomEvent.preventDefault(e);
|
|
console.log('Geocache clicked, marker still exists:', !!geocacheMarkers[geocache.id]);
|
|
const gc = geocaches.find(g => g.id === geocache.id);
|
|
if (gc) {
|
|
showGeocacheDialog(gc, false);
|
|
// Check marker after dialog shown
|
|
setTimeout(() => {
|
|
console.log('After dialog, marker exists:', !!geocacheMarkers[geocache.id]);
|
|
if (geocacheMarkers[geocache.id] && geocacheMarkers[geocache.id]._icon) {
|
|
const icon = geocacheMarkers[geocache.id]._icon;
|
|
console.log('Icon visibility:', icon.style.display, icon.style.visibility, icon.style.opacity);
|
|
console.log('Icon className:', icon.className);
|
|
console.log('Icon innerHTML:', icon.innerHTML);
|
|
}
|
|
}, 100);
|
|
}
|
|
return false;
|
|
});
|
|
|
|
marker.addTo(map);
|
|
geocacheMarkers[geocache.id] = marker;
|
|
console.log(`Geocache marker added to map. Total markers: ${Object.keys(geocacheMarkers).length}`);
|
|
|
|
// Check if marker is actually visible after adding
|
|
setTimeout(() => {
|
|
if (marker._icon) {
|
|
console.log(`Marker ${geocache.id} icon after add:`, {
|
|
exists: true,
|
|
className: marker._icon.className,
|
|
display: marker._icon.style.display,
|
|
visibility: marker._icon.style.visibility,
|
|
innerHTML: marker._icon.innerHTML.substring(0, 50) + '...'
|
|
});
|
|
} else {
|
|
console.log(`WARNING: Marker ${geocache.id} has no _icon after adding!`);
|
|
}
|
|
}, 100);
|
|
}
|
|
|
|
function showGeocacheDialog(geocache, isNew = false) {
|
|
// In nav mode, check if user is close enough to view/interact
|
|
let userDistance = Infinity;
|
|
if (navMode && userLocation) {
|
|
userDistance = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(geocache.lat, geocache.lng));
|
|
}
|
|
|
|
currentGeocache = geocache;
|
|
const dialog = document.getElementById('geocacheDialog');
|
|
const messagesDiv = document.getElementById('geocacheMessages');
|
|
const deleteBtn = document.getElementById('geocacheDelete');
|
|
const submitBtn = document.getElementById('geocacheSubmit');
|
|
const form = document.getElementById('geocacheForm');
|
|
const titleGroup = document.getElementById('geocacheTitleGroup');
|
|
const iconGroup = document.getElementById('geocacheIconGroup');
|
|
const colorGroup = document.getElementById('geocacheColorGroup');
|
|
const visibilityGroup = document.getElementById('geocacheVisibilityGroup');
|
|
const titleInput = document.getElementById('geocacheTitleInput');
|
|
const iconInput = document.getElementById('geocacheIconInput');
|
|
const colorInput = document.getElementById('geocacheColorInput');
|
|
const visibilityInput = document.getElementById('geocacheVisibilityInput');
|
|
const dialogTitle = document.getElementById('geocacheTitle');
|
|
const editBtn = document.getElementById('geocacheEdit');
|
|
|
|
// Clear previous messages
|
|
messagesDiv.innerHTML = '';
|
|
|
|
// Update dialog title
|
|
dialogTitle.textContent = geocache.title ? `📍 ${geocache.title}` : '📍 Geocache';
|
|
|
|
// Show/hide creation fields for new geocaches or edit mode
|
|
const isEditing = currentGeocacheEditMode === true;
|
|
if ((isNew && !geocache.title) || isEditing) {
|
|
titleGroup.style.display = 'block';
|
|
iconGroup.style.display = 'block';
|
|
colorGroup.style.display = 'block';
|
|
visibilityGroup.style.display = 'block';
|
|
|
|
titleInput.value = isEditing ? geocache.title : '';
|
|
iconInput.value = geocache.icon || 'package-variant';
|
|
colorInput.value = geocache.color || '#FFA726';
|
|
visibilityInput.value = geocache.visibilityDistance || 0;
|
|
|
|
// Update color preview
|
|
updateColorPreview();
|
|
} else {
|
|
titleGroup.style.display = 'none';
|
|
iconGroup.style.display = 'none';
|
|
colorGroup.style.display = 'none';
|
|
visibilityGroup.style.display = 'none';
|
|
}
|
|
|
|
// Show edit button only in edit mode and for existing caches
|
|
editBtn.style.display = (!navMode && geocache.title && !isEditing) ? 'block' : 'none';
|
|
|
|
// Check if user can view messages (within 5m in nav mode, or in edit mode)
|
|
const canViewMessages = !navMode || userDistance <= adminSettings.geocacheRange;
|
|
|
|
if (canViewMessages) {
|
|
// Mark as read when viewing messages
|
|
if (!readGeocaches.includes(geocache.id)) {
|
|
readGeocaches.push(geocache.id);
|
|
localStorage.setItem('readGeocaches', JSON.stringify(readGeocaches));
|
|
// Update marker icon
|
|
updateGeocacheMarkerIcon(geocache.id, true);
|
|
}
|
|
|
|
// Show messages
|
|
if (geocache.messages && geocache.messages.length > 0) {
|
|
geocache.messages.forEach(msg => {
|
|
const msgDiv = document.createElement('div');
|
|
msgDiv.className = 'geocache-message';
|
|
|
|
const headerDiv = document.createElement('div');
|
|
headerDiv.className = 'geocache-message-header';
|
|
|
|
const authorSpan = document.createElement('span');
|
|
authorSpan.className = 'geocache-message-author';
|
|
authorSpan.textContent = msg.author || 'Anonymous';
|
|
|
|
const timestampSpan = document.createElement('span');
|
|
const date = new Date(msg.timestamp);
|
|
timestampSpan.textContent = date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
|
|
|
headerDiv.appendChild(authorSpan);
|
|
headerDiv.appendChild(timestampSpan);
|
|
|
|
const textDiv = document.createElement('div');
|
|
textDiv.className = 'geocache-message-text';
|
|
textDiv.textContent = msg.text;
|
|
|
|
msgDiv.appendChild(headerDiv);
|
|
msgDiv.appendChild(textDiv);
|
|
messagesDiv.appendChild(msgDiv);
|
|
});
|
|
} else {
|
|
messagesDiv.innerHTML = '<p style="text-align: center; color: #999;">No messages yet. Be the first to leave one!</p>';
|
|
}
|
|
} else {
|
|
// User is too far away - show distance message
|
|
const distanceText = Math.round(userDistance);
|
|
messagesDiv.innerHTML = `<p style="text-align: center; color: #ff9800;">📍 You are ${distanceText} meters away.<br><br>Get within 5 meters to view and sign this geocache!</p>`;
|
|
}
|
|
|
|
// Show delete button only in edit mode
|
|
deleteBtn.style.display = !navMode ? 'block' : 'none';
|
|
|
|
// In nav mode, only show form if user is within 5m or if in edit mode
|
|
const canAddMessage = !navMode || userDistance <= adminSettings.geocacheRange;
|
|
|
|
if (canAddMessage) {
|
|
form.style.display = 'block';
|
|
submitBtn.style.display = 'block';
|
|
// Clear input fields
|
|
document.getElementById('geocacheName').value = '';
|
|
document.getElementById('geocacheMessage').value = '';
|
|
} else {
|
|
form.style.display = 'none';
|
|
submitBtn.style.display = 'none';
|
|
}
|
|
|
|
// Show dialog
|
|
dialog.style.display = 'flex';
|
|
}
|
|
|
|
function hideGeocacheDialog() {
|
|
document.getElementById('geocacheDialog').style.display = 'none';
|
|
currentGeocache = null;
|
|
currentGeocacheEditMode = false;
|
|
}
|
|
|
|
function updateColorPreview() {
|
|
const iconInput = document.getElementById('geocacheIconInput');
|
|
const colorInput = document.getElementById('geocacheColorInput');
|
|
const preview = document.getElementById('geocacheColorPreview');
|
|
|
|
const icon = iconInput.value || 'package-variant';
|
|
const color = colorInput.value || '#FFA726';
|
|
|
|
preview.innerHTML = `<i class="mdi mdi-${icon}" style="color: ${color};"></i>`;
|
|
}
|
|
|
|
function startEditingGeocache() {
|
|
if (!currentGeocache || !currentGeocache.title) return;
|
|
currentGeocacheEditMode = true;
|
|
showGeocacheDialog(currentGeocache, false);
|
|
}
|
|
|
|
function updateGeocacheList() {
|
|
const content = document.getElementById('geocacheListContent');
|
|
content.innerHTML = '';
|
|
|
|
if (geocaches.length === 0) {
|
|
content.innerHTML = '<p style="color: #aaa; text-align: center;">No geocaches placed yet</p>';
|
|
return;
|
|
}
|
|
|
|
geocaches.forEach(cache => {
|
|
const div = document.createElement('div');
|
|
div.className = 'geocache-list-item';
|
|
|
|
const titleDiv = document.createElement('div');
|
|
titleDiv.className = 'geocache-list-item-title';
|
|
titleDiv.innerHTML = `
|
|
<i class="mdi mdi-${cache.icon || 'package-variant'}" style="color: ${cache.color || '#FFA726'};"></i>
|
|
<span>${cache.title || 'Untitled Cache'}</span>
|
|
${cache.visibilityDistance > 0 ? '<span class="geocache-list-item-secret">SECRET</span>' : ''}
|
|
`;
|
|
|
|
const infoDiv = document.createElement('div');
|
|
infoDiv.className = 'geocache-list-item-info';
|
|
const messageCount = cache.messages ? cache.messages.length : 0;
|
|
const createdDate = new Date(cache.createdAt).toLocaleDateString();
|
|
infoDiv.innerHTML = `
|
|
${messageCount} message${messageCount !== 1 ? 's' : ''} • Created ${createdDate}
|
|
${cache.visibilityDistance > 0 ? `<br>Visible within ${cache.visibilityDistance}m` : ''}
|
|
`;
|
|
|
|
div.appendChild(titleDiv);
|
|
div.appendChild(infoDiv);
|
|
|
|
// Click to go to cache
|
|
div.addEventListener('click', () => {
|
|
map.setView([cache.lat, cache.lng], 16);
|
|
showGeocacheDialog(cache, false);
|
|
document.getElementById('geocacheListSidebar').classList.remove('open');
|
|
});
|
|
|
|
content.appendChild(div);
|
|
});
|
|
}
|
|
|
|
function updateGeocacheVisibility() {
|
|
// Update visibility of all geocache markers based on current user location
|
|
geocaches.forEach(cache => {
|
|
const shouldShow = shouldShowGeocache(cache);
|
|
const marker = geocacheMarkers[cache.id];
|
|
|
|
if (shouldShow && !marker) {
|
|
// Create marker if it should be visible but doesn't exist
|
|
createGeocacheMarker(cache);
|
|
} else if (!shouldShow && marker) {
|
|
// Remove marker if it shouldn't be visible
|
|
map.removeLayer(marker);
|
|
delete geocacheMarkers[cache.id];
|
|
}
|
|
});
|
|
}
|
|
|
|
function updateGeocacheMarkerIcon(geocacheId, isRead) {
|
|
// Since we're not changing icons for read/unread anymore, this function
|
|
// doesn't need to do anything. The animation state is handled by checkGeocacheProximity
|
|
return;
|
|
}
|
|
|
|
function addGeocacheMessage() {
|
|
if (!currentGeocache) return;
|
|
|
|
const nameInput = document.getElementById('geocacheName');
|
|
const messageInput = document.getElementById('geocacheMessage');
|
|
const titleInput = document.getElementById('geocacheTitleInput');
|
|
const iconInput = document.getElementById('geocacheIconInput');
|
|
const colorInput = document.getElementById('geocacheColorInput');
|
|
const visibilityInput = document.getElementById('geocacheVisibilityInput');
|
|
|
|
// For new geocaches or when editing, update all properties
|
|
if (!currentGeocache.title || currentGeocacheEditMode) {
|
|
const title = titleInput.value.trim();
|
|
const icon = iconInput.value.trim();
|
|
const color = colorInput.value || '#FFA726';
|
|
const visibilityDistance = parseInt(visibilityInput.value) || 0;
|
|
|
|
if (!title) {
|
|
alert('Please enter a title for this geocache');
|
|
return;
|
|
}
|
|
|
|
currentGeocache.title = title;
|
|
currentGeocache.icon = icon || 'package-variant';
|
|
currentGeocache.color = color;
|
|
currentGeocache.visibilityDistance = visibilityDistance;
|
|
|
|
// Update or recreate the marker with new properties
|
|
if (geocacheMarkers[currentGeocache.id]) {
|
|
map.removeLayer(geocacheMarkers[currentGeocache.id]);
|
|
delete geocacheMarkers[currentGeocache.id];
|
|
}
|
|
createGeocacheMarker(currentGeocache);
|
|
|
|
// If we were editing, exit edit mode
|
|
if (currentGeocacheEditMode) {
|
|
currentGeocacheEditMode = false;
|
|
// Don't add a message when just editing properties
|
|
if (!messageInput.value.trim()) {
|
|
showGeocacheDialog(currentGeocache, false);
|
|
// Broadcast the update
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'geocacheUpdate',
|
|
geocache: currentGeocache
|
|
}));
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
const name = nameInput.value.trim() || 'Anonymous';
|
|
const text = messageInput.value.trim();
|
|
|
|
if (!text) {
|
|
alert('Please enter a message');
|
|
return;
|
|
}
|
|
|
|
// In nav mode, check if user is close enough
|
|
if (navMode && userLocation) {
|
|
const distance = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(currentGeocache.lat, currentGeocache.lng));
|
|
|
|
if (distance > 5) { // 5 meters
|
|
alert('You must be within 5 meters of the geocache to leave a message!');
|
|
return;
|
|
}
|
|
}
|
|
|
|
const message = {
|
|
author: name,
|
|
text: text,
|
|
timestamp: Date.now()
|
|
};
|
|
|
|
if (!currentGeocache.messages) {
|
|
currentGeocache.messages = [];
|
|
}
|
|
currentGeocache.messages.push(message);
|
|
|
|
// Update display
|
|
showGeocacheDialog(currentGeocache);
|
|
|
|
// Broadcast via WebSocket if connected
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'geocacheUpdate',
|
|
geocache: currentGeocache
|
|
}));
|
|
}
|
|
}
|
|
|
|
function deleteGeocache() {
|
|
if (!currentGeocache) return;
|
|
|
|
const index = geocaches.findIndex(g => g.id === currentGeocache.id);
|
|
if (index > -1) {
|
|
geocaches.splice(index, 1);
|
|
|
|
// Remove marker
|
|
if (geocacheMarkers[currentGeocache.id]) {
|
|
map.removeLayer(geocacheMarkers[currentGeocache.id]);
|
|
delete geocacheMarkers[currentGeocache.id];
|
|
}
|
|
|
|
// Broadcast deletion
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'geocacheDelete',
|
|
geocacheId: currentGeocache.id
|
|
}));
|
|
}
|
|
|
|
hideGeocacheDialog();
|
|
}
|
|
}
|
|
|
|
function checkGeocacheProximity() {
|
|
if (!navMode || !userLocation) return;
|
|
|
|
const now = Date.now();
|
|
// Only check at configured interval
|
|
if (now - lastGeocacheProximityCheck < adminSettings.proximityCheckInterval) return;
|
|
lastGeocacheProximityCheck = now;
|
|
|
|
geocaches.forEach(geocache => {
|
|
const distance = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(geocache.lat, geocache.lng));
|
|
|
|
const isInRange = distance <= adminSettings.geocacheAlertRange;
|
|
|
|
// ONLY show alert - don't touch the marker at all
|
|
if (isInRange && !geocache.alerted) {
|
|
geocache.alerted = true;
|
|
showGeocacheAlert();
|
|
} else if (!isInRange) {
|
|
geocache.alerted = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function showGeocacheAlert() {
|
|
const alert = document.getElementById('geocacheAlert');
|
|
alert.classList.add('show');
|
|
|
|
// Play alert sound
|
|
playGeocacheSound();
|
|
|
|
setTimeout(() => {
|
|
alert.classList.remove('show');
|
|
}, 3000);
|
|
}
|
|
|
|
// Setup geocache dialog handlers
|
|
document.getElementById('geocacheCancel').addEventListener('click', hideGeocacheDialog);
|
|
document.getElementById('geocacheSubmit').addEventListener('click', addGeocacheMessage);
|
|
document.getElementById('geocacheDelete').addEventListener('click', deleteGeocache);
|
|
document.getElementById('geocacheEdit').addEventListener('click', startEditingGeocache);
|
|
|
|
// Color picker events
|
|
document.getElementById('geocacheIconInput').addEventListener('input', updateColorPreview);
|
|
document.getElementById('geocacheColorInput').addEventListener('input', updateColorPreview);
|
|
document.getElementById('geocacheColorReset').addEventListener('click', () => {
|
|
document.getElementById('geocacheColorInput').value = '#FFA726';
|
|
updateColorPreview();
|
|
});
|
|
|
|
// Geocache list sidebar events
|
|
document.getElementById('geocacheListToggle').addEventListener('click', () => {
|
|
document.getElementById('geocacheListSidebar').classList.toggle('open');
|
|
updateGeocacheList();
|
|
});
|
|
document.getElementById('geocacheListClose').addEventListener('click', () => {
|
|
document.getElementById('geocacheListSidebar').classList.remove('open');
|
|
});
|
|
document.getElementById('geocacheAlert').addEventListener('click', function() {
|
|
// Find nearest geocache and open it
|
|
if (userLocation) {
|
|
let nearestCache = null;
|
|
let minDistance = Infinity;
|
|
|
|
geocaches.forEach(gc => {
|
|
const dist = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(gc.lat, gc.lng));
|
|
if (dist < minDistance && dist <= 5) {
|
|
minDistance = dist;
|
|
nearestCache = gc;
|
|
}
|
|
});
|
|
|
|
if (nearestCache) {
|
|
showGeocacheDialog(nearestCache);
|
|
}
|
|
}
|
|
});
|
|
|
|
// WebSocket multi-user tracking
|
|
function connectWebSocket() {
|
|
if (ws && ws.readyState === WebSocket.OPEN) return;
|
|
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const wsUrl = `${protocol}//${window.location.host}`;
|
|
|
|
try {
|
|
ws = new WebSocket(wsUrl);
|
|
|
|
ws.onopen = () => {
|
|
console.log('Connected to multi-user tracking');
|
|
clearTimeout(wsReconnectTimer);
|
|
|
|
// Send our icon info if we have it
|
|
if (myIcon && myColor) {
|
|
setTimeout(() => {
|
|
ws.send(JSON.stringify({
|
|
type: 'iconUpdate',
|
|
icon: myIcon,
|
|
color: myColor
|
|
}));
|
|
}, 100);
|
|
}
|
|
};
|
|
|
|
ws.onmessage = (event) => {
|
|
console.log('Raw WebSocket message:', event.data);
|
|
const data = JSON.parse(event.data);
|
|
console.log('Parsed message type:', data.type);
|
|
|
|
switch (data.type) {
|
|
case 'init':
|
|
userId = data.userId;
|
|
// Add existing users
|
|
if (data.users) {
|
|
data.users.forEach(user => {
|
|
if (user.userId !== userId) {
|
|
updateOtherUser(user.userId, user.lat, user.lng, user.accuracy, user.icon, user.color);
|
|
}
|
|
});
|
|
}
|
|
break;
|
|
|
|
case 'userLocation':
|
|
if (data.visible === false) {
|
|
// User is off-track, hide them
|
|
removeOtherUser(data.userId);
|
|
} else {
|
|
// User is on-track, show them
|
|
updateOtherUser(data.userId, data.lat, data.lng, data.accuracy, data.icon, data.color);
|
|
}
|
|
break;
|
|
|
|
case 'userDisconnected':
|
|
removeOtherUser(data.userId);
|
|
break;
|
|
|
|
case 'geocacheUpdate':
|
|
// Another user or device added or updated a geocache
|
|
if (data.geocache) {
|
|
const existingIndex = geocaches.findIndex(g => g.id === data.geocache.id);
|
|
if (existingIndex >= 0) {
|
|
// Check if this is a new message
|
|
const oldCache = geocaches[existingIndex];
|
|
const oldMessageCount = oldCache.messages ? oldCache.messages.length : 0;
|
|
const newMessageCount = data.geocache.messages ? data.geocache.messages.length : 0;
|
|
const newMessagesCount = newMessageCount - oldMessageCount;
|
|
|
|
// Update existing geocache
|
|
geocaches[existingIndex] = data.geocache;
|
|
|
|
// Send notification if new message added and we're nearby
|
|
// This will notify even if it's from your own account on another device
|
|
if (newMessagesCount > 0 && userLocation && pushSubscription) {
|
|
const distance = L.latLng(userLocation.lat, userLocation.lng)
|
|
.distanceTo(L.latLng(data.geocache.lat, data.geocache.lng));
|
|
|
|
if (distance <= CACHE_NOTIFY_DISTANCE) {
|
|
const latestMessage = data.geocache.messages[data.geocache.messages.length - 1];
|
|
sendPushNotification(
|
|
'💬 New Cache Message',
|
|
`Someone left a note at "${data.geocache.title || 'Untitled Geocache'}"`,
|
|
'cacheMessage'
|
|
);
|
|
}
|
|
}
|
|
|
|
// Refresh dialog if it's open for this geocache
|
|
if (currentGeocache && currentGeocache.id === data.geocache.id) {
|
|
showGeocacheDialog(data.geocache);
|
|
}
|
|
} else {
|
|
// Add new geocache
|
|
geocaches.push(data.geocache);
|
|
createGeocacheMarker(data.geocache);
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'geocacheDelete':
|
|
// Another user deleted a geocache
|
|
if (data.geocacheId) {
|
|
const index = geocaches.findIndex(g => g.id === data.geocacheId);
|
|
if (index > -1) {
|
|
geocaches.splice(index, 1);
|
|
if (geocacheMarkers[data.geocacheId]) {
|
|
map.removeLayer(geocacheMarkers[data.geocacheId]);
|
|
delete geocacheMarkers[data.geocacheId];
|
|
}
|
|
// Close dialog if it's open for this geocache
|
|
if (currentGeocache && currentGeocache.id === data.geocacheId) {
|
|
hideGeocacheDialog();
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'geocachesInit':
|
|
// Receive all geocaches on connection
|
|
console.log('Received geocachesInit message with', data.geocaches ? data.geocaches.length : 0, 'geocaches');
|
|
if (data.geocaches) {
|
|
geocaches = data.geocaches;
|
|
// Clear existing markers
|
|
Object.values(geocacheMarkers).forEach(marker => {
|
|
map.removeLayer(marker);
|
|
});
|
|
geocacheMarkers = {};
|
|
// Create markers for all geocaches
|
|
geocaches.forEach(geocache => {
|
|
createGeocacheMarker(geocache);
|
|
});
|
|
}
|
|
break;
|
|
}
|
|
};
|
|
|
|
ws.onclose = () => {
|
|
console.log('WebSocket disconnected');
|
|
// Attempt reconnect after 3 seconds
|
|
wsReconnectTimer = setTimeout(connectWebSocket, 3000);
|
|
};
|
|
|
|
ws.onerror = (error) => {
|
|
console.error('WebSocket error:', error);
|
|
};
|
|
|
|
} catch (err) {
|
|
console.error('Failed to create WebSocket:', err);
|
|
}
|
|
}
|
|
|
|
function sendLocationToServer(lat, lng, accuracy, visible = true) {
|
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
ws.send(JSON.stringify({
|
|
type: 'location',
|
|
lat: lat,
|
|
lng: lng,
|
|
accuracy: accuracy,
|
|
icon: myIcon,
|
|
color: myColor,
|
|
visible: visible
|
|
}));
|
|
}
|
|
}
|
|
|
|
async function checkLocationNotifications(lat, lng) {
|
|
if (!pushSubscription) return; // No push notifications enabled
|
|
|
|
const now = Date.now();
|
|
const userPos = L.latLng(lat, lng);
|
|
|
|
// 1. Check for nearby geocaches
|
|
geocaches.forEach(cache => {
|
|
if (!cache || !cache.lat || !cache.lng) return;
|
|
|
|
const cachePos = L.latLng(cache.lat, cache.lng);
|
|
const distance = userPos.distanceTo(cachePos);
|
|
|
|
// Check if we should notify about this cache
|
|
const lastNotified = notificationCooldowns.nearbyCache[cache.id] || 0;
|
|
const timeSinceNotification = now - lastNotified;
|
|
|
|
if (distance <= CACHE_NOTIFY_DISTANCE) {
|
|
// Within notification distance
|
|
if (timeSinceNotification > CACHE_COOLDOWN || lastNotified === 0) {
|
|
// Send notification
|
|
sendPushNotification(
|
|
'📍 Geocache Nearby!',
|
|
`"${cache.title || 'Untitled Geocache'}" is ${Math.round(distance)}m away`,
|
|
'nearbyCache'
|
|
);
|
|
notificationCooldowns.nearbyCache[cache.id] = now;
|
|
}
|
|
} else if (distance > CACHE_RESET_DISTANCE && lastNotified > 0) {
|
|
// Reset cooldown if we've moved far enough away
|
|
delete notificationCooldowns.nearbyCache[cache.id];
|
|
}
|
|
});
|
|
|
|
// 2. Check for destination arrival (only in nav mode)
|
|
if (navMode && destinationPin) {
|
|
const destPos = destinationPin.getLatLng();
|
|
const distance = userPos.distanceTo(destPos);
|
|
|
|
if (distance <= DESTINATION_ARRIVAL_DISTANCE) {
|
|
const timeSinceNotification = now - notificationCooldowns.destinationArrival;
|
|
if (timeSinceNotification > 60000) { // 1 minute cooldown for arrival
|
|
sendPushNotification(
|
|
'🎯 Destination Reached!',
|
|
'You have arrived at your destination',
|
|
'destinationArrival'
|
|
);
|
|
notificationCooldowns.destinationArrival = now;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function sendPushNotification(title, body, type) {
|
|
try {
|
|
// Send to server to trigger push notification
|
|
const response = await fetch('/send-notification', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
title: title,
|
|
body: body,
|
|
type: type,
|
|
userId: userId // Send to self
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
console.error('Failed to send push notification');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending push notification:', error);
|
|
}
|
|
}
|
|
|
|
function updateOtherUser(userId, lat, lng, accuracy, icon, color) {
|
|
let userMarker = otherUsers.get(userId);
|
|
|
|
if (!userMarker) {
|
|
// Use provided icon/color or fall back to defaults
|
|
if (!icon || !color) {
|
|
const defaults = [
|
|
{ icon: 'mdi-walk', color: '#ff6b6b' },
|
|
{ icon: 'mdi-bike', color: '#4ecdc4' },
|
|
{ icon: 'mdi-run', color: '#45b7d1' },
|
|
{ icon: 'mdi-hiking', color: '#f7b731' },
|
|
{ icon: 'mdi-ski', color: '#5f27cd' },
|
|
{ icon: 'mdi-skateboard', color: '#00d2d3' }
|
|
];
|
|
const defaultConfig = defaults[otherUsers.size % defaults.length];
|
|
icon = icon || defaultConfig.icon;
|
|
color = color || defaultConfig.color;
|
|
}
|
|
|
|
const userIcon = L.divIcon({
|
|
html: '<i class="mdi ' + icon + '" style="color: ' + color + '; font-size: 32px;"></i>',
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
className: 'custom-div-icon'
|
|
});
|
|
|
|
userMarker = {
|
|
marker: L.marker([lat, lng], { icon: userIcon }).addTo(map),
|
|
accuracyCircle: L.circle([lat, lng], {
|
|
radius: accuracy,
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.1,
|
|
weight: 1
|
|
}).addTo(map),
|
|
color: color,
|
|
icon: icon
|
|
};
|
|
|
|
userMarker.marker.bindTooltip(`User ${userId}`, { permanent: false, direction: 'top' });
|
|
otherUsers.set(userId, userMarker);
|
|
} else {
|
|
// Update existing marker position
|
|
userMarker.marker.setLatLng([lat, lng]);
|
|
userMarker.accuracyCircle.setLatLng([lat, lng]);
|
|
userMarker.accuracyCircle.setRadius(accuracy);
|
|
|
|
// Update icon if it changed
|
|
if (icon && color && (userMarker.icon !== icon || userMarker.color !== color)) {
|
|
const newIcon = L.divIcon({
|
|
html: '<i class="mdi ' + icon + '" style="color: ' + color + '; font-size: 32px;"></i>',
|
|
iconSize: [32, 32],
|
|
iconAnchor: [16, 16],
|
|
className: 'custom-div-icon'
|
|
});
|
|
userMarker.marker.setIcon(newIcon);
|
|
userMarker.accuracyCircle.setStyle({ color: color, fillColor: color });
|
|
userMarker.icon = icon;
|
|
userMarker.color = color;
|
|
}
|
|
}
|
|
}
|
|
|
|
function removeOtherUser(userId) {
|
|
const userMarker = otherUsers.get(userId);
|
|
if (userMarker) {
|
|
map.removeLayer(userMarker.marker);
|
|
map.removeLayer(userMarker.accuracyCircle);
|
|
otherUsers.delete(userId);
|
|
}
|
|
}
|
|
|
|
function updateRouteHighlight(coords) {
|
|
clearRouteHighlight();
|
|
|
|
if (coords.length < 2) return;
|
|
|
|
routeHighlight = L.polyline(coords, {
|
|
color: '#00ff00',
|
|
weight: 10,
|
|
opacity: 0.7
|
|
}).addTo(map);
|
|
|
|
// Make sure route is behind other markers
|
|
routeHighlight.bringToBack();
|
|
}
|
|
|
|
function clearRouteHighlight() {
|
|
if (routeHighlight) {
|
|
map.removeLayer(routeHighlight);
|
|
routeHighlight = null;
|
|
}
|
|
}
|
|
|
|
function updateDirectionArrow(currentPos, nextPoints, color = '#ff6600') {
|
|
if (directionArrow) {
|
|
map.removeLayer(directionArrow);
|
|
directionArrow = null;
|
|
}
|
|
|
|
if (nextPoints.length === 0) return;
|
|
|
|
const arrowCoords = [
|
|
[currentPos.lat, currentPos.lng],
|
|
...nextPoints
|
|
];
|
|
|
|
directionArrow = L.polyline(arrowCoords, {
|
|
color: color,
|
|
weight: 5,
|
|
opacity: 0.8,
|
|
dashArray: '12, 8'
|
|
}).addTo(map);
|
|
}
|
|
|
|
// Build simplified trail network graph for pathfinding
|
|
// Only uses endpoints and intersection points as nodes (much faster)
|
|
function buildTrailGraph() {
|
|
const graph = {
|
|
nodes: new Map() // Key: "trackId:pointIndex", Value: {track, index, neighbors: [{key, distance}]}
|
|
};
|
|
|
|
const nodeKey = (track, index) => `${track.id}:${index}`;
|
|
|
|
// Step 1: Find all intersection points and endpoints
|
|
const keyPoints = new Map(); // trackId -> Set of indices that are key points
|
|
|
|
for (const track of tracks) {
|
|
const points = new Set([0, track.coords.length - 1]); // Start and end
|
|
|
|
// Add intermediate nodes every ~50 meters for better routing flexibility
|
|
let accumulatedDist = 0;
|
|
for (let i = 1; i < track.coords.length - 1; i++) {
|
|
const dist = map.distance(
|
|
L.latLng(track.coords[i-1]),
|
|
L.latLng(track.coords[i])
|
|
);
|
|
accumulatedDist += dist;
|
|
|
|
if (accumulatedDist >= adminSettings.nodeSpacing) { // Create node at configured spacing
|
|
points.add(i);
|
|
accumulatedDist = 0;
|
|
}
|
|
}
|
|
|
|
keyPoints.set(track.id, points);
|
|
}
|
|
|
|
// Find intersections between tracks
|
|
for (const track of tracks) {
|
|
for (const otherTrack of tracks) {
|
|
if (track.id >= otherTrack.id) continue; // Avoid duplicate checks
|
|
|
|
// Sample points to find intersections (check more frequently for accuracy)
|
|
for (let i = 0; i < track.coords.length; i += Math.max(1, Math.floor(track.coords.length / 10))) {
|
|
const point = L.latLng(track.coords[i]);
|
|
|
|
for (let j = 0; j < otherTrack.coords.length; j += Math.max(1, Math.floor(otherTrack.coords.length / 10))) {
|
|
const otherPoint = L.latLng(otherTrack.coords[j]);
|
|
const dist = map.distance(point, otherPoint);
|
|
|
|
if (dist <= adminSettings.intersectionThreshold) {
|
|
keyPoints.get(track.id).add(i);
|
|
keyPoints.get(otherTrack.id).add(j);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Step 2: Create nodes for key points
|
|
for (const track of tracks) {
|
|
const indices = Array.from(keyPoints.get(track.id)).sort((a, b) => a - b);
|
|
|
|
for (const idx of indices) {
|
|
const key = nodeKey(track, idx);
|
|
graph.nodes.set(key, {
|
|
track: track,
|
|
index: idx,
|
|
neighbors: []
|
|
});
|
|
}
|
|
|
|
// Connect consecutive key points on same track
|
|
for (let i = 0; i < indices.length - 1; i++) {
|
|
const fromIdx = indices[i];
|
|
const toIdx = indices[i + 1];
|
|
const fromKey = nodeKey(track, fromIdx);
|
|
const toKey = nodeKey(track, toIdx);
|
|
|
|
// Calculate distance along track
|
|
let dist = 0;
|
|
for (let j = fromIdx; j < toIdx; j++) {
|
|
dist += map.distance(
|
|
L.latLng(track.coords[j]),
|
|
L.latLng(track.coords[j + 1])
|
|
);
|
|
}
|
|
|
|
graph.nodes.get(fromKey).neighbors.push({ key: toKey, distance: dist });
|
|
graph.nodes.get(toKey).neighbors.push({ key: fromKey, distance: dist });
|
|
}
|
|
}
|
|
|
|
// Step 3: Connect intersection points across tracks
|
|
for (const track of tracks) {
|
|
const trackKeyPoints = keyPoints.get(track.id);
|
|
|
|
for (const idx of trackKeyPoints) {
|
|
const point = L.latLng(track.coords[idx]);
|
|
const key = nodeKey(track, idx);
|
|
const node = graph.nodes.get(key);
|
|
|
|
for (const otherTrack of tracks) {
|
|
if (track === otherTrack) continue;
|
|
|
|
const otherKeyPoints = keyPoints.get(otherTrack.id);
|
|
for (const otherIdx of otherKeyPoints) {
|
|
const otherPoint = L.latLng(otherTrack.coords[otherIdx]);
|
|
const dist = map.distance(point, otherPoint);
|
|
|
|
if (dist <= adminSettings.intersectionThreshold) {
|
|
const otherKey = nodeKey(otherTrack, otherIdx);
|
|
if (!node.neighbors.some(n => n.key === otherKey)) {
|
|
node.neighbors.push({ key: otherKey, distance: dist });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return graph;
|
|
}
|
|
|
|
// Find nearest graph node on a track to a given index
|
|
function findNearestGraphNode(graph, track, index) {
|
|
let bestKey = null;
|
|
let bestDist = Infinity;
|
|
let bestIdx = 0;
|
|
|
|
for (const [key, node] of graph.nodes) {
|
|
if (node.track.id === track.id) {
|
|
const dist = Math.abs(node.index - index);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
bestKey = key;
|
|
bestIdx = node.index;
|
|
}
|
|
}
|
|
}
|
|
|
|
return { key: bestKey, index: bestIdx };
|
|
}
|
|
|
|
// Calculate distance along track between two indices
|
|
function trackDistanceBetween(track, idx1, idx2) {
|
|
let dist = 0;
|
|
const start = Math.min(idx1, idx2);
|
|
const end = Math.max(idx1, idx2);
|
|
for (let i = start; i < end; i++) {
|
|
dist += map.distance(
|
|
L.latLng(track.coords[i]),
|
|
L.latLng(track.coords[i + 1])
|
|
);
|
|
}
|
|
return dist;
|
|
}
|
|
|
|
// Find shortest path using Dijkstra's algorithm
|
|
function findShortestPath(startTrack, startIndex, endTrack, endIndex) {
|
|
const graph = buildTrailGraph();
|
|
|
|
if (graph.nodes.size === 0) return null;
|
|
|
|
// Find nearest graph nodes to start and end
|
|
const startNode = findNearestGraphNode(graph, startTrack, startIndex);
|
|
const endNode = findNearestGraphNode(graph, endTrack, endIndex);
|
|
|
|
if (!startNode.key || !endNode.key) return null;
|
|
|
|
// Distance from actual start to nearest graph node
|
|
const startToNodeDist = trackDistanceBetween(startTrack, startIndex, startNode.index);
|
|
// Distance from nearest graph node to actual end
|
|
const nodeToEndDist = trackDistanceBetween(endTrack, endNode.index, endIndex);
|
|
|
|
const startKey = startNode.key;
|
|
const endKey = endNode.key;
|
|
|
|
// Always use full pathfinding to find optimal route
|
|
// (removed same-track shortcut that was causing inefficient routes)
|
|
|
|
// Dijkstra's algorithm
|
|
const distances = new Map();
|
|
const previous = new Map();
|
|
const unvisited = new Set(graph.nodes.keys());
|
|
|
|
for (const key of graph.nodes.keys()) {
|
|
distances.set(key, Infinity);
|
|
}
|
|
distances.set(startKey, startToNodeDist);
|
|
|
|
while (unvisited.size > 0) {
|
|
let currentKey = null;
|
|
let minDist = Infinity;
|
|
for (const key of unvisited) {
|
|
if (distances.get(key) < minDist) {
|
|
minDist = distances.get(key);
|
|
currentKey = key;
|
|
}
|
|
}
|
|
|
|
if (currentKey === null || minDist === Infinity) break;
|
|
if (currentKey === endKey) break;
|
|
|
|
unvisited.delete(currentKey);
|
|
|
|
const currentNode = graph.nodes.get(currentKey);
|
|
for (const neighbor of currentNode.neighbors) {
|
|
if (!unvisited.has(neighbor.key)) continue;
|
|
|
|
const newDist = distances.get(currentKey) + neighbor.distance;
|
|
if (newDist < distances.get(neighbor.key)) {
|
|
distances.set(neighbor.key, newDist);
|
|
previous.set(neighbor.key, currentKey);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!previous.has(endKey) && startKey !== endKey) {
|
|
return null;
|
|
}
|
|
|
|
// Reconstruct path through graph nodes
|
|
const graphPath = [];
|
|
let current = endKey;
|
|
while (current) {
|
|
const node = graph.nodes.get(current);
|
|
graphPath.unshift({ track: node.track, index: node.index });
|
|
current = previous.get(current);
|
|
}
|
|
|
|
// Build full path including actual start and end points
|
|
const fullPath = [{ track: startTrack, index: startIndex }];
|
|
|
|
for (const node of graphPath) {
|
|
// Avoid duplicates
|
|
const last = fullPath[fullPath.length - 1];
|
|
if (last.track.id !== node.track.id || last.index !== node.index) {
|
|
fullPath.push(node);
|
|
}
|
|
}
|
|
|
|
// Add end point if different from last
|
|
const last = fullPath[fullPath.length - 1];
|
|
if (last.track.id !== endTrack.id || last.index !== endIndex) {
|
|
fullPath.push({ track: endTrack, index: endIndex });
|
|
}
|
|
|
|
return {
|
|
path: fullPath,
|
|
totalDistance: distances.get(endKey) + nodeToEndDist
|
|
};
|
|
}
|
|
|
|
// Convert path to route segments for display
|
|
function pathToRouteSegments(path) {
|
|
if (!path || path.length === 0) return [];
|
|
|
|
const segments = [];
|
|
let currentSegment = null;
|
|
|
|
for (const node of path) {
|
|
if (!currentSegment || currentSegment.track !== node.track) {
|
|
// Start new segment
|
|
if (currentSegment) {
|
|
segments.push(currentSegment);
|
|
}
|
|
currentSegment = {
|
|
track: node.track,
|
|
fromIndex: node.index,
|
|
toIndex: node.index
|
|
};
|
|
} else {
|
|
// Extend current segment
|
|
currentSegment.toIndex = node.index;
|
|
}
|
|
}
|
|
|
|
if (currentSegment) {
|
|
segments.push(currentSegment);
|
|
}
|
|
|
|
return segments;
|
|
}
|
|
|
|
// Get all coordinates along the route
|
|
function getRouteCoordinates(segments) {
|
|
const coords = [];
|
|
|
|
for (const segment of segments) {
|
|
const start = Math.min(segment.fromIndex, segment.toIndex);
|
|
const end = Math.max(segment.fromIndex, segment.toIndex);
|
|
const reverse = segment.fromIndex > segment.toIndex;
|
|
|
|
const segmentCoords = [];
|
|
for (let i = start; i <= end; i++) {
|
|
segmentCoords.push(segment.track.coords[i]);
|
|
}
|
|
|
|
if (reverse) {
|
|
segmentCoords.reverse();
|
|
}
|
|
|
|
// Avoid duplicate points at segment boundaries
|
|
if (coords.length > 0 && segmentCoords.length > 0) {
|
|
const lastCoord = coords[coords.length - 1];
|
|
const firstCoord = segmentCoords[0];
|
|
if (lastCoord[0] === firstCoord[0] && lastCoord[1] === firstCoord[1]) {
|
|
segmentCoords.shift();
|
|
}
|
|
}
|
|
|
|
coords.push(...segmentCoords);
|
|
}
|
|
|
|
return coords;
|
|
}
|
|
|
|
function switchTab(tabName) {
|
|
const editTab = document.getElementById('editTab');
|
|
const navTab = document.getElementById('navTab');
|
|
const adminTab = document.getElementById('adminTab');
|
|
const editContent = document.getElementById('editContent');
|
|
const navContent = document.getElementById('navContent');
|
|
const adminContent = document.getElementById('adminContent');
|
|
|
|
// Save nav mode state
|
|
localStorage.setItem('navMode', tabName === 'navigate' ? 'true' : 'false');
|
|
|
|
// Remove active class from all tabs and content
|
|
[editTab, navTab, adminTab].forEach(tab => tab.classList.remove('active'));
|
|
[editContent, navContent, adminContent].forEach(content => content.classList.remove('active'));
|
|
|
|
if (tabName === 'edit') {
|
|
// Check authentication for edit mode
|
|
if (!isAuthenticated) {
|
|
showPasswordDialog();
|
|
return;
|
|
}
|
|
|
|
editTab.classList.add('active');
|
|
editContent.classList.add('active');
|
|
navMode = false;
|
|
|
|
// Show geocache list toggle in edit mode
|
|
document.getElementById('geocacheListToggle').style.display = 'flex';
|
|
updateGeocacheVisibility();
|
|
|
|
// In edit mode, disable auto-center
|
|
if (autoCenterMode) {
|
|
autoCenterMode = false;
|
|
const btn = document.getElementById('autoCenterBtn');
|
|
btn.textContent = 'Auto-Center: OFF';
|
|
btn.classList.remove('active');
|
|
}
|
|
|
|
// Clear any navigation visuals when leaving nav mode
|
|
if (directionArrow) {
|
|
map.removeLayer(directionArrow);
|
|
directionArrow = null;
|
|
}
|
|
clearRouteHighlight();
|
|
|
|
// Reset map rotation when leaving nav mode
|
|
if (rotateMapMode) {
|
|
toggleRotateMap();
|
|
}
|
|
} else if (tabName === 'navigate') {
|
|
navTab.classList.add('active');
|
|
navContent.classList.add('active');
|
|
navMode = true;
|
|
|
|
// Hide geocache list toggle in nav mode
|
|
document.getElementById('geocacheListToggle').style.display = 'none';
|
|
document.getElementById('geocacheListSidebar').classList.remove('open');
|
|
updateGeocacheVisibility();
|
|
|
|
// Deactivate edit tools when entering nav mode
|
|
Object.values(toolButtons).forEach(btn => btn.classList.remove('active'));
|
|
document.getElementById('reshapeControls').style.display = 'none';
|
|
document.getElementById('smoothControls').style.display = 'none';
|
|
map.getContainer().style.cursor = '';
|
|
currentTool = null;
|
|
|
|
// Cancel any in-progress operations
|
|
if (isDrawing) cancelDrawing();
|
|
if (isDragging) cancelReshapeDrag();
|
|
|
|
// Update nav track list
|
|
updateNavTrackList();
|
|
} else if (tabName === 'admin') {
|
|
// Check authentication for admin mode
|
|
if (!isAuthenticated) {
|
|
showPasswordDialog();
|
|
return;
|
|
}
|
|
|
|
// Show admin overlay instead of tab content
|
|
adminTab.classList.add('active');
|
|
const adminOverlay = document.querySelector('.admin-panel-overlay');
|
|
if (adminOverlay) {
|
|
adminOverlay.classList.add('active');
|
|
}
|
|
navMode = false;
|
|
|
|
// In admin mode, disable auto-center
|
|
if (autoCenterMode) {
|
|
autoCenterMode = false;
|
|
const btn = document.getElementById('autoCenterBtn');
|
|
if (btn) {
|
|
btn.textContent = 'Auto-Center: OFF';
|
|
btn.classList.remove('active');
|
|
}
|
|
}
|
|
|
|
// Clear any navigation visuals when leaving nav mode
|
|
if (directionArrow) {
|
|
map.removeLayer(directionArrow);
|
|
directionArrow = null;
|
|
}
|
|
clearRouteHighlight();
|
|
|
|
// Reset map rotation when leaving nav mode
|
|
if (rotateMapMode) {
|
|
toggleRotateMap();
|
|
}
|
|
|
|
// Deactivate edit tools
|
|
Object.values(toolButtons).forEach(btn => btn.classList.remove('active'));
|
|
document.getElementById('reshapeControls').style.display = 'none';
|
|
document.getElementById('smoothControls').style.display = 'none';
|
|
map.getContainer().style.cursor = '';
|
|
currentTool = null;
|
|
}
|
|
}
|
|
|
|
function updateNavTrackList() {
|
|
const listEl = document.getElementById('trackListNav');
|
|
const countEl = document.getElementById('trackCountNav');
|
|
|
|
if (!listEl || !countEl) return;
|
|
|
|
countEl.textContent = tracks.length;
|
|
listEl.innerHTML = '';
|
|
|
|
tracks.forEach(track => {
|
|
const item = document.createElement('div');
|
|
item.className = 'track-item';
|
|
item.innerHTML = `<span>${track.name}</span>`;
|
|
listEl.appendChild(item);
|
|
});
|
|
}
|
|
|
|
// Tool buttons
|
|
const toolButtons = {
|
|
select: document.getElementById('selectTool'),
|
|
split: document.getElementById('splitTool'),
|
|
draw: document.getElementById('drawTool'),
|
|
reshape: document.getElementById('reshapeTool'),
|
|
smooth: document.getElementById('smoothTool'),
|
|
geocache: document.getElementById('geocacheTool')
|
|
};
|
|
|
|
// Set active tool
|
|
function setTool(tool) {
|
|
currentTool = tool;
|
|
Object.keys(toolButtons).forEach(t => {
|
|
toolButtons[t].classList.toggle('active', t === tool);
|
|
});
|
|
|
|
// Cancel any drawing in progress
|
|
if (tool !== 'draw' && isDrawing) {
|
|
cancelDrawing();
|
|
}
|
|
|
|
// Show/hide tool-specific controls
|
|
document.getElementById('reshapeControls').style.display = tool === 'reshape' ? 'block' : 'none';
|
|
document.getElementById('smoothControls').style.display = tool === 'smooth' ? 'block' : 'none';
|
|
|
|
// Update cursor
|
|
const container = map.getContainer();
|
|
container.style.cursor = (tool === 'draw' || tool === 'reshape' || tool === 'smooth') ? 'crosshair' : '';
|
|
|
|
updateStatus(getToolHint(tool), 'info');
|
|
}
|
|
|
|
function getToolHint(tool) {
|
|
switch(tool) {
|
|
case 'select': return 'Click a track to select it, press Delete to remove';
|
|
case 'split': return 'Click on a track to split it at that point';
|
|
case 'draw': return 'Click to add points, double-click to finish';
|
|
case 'reshape': return 'Drag any point on a track to reshape it';
|
|
case 'smooth': return 'Click and drag over a track to smooth it';
|
|
default: return '';
|
|
}
|
|
}
|
|
|
|
// Tool button event listeners
|
|
Object.keys(toolButtons).forEach(tool => {
|
|
toolButtons[tool].addEventListener('click', () => setTool(tool));
|
|
});
|
|
|
|
// Track class
|
|
class Track {
|
|
constructor(coords, name = 'Track', description = '') {
|
|
this.id = Date.now() + Math.random();
|
|
this.coords = coords; // Array of [lat, lng]
|
|
this.name = name;
|
|
this.description = description;
|
|
this.layer = null;
|
|
this.pointMarkers = [];
|
|
this.createLayer();
|
|
}
|
|
|
|
createLayer() {
|
|
if (this.layer) {
|
|
map.removeLayer(this.layer);
|
|
}
|
|
this.layer = L.polyline(this.coords, {
|
|
color: '#3388ff',
|
|
weight: 4,
|
|
opacity: 0.8
|
|
});
|
|
this.layer.on('click', (e) => this.onClick(e));
|
|
this.layer.addTo(map);
|
|
}
|
|
|
|
onClick(e) {
|
|
L.DomEvent.stopPropagation(e);
|
|
|
|
// Don't allow track selection in nav mode
|
|
if (navMode) {
|
|
return;
|
|
}
|
|
|
|
switch(currentTool) {
|
|
case 'select':
|
|
selectTrack(this);
|
|
break;
|
|
case 'split':
|
|
splitTrack(this, e.latlng);
|
|
break;
|
|
}
|
|
}
|
|
|
|
setSelected(selected) {
|
|
this.layer.setStyle({
|
|
color: selected ? '#ff4444' : '#3388ff',
|
|
weight: selected ? 6 : 4
|
|
});
|
|
|
|
// Show/hide point markers in edit mode
|
|
if (!navMode) {
|
|
if (selected) {
|
|
this.showPointMarkers();
|
|
updateStatus(`Selected: ${this.name} (${this.coords.length} points)`, 'info');
|
|
} else {
|
|
this.hidePointMarkers();
|
|
}
|
|
}
|
|
}
|
|
|
|
remove() {
|
|
if (this.layer) {
|
|
map.removeLayer(this.layer);
|
|
}
|
|
if (this.pointMarkers) {
|
|
this.pointMarkers.forEach(marker => map.removeLayer(marker));
|
|
this.pointMarkers = [];
|
|
}
|
|
}
|
|
|
|
updateDisplay() {
|
|
// Recreate the layer with new coordinates
|
|
if (this.layer) {
|
|
this.layer.setLatLngs(this.coords);
|
|
} else {
|
|
this.createLayer();
|
|
}
|
|
|
|
// Update point markers if selected
|
|
if (this.pointMarkers && this.pointMarkers.length > 0) {
|
|
this.hidePointMarkers();
|
|
this.showPointMarkers();
|
|
}
|
|
}
|
|
|
|
showPointMarkers() {
|
|
this.hidePointMarkers();
|
|
this.pointMarkers = [];
|
|
|
|
// Find intersection points
|
|
const intersectionIndices = new Set();
|
|
for (const otherTrack of tracks) {
|
|
if (otherTrack === this) continue;
|
|
for (let i = 0; i < this.coords.length; i++) {
|
|
const point = L.latLng(this.coords[i]);
|
|
for (let j = 0; j < otherTrack.coords.length; j++) {
|
|
const otherPoint = L.latLng(otherTrack.coords[j]);
|
|
if (map.distance(point, otherPoint) <= adminSettings.intersectionThreshold) {
|
|
intersectionIndices.add(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add markers for each point
|
|
this.coords.forEach((coord, i) => {
|
|
const isIntersection = intersectionIndices.has(i);
|
|
const isEndpoint = i === 0 || i === this.coords.length - 1;
|
|
|
|
let color = '#3388ff'; // Regular point
|
|
let radius = 3;
|
|
|
|
if (isEndpoint) {
|
|
color = '#00ff00'; // Green for endpoints
|
|
radius = 5;
|
|
} else if (isIntersection) {
|
|
color = '#ff9900'; // Orange for intersections
|
|
radius = 4;
|
|
}
|
|
|
|
const marker = L.circleMarker(coord, {
|
|
radius: radius,
|
|
color: color,
|
|
fillColor: color,
|
|
fillOpacity: 0.8,
|
|
weight: 1
|
|
}).addTo(map);
|
|
|
|
this.pointMarkers.push(marker);
|
|
});
|
|
}
|
|
|
|
hidePointMarkers() {
|
|
if (this.pointMarkers) {
|
|
this.pointMarkers.forEach(marker => map.removeLayer(marker));
|
|
this.pointMarkers = [];
|
|
}
|
|
}
|
|
|
|
getStartPoint() {
|
|
return this.coords[0];
|
|
}
|
|
|
|
getEndPoint() {
|
|
return this.coords[this.coords.length - 1];
|
|
}
|
|
}
|
|
|
|
// === RESHAPE TOOL FUNCTIONS ===
|
|
|
|
// Find the nearest point on any track to a given latlng
|
|
function findNearestTrackPoint(latlng, threshold = 20) {
|
|
let best = null;
|
|
let bestDist = threshold;
|
|
|
|
for (const track of tracks) {
|
|
const point = map.latLngToContainerPoint(latlng);
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const trackPoint = map.latLngToContainerPoint(L.latLng(track.coords[i]));
|
|
const dx = point.x - trackPoint.x;
|
|
const dy = point.y - trackPoint.y;
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
if (dist < bestDist) {
|
|
bestDist = dist;
|
|
best = { track, index: i, dist };
|
|
}
|
|
}
|
|
}
|
|
return best;
|
|
}
|
|
|
|
// Calculate distance between two lat/lng points (simple Euclidean for small areas)
|
|
function pointDistance(p1, p2) {
|
|
const dx = p1[0] - p2[0];
|
|
const dy = p1[1] - p2[1];
|
|
return Math.sqrt(dx * dx + dy * dy);
|
|
}
|
|
|
|
// Constrain point A to be at most maxDist from point B
|
|
function constrainDistance(pointA, pointB, maxDist) {
|
|
const dx = pointA[0] - pointB[0];
|
|
const dy = pointA[1] - pointB[1];
|
|
const dist = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
if (dist <= maxDist) {
|
|
return [...pointA]; // Already within range, no change
|
|
}
|
|
|
|
// Move point A to be exactly maxDist from B (along the line A-B)
|
|
const ratio = maxDist / dist;
|
|
return [
|
|
pointB[0] + dx * ratio,
|
|
pointB[1] + dy * ratio
|
|
];
|
|
}
|
|
|
|
// Smooth reshape: weighted translation that preserves curve shape
|
|
// Each point moves by the drag delta, weighted by distance from anchor
|
|
function applyRopePhysics(originalCoords, dragIndex, newPos, anchorDist) {
|
|
const coords = originalCoords.map(c => [...c]); // Deep copy
|
|
|
|
// Get falloff exponent from slider (controls how sharply weight drops off)
|
|
const falloff = parseFloat(document.getElementById('reshapeFalloff').value);
|
|
|
|
// Calculate anchor indices (both directions from drag point)
|
|
const anchorBefore = Math.max(0, dragIndex - anchorDist);
|
|
const anchorAfter = Math.min(coords.length - 1, dragIndex + anchorDist);
|
|
|
|
// Calculate the drag delta (how much the drag point moved)
|
|
const deltaLat = newPos[0] - originalCoords[dragIndex][0];
|
|
const deltaLng = newPos[1] - originalCoords[dragIndex][1];
|
|
|
|
// Set dragged point to new position
|
|
coords[dragIndex] = [newPos[0], newPos[1]];
|
|
|
|
// === Handle chain BEFORE drag point ===
|
|
if (dragIndex > anchorBefore) {
|
|
const chainLength = dragIndex - anchorBefore;
|
|
|
|
for (let i = anchorBefore; i < dragIndex; i++) {
|
|
if (i === anchorBefore) {
|
|
// Anchor stays fixed
|
|
coords[i] = [...originalCoords[anchorBefore]];
|
|
} else {
|
|
// Weight: 0 at anchor, 1 at drag point
|
|
// Falloff controls the curve: <1 = gradual, 1 = linear, >1 = sharp
|
|
const linearWeight = (i - anchorBefore) / chainLength;
|
|
const weight = Math.pow(linearWeight, falloff);
|
|
|
|
// Apply weighted delta to original position
|
|
coords[i] = [
|
|
originalCoords[i][0] + deltaLat * weight,
|
|
originalCoords[i][1] + deltaLng * weight
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
// === Handle chain AFTER drag point ===
|
|
if (dragIndex < anchorAfter) {
|
|
const chainLength = anchorAfter - dragIndex;
|
|
|
|
for (let i = dragIndex + 1; i <= anchorAfter; i++) {
|
|
if (i === anchorAfter) {
|
|
// Anchor stays fixed
|
|
coords[i] = [...originalCoords[anchorAfter]];
|
|
} else {
|
|
// Weight: 1 near drag, 0 at anchor
|
|
const linearWeight = 1 - (i - dragIndex) / chainLength;
|
|
const weight = Math.pow(linearWeight, falloff);
|
|
|
|
// Apply weighted delta to original position
|
|
coords[i] = [
|
|
originalCoords[i][0] + deltaLat * weight,
|
|
originalCoords[i][1] + deltaLng * weight
|
|
];
|
|
}
|
|
}
|
|
}
|
|
|
|
return coords;
|
|
}
|
|
|
|
// === SMOOTH BRUSH FUNCTIONS ===
|
|
|
|
// Start smoothing brush stroke
|
|
function startSmoothing(latlng) {
|
|
isSmoothing = true;
|
|
smoothedPoints.clear();
|
|
saveStateForUndo();
|
|
|
|
// Create brush circle visualization
|
|
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
|
|
smoothBrushCircle = L.circle(latlng, {
|
|
radius: brushSize * getMetersPerPixel(),
|
|
color: '#00aaff',
|
|
fillColor: '#00aaff',
|
|
fillOpacity: 0.2,
|
|
weight: 2
|
|
}).addTo(map);
|
|
|
|
// Apply smoothing at initial position
|
|
applySmoothing(latlng);
|
|
}
|
|
|
|
// Continue smoothing as brush moves
|
|
function continueSmoothing(latlng) {
|
|
if (!isSmoothing) return;
|
|
|
|
// Update brush circle position and size
|
|
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
|
|
if (smoothBrushCircle) {
|
|
smoothBrushCircle.setLatLng(latlng);
|
|
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
|
|
}
|
|
|
|
// Apply smoothing at current position
|
|
applySmoothing(latlng);
|
|
}
|
|
|
|
// Finish smoothing stroke
|
|
function finishSmoothing() {
|
|
isSmoothing = false;
|
|
smoothedPoints.clear();
|
|
|
|
if (smoothBrushCircle) {
|
|
map.removeLayer(smoothBrushCircle);
|
|
smoothBrushCircle = null;
|
|
}
|
|
|
|
updateStatus('Smoothing complete', 'success');
|
|
}
|
|
|
|
// Get approximate meters per pixel at current zoom
|
|
function getMetersPerPixel() {
|
|
const center = map.getCenter();
|
|
const zoom = map.getZoom();
|
|
// Approximate meters per pixel at equator, adjusted for latitude
|
|
return 156543.03392 * Math.cos(center.lat * Math.PI / 180) / Math.pow(2, zoom);
|
|
}
|
|
|
|
// Apply smoothing to points within brush radius
|
|
function applySmoothing(latlng) {
|
|
const brushSize = parseInt(document.getElementById('smoothBrushSize').value);
|
|
const strength = parseFloat(document.getElementById('smoothStrength').value);
|
|
const brushRadius = brushSize * getMetersPerPixel();
|
|
|
|
for (const track of tracks) {
|
|
let modified = false;
|
|
|
|
for (let i = 1; i < track.coords.length - 1; i++) {
|
|
const pointLatLng = L.latLng(track.coords[i]);
|
|
const dist = map.distance(latlng, pointLatLng);
|
|
|
|
if (dist < brushRadius) {
|
|
// Point is within brush - smooth it using wider neighborhood
|
|
const curr = track.coords[i];
|
|
|
|
// Use wider neighborhood for stronger smoothing (up to 3 points each side)
|
|
let sumLat = 0, sumLng = 0, count = 0;
|
|
const neighborRange = Math.max(1, Math.floor(strength * 3)); // 1-3 neighbors based on strength
|
|
|
|
for (let j = Math.max(0, i - neighborRange); j <= Math.min(track.coords.length - 1, i + neighborRange); j++) {
|
|
sumLat += track.coords[j][0];
|
|
sumLng += track.coords[j][1];
|
|
count++;
|
|
}
|
|
|
|
const smoothedLat = sumLat / count;
|
|
const smoothedLng = sumLng / count;
|
|
|
|
// Blend based on strength and distance from brush center
|
|
const distFactor = 1 - (dist / brushRadius); // 1 at center, 0 at edge
|
|
const blend = strength * distFactor;
|
|
|
|
track.coords[i] = [
|
|
curr[0] + (smoothedLat - curr[0]) * blend,
|
|
curr[1] + (smoothedLng - curr[1]) * blend
|
|
];
|
|
|
|
modified = true;
|
|
}
|
|
}
|
|
|
|
if (modified) {
|
|
track.layer.setLatLngs(track.coords);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Start dragging a point
|
|
function startReshapeDrag(latlng) {
|
|
const nearest = findNearestTrackPoint(latlng);
|
|
if (!nearest) return false;
|
|
|
|
saveStateForUndo();
|
|
|
|
isDragging = true;
|
|
dragTrack = nearest.track;
|
|
dragPointIndex = nearest.index;
|
|
originalCoords = dragTrack.coords.map(c => [...c]); // Deep copy
|
|
|
|
// Create marker at drag point
|
|
dragMarker = L.circleMarker(latlng, {
|
|
radius: 8,
|
|
color: '#ff0000',
|
|
fillColor: '#ff0000',
|
|
fillOpacity: 1,
|
|
weight: 2
|
|
}).addTo(map);
|
|
|
|
// Show affected range markers
|
|
showAffectedRange();
|
|
|
|
// Disable map dragging while we drag the point
|
|
map.dragging.disable();
|
|
|
|
updateStatus(`Dragging point ${dragPointIndex} of "${dragTrack.name}"`, 'info');
|
|
return true;
|
|
}
|
|
|
|
// Show markers for affected points range
|
|
function showAffectedRange() {
|
|
clearAffectedMarkers();
|
|
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
|
|
|
|
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
|
|
const anchorAfter = Math.min(originalCoords.length - 1, dragPointIndex + anchorDist);
|
|
|
|
// Show anchor points (green - fixed)
|
|
if (anchorBefore < dragPointIndex) {
|
|
const anchorMarkerBefore = L.circleMarker(originalCoords[anchorBefore], {
|
|
radius: 8,
|
|
color: '#00cc00',
|
|
fillColor: '#00cc00',
|
|
fillOpacity: 1,
|
|
weight: 2
|
|
}).addTo(map);
|
|
affectedMarkers.push(anchorMarkerBefore);
|
|
}
|
|
|
|
if (anchorAfter > dragPointIndex) {
|
|
const anchorMarkerAfter = L.circleMarker(originalCoords[anchorAfter], {
|
|
radius: 8,
|
|
color: '#00cc00',
|
|
fillColor: '#00cc00',
|
|
fillOpacity: 1,
|
|
weight: 2
|
|
}).addTo(map);
|
|
affectedMarkers.push(anchorMarkerAfter);
|
|
}
|
|
|
|
// Show chain points (orange - will move)
|
|
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
|
|
if (i === dragPointIndex) continue; // Skip the drag point itself
|
|
|
|
const marker = L.circleMarker(originalCoords[i], {
|
|
radius: 5,
|
|
color: '#ff8800',
|
|
fillColor: '#ff8800',
|
|
fillOpacity: 0.7,
|
|
weight: 1
|
|
}).addTo(map);
|
|
affectedMarkers.push(marker);
|
|
}
|
|
}
|
|
|
|
// Clear affected range markers
|
|
function clearAffectedMarkers() {
|
|
affectedMarkers.forEach(m => map.removeLayer(m));
|
|
affectedMarkers = [];
|
|
}
|
|
|
|
// Find nearest snap target on other tracks
|
|
function findSnapTarget(latlng) {
|
|
if (!dragTrack) return null;
|
|
|
|
const snapDistPx = SNAP_DISTANCE_PX;
|
|
let nearest = null;
|
|
let minDist = Infinity;
|
|
|
|
for (const track of tracks) {
|
|
if (track === dragTrack) continue; // Skip the track we're dragging
|
|
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const pointLatLng = L.latLng(track.coords[i]);
|
|
const pixelDist = map.latLngToLayerPoint(latlng).distanceTo(
|
|
map.latLngToLayerPoint(pointLatLng)
|
|
);
|
|
|
|
if (pixelDist < snapDistPx && pixelDist < minDist) {
|
|
minDist = pixelDist;
|
|
nearest = {
|
|
track: track,
|
|
index: i,
|
|
latlng: pointLatLng
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
return nearest;
|
|
}
|
|
|
|
// Show/hide snap indicator
|
|
function updateSnapIndicator(target) {
|
|
if (target) {
|
|
if (!snapMarker) {
|
|
snapMarker = L.circleMarker(target.latlng, {
|
|
radius: 12,
|
|
color: '#00ffff',
|
|
fillColor: '#00ffff',
|
|
fillOpacity: 0.5,
|
|
weight: 3
|
|
}).addTo(map);
|
|
} else {
|
|
snapMarker.setLatLng(target.latlng);
|
|
}
|
|
} else if (snapMarker) {
|
|
map.removeLayer(snapMarker);
|
|
snapMarker = null;
|
|
}
|
|
}
|
|
|
|
// Continue dragging
|
|
function continueReshapeDrag(latlng) {
|
|
if (!isDragging || !dragTrack) return;
|
|
|
|
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
|
|
const newPos = [latlng.lat, latlng.lng];
|
|
|
|
// Check for snap target if dragging an endpoint
|
|
const isEndpoint = dragPointIndex === 0 || dragPointIndex === originalCoords.length - 1;
|
|
if (isEndpoint) {
|
|
snapTarget = findSnapTarget(latlng);
|
|
updateSnapIndicator(snapTarget);
|
|
|
|
// If snapping, use snap position instead of mouse position
|
|
if (snapTarget) {
|
|
newPos[0] = snapTarget.latlng.lat;
|
|
newPos[1] = snapTarget.latlng.lng;
|
|
}
|
|
} else {
|
|
snapTarget = null;
|
|
updateSnapIndicator(null);
|
|
}
|
|
|
|
// Apply rope physics and update track
|
|
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, newPos, anchorDist);
|
|
dragTrack.coords = newCoords;
|
|
dragTrack.layer.setLatLngs(newCoords);
|
|
|
|
// Update drag marker position
|
|
if (dragMarker) {
|
|
dragMarker.setLatLng(snapTarget ? snapTarget.latlng : latlng);
|
|
}
|
|
|
|
// Update affected markers positions
|
|
updateAffectedMarkersPositions(newCoords);
|
|
}
|
|
|
|
// Update affected markers to new positions
|
|
function updateAffectedMarkersPositions(newCoords) {
|
|
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
|
|
const anchorBefore = Math.max(0, dragPointIndex - anchorDist);
|
|
const anchorAfter = Math.min(newCoords.length - 1, dragPointIndex + anchorDist);
|
|
|
|
let markerIdx = 0;
|
|
|
|
// Skip anchor markers (they don't move), update chain markers
|
|
// First marker(s) are anchors, then chain points
|
|
if (anchorBefore < dragPointIndex) markerIdx++; // Skip anchor before
|
|
if (anchorAfter > dragPointIndex) markerIdx++; // Account for anchor after being in list
|
|
|
|
// Actually, let's rebuild to be clearer - anchors don't move, only chain points do
|
|
// Anchors are at indices 0 and 1 (if both exist), chain points follow
|
|
let anchorCount = 0;
|
|
if (anchorBefore < dragPointIndex) anchorCount++;
|
|
if (anchorAfter > dragPointIndex) anchorCount++;
|
|
|
|
let chainMarkerIdx = anchorCount;
|
|
for (let i = anchorBefore + 1; i < anchorAfter; i++) {
|
|
if (i === dragPointIndex) continue;
|
|
if (chainMarkerIdx < affectedMarkers.length) {
|
|
affectedMarkers[chainMarkerIdx].setLatLng(newCoords[i]);
|
|
chainMarkerIdx++;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Finish dragging
|
|
function finishReshapeDrag() {
|
|
if (!isDragging) return;
|
|
|
|
const trackToMerge = dragTrack;
|
|
const wasSnapping = snapTarget !== null;
|
|
const snapInfo = snapTarget;
|
|
const draggingFromStart = dragPointIndex === 0;
|
|
const draggingFromEnd = dragPointIndex === originalCoords.length - 1;
|
|
|
|
isDragging = false;
|
|
dragTrack = null;
|
|
dragPointIndex = -1;
|
|
originalCoords = null;
|
|
|
|
// Remove markers
|
|
if (dragMarker) {
|
|
map.removeLayer(dragMarker);
|
|
dragMarker = null;
|
|
}
|
|
clearAffectedMarkers();
|
|
updateSnapIndicator(null);
|
|
snapTarget = null;
|
|
|
|
// Re-enable map dragging
|
|
map.dragging.enable();
|
|
|
|
// If snapping, connect the tracks at intersection
|
|
if (wasSnapping && snapInfo) {
|
|
mergeTracks(trackToMerge, draggingFromStart, snapInfo);
|
|
updateStatus('Tracks connected at intersection', 'success');
|
|
} else {
|
|
updateStatus('Reshape complete', 'success');
|
|
}
|
|
}
|
|
|
|
// Connect tracks at snap point (split target if snapping to middle)
|
|
function mergeTracks(sourceTrack, fromStart, snapInfo) {
|
|
const targetTrack = snapInfo.track;
|
|
const targetIndex = snapInfo.index;
|
|
const isTargetStart = targetIndex === 0;
|
|
const isTargetEnd = targetIndex === targetTrack.coords.length - 1;
|
|
const snapCoord = targetTrack.coords[targetIndex];
|
|
|
|
// Update source track endpoint to snap position
|
|
if (fromStart) {
|
|
sourceTrack.coords[0] = [...snapCoord];
|
|
} else {
|
|
sourceTrack.coords[sourceTrack.coords.length - 1] = [...snapCoord];
|
|
}
|
|
sourceTrack.layer.setLatLngs(sourceTrack.coords);
|
|
|
|
// If snapping to middle of target, split it into two tracks
|
|
if (!isTargetStart && !isTargetEnd) {
|
|
// Create first part (start to snap point)
|
|
const coords1 = targetTrack.coords.slice(0, targetIndex + 1);
|
|
|
|
// Create second part (snap point to end)
|
|
const coords2 = targetTrack.coords.slice(targetIndex);
|
|
|
|
// Update target track to be first part
|
|
targetTrack.coords = coords1;
|
|
targetTrack.layer.setLatLngs(coords1);
|
|
targetTrack.name = targetTrack.name + ' (1)';
|
|
|
|
// Create new track for second part
|
|
const newTrack = new Track(coords2, targetTrack.name.replace(' (1)', ' (2)'));
|
|
tracks.push(newTrack);
|
|
}
|
|
|
|
updateTrackList();
|
|
}
|
|
|
|
// Cancel dragging (restore original)
|
|
function cancelReshapeDrag() {
|
|
if (!isDragging || !dragTrack || !originalCoords) return;
|
|
|
|
// Restore original coordinates
|
|
dragTrack.coords = originalCoords;
|
|
dragTrack.layer.setLatLngs(originalCoords);
|
|
|
|
// Remove the undo state we just added
|
|
undoStack.pop();
|
|
updateUndoButton();
|
|
|
|
isDragging = false;
|
|
dragTrack = null;
|
|
dragPointIndex = -1;
|
|
originalCoords = null;
|
|
|
|
if (dragMarker) {
|
|
map.removeLayer(dragMarker);
|
|
dragMarker = null;
|
|
}
|
|
clearAffectedMarkers();
|
|
updateSnapIndicator(null);
|
|
snapTarget = null;
|
|
|
|
map.dragging.enable();
|
|
|
|
updateStatus('Reshape cancelled', 'info');
|
|
}
|
|
|
|
// Toggle track selection (multi-select)
|
|
function selectTrack(track) {
|
|
const idx = selectedTracks.indexOf(track);
|
|
if (idx > -1) {
|
|
// Deselect
|
|
selectedTracks.splice(idx, 1);
|
|
track.setSelected(false);
|
|
} else {
|
|
// Add to selection
|
|
selectedTracks.push(track);
|
|
track.setSelected(true);
|
|
}
|
|
updateTrackList();
|
|
if (selectedTracks.length === 0) {
|
|
updateStatus('Click tracks to select them', 'info');
|
|
} else if (selectedTracks.length === 1) {
|
|
updateStatus(`Selected: ${selectedTracks[0].name} (${selectedTracks[0].coords.length} points)`);
|
|
} else {
|
|
updateStatus(`Selected ${selectedTracks.length} tracks`);
|
|
}
|
|
}
|
|
|
|
// Clear all selections
|
|
function clearSelection() {
|
|
selectedTracks.forEach(t => t.setSelected(false));
|
|
selectedTracks = [];
|
|
updateTrackList();
|
|
updateStatus('Selection cleared', 'info');
|
|
}
|
|
|
|
// Select all tracks
|
|
function selectAll() {
|
|
selectedTracks.forEach(t => t.setSelected(false));
|
|
selectedTracks = [...tracks];
|
|
selectedTracks.forEach(t => t.setSelected(true));
|
|
updateTrackList();
|
|
updateStatus(`Selected all ${tracks.length} tracks`);
|
|
}
|
|
|
|
// Store split markers
|
|
const splitMarkers = [];
|
|
|
|
// Split a track at a point
|
|
function remeshTrack(track, targetSpacing = 5) {
|
|
saveStateForUndo();
|
|
|
|
const coords = track.coords;
|
|
if (coords.length < 2) return;
|
|
const newCoords = [];
|
|
|
|
// Find all intersection points with other tracks
|
|
const intersectionIndices = new Set();
|
|
for (const otherTrack of tracks) {
|
|
if (otherTrack === track) continue;
|
|
|
|
for (let i = 0; i < coords.length; i++) {
|
|
const point = L.latLng(coords[i]);
|
|
|
|
for (const otherCoord of otherTrack.coords) {
|
|
const otherPoint = L.latLng(otherCoord);
|
|
const dist = map.distance(point, otherPoint);
|
|
|
|
if (dist <= adminSettings.intersectionThreshold) {
|
|
intersectionIndices.add(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Always start with first point
|
|
newCoords.push(coords[0]);
|
|
|
|
// Walk along the track and place points at regular intervals
|
|
let distanceFromLastPoint = 0;
|
|
|
|
for (let i = 1; i < coords.length; i++) {
|
|
const prevCoord = coords[i - 1];
|
|
const currCoord = coords[i];
|
|
const prevPoint = L.latLng(prevCoord);
|
|
const currPoint = L.latLng(currCoord);
|
|
const segmentDist = map.distance(prevPoint, currPoint);
|
|
|
|
// If this segment is longer than target spacing, we need to add intermediate points
|
|
if (distanceFromLastPoint + segmentDist > targetSpacing) {
|
|
// Calculate how many points we need to add
|
|
let remainingDist = segmentDist;
|
|
let segmentStart = prevPoint;
|
|
|
|
while (distanceFromLastPoint + remainingDist > targetSpacing) {
|
|
// Calculate where to place the next point
|
|
const distToNext = targetSpacing - distanceFromLastPoint;
|
|
const ratio = distToNext / remainingDist;
|
|
|
|
// Interpolate the position
|
|
const lat = segmentStart.lat + ratio * (currPoint.lat - segmentStart.lat);
|
|
const lng = segmentStart.lng + ratio * (currPoint.lng - segmentStart.lng);
|
|
|
|
newCoords.push([lat, lng]);
|
|
|
|
// Update for next iteration
|
|
segmentStart = L.latLng(lat, lng);
|
|
remainingDist = map.distance(segmentStart, currPoint);
|
|
distanceFromLastPoint = 0;
|
|
}
|
|
|
|
// Check if we should add the current point (intersection or endpoint)
|
|
if (intersectionIndices.has(i) || i === coords.length - 1) {
|
|
newCoords.push(currCoord);
|
|
distanceFromLastPoint = 0;
|
|
} else {
|
|
distanceFromLastPoint = remainingDist;
|
|
}
|
|
} else {
|
|
// Segment is short, accumulate distance
|
|
distanceFromLastPoint += segmentDist;
|
|
|
|
// Add point if it's an intersection or the last point
|
|
if (intersectionIndices.has(i) || i === coords.length - 1) {
|
|
newCoords.push(currCoord);
|
|
distanceFromLastPoint = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update track with new coordinates
|
|
track.coords = newCoords;
|
|
track.updateDisplay();
|
|
|
|
// Clear track cache and force graph rebuild
|
|
trailGraph = null;
|
|
|
|
// Visual feedback - briefly highlight the track
|
|
track.layer.setStyle({ color: '#00ff00', weight: 6 });
|
|
setTimeout(() => {
|
|
track.layer.setStyle({ color: '#3388ff', weight: 4 });
|
|
}, 500);
|
|
|
|
updateStatus(`Remeshed "${track.name}": ${coords.length} points → ${newCoords.length} points`, 'success');
|
|
}
|
|
|
|
function splitTrack(track, latlng) {
|
|
saveStateForUndo();
|
|
|
|
// Find closest point on track
|
|
let minDist = Infinity;
|
|
let splitIndex = 0;
|
|
|
|
for (let i = 0; i < track.coords.length; i++) {
|
|
const dist = map.distance(latlng, L.latLng(track.coords[i]));
|
|
if (dist < minDist) {
|
|
minDist = dist;
|
|
splitIndex = i;
|
|
}
|
|
}
|
|
|
|
if (splitIndex === 0 || splitIndex === track.coords.length - 1) {
|
|
updateStatus('Cannot split at track endpoints', 'error');
|
|
return;
|
|
}
|
|
|
|
// Get the actual split point coordinates
|
|
const splitPoint = track.coords[splitIndex];
|
|
|
|
// Create two new tracks
|
|
const coords1 = track.coords.slice(0, splitIndex + 1);
|
|
const coords2 = track.coords.slice(splitIndex);
|
|
|
|
const track1 = new Track(coords1, track.name + ' (part 1)', track.description);
|
|
const track2 = new Track(coords2, track.name + ' (part 2)', track.description);
|
|
|
|
// Add a marker at the split point
|
|
const splitMarker = L.marker(splitPoint, {
|
|
icon: L.divIcon({
|
|
className: 'split-marker',
|
|
html: '<div style="background: #ff4444; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.4);"></div>',
|
|
iconSize: [12, 12],
|
|
iconAnchor: [6, 6]
|
|
})
|
|
}).addTo(map);
|
|
splitMarker.bindPopup(`Split point<br><small>${track.name}</small>`);
|
|
splitMarkers.push(splitMarker);
|
|
|
|
// Remove original and add new tracks
|
|
const idx = tracks.indexOf(track);
|
|
track.remove();
|
|
tracks.splice(idx, 1, track1, track2);
|
|
|
|
updateTrackList();
|
|
updateStatus(`Split "${track.name}" into 2 tracks`);
|
|
}
|
|
|
|
// Delete a track
|
|
function deleteTrack(track) {
|
|
saveStateForUndo();
|
|
|
|
const idx = tracks.indexOf(track);
|
|
if (idx > -1) {
|
|
track.remove();
|
|
tracks.splice(idx, 1);
|
|
// Remove from selection if selected
|
|
const selIdx = selectedTracks.indexOf(track);
|
|
if (selIdx > -1) {
|
|
selectedTracks.splice(selIdx, 1);
|
|
}
|
|
updateTrackList();
|
|
updateStatus(`Deleted: ${track.name}`);
|
|
}
|
|
}
|
|
|
|
// Drawing functions
|
|
function startDrawing(latlng) {
|
|
isDrawing = true;
|
|
drawingPoints = [latlng];
|
|
drawingLine = L.polyline(drawingPoints, {
|
|
color: '#ff8800',
|
|
weight: 3,
|
|
dashArray: '5, 10'
|
|
}).addTo(map);
|
|
}
|
|
|
|
function continueDrawing(latlng) {
|
|
drawingPoints.push(latlng);
|
|
drawingLine.setLatLngs(drawingPoints);
|
|
}
|
|
|
|
function finishDrawing() {
|
|
if (drawingPoints.length < 2) {
|
|
cancelDrawing();
|
|
return;
|
|
}
|
|
|
|
const name = prompt('Enter track name:', `Track ${tracks.length + 1}`);
|
|
if (name !== null) {
|
|
const coords = drawingPoints.map(ll => [ll.lat, ll.lng]);
|
|
const track = new Track(coords, name || `Track ${tracks.length + 1}`);
|
|
tracks.push(track);
|
|
updateTrackList();
|
|
updateStatus(`Created: ${track.name}`);
|
|
}
|
|
|
|
cancelDrawing();
|
|
}
|
|
|
|
function cancelDrawing() {
|
|
isDrawing = false;
|
|
drawingPoints = [];
|
|
if (drawingLine) {
|
|
map.removeLayer(drawingLine);
|
|
drawingLine = null;
|
|
}
|
|
}
|
|
|
|
// Navigation press-and-hold variables
|
|
let pressTimer = null;
|
|
let isPressing = false;
|
|
let pendingDestination = null;
|
|
let touchStartTime = 0;
|
|
let lastTapTime = 0;
|
|
let lastTapLocation = null;
|
|
|
|
// Navigation confirmation dialog handlers
|
|
document.getElementById('navConfirmYes').addEventListener('click', () => {
|
|
document.getElementById('navConfirmDialog').style.display = 'none';
|
|
if (pendingDestination) {
|
|
setDestination(pendingDestination.track, pendingDestination.index);
|
|
pendingDestination = null;
|
|
}
|
|
});
|
|
|
|
document.getElementById('navConfirmNo').addEventListener('click', () => {
|
|
document.getElementById('navConfirmDialog').style.display = 'none';
|
|
pendingDestination = null;
|
|
});
|
|
|
|
// Press and hold handlers for navigation mode
|
|
function startPressHold(e) {
|
|
if (!navMode) return false;
|
|
|
|
const nearest = findNearestTrackPoint(e.latlng, 100);
|
|
if (!nearest) return false;
|
|
|
|
isPressing = true;
|
|
pendingDestination = nearest;
|
|
|
|
// Show indicator
|
|
document.getElementById('pressHoldIndicator').style.display = 'block';
|
|
|
|
// Start timer for 500ms hold
|
|
pressTimer = setTimeout(() => {
|
|
if (isPressing) {
|
|
document.getElementById('pressHoldIndicator').style.display = 'none';
|
|
// Show confirmation dialog
|
|
const message = `Navigate to ${nearest.track.name}?`;
|
|
document.getElementById('navConfirmMessage').textContent = message;
|
|
document.getElementById('navConfirmDialog').style.display = 'flex';
|
|
isPressing = false;
|
|
}
|
|
}, 500);
|
|
|
|
return true;
|
|
}
|
|
|
|
function cancelPressHold() {
|
|
if (pressTimer) {
|
|
clearTimeout(pressTimer);
|
|
pressTimer = null;
|
|
}
|
|
isPressing = false;
|
|
document.getElementById('pressHoldIndicator').style.display = 'none';
|
|
}
|
|
|
|
// Map mouse/touch handlers
|
|
map.on('mousedown', (e) => {
|
|
if (navMode) {
|
|
if (startPressHold(e)) {
|
|
L.DomEvent.stopPropagation(e);
|
|
L.DomEvent.preventDefault(e);
|
|
}
|
|
}
|
|
});
|
|
|
|
// Direct touch event binding for mobile (Leaflet doesn't support touchstart through map.on)
|
|
const mapContainer = map.getContainer();
|
|
|
|
// Fix for Chrome and PWA - use native addEventListener with passive: false
|
|
mapContainer.addEventListener('touchstart', function(e) {
|
|
if (navMode && e.touches.length === 1) {
|
|
touchStartTime = Date.now();
|
|
const touch = e.touches[0];
|
|
const rect = mapContainer.getBoundingClientRect();
|
|
|
|
// Accurate coordinate calculation using getBoundingClientRect
|
|
const x = touch.clientX - rect.left;
|
|
const y = touch.clientY - rect.top;
|
|
const containerPoint = L.point(x, y);
|
|
const latlng = map.containerPointToLatLng(containerPoint);
|
|
|
|
// Pass event with correct latlng structure
|
|
if (startPressHold({ latlng: latlng })) {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
e.stopImmediatePropagation();
|
|
return false;
|
|
}
|
|
}
|
|
}, { passive: false, capture: true });
|
|
|
|
mapContainer.addEventListener('touchend', function(e) {
|
|
if (navMode) {
|
|
const now = Date.now();
|
|
const timeSinceLastTap = now - lastTapTime;
|
|
|
|
// Get current tap location
|
|
let currentTapLocation = null;
|
|
if (e.changedTouches && e.changedTouches.length > 0) {
|
|
const touch = e.changedTouches[0];
|
|
currentTapLocation = { x: touch.clientX, y: touch.clientY };
|
|
}
|
|
|
|
// Check for double-tap (two taps within 300ms at roughly same location)
|
|
if (timeSinceLastTap < 300 && lastTapLocation && currentTapLocation && pendingDestination) {
|
|
// Calculate distance between taps
|
|
const dx = currentTapLocation.x - lastTapLocation.x;
|
|
const dy = currentTapLocation.y - lastTapLocation.y;
|
|
const distance = Math.sqrt(dx * dx + dy * dy);
|
|
|
|
// Only trigger if taps are within 30 pixels of each other
|
|
if (distance < 30) {
|
|
// Double-tap detected at same location - show navigation dialog
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
|
|
document.getElementById('pressHoldIndicator').style.display = 'none';
|
|
const message = `Navigate to ${pendingDestination.track.name}?`;
|
|
document.getElementById('navConfirmMessage').textContent = message;
|
|
document.getElementById('navConfirmDialog').style.display = 'flex';
|
|
|
|
lastTapTime = 0; // Reset to prevent triple tap
|
|
lastTapLocation = null;
|
|
} else {
|
|
// Taps too far apart - treat as new first tap
|
|
lastTapTime = now;
|
|
lastTapLocation = currentTapLocation;
|
|
}
|
|
} else {
|
|
// Store this tap for double-tap detection
|
|
lastTapTime = now;
|
|
lastTapLocation = currentTapLocation;
|
|
}
|
|
|
|
if (isPressing) {
|
|
e.preventDefault();
|
|
}
|
|
cancelPressHold();
|
|
} else if (isPressing) {
|
|
e.preventDefault();
|
|
cancelPressHold();
|
|
}
|
|
}, { passive: false });
|
|
|
|
mapContainer.addEventListener('touchcancel', cancelPressHold, { passive: false });
|
|
|
|
mapContainer.addEventListener('touchmove', function(e) {
|
|
if (isPressing) {
|
|
e.preventDefault();
|
|
cancelPressHold();
|
|
}
|
|
}, { passive: false });
|
|
|
|
// Mouse events for desktop
|
|
map.on('mouseup', cancelPressHold);
|
|
map.on('mousemove', (e) => {
|
|
if (isPressing) {
|
|
// Cancel if mouse moves too much during press
|
|
cancelPressHold();
|
|
}
|
|
});
|
|
|
|
// Map click handler
|
|
map.on('click', (e) => {
|
|
// In navigation mode, clicks are handled by press-and-hold
|
|
if (navMode) {
|
|
return;
|
|
}
|
|
|
|
if (currentTool === 'draw') {
|
|
if (!isDrawing) {
|
|
startDrawing(e.latlng);
|
|
} else {
|
|
continueDrawing(e.latlng);
|
|
}
|
|
} else if (currentTool === 'geocache') {
|
|
// Place a new geocache
|
|
if (!navMode) {
|
|
// In edit mode, place anywhere
|
|
placeGeocache(e.latlng);
|
|
} else {
|
|
// In nav mode, must have GPS enabled and be at the location
|
|
if (userLocation) {
|
|
const distance = L.latLng(userLocation.lat, userLocation.lng).distanceTo(e.latlng);
|
|
if (distance <= 10) { // Within 10 meters of click location
|
|
placeGeocache(e.latlng);
|
|
} else {
|
|
alert('You must be at the location to place a geocache! (within 10 meters)');
|
|
}
|
|
} else {
|
|
alert('GPS tracking must be enabled to place geocaches in navigation mode!');
|
|
}
|
|
}
|
|
}
|
|
// Don't auto-deselect on map click - use Clear Selection button instead
|
|
});
|
|
|
|
map.on('dblclick', (e) => {
|
|
if (navMode) {
|
|
// In navigation mode, double-click sets destination
|
|
L.DomEvent.stopPropagation(e);
|
|
L.DomEvent.preventDefault(e);
|
|
|
|
// Find nearest track point
|
|
const nearest = findNearestTrackPoint(e.latlng);
|
|
if (nearest && nearest.distance < 50) {
|
|
// Show navigation dialog
|
|
pendingDestination = nearest;
|
|
const message = `Navigate to ${nearest.track.name}?`;
|
|
document.getElementById('navConfirmMessage').textContent = message;
|
|
document.getElementById('navConfirmDialog').style.display = 'flex';
|
|
}
|
|
} else if (currentTool === 'draw' && isDrawing) {
|
|
L.DomEvent.stopPropagation(e);
|
|
finishDrawing();
|
|
}
|
|
});
|
|
|
|
// Reshape tool mouse handlers
|
|
map.on('mousedown', (e) => {
|
|
if (currentTool === 'reshape' && !isDragging) {
|
|
if (startReshapeDrag(e.latlng)) {
|
|
L.DomEvent.stopPropagation(e);
|
|
}
|
|
}
|
|
if (currentTool === 'smooth' && !isSmoothing) {
|
|
startSmoothing(e.latlng);
|
|
map.dragging.disable();
|
|
}
|
|
});
|
|
|
|
map.on('mousemove', (e) => {
|
|
if (currentTool === 'reshape' && isDragging) {
|
|
continueReshapeDrag(e.latlng);
|
|
}
|
|
if (currentTool === 'smooth' && isSmoothing) {
|
|
continueSmoothing(e.latlng);
|
|
}
|
|
});
|
|
|
|
map.on('mouseup', (e) => {
|
|
if (currentTool === 'reshape' && isDragging) {
|
|
finishReshapeDrag();
|
|
}
|
|
if (currentTool === 'smooth' && isSmoothing) {
|
|
finishSmoothing();
|
|
map.dragging.enable();
|
|
}
|
|
});
|
|
|
|
// Keyboard shortcuts
|
|
document.addEventListener('keydown', (e) => {
|
|
// Escape to cancel reshape
|
|
if (e.key === 'Escape' && isDragging) {
|
|
cancelReshapeDrag();
|
|
}
|
|
|
|
// Delete key to delete selected tracks
|
|
if (e.key === 'Delete' && selectedTracks.length > 0) {
|
|
saveStateForUndo();
|
|
const count = selectedTracks.length;
|
|
selectedTracks.forEach(track => {
|
|
const idx = tracks.indexOf(track);
|
|
if (idx > -1) {
|
|
track.remove();
|
|
tracks.splice(idx, 1);
|
|
}
|
|
});
|
|
selectedTracks = [];
|
|
updateTrackList();
|
|
updateStatus(`Deleted ${count} track(s)`, 'success');
|
|
}
|
|
});
|
|
|
|
// Merge selected tracks by connecting end-to-end (in selection order)
|
|
function mergeConnect() {
|
|
if (selectedTracks.length < 2) {
|
|
updateStatus('Select at least 2 tracks to merge', 'error');
|
|
return;
|
|
}
|
|
|
|
saveStateForUndo();
|
|
|
|
// Connect tracks in selection order, finding best endpoint connections
|
|
let mergedCoords = [...selectedTracks[0].coords];
|
|
|
|
for (let i = 1; i < selectedTracks.length; i++) {
|
|
const nextCoords = selectedTracks[i].coords;
|
|
|
|
// Find best connection: check all 4 endpoint combinations
|
|
const currentEnd = L.latLng(mergedCoords[mergedCoords.length - 1]);
|
|
const currentStart = L.latLng(mergedCoords[0]);
|
|
const nextStart = L.latLng(nextCoords[0]);
|
|
const nextEnd = L.latLng(nextCoords[nextCoords.length - 1]);
|
|
|
|
const d1 = map.distance(currentEnd, nextStart); // end -> start (normal)
|
|
const d2 = map.distance(currentEnd, nextEnd); // end -> end (reverse next)
|
|
const d3 = map.distance(currentStart, nextStart); // start -> start (reverse current)
|
|
const d4 = map.distance(currentStart, nextEnd); // start -> end (reverse both)
|
|
|
|
const minDist = Math.min(d1, d2, d3, d4);
|
|
|
|
if (minDist === d1) {
|
|
// Normal: append next
|
|
mergedCoords = [...mergedCoords, ...nextCoords.slice(1)];
|
|
} else if (minDist === d2) {
|
|
// Reverse next track
|
|
mergedCoords = [...mergedCoords, ...nextCoords.slice(0, -1).reverse()];
|
|
} else if (minDist === d3) {
|
|
// Reverse current, then append next
|
|
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(1)];
|
|
} else {
|
|
// Reverse current, reverse next
|
|
mergedCoords = [...mergedCoords.reverse(), ...nextCoords.slice(0, -1).reverse()];
|
|
}
|
|
}
|
|
|
|
// Create new track
|
|
const numMerged = selectedTracks.length;
|
|
const name = prompt('Enter name for merged track:', selectedTracks.map(t => t.name).join(' + '));
|
|
if (name === null) return;
|
|
|
|
const newTrack = new Track(mergedCoords, name || 'Merged Track');
|
|
|
|
// Remove old tracks
|
|
selectedTracks.forEach(track => {
|
|
const idx = tracks.indexOf(track);
|
|
if (idx > -1) {
|
|
track.remove();
|
|
tracks.splice(idx, 1);
|
|
}
|
|
});
|
|
|
|
tracks.push(newTrack);
|
|
selectedTracks = [newTrack];
|
|
newTrack.setSelected(true);
|
|
|
|
updateTrackList();
|
|
updateStatus(`Merged ${numMerged} tracks into "${newTrack.name}"`, 'success');
|
|
}
|
|
|
|
// Merge selected tracks by averaging overlapping points
|
|
function mergeAverage() {
|
|
if (selectedTracks.length < 2) {
|
|
updateStatus('Select at least 2 tracks to merge', 'error');
|
|
return;
|
|
}
|
|
|
|
const threshold = 25; // meters - points within this distance get averaged
|
|
|
|
// Use the longest track as the base
|
|
const sortedByLength = [...selectedTracks].sort((a, b) => b.coords.length - a.coords.length);
|
|
const baseTrack = sortedByLength[0];
|
|
const otherTracks = sortedByLength.slice(1);
|
|
|
|
// For each point in other tracks, check if it's near ANY point in base track
|
|
const otherTrackData = otherTracks.map(track => ({
|
|
track,
|
|
points: track.coords.map(coord => {
|
|
// Check if this point is near any base track point
|
|
let isNearBase = false;
|
|
for (const baseCoord of baseTrack.coords) {
|
|
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
|
|
isNearBase = true;
|
|
break;
|
|
}
|
|
}
|
|
return { coord, isNearBase };
|
|
})
|
|
}));
|
|
|
|
// For each point in base, find and average nearby points from other tracks
|
|
const result = [];
|
|
const usedPoints = otherTrackData.map(d => d.points.map(() => false));
|
|
|
|
for (let i = 0; i < baseTrack.coords.length; i++) {
|
|
const basePt = L.latLng(baseTrack.coords[i]);
|
|
let sumLat = baseTrack.coords[i][0];
|
|
let sumLng = baseTrack.coords[i][1];
|
|
let count = 1;
|
|
|
|
// Find closest point from each other track
|
|
for (let t = 0; t < otherTrackData.length; t++) {
|
|
const otherData = otherTrackData[t];
|
|
let closestDist = Infinity;
|
|
let closestIdx = -1;
|
|
|
|
for (let j = 0; j < otherData.points.length; j++) {
|
|
if (usedPoints[t][j]) continue;
|
|
|
|
const pt = otherData.points[j];
|
|
const dist = map.distance(basePt, L.latLng(pt.coord));
|
|
if (dist < closestDist && dist < threshold) {
|
|
closestDist = dist;
|
|
closestIdx = j;
|
|
}
|
|
}
|
|
|
|
if (closestIdx >= 0) {
|
|
const pt = otherData.points[closestIdx];
|
|
sumLat += pt.coord[0];
|
|
sumLng += pt.coord[1];
|
|
count++;
|
|
usedPoints[t][closestIdx] = true;
|
|
}
|
|
}
|
|
|
|
result.push([sumLat / count, sumLng / count]);
|
|
}
|
|
|
|
// Collect non-overlapping segments (points NOT near base track)
|
|
const branches = [];
|
|
for (let t = 0; t < otherTrackData.length; t++) {
|
|
const otherData = otherTrackData[t];
|
|
let currentBranch = [];
|
|
|
|
for (let i = 0; i < otherData.points.length; i++) {
|
|
const pt = otherData.points[i];
|
|
if (!pt.isNearBase) {
|
|
// This point is NOT near the base track - it's a branch
|
|
currentBranch.push(pt.coord);
|
|
} else {
|
|
// Point is near base, save any accumulated branch
|
|
if (currentBranch.length >= 2) {
|
|
branches.push({
|
|
coords: [...currentBranch],
|
|
trackName: otherData.track.name
|
|
});
|
|
}
|
|
currentBranch = [];
|
|
}
|
|
}
|
|
|
|
// Don't forget trailing branch
|
|
if (currentBranch.length >= 2) {
|
|
branches.push({
|
|
coords: [...currentBranch],
|
|
trackName: otherData.track.name
|
|
});
|
|
}
|
|
}
|
|
|
|
// First, remove old tracks
|
|
const numMerged = selectedTracks.length;
|
|
selectedTracks.forEach(track => {
|
|
const idx = tracks.indexOf(track);
|
|
if (idx > -1) {
|
|
track.remove();
|
|
tracks.splice(idx, 1);
|
|
}
|
|
});
|
|
|
|
// Create branch tracks (in orange so they're visible)
|
|
const branchTracks = [];
|
|
for (let i = 0; i < branches.length; i++) {
|
|
const branch = branches[i];
|
|
const branchTrack = new Track(branch.coords, `${branch.trackName} (branch ${i + 1})`);
|
|
branchTrack.layer.setStyle({ color: '#ff8800', weight: 4 }); // Orange
|
|
tracks.push(branchTrack);
|
|
branchTracks.push(branchTrack);
|
|
}
|
|
|
|
// Create new track for the averaged main path
|
|
const name = prompt('Enter name for averaged track:', baseTrack.name + ' (averaged)');
|
|
if (name === null) {
|
|
// User cancelled, but we already deleted tracks... restore branches at least
|
|
updateTrackList();
|
|
updateStatus('Cancelled - branches preserved', 'info');
|
|
return;
|
|
}
|
|
|
|
const newTrack = new Track(result, name || 'Averaged Track');
|
|
tracks.push(newTrack);
|
|
|
|
selectedTracks = [newTrack];
|
|
newTrack.setSelected(true);
|
|
|
|
updateTrackList();
|
|
const branchMsg = branches.length > 0 ? ` + ${branches.length} branch(es) in orange` : '';
|
|
updateStatus(`Averaged ${numMerged} tracks${branchMsg}`, 'success');
|
|
}
|
|
|
|
// === PREVIEW SYSTEM ===
|
|
|
|
function startPreview() {
|
|
if (selectedTracks.length === 0) {
|
|
updateStatus('Select at least 1 track to preview', 'error');
|
|
return;
|
|
}
|
|
|
|
previewMode = true;
|
|
const threshold = parseInt(document.getElementById('mergeThreshold').value);
|
|
|
|
// Hide original tracks
|
|
selectedTracks.forEach(t => t.layer.setStyle({ opacity: 0.2 }));
|
|
|
|
// Generate and show preview
|
|
updatePreview(threshold);
|
|
|
|
// Update UI
|
|
document.getElementById('previewBtn').style.display = 'none';
|
|
document.getElementById('applyMergeBtn').style.display = 'block';
|
|
document.getElementById('cancelPreviewBtn').style.display = 'block';
|
|
document.getElementById('mergeThreshold').classList.add('preview-active');
|
|
|
|
updateStatus('Adjust slider to fine-tune, then Apply or Cancel', 'info');
|
|
}
|
|
|
|
function updatePreview(threshold) {
|
|
// Clear old preview layers
|
|
previewLayers.forEach(layer => map.removeLayer(layer));
|
|
previewLayers = [];
|
|
|
|
if (selectedTracks.length === 1) {
|
|
// Single track: simplify by merging points that double back
|
|
previewData = simplifySingleTrack(selectedTracks[0].coords, threshold);
|
|
} else {
|
|
// Multiple tracks: merge them together
|
|
previewData = mergeMultipleTracks(selectedTracks, threshold);
|
|
}
|
|
|
|
// Show preview of main track (green dashed)
|
|
if (previewData.mainCoords.length > 0) {
|
|
const mainPreview = L.polyline(previewData.mainCoords, {
|
|
color: '#00cc00',
|
|
weight: 5,
|
|
opacity: 0.9,
|
|
dashArray: '10, 5'
|
|
}).addTo(map);
|
|
previewLayers.push(mainPreview);
|
|
}
|
|
|
|
// Show preview of branches (orange dashed)
|
|
previewData.branches.forEach(branch => {
|
|
const branchPreview = L.polyline(branch.coords, {
|
|
color: '#ff8800',
|
|
weight: 4,
|
|
opacity: 0.9,
|
|
dashArray: '10, 5'
|
|
}).addTo(map);
|
|
previewLayers.push(branchPreview);
|
|
});
|
|
|
|
// Update status with stats
|
|
const reduction = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0) - previewData.mainCoords.length;
|
|
const branchInfo = previewData.branches.length > 0 ? `, ${previewData.branches.length} branch(es)` : '';
|
|
updateStatus(`Preview: ${previewData.mainCoords.length} points${branchInfo} (${reduction} points merged)`, 'info');
|
|
}
|
|
|
|
function cancelPreview() {
|
|
previewMode = false;
|
|
|
|
// Remove preview layers
|
|
previewLayers.forEach(layer => map.removeLayer(layer));
|
|
previewLayers = [];
|
|
previewData = null;
|
|
|
|
// Restore original tracks
|
|
selectedTracks.forEach(t => {
|
|
t.layer.setStyle({ opacity: 0.8 });
|
|
t.setSelected(true);
|
|
});
|
|
|
|
// Update UI
|
|
document.getElementById('previewBtn').style.display = 'block';
|
|
document.getElementById('applyMergeBtn').style.display = 'none';
|
|
document.getElementById('cancelPreviewBtn').style.display = 'none';
|
|
document.getElementById('mergeThreshold').classList.remove('preview-active');
|
|
|
|
updateStatus('Preview cancelled', 'info');
|
|
}
|
|
|
|
function applyMerge() {
|
|
if (!previewData) return;
|
|
|
|
saveStateForUndo();
|
|
|
|
// Remove preview layers
|
|
previewLayers.forEach(layer => map.removeLayer(layer));
|
|
previewLayers = [];
|
|
|
|
// Remove old tracks
|
|
const numMerged = selectedTracks.length;
|
|
const oldNames = selectedTracks.map(t => t.name);
|
|
selectedTracks.forEach(track => {
|
|
const idx = tracks.indexOf(track);
|
|
if (idx > -1) {
|
|
track.remove();
|
|
tracks.splice(idx, 1);
|
|
}
|
|
});
|
|
|
|
// Create branch tracks
|
|
previewData.branches.forEach((branch, i) => {
|
|
const branchTrack = new Track(branch.coords, `${oldNames[0]} (branch ${i + 1})`);
|
|
branchTrack.layer.setStyle({ color: '#ff8800' });
|
|
tracks.push(branchTrack);
|
|
});
|
|
|
|
// Create main merged track
|
|
const defaultName = numMerged === 1 ? `${oldNames[0]} (simplified)` : oldNames.join(' + ');
|
|
const name = prompt('Enter name for merged track:', defaultName);
|
|
|
|
if (name !== null) {
|
|
const newTrack = new Track(previewData.mainCoords, name || defaultName);
|
|
tracks.push(newTrack);
|
|
selectedTracks = [newTrack];
|
|
newTrack.setSelected(true);
|
|
} else {
|
|
selectedTracks = [];
|
|
}
|
|
|
|
// Reset state
|
|
previewMode = false;
|
|
previewData = null;
|
|
|
|
// Update UI
|
|
document.getElementById('previewBtn').style.display = 'block';
|
|
document.getElementById('applyMergeBtn').style.display = 'none';
|
|
document.getElementById('cancelPreviewBtn').style.display = 'none';
|
|
document.getElementById('mergeThreshold').classList.remove('preview-active');
|
|
|
|
updateTrackList();
|
|
updateStatus(`Applied merge: ${numMerged} track(s)`, 'success');
|
|
}
|
|
|
|
// Simplify a single track: merge points that are spatially close AND temporally far apart
|
|
// This detects double-backs while preserving normal path segments
|
|
function simplifySingleTrack(coords, threshold) {
|
|
if (coords.length < 3) {
|
|
return { mainCoords: coords, branches: [] };
|
|
}
|
|
|
|
// Minimum sequence gap required to consider merging
|
|
// Points closer than this in sequence won't be merged even if spatially close
|
|
const minSequenceGap = 15;
|
|
|
|
const clusters = []; // { points: [], centroid: [], pointIndices: [] }
|
|
const pointToCluster = new Array(coords.length).fill(-1);
|
|
|
|
for (let i = 0; i < coords.length; i++) {
|
|
const pt = L.latLng(coords[i]);
|
|
|
|
// Find if this point should join an existing cluster
|
|
// Requirements:
|
|
// 1. Spatially close (within threshold)
|
|
// 2. Temporally far (no points from that cluster in recent window)
|
|
let bestCluster = -1;
|
|
let bestDist = Infinity;
|
|
|
|
for (let c = 0; c < clusters.length; c++) {
|
|
const cluster = clusters[c];
|
|
const dist = map.distance(pt, L.latLng(cluster.centroid));
|
|
|
|
if (dist < threshold && dist < bestDist) {
|
|
// Check temporal distance - cluster must not have recent points
|
|
const mostRecentInCluster = Math.max(...cluster.pointIndices);
|
|
const sequenceGap = i - mostRecentInCluster;
|
|
|
|
if (sequenceGap >= minSequenceGap) {
|
|
// Far enough apart temporally - this is a double-back
|
|
bestDist = dist;
|
|
bestCluster = c;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (bestCluster >= 0) {
|
|
// Merge with existing cluster (double-back detected)
|
|
const cluster = clusters[bestCluster];
|
|
cluster.points.push(coords[i]);
|
|
cluster.pointIndices.push(i);
|
|
// Recalculate centroid
|
|
let sumLat = 0, sumLng = 0;
|
|
for (const p of cluster.points) {
|
|
sumLat += p[0];
|
|
sumLng += p[1];
|
|
}
|
|
cluster.centroid = [sumLat / cluster.points.length, sumLng / cluster.points.length];
|
|
pointToCluster[i] = bestCluster;
|
|
} else {
|
|
// Create new cluster (new territory or too recent to merge)
|
|
clusters.push({
|
|
points: [coords[i]],
|
|
centroid: [...coords[i]],
|
|
pointIndices: [i]
|
|
});
|
|
pointToCluster[i] = clusters.length - 1;
|
|
}
|
|
}
|
|
|
|
// Rebuild path following original order, using cluster centroids
|
|
// Remove consecutive duplicates (same cluster visited multiple times in a row)
|
|
const result = [];
|
|
let lastCluster = -1;
|
|
|
|
for (let i = 0; i < coords.length; i++) {
|
|
const clusterIdx = pointToCluster[i];
|
|
if (clusterIdx !== lastCluster) {
|
|
result.push([...clusters[clusterIdx].centroid]);
|
|
lastCluster = clusterIdx;
|
|
}
|
|
}
|
|
|
|
return { mainCoords: result, branches: [] };
|
|
}
|
|
|
|
// Merge multiple tracks together
|
|
function mergeMultipleTracks(tracksToMerge, threshold) {
|
|
// Use the longest track as the base
|
|
const sortedByLength = [...tracksToMerge].sort((a, b) => b.coords.length - a.coords.length);
|
|
const baseTrack = sortedByLength[0];
|
|
const otherTracks = sortedByLength.slice(1);
|
|
|
|
// For each point in other tracks, check if it's near ANY point in base track
|
|
const otherTrackData = otherTracks.map(track => ({
|
|
track,
|
|
points: track.coords.map(coord => {
|
|
let isNearBase = false;
|
|
for (const baseCoord of baseTrack.coords) {
|
|
if (map.distance(L.latLng(coord), L.latLng(baseCoord)) < threshold) {
|
|
isNearBase = true;
|
|
break;
|
|
}
|
|
}
|
|
return { coord, isNearBase };
|
|
})
|
|
}));
|
|
|
|
// For each base point, find corresponding points in other tracks using sequence matching
|
|
const result = [];
|
|
|
|
// Track position in each other track (to maintain order)
|
|
const trackPositions = otherTracks.map(() => 0);
|
|
|
|
for (let i = 0; i < baseTrack.coords.length; i++) {
|
|
const basePt = L.latLng(baseTrack.coords[i]);
|
|
let sumLat = baseTrack.coords[i][0];
|
|
let sumLng = baseTrack.coords[i][1];
|
|
let count = 1;
|
|
|
|
// For each other track, find the best matching point near current position
|
|
for (let t = 0; t < otherTracks.length; t++) {
|
|
const otherCoords = otherTracks[t].coords;
|
|
let bestIdx = -1;
|
|
let bestDist = Infinity;
|
|
|
|
// Search in a window around the current track position
|
|
const searchStart = Math.max(0, trackPositions[t] - 10);
|
|
const searchEnd = Math.min(otherCoords.length, trackPositions[t] + 50);
|
|
|
|
for (let j = searchStart; j < searchEnd; j++) {
|
|
const dist = map.distance(basePt, L.latLng(otherCoords[j]));
|
|
if (dist < threshold && dist < bestDist) {
|
|
bestDist = dist;
|
|
bestIdx = j;
|
|
}
|
|
}
|
|
|
|
if (bestIdx >= 0) {
|
|
sumLat += otherCoords[bestIdx][0];
|
|
sumLng += otherCoords[bestIdx][1];
|
|
count++;
|
|
// Move track position forward (only forward, to maintain order)
|
|
if (bestIdx >= trackPositions[t]) {
|
|
trackPositions[t] = bestIdx + 1;
|
|
}
|
|
}
|
|
}
|
|
|
|
result.push([sumLat / count, sumLng / count]);
|
|
}
|
|
|
|
// Collect branches (non-overlapping segments)
|
|
const branches = [];
|
|
for (let t = 0; t < otherTrackData.length; t++) {
|
|
const otherData = otherTrackData[t];
|
|
let currentBranch = [];
|
|
|
|
for (let i = 0; i < otherData.points.length; i++) {
|
|
const pt = otherData.points[i];
|
|
if (!pt.isNearBase) {
|
|
currentBranch.push(pt.coord);
|
|
} else {
|
|
if (currentBranch.length >= 2) {
|
|
branches.push({ coords: [...currentBranch] });
|
|
}
|
|
currentBranch = [];
|
|
}
|
|
}
|
|
if (currentBranch.length >= 2) {
|
|
branches.push({ coords: [...currentBranch] });
|
|
}
|
|
}
|
|
|
|
return { mainCoords: result, branches };
|
|
}
|
|
|
|
// Parse KML
|
|
function parseKML(kmlText) {
|
|
const parser = new DOMParser();
|
|
const kml = parser.parseFromString(kmlText, 'text/xml');
|
|
const placemarks = kml.querySelectorAll('Placemark');
|
|
let count = 0;
|
|
|
|
placemarks.forEach(placemark => {
|
|
const name = placemark.querySelector('name')?.textContent || 'Track';
|
|
const description = placemark.querySelector('description')?.textContent || '';
|
|
|
|
// Handle LineString
|
|
const lineStrings = placemark.querySelectorAll('LineString');
|
|
lineStrings.forEach(lineString => {
|
|
const coordsText = lineString.querySelector('coordinates')?.textContent;
|
|
const coords = parseCoordinates(coordsText);
|
|
if (coords.length > 0) {
|
|
const track = new Track(coords, name, description);
|
|
tracks.push(track);
|
|
count++;
|
|
}
|
|
});
|
|
});
|
|
|
|
return count;
|
|
}
|
|
|
|
// Parse coordinates
|
|
function parseCoordinates(coordString) {
|
|
if (!coordString) return [];
|
|
const coords = [];
|
|
const points = coordString.trim().split(/\s+/);
|
|
|
|
points.forEach(point => {
|
|
const parts = point.split(',');
|
|
if (parts.length >= 2) {
|
|
const lng = parseFloat(parts[0]);
|
|
const lat = parseFloat(parts[1]);
|
|
if (!isNaN(lat) && !isNaN(lng)) {
|
|
coords.push([lat, lng]);
|
|
}
|
|
}
|
|
});
|
|
|
|
return coords;
|
|
}
|
|
|
|
// Export to KML
|
|
function exportToKML() {
|
|
let placemarks = '';
|
|
|
|
tracks.forEach(track => {
|
|
const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' ');
|
|
placemarks += `
|
|
<Placemark>
|
|
<name>${escapeXml(track.name)}</name>
|
|
<description>${escapeXml(track.description)}</description>
|
|
<LineString>
|
|
<coordinates>${coords}</coordinates>
|
|
</LineString>
|
|
</Placemark>`;
|
|
});
|
|
|
|
const kml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
|
<Document>
|
|
<name>Exported Tracks</name>${placemarks}
|
|
</Document>
|
|
</kml>`;
|
|
|
|
const blob = new Blob([kml], { type: 'application/vnd.google-earth.kml+xml' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'tracks-export.kml';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
updateStatus('Exported KML file', 'success');
|
|
}
|
|
|
|
function reloadTracks() {
|
|
// Clear existing tracks
|
|
tracks.forEach(track => track.remove());
|
|
tracks.length = 0;
|
|
selectedTracks = [];
|
|
|
|
// Reload from server with cache busting
|
|
fetch('default.kml?t=' + Date.now())
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('default.kml not found');
|
|
return response.text();
|
|
})
|
|
.then(kmlText => {
|
|
const count = parseKML(kmlText);
|
|
updateTrackList();
|
|
updateStatus(`Reloaded ${count} track(s) from server`, 'success');
|
|
|
|
if (tracks.length > 0) {
|
|
const bounds = L.latLngBounds(tracks.flatMap(t => t.coords));
|
|
map.fitBounds(bounds, { padding: [20, 20] });
|
|
}
|
|
})
|
|
.catch(err => {
|
|
updateStatus('Failed to reload tracks: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function saveToServer() {
|
|
// Generate KML content
|
|
let placemarks = '';
|
|
tracks.forEach(track => {
|
|
const coords = track.coords.map(c => `${c[1]},${c[0]},0`).join(' ');
|
|
placemarks += `
|
|
<Placemark>
|
|
<name>${escapeXml(track.name)}</name>
|
|
<description>${escapeXml(track.description)}</description>
|
|
<LineString>
|
|
<coordinates>${coords}</coordinates>
|
|
</LineString>
|
|
</Placemark>`;
|
|
});
|
|
|
|
const kml = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<kml xmlns="http://www.opengis.net/kml/2.2">
|
|
<Document>
|
|
<name>Saved Tracks</name>${placemarks}
|
|
</Document>
|
|
</kml>`;
|
|
|
|
// Send to server
|
|
updateStatus('Saving to server...', 'info');
|
|
|
|
fetch('/save-kml', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/xml'
|
|
},
|
|
body: kml
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
return response.text().then(text => {
|
|
throw new Error(text || 'Failed to save');
|
|
});
|
|
}
|
|
return response.json();
|
|
})
|
|
.then(result => {
|
|
updateStatus(result.message || 'Saved to server successfully', 'success');
|
|
})
|
|
.catch(err => {
|
|
updateStatus('Failed to save: ' + err.message, 'error');
|
|
});
|
|
}
|
|
|
|
function escapeXml(str) {
|
|
if (!str) return '';
|
|
return str.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
// Update UI
|
|
function updateStatus(message, type = '') {
|
|
const statusEl = document.getElementById('status');
|
|
statusEl.textContent = message;
|
|
statusEl.className = 'status' + (type ? ' ' + type : '');
|
|
}
|
|
|
|
function updateTrackList() {
|
|
const listEl = document.getElementById('trackList');
|
|
const countEl = document.getElementById('trackCount');
|
|
|
|
countEl.textContent = tracks.length;
|
|
|
|
listEl.innerHTML = tracks.map((track, i) => `
|
|
<div class="track-item ${selectedTracks.includes(track) ? 'selected' : ''}" data-index="${i}">
|
|
<span>${track.name}</span>
|
|
<button class="delete-btn" data-index="${i}">×</button>
|
|
</div>
|
|
`).join('');
|
|
|
|
// Add click handlers
|
|
listEl.querySelectorAll('.track-item').forEach(item => {
|
|
item.addEventListener('click', (e) => {
|
|
if (!e.target.classList.contains('delete-btn')) {
|
|
const idx = parseInt(item.dataset.index);
|
|
selectTrack(tracks[idx]);
|
|
map.fitBounds(tracks[idx].layer.getBounds(), { padding: [50, 50] });
|
|
}
|
|
});
|
|
});
|
|
|
|
listEl.querySelectorAll('.delete-btn').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const idx = parseInt(btn.dataset.index);
|
|
deleteTrack(tracks[idx]);
|
|
});
|
|
});
|
|
}
|
|
|
|
// Event listeners
|
|
document.getElementById('kmlFile').addEventListener('change', function(e) {
|
|
const file = e.target.files[0];
|
|
if (!file) return;
|
|
|
|
updateStatus('Loading...', 'info');
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = function(e) {
|
|
try {
|
|
const count = parseKML(e.target.result);
|
|
updateTrackList();
|
|
|
|
if (tracks.length > 0) {
|
|
const bounds = L.latLngBounds(tracks.flatMap(t => t.coords));
|
|
map.fitBounds(bounds, { padding: [20, 20] });
|
|
}
|
|
|
|
updateStatus(`Loaded ${count} track(s) from ${file.name}`, 'success');
|
|
} catch (err) {
|
|
updateStatus(`Error: ${err.message}`, 'error');
|
|
}
|
|
};
|
|
reader.readAsText(file);
|
|
});
|
|
|
|
document.getElementById('exportBtn').addEventListener('click', exportToKML);
|
|
document.getElementById('reloadBtn').addEventListener('click', reloadTracks);
|
|
document.getElementById('saveServerBtn').addEventListener('click', saveToServer);
|
|
document.getElementById('gpsBtn').addEventListener('click', toggleGPS);
|
|
document.getElementById('rotateMapBtn').addEventListener('click', toggleRotateMap);
|
|
document.getElementById('autoCenterBtn').addEventListener('click', toggleAutoCenter);
|
|
|
|
// Tab switching
|
|
document.getElementById('editTab').addEventListener('click', () => switchTab('edit'));
|
|
document.getElementById('navTab').addEventListener('click', () => switchTab('navigate'));
|
|
document.getElementById('adminTab').addEventListener('click', () => switchTab('admin'));
|
|
|
|
// Admin overlay close button - defer to ensure DOM element exists
|
|
setTimeout(() => {
|
|
const adminCloseBtn = document.getElementById('adminCloseBtn');
|
|
if (adminCloseBtn) {
|
|
console.log('Admin close button found, attaching listener');
|
|
adminCloseBtn.addEventListener('click', (e) => {
|
|
console.log('Admin close button clicked');
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
const adminOverlay = document.querySelector('.admin-panel-overlay');
|
|
if (adminOverlay) {
|
|
adminOverlay.classList.remove('active');
|
|
}
|
|
// Remove active class from admin tab
|
|
document.getElementById('adminTab').classList.remove('active');
|
|
// Switch back to edit tab
|
|
switchTab('edit');
|
|
});
|
|
} else {
|
|
console.error('Admin close button not found!');
|
|
}
|
|
}, 100);
|
|
|
|
// Password dialog
|
|
document.getElementById('passwordSubmit').addEventListener('click', checkPassword);
|
|
document.getElementById('passwordCancel').addEventListener('click', hidePasswordDialog);
|
|
document.getElementById('passwordInput').addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter') checkPassword();
|
|
});
|
|
|
|
// Navigation
|
|
document.getElementById('clearNavBtn').addEventListener('click', clearDestination);
|
|
|
|
// Panel toggle
|
|
document.getElementById('panelToggle').addEventListener('click', function() {
|
|
const panel = document.getElementById('controlPanel');
|
|
const toggleBtn = document.getElementById('panelToggle');
|
|
|
|
if (panel.style.display === 'none') {
|
|
panel.style.display = 'block';
|
|
toggleBtn.style.right = '300px';
|
|
} else {
|
|
panel.style.display = 'none';
|
|
toggleBtn.style.right = '10px';
|
|
}
|
|
});
|
|
|
|
document.getElementById('undoBtn').addEventListener('click', undo);
|
|
|
|
document.getElementById('mergeConnectBtn').addEventListener('click', mergeConnect);
|
|
document.getElementById('selectAllBtn').addEventListener('click', selectAll);
|
|
document.getElementById('clearSelectionBtn').addEventListener('click', clearSelection);
|
|
|
|
// Remesh button and dialog
|
|
const remeshSlider = document.getElementById('remeshSpacing');
|
|
const remeshValueDisplay = document.getElementById('remeshSpacingValue');
|
|
|
|
remeshSlider.addEventListener('input', () => {
|
|
remeshValueDisplay.textContent = remeshSlider.value;
|
|
});
|
|
|
|
document.getElementById('remeshBtn').addEventListener('click', () => {
|
|
if (selectedTracks.length === 0) {
|
|
updateStatus('Please select tracks to remesh first', 'error');
|
|
return;
|
|
}
|
|
|
|
// Reset slider to default
|
|
remeshSlider.value = 5;
|
|
remeshValueDisplay.textContent = '5';
|
|
|
|
// Show confirmation dialog
|
|
const trackNames = selectedTracks.map(t => t.name).join(', ');
|
|
const totalPoints = selectedTracks.reduce((sum, t) => sum + t.coords.length, 0);
|
|
document.getElementById('remeshDetails').innerHTML =
|
|
`<strong>Selected tracks:</strong> ${trackNames}<br>` +
|
|
`<strong>Current total points:</strong> ${totalPoints}`;
|
|
document.getElementById('remeshDialog').style.display = 'flex';
|
|
});
|
|
|
|
document.getElementById('remeshYes').addEventListener('click', () => {
|
|
document.getElementById('remeshDialog').style.display = 'none';
|
|
|
|
// Get the spacing value from slider
|
|
const spacing = parseInt(remeshSlider.value);
|
|
|
|
// Remesh all selected tracks with the chosen spacing
|
|
selectedTracks.forEach(track => {
|
|
remeshTrack(track, spacing);
|
|
});
|
|
|
|
// Clear selection after remeshing
|
|
clearSelection();
|
|
});
|
|
|
|
document.getElementById('remeshNo').addEventListener('click', () => {
|
|
document.getElementById('remeshDialog').style.display = 'none';
|
|
});
|
|
|
|
// Preview system
|
|
document.getElementById('previewBtn').addEventListener('click', startPreview);
|
|
document.getElementById('applyMergeBtn').addEventListener('click', applyMerge);
|
|
document.getElementById('cancelPreviewBtn').addEventListener('click', cancelPreview);
|
|
|
|
// Live slider update during preview
|
|
document.getElementById('mergeThreshold').addEventListener('input', (e) => {
|
|
document.getElementById('thresholdValue').textContent = e.target.value;
|
|
if (previewMode) {
|
|
updatePreview(parseInt(e.target.value));
|
|
}
|
|
});
|
|
|
|
// Anchor distance slider update
|
|
document.getElementById('anchorDistance').addEventListener('input', (e) => {
|
|
document.getElementById('anchorValue').textContent = e.target.value;
|
|
// If currently dragging, update the affected markers display
|
|
if (isDragging && originalCoords) {
|
|
showAffectedRange();
|
|
// Re-apply rope physics with new anchor distance
|
|
const anchorDist = parseInt(e.target.value);
|
|
const draggedPoint = dragTrack.coords[dragPointIndex];
|
|
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
|
|
dragTrack.coords = newCoords;
|
|
dragTrack.layer.setLatLngs(newCoords);
|
|
updateAffectedMarkersPositions(newCoords);
|
|
}
|
|
});
|
|
|
|
// Falloff slider update
|
|
document.getElementById('reshapeFalloff').addEventListener('input', (e) => {
|
|
document.getElementById('falloffValue').textContent = parseFloat(e.target.value).toFixed(1);
|
|
// If currently dragging, re-apply with new falloff
|
|
if (isDragging && originalCoords) {
|
|
const anchorDist = parseInt(document.getElementById('anchorDistance').value);
|
|
const draggedPoint = dragTrack.coords[dragPointIndex];
|
|
const newCoords = applyRopePhysics(originalCoords, dragPointIndex, draggedPoint, anchorDist);
|
|
dragTrack.coords = newCoords;
|
|
dragTrack.layer.setLatLngs(newCoords);
|
|
updateAffectedMarkersPositions(newCoords);
|
|
}
|
|
});
|
|
|
|
// Smooth brush size slider update
|
|
document.getElementById('smoothBrushSize').addEventListener('input', (e) => {
|
|
document.getElementById('brushSizeValue').textContent = e.target.value;
|
|
// Update brush circle if currently smoothing
|
|
if (isSmoothing && smoothBrushCircle) {
|
|
const brushSize = parseInt(e.target.value);
|
|
smoothBrushCircle.setRadius(brushSize * getMetersPerPixel());
|
|
}
|
|
});
|
|
|
|
// Smooth strength slider update
|
|
document.getElementById('smoothStrength').addEventListener('input', (e) => {
|
|
document.getElementById('strengthValue').textContent = parseFloat(e.target.value).toFixed(1);
|
|
});
|
|
|
|
// Register Service Worker for PWA functionality
|
|
if ('serviceWorker' in navigator) {
|
|
window.addEventListener('load', () => {
|
|
navigator.serviceWorker.register('/service-worker.js')
|
|
.then(registration => {
|
|
console.log('Service Worker registered:', registration.scope);
|
|
|
|
// Check for updates periodically
|
|
setInterval(() => {
|
|
registration.update();
|
|
}, 60 * 60 * 1000); // Check every hour
|
|
})
|
|
.catch(err => {
|
|
console.log('Service Worker registration failed:', err);
|
|
});
|
|
});
|
|
|
|
// Listen for app install prompt
|
|
let deferredPrompt;
|
|
window.addEventListener('beforeinstallprompt', (e) => {
|
|
// Prevent Chrome 67 and earlier from automatically showing the prompt
|
|
e.preventDefault();
|
|
// Stash the event so it can be triggered later
|
|
deferredPrompt = e;
|
|
console.log('Install prompt ready');
|
|
});
|
|
}
|
|
|
|
// Push Notification Functions
|
|
let pushSubscription = null;
|
|
|
|
async function setupPushNotifications() {
|
|
try {
|
|
// Check if notifications are supported
|
|
if (!('Notification' in window)) {
|
|
alert('This browser does not support notifications');
|
|
return;
|
|
}
|
|
|
|
// Request notification permission
|
|
const permission = await Notification.requestPermission();
|
|
if (permission !== 'granted') {
|
|
alert('Notification permission denied');
|
|
return;
|
|
}
|
|
|
|
// Get service worker registration
|
|
const registration = await navigator.serviceWorker.ready;
|
|
|
|
// Get VAPID public key from server
|
|
const response = await fetch('/vapid-public-key');
|
|
const { publicKey } = await response.json();
|
|
|
|
if (!publicKey) {
|
|
alert('Push notifications not configured on server');
|
|
return;
|
|
}
|
|
|
|
// Subscribe to push notifications
|
|
const subscription = await registration.pushManager.subscribe({
|
|
userVisibleOnly: true,
|
|
applicationServerKey: urlBase64ToUint8Array(publicKey)
|
|
});
|
|
|
|
// Send subscription to server
|
|
const subResponse = await fetch('/subscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(subscription)
|
|
});
|
|
|
|
if (subResponse.ok) {
|
|
pushSubscription = subscription;
|
|
updateNotificationUI(true);
|
|
updateStatus('Push notifications enabled!', 'success');
|
|
|
|
// Test notification using service worker
|
|
if ('serviceWorker' in navigator && registration) {
|
|
registration.showNotification('HikeMap Notifications Active', {
|
|
body: 'You will receive alerts about new geocaches and trail updates',
|
|
icon: '/icon-192x192.png',
|
|
badge: '/icon-72x72.png',
|
|
vibrate: [200, 100, 200]
|
|
});
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to setup push notifications:', error);
|
|
alert('Failed to enable notifications: ' + error.message);
|
|
}
|
|
}
|
|
|
|
async function disablePushNotifications() {
|
|
try {
|
|
if (pushSubscription) {
|
|
// Unsubscribe from push
|
|
await pushSubscription.unsubscribe();
|
|
|
|
// Tell server to remove subscription
|
|
await fetch('/unsubscribe', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
endpoint: pushSubscription.endpoint
|
|
})
|
|
});
|
|
|
|
pushSubscription = null;
|
|
updateNotificationUI(false);
|
|
updateStatus('Push notifications disabled', 'info');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to disable notifications:', error);
|
|
}
|
|
}
|
|
|
|
function updateNotificationUI(enabled) {
|
|
const statusText = document.getElementById('notificationStatusText');
|
|
const enableBtn = document.getElementById('enableNotifications');
|
|
const disableBtn = document.getElementById('disableNotifications');
|
|
const testBtn = document.getElementById('testNotification');
|
|
|
|
if (enabled) {
|
|
statusText.textContent = 'Enabled';
|
|
statusText.style.color = '#4CAF50';
|
|
enableBtn.style.display = 'none';
|
|
disableBtn.style.display = 'block';
|
|
testBtn.style.display = 'block';
|
|
} else {
|
|
statusText.textContent = 'Disabled';
|
|
statusText.style.color = '#666';
|
|
enableBtn.style.display = 'block';
|
|
disableBtn.style.display = 'none';
|
|
testBtn.style.display = 'none';
|
|
}
|
|
}
|
|
|
|
async function sendTestNotification() {
|
|
try {
|
|
const response = await fetch('/test-notification', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
message: 'Test notification from HikeMap admin'
|
|
})
|
|
});
|
|
|
|
const result = await response.json();
|
|
if (result.success) {
|
|
updateStatus(`Test notification sent to ${result.sent} users!`, 'success');
|
|
} else {
|
|
updateStatus('Failed to send test notification', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error sending test notification:', error);
|
|
updateStatus('Error sending test notification', 'error');
|
|
}
|
|
}
|
|
|
|
// Helper function to convert VAPID key
|
|
function urlBase64ToUint8Array(base64String) {
|
|
const padding = '='.repeat((4 - base64String.length % 4) % 4);
|
|
const base64 = (base64String + padding)
|
|
.replace(/\-/g, '+')
|
|
.replace(/_/g, '/');
|
|
|
|
const rawData = window.atob(base64);
|
|
const outputArray = new Uint8Array(rawData.length);
|
|
|
|
for (let i = 0; i < rawData.length; ++i) {
|
|
outputArray[i] = rawData.charCodeAt(i);
|
|
}
|
|
return outputArray;
|
|
}
|
|
|
|
// Check existing push subscription on load
|
|
async function checkPushSubscription() {
|
|
if ('serviceWorker' in navigator && 'PushManager' in window) {
|
|
try {
|
|
const registration = await navigator.serviceWorker.ready;
|
|
const subscription = await registration.pushManager.getSubscription();
|
|
if (subscription) {
|
|
pushSubscription = subscription;
|
|
updateNotificationUI(true);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error checking push subscription:', error);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Initialize
|
|
setTool('select');
|
|
updateTrackList();
|
|
updateUndoButton();
|
|
|
|
// Start in navigation mode
|
|
navMode = true;
|
|
|
|
// Load admin settings
|
|
loadAdminSettings();
|
|
|
|
// Setup admin panel event handlers
|
|
setupAdminInputListeners();
|
|
|
|
// Load user's icon choice or show selector
|
|
loadUserIcon();
|
|
|
|
// Connect to WebSocket for multi-user tracking
|
|
connectWebSocket();
|
|
|
|
// Check if push notifications are already enabled
|
|
checkPushSubscription();
|
|
|
|
// Setup resume navigation dialog handlers
|
|
document.getElementById('resumeNavYes').addEventListener('click', () => {
|
|
document.getElementById('resumeNavDialog').style.display = 'none';
|
|
// Restore saved navigation
|
|
const savedNav = localStorage.getItem('navMode');
|
|
if (savedNav === 'true') {
|
|
switchTab('navigate');
|
|
restoreDestination();
|
|
}
|
|
});
|
|
|
|
document.getElementById('resumeNavNo').addEventListener('click', () => {
|
|
document.getElementById('resumeNavDialog').style.display = 'none';
|
|
localStorage.removeItem('navDestination');
|
|
localStorage.removeItem('navMode');
|
|
});
|
|
|
|
// Auto-load default.kml with cache busting
|
|
fetch('default.kml?t=' + Date.now())
|
|
.then(response => {
|
|
if (!response.ok) throw new Error('default.kml not found');
|
|
return response.text();
|
|
})
|
|
.then(kmlText => {
|
|
const count = parseKML(kmlText);
|
|
updateTrackList();
|
|
updateStatus(`Loaded ${count} track(s) from default.kml`, 'success');
|
|
|
|
// Check for saved navigation state after tracks are loaded
|
|
const savedDestination = localStorage.getItem('navDestination');
|
|
const savedNavMode = localStorage.getItem('navMode');
|
|
|
|
if (savedDestination && savedNavMode === 'true') {
|
|
// Show resume navigation dialog
|
|
document.getElementById('resumeNavDialog').style.display = 'flex';
|
|
} else {
|
|
// Start in navigate mode by default if no saved state
|
|
if (savedNavMode !== 'false') {
|
|
switchTab('navigate');
|
|
}
|
|
}
|
|
|
|
// Restore saved destination after tracks are loaded
|
|
restoreDestination();
|
|
})
|
|
.catch(err => {
|
|
console.log('No default.kml found, starting empty');
|
|
});
|
|
|
|
// Auto-start GPS and zoom to location
|
|
if (navigator.geolocation) {
|
|
toggleGPS();
|
|
}
|
|
|
|
</script>
|
|
|
|
<!-- Admin Panel Overlay -->
|
|
<div class="admin-panel-overlay" id="adminOverlay">
|
|
<div class="admin-panel-header">
|
|
<h2>⚙️ Admin Settings</h2>
|
|
<button class="admin-panel-close" id="adminCloseBtn" onclick="document.querySelector('.admin-panel-overlay').classList.remove('active'); document.getElementById('adminTab').classList.remove('active'); switchTab('edit');">×</button>
|
|
</div>
|
|
<div class="admin-panel-content" id="adminContent">
|
|
<div class="section collapsible" data-section="geocache">
|
|
<div class="section-title">Geocache Settings</div>
|
|
<div class="section-content">
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label for="geocacheRange">Interaction Range:</label>
|
|
<div class="admin-input-container">
|
|
<input type="number" id="geocacheRange" min="1" max="50" value="5">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label for="geocacheAlertRange">Alert Distance:</label>
|
|
<div class="admin-input-container">
|
|
<input type="number" id="geocacheAlertRange" min="1" max="50" value="5">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
<div class="admin-checkbox-row">
|
|
<input type="checkbox" id="geocacheSoundEnabled" checked>
|
|
<label for="geocacheSoundEnabled">Enable alert sound</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Navigation Settings</div>
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label>Track Proximity:</label>
|
|
<input type="number" id="trackProximity" min="10" max="200" value="50">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Intersection Threshold:</label>
|
|
<input type="number" id="intersectionThreshold" min="5" max="30" value="15">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>On-Trail Threshold:</label>
|
|
<input type="number" id="onTrailThreshold" min="20" max="200" value="100">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Node Spacing:</label>
|
|
<input type="number" id="nodeSpacing" min="10" max="100" value="50">
|
|
<span class="unit">meters</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Performance Settings</div>
|
|
<div class="admin-setting-group">
|
|
<div class="admin-input-row">
|
|
<label>GPS Poll Interval:</label>
|
|
<input type="number" id="gpsPollInterval" min="1000" max="10000" value="3000" step="500">
|
|
<span class="unit">ms</span>
|
|
</div>
|
|
<div class="admin-input-row">
|
|
<label>Proximity Check Interval:</label>
|
|
<input type="number" id="proximityCheckInterval" min="1000" max="10000" value="3000" step="500">
|
|
<span class="unit">ms</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="section-title">Push Notifications</div>
|
|
<div class="admin-setting-group">
|
|
<div id="notificationStatus" style="margin-bottom: 10px; color: #666;">
|
|
Status: <span id="notificationStatusText">Not configured</span>
|
|
</div>
|
|
<button class="action-btn" id="enableNotifications" onclick="setupPushNotifications()">
|
|
Enable Push Notifications
|
|
</button>
|
|
<button class="action-btn secondary" id="disableNotifications" onclick="disablePushNotifications()" style="display:none;">
|
|
Disable Notifications
|
|
</button>
|
|
<button class="action-btn" id="testNotification" onclick="sendTestNotification()" style="display:none; margin-top: 10px; background: #9c27b0;">
|
|
📨 Send Test Notification to All Users
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="section">
|
|
<div class="admin-button-group">
|
|
<button class="action-btn" onclick="resetAdminSettings()">Reset to Defaults</button>
|
|
<button class="action-btn" onclick="exportAdminSettings()">Export Settings</button>
|
|
<button class="action-btn" onclick="importAdminSettings()">Import Settings</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</body>
|
|
</html>
|