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.
 
 
 
 
 

10015 lines
381 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;
}
/* In nav mode, disable pointer events on trails so markers are easier to tap */
body.nav-mode .leaflet-overlay-pane path.leaflet-interactive {
pointer-events: none;
}
/* But keep route highlight interactive for potential future use */
body.nav-mode .leaflet-overlay-pane path.route-highlight {
pointer-events: auto;
}
/* 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;
}
/* Geocache styles */
.geocache-marker {
background: transparent;
border: none;
font-size: 20px;
cursor: pointer;
/* Larger tap target for mobile */
display: flex;
align-items: center;
justify-content: center;
width: 48px;
height: 48px;
}
.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%;
}
/* Increase tap area for geocache icon */
.geocache-marker i {
padding: 10px;
}
.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;
right: 0;
width: 100%;
max-width: 500px;
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;
}
@media (max-width: 768px) {
.admin-panel-overlay {
max-width: 100%;
border-radius: 20px 20px 0 0;
}
}
@media (min-width: 769px) {
.admin-panel-overlay {
border-radius: 20px 0 0 0;
}
}
.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;
}
/* Edit Panel Overlay - Similar to Admin */
.edit-panel-overlay {
position: fixed;
bottom: 0;
right: 0;
width: 100%;
max-width: 500px;
height: 60vh;
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: 1001;
border-radius: 20px 20px 0 0;
box-shadow: 0 -4px 20px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
}
@media (max-width: 768px) {
.edit-panel-overlay {
max-width: 100%;
border-radius: 20px 20px 0 0;
}
}
@media (min-width: 769px) {
.edit-panel-overlay {
border-radius: 20px 0 0 0;
}
}
.edit-panel-overlay.active {
transform: translateY(0);
}
.edit-panel-header {
padding: 20px;
border-bottom: 1px solid #444;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.edit-panel-header h2 {
color: #4CAF50;
margin: 0;
font-size: 20px;
}
.edit-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;
}
.edit-panel-close:hover {
color: white;
}
.edit-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;
}
/* Auth Modal Styles */
.auth-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 999999;
}
.auth-modal {
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 400px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
.auth-modal h2 {
text-align: center;
margin-bottom: 20px;
color: #333;
}
.auth-tabs {
display: flex;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.auth-tab {
flex: 1;
padding: 10px;
border: none;
background: #f0f0f0;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.2s;
}
.auth-tab:first-child {
border-radius: 4px 0 0 0;
}
.auth-tab:last-child {
border-radius: 0 4px 0 0;
}
.auth-tab.active {
background: #4CAF50;
color: white;
}
.auth-form {
display: none;
}
.auth-form.active {
display: block;
}
.auth-input-group {
margin-bottom: 15px;
}
.auth-input-group label {
display: block;
margin-bottom: 5px;
font-size: 13px;
color: #555;
}
.auth-input-group input {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.auth-input-group input:focus {
border-color: #4CAF50;
outline: none;
}
.auth-error {
color: #dc3545;
font-size: 13px;
margin-bottom: 10px;
padding: 8px;
background: #f8d7da;
border-radius: 4px;
display: none;
}
.auth-error.visible {
display: block;
}
.auth-submit-btn {
width: 100%;
padding: 12px;
background: #4CAF50;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
.auth-submit-btn:hover {
background: #43a047;
}
.auth-submit-btn:disabled {
background: #9e9e9e;
cursor: not-allowed;
}
.auth-close-btn {
position: absolute;
top: 10px;
right: 15px;
background: none;
border: none;
font-size: 24px;
cursor: pointer;
color: #666;
}
.auth-guest-divider {
display: flex;
align-items: center;
margin: 20px 0 15px 0;
color: #999;
font-size: 13px;
}
.auth-guest-divider::before,
.auth-guest-divider::after {
content: '';
flex: 1;
height: 1px;
background: #ddd;
}
.auth-guest-divider span {
padding: 0 15px;
}
.auth-guest-btn {
width: 100%;
padding: 12px;
background: transparent;
color: #666;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.auth-guest-btn:hover {
border-color: #999;
color: #333;
background: #f5f5f5;
}
/* Character Creator Modal */
.char-creator-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
padding: 0;
border-radius: 16px;
width: 95%;
max-width: 500px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
color: white;
}
.char-creator-header {
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
padding: 20px;
text-align: center;
border-radius: 16px 16px 0 0;
}
.char-creator-header h2 {
margin: 0;
font-size: 24px;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.char-creator-header p {
margin: 5px 0 0 0;
font-size: 14px;
opacity: 0.9;
}
.char-creator-content {
padding: 20px;
}
.char-creator-step {
display: none;
}
.char-creator-step.active {
display: block;
}
.char-creator-section {
margin-bottom: 20px;
}
.char-creator-section h3 {
margin: 0 0 10px 0;
font-size: 16px;
color: #8BC34A;
}
.char-creator-input {
width: 100%;
padding: 12px;
border: 2px solid #333;
border-radius: 8px;
background: #0f0f23;
color: white;
font-size: 16px;
box-sizing: border-box;
}
.char-creator-input:focus {
border-color: #4CAF50;
outline: none;
}
.char-creator-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.char-creator-option {
background: #0f0f23;
border: 2px solid #333;
border-radius: 12px;
padding: 15px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.char-creator-option:hover {
border-color: #4CAF50;
background: #1a1a2e;
}
.char-creator-option.selected {
border-color: #4CAF50;
background: rgba(76, 175, 80, 0.2);
}
.char-creator-option.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.char-creator-option.disabled:hover {
border-color: #333;
background: #0f0f23;
}
.char-creator-option-icon {
font-size: 36px;
margin-bottom: 8px;
}
.char-creator-option-name {
font-weight: bold;
margin-bottom: 4px;
}
.char-creator-option-desc {
font-size: 12px;
color: #aaa;
}
.char-creator-option-badge {
display: inline-block;
background: #666;
color: #ccc;
font-size: 10px;
padding: 2px 8px;
border-radius: 10px;
margin-top: 5px;
}
.char-creator-stats {
display: flex;
justify-content: center;
gap: 15px;
margin-top: 8px;
font-size: 11px;
}
.char-creator-stat {
display: flex;
align-items: center;
gap: 3px;
}
.char-creator-stat.positive { color: #4CAF50; }
.char-creator-stat.negative { color: #f44336; }
.char-creator-stat.neutral { color: #888; }
.char-creator-preview {
background: #0f0f23;
border: 2px solid #333;
border-radius: 12px;
padding: 20px;
text-align: center;
margin-bottom: 20px;
}
.char-creator-preview-icon {
font-size: 48px;
margin-bottom: 10px;
}
.char-creator-preview-name {
font-size: 20px;
font-weight: bold;
color: #8BC34A;
}
.char-creator-preview-info {
font-size: 14px;
color: #aaa;
margin-top: 5px;
}
.char-creator-preview-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 15px;
text-align: left;
}
.char-creator-preview-stat {
display: flex;
justify-content: space-between;
padding: 8px 12px;
background: #1a1a2e;
border-radius: 6px;
}
.char-creator-preview-stat-label {
color: #888;
}
.char-creator-preview-stat-value {
font-weight: bold;
}
.char-creator-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.char-creator-btn {
flex: 1;
padding: 14px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
transition: all 0.2s;
}
.char-creator-btn-back {
background: #333;
color: white;
}
.char-creator-btn-back:hover {
background: #444;
}
.char-creator-btn-next {
background: linear-gradient(90deg, #4CAF50 0%, #8BC34A 100%);
color: white;
}
.char-creator-btn-next:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(76, 175, 80, 0.4);
}
.char-creator-btn-next:disabled {
background: #333;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.char-step-indicator {
display: flex;
justify-content: center;
gap: 8px;
padding: 15px 0;
background: rgba(0,0,0,0.2);
}
.char-step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: #333;
transition: all 0.2s;
}
.char-step-dot.active {
background: #4CAF50;
transform: scale(1.2);
}
.char-step-dot.completed {
background: #8BC34A;
}
/* Character Sheet Modal */
.char-sheet-modal {
background: linear-gradient(135deg, #1a1a2e 0%, #0f0f23 100%);
border-radius: 20px;
padding: 0;
width: 90%;
max-width: 400px;
max-height: 85vh;
overflow-y: auto;
position: relative;
border: 2px solid #4CAF50;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
}
.char-sheet-close {
position: absolute;
top: 15px;
right: 15px;
background: none;
border: none;
color: #aaa;
font-size: 28px;
cursor: pointer;
z-index: 10;
transition: color 0.2s;
}
.char-sheet-close:hover {
color: #fff;
}
.char-sheet-header {
background: linear-gradient(135deg, #4CAF50, #8BC34A);
padding: 25px 20px;
text-align: center;
border-radius: 18px 18px 0 0;
}
.char-sheet-icon {
font-size: 48px;
margin-bottom: 10px;
}
.char-sheet-name {
font-size: 22px;
font-weight: bold;
color: #fff;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.char-sheet-info {
font-size: 14px;
color: rgba(255,255,255,0.9);
margin-top: 5px;
}
.char-sheet-content {
padding: 20px;
}
.char-sheet-section {
background: rgba(255,255,255,0.05);
border-radius: 12px;
padding: 15px;
margin-bottom: 15px;
}
.char-sheet-section h3 {
color: #8BC34A;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 1px;
margin: 0 0 12px 0;
padding-bottom: 8px;
border-bottom: 1px solid rgba(139,195,74,0.3);
}
.char-sheet-stat-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.char-sheet-stat {
display: flex;
flex-direction: column;
gap: 4px;
}
.char-sheet-stat .stat-label {
font-size: 12px;
color: #aaa;
}
.char-sheet-stat .stat-value {
font-size: 16px;
font-weight: bold;
color: #fff;
}
.char-sheet-stat .stat-bar {
height: 8px;
background: rgba(0,0,0,0.3);
border-radius: 4px;
overflow: hidden;
}
.char-sheet-stat .stat-bar.hp-bar .stat-fill {
background: linear-gradient(90deg, #ff6b6b, #ee5a5a);
}
.char-sheet-stat .stat-bar.mp-bar .stat-fill {
background: linear-gradient(90deg, #4ecdc4, #45b7aa);
}
.char-sheet-stat .stat-fill {
height: 100%;
transition: width 0.3s ease;
}
.char-sheet-xp .xp-bar-container {
height: 12px;
background: rgba(0,0,0,0.3);
border-radius: 6px;
overflow: hidden;
margin-bottom: 8px;
}
.char-sheet-xp .xp-bar-fill {
height: 100%;
background: linear-gradient(90deg, #ffd93d, #f0c419);
transition: width 0.3s ease;
}
.char-sheet-xp .xp-text {
font-size: 14px;
font-weight: bold;
color: #ffd93d;
text-align: center;
}
.char-sheet-xp .xp-next {
font-size: 12px;
color: #888;
text-align: center;
margin-top: 4px;
}
.char-sheet-skill {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 10px;
background: rgba(0,0,0,0.2);
border-radius: 8px;
margin-bottom: 8px;
}
.char-sheet-skill:last-child {
margin-bottom: 0;
}
.char-sheet-skill .skill-icon {
font-size: 24px;
min-width: 30px;
text-align: center;
}
.char-sheet-skill .skill-info {
flex: 1;
}
.char-sheet-skill .skill-name {
font-size: 14px;
font-weight: bold;
color: #fff;
}
.char-sheet-skill .skill-desc {
font-size: 12px;
color: #aaa;
margin-top: 2px;
}
.char-sheet-skill .skill-cost {
font-size: 11px;
color: #4ecdc4;
margin-top: 4px;
}
.char-sheet-skill.locked {
opacity: 0.5;
}
.char-sheet-skill.locked .skill-name {
color: #666;
}
/* User Profile Display */
.user-profile {
display: flex;
align-items: center;
padding: 10px;
background: linear-gradient(135deg, #4CAF50 0%, #2E7D32 100%);
border-radius: 8px;
margin-bottom: 15px;
color: white;
}
.user-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.user-avatar i {
font-size: 24px;
}
.user-info {
flex: 1;
}
.user-name {
font-weight: bold;
font-size: 14px;
}
.user-points {
font-size: 12px;
opacity: 0.9;
}
.user-logout-btn {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
padding: 5px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
.user-logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
}
.login-prompt {
text-align: center;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
margin-bottom: 15px;
}
.login-prompt-text {
font-size: 13px;
color: #666;
margin-bottom: 10px;
}
.login-prompt-btn {
background: #4CAF50;
color: white;
border: none;
padding: 8px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 13px;
}
.login-prompt-btn:hover {
background: #43a047;
}
/* Found It Button */
.found-it-btn {
width: 100%;
padding: 12px;
background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%);
color: white;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: bold;
cursor: pointer;
margin-top: 10px;
transition: all 0.3s;
}
.found-it-btn:hover {
transform: scale(1.02);
box-shadow: 0 4px 15px rgba(255, 167, 38, 0.4);
}
.found-it-btn:disabled {
background: #9e9e9e;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.found-it-btn.already-found {
background: linear-gradient(135deg, #66BB6A 0%, #4CAF50 100%);
}
/* Points Animation */
.points-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: linear-gradient(135deg, #FFA726 0%, #FF7043 100%);
color: white;
padding: 30px 50px;
border-radius: 20px;
font-size: 36px;
font-weight: bold;
z-index: 999999;
animation: pointsPopup 2s ease-out forwards;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.3);
}
.points-popup .bonus {
font-size: 18px;
display: block;
margin-top: 5px;
opacity: 0.9;
}
@keyframes pointsPopup {
0% {
opacity: 0;
transform: translate(-50%, -50%) scale(0.5);
}
20% {
opacity: 1;
transform: translate(-50%, -50%) scale(1.1);
}
40% {
transform: translate(-50%, -50%) scale(1);
}
80% {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
100% {
opacity: 0;
transform: translate(-50%, -60%) scale(0.9);
}
}
/* Leaderboard Styles */
.leaderboard-modal {
background: white;
padding: 20px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.leaderboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15px;
}
.leaderboard-tabs {
display: flex;
gap: 5px;
margin-bottom: 15px;
}
.leaderboard-tab {
padding: 8px 15px;
border: none;
background: #f0f0f0;
border-radius: 20px;
cursor: pointer;
font-size: 12px;
}
.leaderboard-tab.active {
background: #4CAF50;
color: white;
}
.leaderboard-list {
list-style: none;
padding: 0;
margin: 0;
}
.leaderboard-item {
display: flex;
align-items: center;
padding: 12px;
border-bottom: 1px solid #eee;
}
.leaderboard-rank {
width: 30px;
font-weight: bold;
color: #666;
}
.leaderboard-rank.gold { color: #FFD700; }
.leaderboard-rank.silver { color: #C0C0C0; }
.leaderboard-rank.bronze { color: #CD7F32; }
.leaderboard-avatar {
width: 35px;
height: 35px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 10px;
}
.leaderboard-user {
flex: 1;
}
.leaderboard-username {
font-weight: bold;
font-size: 14px;
}
.leaderboard-finds {
font-size: 12px;
color: #666;
}
.leaderboard-points {
font-weight: bold;
color: #4CAF50;
}
/* ========================================
RPG COMBAT SYSTEM STYLES
======================================== */
/* Monster Marker Styles */
.monster-marker-container {
background: transparent !important;
border: none !important;
}
.monster-marker {
position: relative;
cursor: pointer;
transition: transform 0.2s;
/* Large tap target for mobile */
display: flex;
align-items: center;
justify-content: center;
width: 70px;
height: 70px;
/* Semi-transparent background for tap area */
background: radial-gradient(circle, rgba(255,100,100,0.25) 0%, rgba(255,100,100,0) 70%);
border-radius: 50%;
}
.monster-marker:hover {
transform: scale(1.2);
}
.monster-marker:active {
transform: scale(0.95);
background: radial-gradient(circle, rgba(255,100,100,0.4) 0%, rgba(255,100,100,0) 70%);
}
.monster-icon {
font-size: 44px;
filter: drop-shadow(2px 2px 4px rgba(0,0,0,0.6));
animation: monster-bob 2s ease-in-out infinite;
}
@keyframes monster-bob {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-6px); }
}
.monster-dialogue-bubble {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: white;
border: 2px solid #333;
border-radius: 12px;
padding: 8px 14px;
max-width: 220px;
font-size: 13px;
white-space: normal;
box-shadow: 0 3px 12px rgba(0,0,0,0.25);
z-index: 1000;
pointer-events: none;
margin-bottom: 8px;
}
.monster-dialogue-bubble::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 10px solid transparent;
border-top-color: #333;
}
/* RPG HUD Styles */
.rpg-hud {
position: fixed;
top: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.85);
color: white;
padding: 10px 20px;
border-radius: 25px;
font-size: 13px;
z-index: 1000;
display: flex;
gap: 18px;
align-items: center;
border: 2px solid #e94560;
box-shadow: 0 4px 15px rgba(233, 69, 96, 0.3);
}
.rpg-hud-class {
font-weight: bold;
color: #e94560;
}
.rpg-hud-stats {
display: flex;
gap: 12px;
}
.rpg-hud-stat {
display: flex;
align-items: center;
gap: 4px;
}
.rpg-hud-stat-label {
color: #888;
font-size: 11px;
}
.rpg-hud-hp { color: #ff6b6b; }
.rpg-hud-mp { color: #4ecdc4; }
.rpg-hud-xp {
display: flex;
align-items: center;
gap: 6px;
}
.rpg-hud-xp-bar {
width: 60px;
height: 8px;
background: #333;
border-radius: 4px;
overflow: hidden;
border: 1px solid #555;
}
.rpg-hud-xp-fill {
height: 100%;
background: linear-gradient(90deg, #ffd93d, #f0c419);
transition: width 0.3s ease;
border-radius: 3px;
}
.rpg-hud-xp-text {
color: #ffd93d;
font-size: 10px;
min-width: 45px;
}
.rpg-hud-monsters {
color: #ffd93d;
}
/* Combat Overlay Styles */
.combat-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.9);
z-index: 100000;
display: flex;
align-items: center;
justify-content: center;
}
.combat-container {
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
border: 3px solid #e94560;
border-radius: 20px;
padding: 25px 30px;
max-width: 480px;
width: 95%;
color: white;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
box-shadow: 0 0 40px rgba(233, 69, 96, 0.4);
}
.combat-header {
text-align: center;
margin-bottom: 20px;
}
.combat-header h2 {
color: #e94560;
font-size: 32px;
margin: 0;
text-shadow: 0 0 20px rgba(233, 69, 96, 0.6);
letter-spacing: 3px;
}
.combat-arena {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.combatant {
text-align: center;
flex: 1;
}
.combatant-icon {
font-size: 56px;
margin-bottom: 8px;
}
.combatant-name {
font-weight: bold;
font-size: 15px;
margin-bottom: 10px;
}
.combat-vs {
font-size: 28px;
font-weight: bold;
color: #e94560;
padding: 0 15px;
text-shadow: 0 0 10px rgba(233, 69, 96, 0.5);
}
.stat-bars {
width: 100%;
padding: 0 5px;
}
.stat-bar-container {
margin: 6px 0;
}
.stat-bar-label {
font-size: 10px;
color: #888;
margin-bottom: 2px;
}
.hp-bar, .mp-bar {
height: 14px;
background: #333;
border-radius: 7px;
overflow: hidden;
border: 1px solid #555;
}
.hp-fill {
height: 100%;
background: linear-gradient(90deg, #e94560, #ff6b6b);
transition: width 0.4s ease;
border-radius: 6px;
}
.mp-fill {
height: 100%;
background: linear-gradient(90deg, #4ecdc4, #45b7aa);
transition: width 0.4s ease;
border-radius: 6px;
}
.stat-text {
font-size: 11px;
color: #aaa;
margin-top: 5px;
}
.combat-log {
background: rgba(0, 0, 0, 0.5);
border-radius: 10px;
padding: 12px 15px;
height: 100px;
overflow-y: auto;
margin-bottom: 18px;
font-size: 13px;
border: 1px solid #333;
}
.combat-log-entry {
margin-bottom: 6px;
padding-bottom: 6px;
border-bottom: 1px solid #333;
}
.combat-log-entry:last-child {
margin-bottom: 0;
padding-bottom: 0;
border-bottom: none;
}
.combat-log-damage {
color: #ff6b6b;
}
.combat-log-heal {
color: #4ecdc4;
}
.combat-log-victory {
color: #ffd93d;
font-weight: bold;
}
.combat-skills {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-bottom: 12px;
}
.skill-btn {
background: linear-gradient(135deg, #0f3460 0%, #16213e 100%);
border: 2px solid #e94560;
color: white;
padding: 14px 10px;
border-radius: 10px;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.skill-btn:hover:not(:disabled) {
background: linear-gradient(135deg, #e94560 0%, #c73e54 100%);
transform: scale(1.03);
box-shadow: 0 0 15px rgba(233, 69, 96, 0.4);
}
.skill-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.skill-btn .skill-name {
font-weight: bold;
display: block;
font-size: 13px;
margin-bottom: 3px;
}
.skill-btn .skill-cost {
font-size: 11px;
color: #4ecdc4;
}
.skill-btn .skill-cost.free {
color: #8f8;
}
.skill-btn .skill-cost.locked {
color: #888;
}
.skill-btn.skill-locked {
opacity: 0.5;
background: linear-gradient(135deg, #1a1a1a 0%, #0a0a0a 100%);
border-color: #444;
}
.skill-btn.skill-locked .skill-name {
color: #666;
}
.combat-flee-btn {
width: 100%;
background: #333;
border: 2px solid #555;
color: #aaa;
padding: 12px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: all 0.2s;
}
.combat-flee-btn:hover {
background: #444;
border-color: #666;
color: white;
}
/* Multi-monster combat styles */
.combat-arena {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
min-height: 180px;
}
.player-side {
flex: 0 0 140px;
}
.monster-side {
flex: 1;
max-width: 200px;
}
.monster-list {
max-height: 200px;
overflow-y: auto;
padding-right: 5px;
}
.monster-entry {
background: rgba(0, 0, 0, 0.4);
border: 2px solid #444;
border-radius: 10px;
padding: 10px;
margin-bottom: 8px;
cursor: pointer;
transition: all 0.2s;
}
.monster-entry:last-child {
margin-bottom: 0;
}
.monster-entry:hover {
border-color: #888;
background: rgba(255, 255, 255, 0.05);
}
.monster-entry.selected {
border-color: #e94560;
box-shadow: 0 0 10px rgba(233, 69, 96, 0.4);
background: rgba(233, 69, 96, 0.1);
}
.monster-entry.dead {
opacity: 0.3;
pointer-events: none;
text-decoration: line-through;
}
.monster-entry-header {
display: flex;
align-items: center;
margin-bottom: 6px;
}
.monster-entry-icon {
font-size: 24px;
margin-right: 8px;
}
.monster-entry-name {
font-size: 12px;
font-weight: bold;
flex: 1;
}
.monster-entry-hp {
margin-top: 4px;
}
.monster-entry-hp .hp-bar {
height: 10px;
}
.monster-entry-hp .stat-text {
font-size: 10px;
margin-top: 2px;
}
.turn-indicator {
text-align: center;
padding: 8px;
margin-bottom: 12px;
border-radius: 8px;
font-weight: bold;
font-size: 14px;
}
.turn-indicator.player-turn {
background: linear-gradient(90deg, rgba(78, 205, 196, 0.2), transparent);
color: #4ecdc4;
border-left: 3px solid #4ecdc4;
}
.turn-indicator.monster-turn {
background: linear-gradient(90deg, rgba(233, 69, 96, 0.2), transparent);
color: #e94560;
border-left: 3px solid #e94560;
}
.target-arrow {
color: #e94560;
font-size: 14px;
margin-right: 4px;
}
</style>
</head>
<body>
<div id="map"></div>
<div id="compassIndicator" class="compass-indicator">N</div>
<!-- RPG HUD (shown when player has class) -->
<div id="rpgHud" class="rpg-hud" style="display: none;">
<div class="rpg-hud-class" style="cursor: pointer;" onclick="showCharacterSheet()" title="View Character Sheet">🏃 <span id="hudClassName">Trail Runner</span></div>
<div class="rpg-hud-stats">
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">Lv</span>
<span id="hudLevel" class="rpg-hud-hp">1</span>
</div>
<div class="rpg-hud-xp">
<span class="rpg-hud-stat-label">XP</span>
<div class="rpg-hud-xp-bar">
<div class="rpg-hud-xp-fill" id="hudXpBar" style="width: 0%;"></div>
</div>
<span class="rpg-hud-xp-text" id="hudXpText">0/100</span>
</div>
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">HP</span>
<span id="hudHp" class="rpg-hud-hp">100/100</span>
</div>
<div class="rpg-hud-stat">
<span class="rpg-hud-stat-label">MP</span>
<span id="hudMp" class="rpg-hud-mp">50/50</span>
</div>
</div>
<div class="rpg-hud-monsters">
👹 <span id="hudMonsterCount">0</span>/<span id="hudMonsterMax">2</span>
</div>
</div>
<!-- Combat Overlay -->
<div id="combatOverlay" class="combat-overlay" style="display: none;">
<div class="combat-container">
<div class="combat-header">
<h2>⚔️ COMBAT ⚔️</h2>
</div>
<div id="turnIndicator" class="turn-indicator player-turn">⚡ Your Turn</div>
<div class="combat-arena">
<div class="combatant player-side">
<div class="combatant-icon">🏃</div>
<div class="combatant-name">Trail Runner</div>
<div class="stat-bars">
<div class="stat-bar-container">
<div class="stat-bar-label">HP</div>
<div class="hp-bar"><div class="hp-fill" id="playerHpBar" style="width: 100%;"></div></div>
</div>
<div class="stat-bar-container">
<div class="stat-bar-label">MP</div>
<div class="mp-bar"><div class="mp-fill" id="playerMpBar" style="width: 100%;"></div></div>
</div>
</div>
<div class="stat-text">
HP: <span id="playerHpText">100/100</span> | MP: <span id="playerMpText">50/50</span>
</div>
</div>
<div class="combat-vs">VS</div>
<div class="monster-side">
<div class="monster-list" id="monsterList">
<!-- Monster entries populated dynamically -->
</div>
</div>
</div>
<div class="combat-log" id="combatLog">
<div class="combat-log-entry">Combat begins!</div>
</div>
<div class="combat-skills" id="combatSkills">
<!-- Skills populated dynamically -->
</div>
<button class="combat-flee-btn" id="combatFleeBtn">🏃 Flee</button>
</div>
</div>
<button id="panelToggle" class="panel-toggle" style="right: 10px;"></button>
<div class="controls" id="controlPanel" style="display: none;">
<!-- User Profile Section -->
<div id="userProfileSection" style="display: none;">
<div class="user-profile">
<div class="user-avatar" id="userAvatar">
<i class="mdi mdi-account"></i>
</div>
<div class="user-info">
<div class="user-name" id="userName">User</div>
<div class="user-points"><span id="userPoints">0</span> points | <span id="userFinds">0</span> finds</div>
</div>
<button class="user-logout-btn" id="logoutBtn">Logout</button>
</div>
</div>
<!-- Login Prompt (shown when not logged in) -->
<div id="loginPromptSection" class="login-prompt">
<div class="login-prompt-text">Login to track your geocache finds and earn points!</div>
<button class="login-prompt-btn" id="openLoginBtn">Login / Register</button>
<button class="action-btn secondary" id="openLeaderboardBtn" style="margin-top: 8px; width: 100%;">View Leaderboard</button>
</div>
<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">
<!-- Content moved to edit overlay -->
</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">
<!-- Content moved to admin overlay -->
</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>
<!-- 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>
<!-- Auth Modal -->
<div id="authModal" class="auth-modal-overlay" style="display: none;">
<div class="auth-modal" style="position: relative;">
<button class="auth-close-btn" id="authCloseBtn">&times;</button>
<h2>HikeMap</h2>
<div class="auth-tabs">
<button class="auth-tab active" id="loginTabBtn">Login</button>
<button class="auth-tab" id="registerTabBtn">Register</button>
</div>
<div id="authError" class="auth-error"></div>
<!-- Login Form -->
<form id="loginForm" class="auth-form active">
<div class="auth-input-group">
<label for="loginUsername">Username or Email</label>
<input type="text" id="loginUsername" required autocomplete="username">
</div>
<div class="auth-input-group">
<label for="loginPassword">Password</label>
<input type="password" id="loginPassword" required autocomplete="current-password">
</div>
<button type="submit" class="auth-submit-btn" id="loginSubmitBtn">Login</button>
</form>
<!-- Register Form -->
<form id="registerForm" class="auth-form">
<div class="auth-input-group">
<label for="registerUsername">Username</label>
<input type="text" id="registerUsername" required minlength="3" maxlength="20" pattern="[a-zA-Z0-9_]+" autocomplete="username">
</div>
<div class="auth-input-group">
<label for="registerEmail">Email</label>
<input type="email" id="registerEmail" required autocomplete="email">
</div>
<div class="auth-input-group">
<label for="registerPassword">Password (min 8 characters)</label>
<input type="password" id="registerPassword" required minlength="8" autocomplete="new-password">
</div>
<div class="auth-input-group">
<label for="registerPasswordConfirm">Confirm Password</label>
<input type="password" id="registerPasswordConfirm" required minlength="8" autocomplete="new-password">
</div>
<button type="submit" class="auth-submit-btn" id="registerSubmitBtn">Create Account</button>
</form>
<!-- Guest mode option -->
<div class="auth-guest-divider">
<span>or</span>
</div>
<button type="button" class="auth-guest-btn" id="guestModeBtn">Continue as Guest</button>
</div>
</div>
<!-- Leaderboard Modal -->
<div id="leaderboardModal" class="auth-modal-overlay" style="display: none;">
<div class="leaderboard-modal">
<div class="leaderboard-header">
<h2 style="margin: 0;">Leaderboard</h2>
<button id="leaderboardCloseBtn" style="background: none; border: none; font-size: 24px; cursor: pointer;">&times;</button>
</div>
<div class="leaderboard-tabs">
<button class="leaderboard-tab active" data-period="all">All Time</button>
<button class="leaderboard-tab" data-period="monthly">Monthly</button>
<button class="leaderboard-tab" data-period="weekly">Weekly</button>
</div>
<ul class="leaderboard-list" id="leaderboardList">
<!-- Populated dynamically -->
</ul>
</div>
</div>
<!-- Character Creator Modal -->
<div id="charCreatorModal" class="auth-modal-overlay" style="display: none;">
<div class="char-creator-modal">
<div class="char-creator-header">
<h2>Create Your Character</h2>
<p>Begin your hiking adventure</p>
</div>
<div class="char-step-indicator">
<div class="char-step-dot active" data-step="1"></div>
<div class="char-step-dot" data-step="2"></div>
<div class="char-step-dot" data-step="3"></div>
<div class="char-step-dot" data-step="4"></div>
</div>
<div class="char-creator-content">
<!-- Step 1: Name -->
<div class="char-creator-step active" data-step="1">
<div class="char-creator-section">
<h3>What's your character's name?</h3>
<input type="text" class="char-creator-input" id="charNameInput" placeholder="Enter a name..." maxlength="20">
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-next" id="charStep1Next" disabled>Next</button>
</div>
</div>
<!-- Step 2: Race -->
<div class="char-creator-step" data-step="2">
<div class="char-creator-section">
<h3>Choose your race</h3>
<div class="char-creator-grid" id="raceSelection">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep2Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charStep2Next" disabled>Next</button>
</div>
</div>
<!-- Step 3: Class -->
<div class="char-creator-step" data-step="3">
<div class="char-creator-section">
<h3>Choose your class</h3>
<div class="char-creator-grid" id="classSelection">
<!-- Populated by JavaScript -->
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep3Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charStep3Next" disabled>Next</button>
</div>
</div>
<!-- Step 4: Confirm -->
<div class="char-creator-step" data-step="4">
<div class="char-creator-section">
<h3>Confirm your character</h3>
<div class="char-creator-preview">
<div class="char-creator-preview-icon" id="charPreviewIcon">🧑</div>
<div class="char-creator-preview-name" id="charPreviewName">Character Name</div>
<div class="char-creator-preview-info" id="charPreviewInfo">Human Trail Runner</div>
<div class="char-creator-preview-stats" id="charPreviewStats">
<!-- Populated by JavaScript -->
</div>
</div>
</div>
<div class="char-creator-buttons">
<button class="char-creator-btn char-creator-btn-back" id="charStep4Back">Back</button>
<button class="char-creator-btn char-creator-btn-next" id="charCreateBtn">Create Character</button>
</div>
</div>
</div>
</div>
</div>
<!-- Character Sheet Modal -->
<div id="charSheetModal" class="auth-modal-overlay" style="display: none;">
<div class="char-sheet-modal">
<button class="char-sheet-close" id="charSheetClose">&times;</button>
<div class="char-sheet-header">
<div class="char-sheet-icon" id="charSheetIcon">🧑</div>
<div class="char-sheet-name" id="charSheetName">Character Name</div>
<div class="char-sheet-info" id="charSheetInfo">Human Trail Runner - Level 1</div>
</div>
<div class="char-sheet-content">
<div class="char-sheet-section">
<h3>Stats</h3>
<div class="char-sheet-stat-grid" id="charSheetStats">
<!-- Populated by JS -->
</div>
</div>
<div class="char-sheet-section">
<h3>Experience</h3>
<div class="char-sheet-xp" id="charSheetXp">
<!-- XP bar populated by JS -->
</div>
</div>
<div class="char-sheet-section">
<h3>Skills</h3>
<div class="char-sheet-skills" id="charSheetSkills">
<!-- Populated by JS -->
</div>
</div>
</div>
</div>
</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>
<!-- Edit Panel Overlay -->
<div class="edit-panel-overlay" id="editOverlay">
<div class="edit-panel-header">
<h2>✏️ Edit Tools</h2>
<button class="edit-panel-close" id="editCloseBtn">×</button>
</div>
<div class="edit-panel-content" id="editOverlayContent">
<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>
<!-- 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="section-title">🛠️ Developer Tools</div>
<div class="admin-setting-group">
<div class="admin-checkbox-row">
<input type="checkbox" id="gpsTestModeToggle">
<label for="gpsTestModeToggle">GPS Test Mode (WASD to move)</label>
</div>
<div id="gpsTestModeInfo" style="display: none; margin-top: 10px; padding: 10px; background: #e3f2fd; border-radius: 4px; font-size: 12px;">
<strong>Controls:</strong><br>
W - Move North<br>
S - Move South<br>
A - Move West<br>
D - Move East<br>
<div style="margin-top: 8px;">
<strong>Position:</strong> <span id="testPositionDisplay">--</span>
</div>
</div>
</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>
<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: '&copy; <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: '&copy; Esri &mdash; 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;
// GPS test mode (admin only)
let gpsTestMode = false;
let testPosition = { lat: 37.7749, lng: -122.4194 }; // Default to SF
const GPS_TEST_STEP = 0.0001; // ~11 meters per step
// 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;
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
// ==========================================
// RPG COMBAT SYSTEM
// ==========================================
// Player class definitions
// Character races with stat bonuses
const RACES = {
'human': {
name: 'Human',
icon: '🧑',
description: 'Balanced and adaptable',
bonuses: { hp: 5, mp: 5, atk: 0, def: 0 }
},
'elf': {
name: 'Elf',
icon: '🧝',
description: 'Swift and magical',
bonuses: { hp: -5, mp: 15, atk: 0, def: -2 }
},
'dwarf': {
name: 'Dwarf',
icon: '🧔',
description: 'Tough and sturdy',
bonuses: { hp: 15, mp: -5, atk: 0, def: 3 }
},
'halfling': {
name: 'Halfling',
icon: '🧒',
description: 'Quick and nimble',
bonuses: { hp: -5, mp: 0, atk: 2, def: 5 }
}
};
const PLAYER_CLASSES = {
'trail_runner': {
name: 'Trail Runner',
icon: '🏃',
available: true,
description: 'Masters of endurance and speed',
baseStats: { hp: 100, mp: 50, atk: 12, def: 8 },
hpPerLevel: 20,
mpPerLevel: 10,
atkPerLevel: 3,
defPerLevel: 2,
skills: ['basic_attack', 'brand_new_hokas', 'runners_high', 'shin_kick']
},
'gym_bro': {
name: 'Gym Bro',
icon: '💪',
available: false,
description: 'Raw power and protein',
baseStats: { hp: 130, mp: 30, atk: 18, def: 10 },
hpPerLevel: 25,
mpPerLevel: 5,
atkPerLevel: 5,
defPerLevel: 3,
skills: []
},
'yoga_master': {
name: 'Yoga Master',
icon: '🧘',
available: false,
description: 'Flexible and mystical',
baseStats: { hp: 80, mp: 80, atk: 8, def: 6 },
hpPerLevel: 15,
mpPerLevel: 15,
atkPerLevel: 2,
defPerLevel: 1,
skills: []
},
'crossfit_crusader': {
name: 'CrossFit Crusader',
icon: '🏋️',
available: false,
description: 'Jack of all workouts',
baseStats: { hp: 110, mp: 40, atk: 15, def: 12 },
hpPerLevel: 22,
mpPerLevel: 8,
atkPerLevel: 4,
defPerLevel: 3,
skills: []
}
};
// Skill definitions
const SKILLS = {
'basic_attack': {
name: 'Attack',
icon: '👊',
mpCost: 0,
levelReq: 1,
type: 'damage',
calculate: (atk) => atk,
description: 'A basic attack'
},
'brand_new_hokas': {
name: 'Brand New Hokas',
icon: '👟',
mpCost: 10,
levelReq: 2,
type: 'damage',
calculate: (atk) => Math.floor(atk * 2),
description: 'Strike twice with premium footwear! (2x damage)'
},
'runners_high': {
name: "Runner's High",
icon: '🏃‍♂️',
mpCost: 15,
levelReq: 3,
type: 'heal',
calculate: (maxHp) => Math.floor(maxHp * 0.3),
description: 'Heal 30% of max HP'
},
'shin_kick': {
name: 'Shin Kick',
icon: '🦵',
mpCost: 20,
levelReq: 5,
type: 'damage',
calculate: (atk) => Math.floor(atk * 3),
description: 'Devastating kick! (3x damage)'
}
};
// Monster type definitions
const MONSTER_TYPES = {
'discarded_gu': {
name: 'Discarded GU',
icon: '🟢',
baseHp: 30,
baseAtk: 5,
baseDef: 2,
xpReward: 15,
levelScale: { hp: 10, atk: 2, def: 1 }
}
};
// Monster dialogue by time phase
const MONSTER_DIALOGUES = {
'discarded_gu': {
annoyed: [
"Hey! HEY! You dropped something!",
"Excuse me, I believe you littered me!",
"This is a Leave No Trace trail!",
"I was perfectly good, you know...",
"One squeeze left! ONE SQUEEZE!"
],
frustrated: [
"STOP IGNORING ME!",
"I gave you ELECTROLYTES!",
"You used to need me every 45 minutes!",
"I'm worth $3 per packet!",
"Fine! Just keep walking! SEE IF I CARE!"
],
desperate: [
"Please... just acknowledge me...",
"I'll be strawberry flavor! Your favorite!",
"What if I promised no sticky fingers?",
"I just want closure...",
"Remember mile 18? I COULD HAVE HELPED!"
],
philosophical: [
"What even IS a gel, when you think about it?",
"If a GU falls in the forest and no one eats it...",
"Perhaps being discarded is the true ultramarathon.",
"Do you think I have a soul? Is maltodextrin sentient?",
"We're not so different, you and I..."
],
existential: [
"I have stared into the void. The void is caffeinated.",
"We are all just temporary vessels for maltodextrin.",
"I've accepted my fate.",
"The trail will reclaim me eventually.",
"It's actually kind of nice out here. Good views."
]
}
};
// Dialogue phase thresholds (in minutes)
const DIALOGUE_PHASES = [
{ maxMinutes: 5, phase: 'annoyed' },
{ maxMinutes: 10, phase: 'frustrated' },
{ maxMinutes: 30, phase: 'desperate' },
{ maxMinutes: 60, phase: 'philosophical' },
{ maxMinutes: Infinity, phase: 'existential' }
];
// RPG State variables
let playerStats = null; // Player RPG stats
let monsterEntourage = []; // Array of spawned monsters following player
let combatState = null; // Active combat state or null
let monsterSpawnTimer = null; // Interval for spawning monsters
let monsterUpdateTimer = null; // Interval for updating monster positions/dialogue
// Find nearest monster to a location (for double-tap to battle on mobile)
function findNearestMonster(latlng, maxDistanceMeters = 50) {
if (monsterEntourage.length === 0) return null;
let nearest = null;
let nearestDist = Infinity;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(latlng.lat * Math.PI / 180);
monsterEntourage.forEach(monster => {
const dx = (latlng.lng - monster.position.lng) * metersPerDegLng;
const dy = (latlng.lat - monster.position.lat) * metersPerDegLat;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < nearestDist) {
nearestDist = dist;
nearest = monster;
}
});
return nearestDist <= maxDistanceMeters ? { monster: nearest, distance: nearestDist } : null;
}
// Max monsters = 2 per player level
const getMaxMonsters = () => 2 * (playerStats?.level || 1);
// ==========================================
// END RPG COMBAT SYSTEM DEFINITIONS
// ==========================================
// 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');
if (btn) {
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,
interactive: false // Don't capture touch events
}).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;
}
}
}
// GPS Test Mode functions (admin only)
function toggleGpsTestMode(enabled) {
// Only allow admins
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required for GPS test mode', 'error');
document.getElementById('gpsTestModeToggle').checked = false;
return;
}
gpsTestMode = enabled;
const infoDiv = document.getElementById('gpsTestModeInfo');
if (enabled) {
// Initialize test position to current map center
const center = map.getCenter();
testPosition = { lat: center.lat, lng: center.lng };
infoDiv.style.display = 'block';
updateTestPositionDisplay();
// Stop real GPS if running
if (gpsWatchId !== null) {
navigator.geolocation.clearWatch(gpsWatchId);
gpsWatchId = null;
}
if (gpsBackupInterval) {
clearInterval(gpsBackupInterval);
gpsBackupInterval = null;
}
// Trigger initial position update
simulateGpsPosition();
updateStatus('GPS Test Mode enabled - use WASD to move', 'info');
} else {
infoDiv.style.display = 'none';
updateStatus('GPS Test Mode disabled', 'info');
}
}
function simulateGpsPosition() {
if (!gpsTestMode) return;
// Create a fake position object matching the geolocation API format
const fakePosition = {
coords: {
latitude: testPosition.lat,
longitude: testPosition.lng,
accuracy: 5 // Very accurate in test mode
}
};
// Call the normal GPS handler
onGPSSuccess(fakePosition);
updateTestPositionDisplay();
}
function updateTestPositionDisplay() {
const display = document.getElementById('testPositionDisplay');
if (display) {
display.textContent = `${testPosition.lat.toFixed(6)}, ${testPosition.lng.toFixed(6)}`;
}
}
function handleGpsTestKeydown(e) {
if (!gpsTestMode) return;
// Don't handle if typing in an input
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
let moved = false;
switch (e.key.toLowerCase()) {
case 'w':
testPosition.lat += GPS_TEST_STEP;
moved = true;
break;
case 's':
testPosition.lat -= GPS_TEST_STEP;
moved = true;
break;
case 'a':
testPosition.lng -= GPS_TEST_STEP;
moved = true;
break;
case 'd':
testPosition.lng += GPS_TEST_STEP;
moved = true;
break;
}
if (moved) {
e.preventDefault();
simulateGpsPosition();
}
}
// Add keydown listener for GPS test mode
document.addEventListener('keydown', handleGpsTestKeydown);
// 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
ensurePopupInBody('routeOverlay');
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);
}
}
});
// GPS Test Mode toggle (not a saved setting)
const gpsTestToggle = document.getElementById('gpsTestModeToggle');
if (gpsTestToggle) {
gpsTestToggle.addEventListener('change', function() {
toggleGpsTestMode(this.checked);
});
}
// 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() {
let selector = document.getElementById('iconSelector');
if (!selector) {
console.error('Icon selector element not found!');
return;
}
// CRITICAL: Move popup to body if it's inside another container
if (selector.parentElement !== document.body) {
console.log('Moving icon selector to body from:', selector.parentElement.id || selector.parentElement.className);
document.body.appendChild(selector);
}
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 = '999999';
document.body.style.overflow = 'visible';
// Force a reflow to ensure rendering
selector.offsetHeight;
console.log('Icon selector parent:', selector.parentElement.tagName,
'Display:', selector.style.display,
'Computed:', window.getComputedStyle(selector).display,
'Position:', selector.getBoundingClientRect());
}
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');
// Note: Icon selector now accessible from profile, not auto-shown on load
// Users can set their icon after logging in or from the profile section
}
// 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: 32px; color: ${color}; opacity: ${opacity};"></i>`,
iconSize: [48, 48],
iconAnchor: [24, 40]
}),
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);
}
// Helper function to ensure popups are in body
function ensurePopupInBody(elementId) {
const element = document.getElementById(elementId);
if (element && element.parentElement !== document.body) {
console.log(`Moving ${elementId} to body from:`, element.parentElement.id || element.parentElement.className);
document.body.appendChild(element);
}
return element;
}
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;
// Ensure dialog is in body
const dialog = ensurePopupInBody('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 - wrapped to prevent errors
function setupGeocacheListeners() {
try {
const geocacheCancel = document.getElementById('geocacheCancel');
if (geocacheCancel) geocacheCancel.addEventListener('click', hideGeocacheDialog);
const geocacheSubmit = document.getElementById('geocacheSubmit');
if (geocacheSubmit) geocacheSubmit.addEventListener('click', addGeocacheMessage);
const geocacheDelete = document.getElementById('geocacheDelete');
if (geocacheDelete) geocacheDelete.addEventListener('click', deleteGeocache);
const geocacheEdit = document.getElementById('geocacheEdit');
if (geocacheEdit) geocacheEdit.addEventListener('click', startEditingGeocache);
// Color picker events
const iconInput = document.getElementById('geocacheIconInput');
if (iconInput) iconInput.addEventListener('input', updateColorPreview);
const colorInput = document.getElementById('geocacheColorInput');
if (colorInput) colorInput.addEventListener('input', updateColorPreview);
const colorReset = document.getElementById('geocacheColorReset');
if (colorReset) {
colorReset.addEventListener('click', () => {
const input = document.getElementById('geocacheColorInput');
if (input) input.value = '#FFA726';
updateColorPreview();
});
}
// Geocache list sidebar events
const listToggle = document.getElementById('geocacheListToggle');
if (listToggle) {
listToggle.addEventListener('click', () => {
const sidebar = document.getElementById('geocacheListSidebar');
if (sidebar) {
sidebar.classList.toggle('open');
updateGeocacheList();
}
});
}
const listClose = document.getElementById('geocacheListClose');
if (listClose) {
listClose.addEventListener('click', () => {
const sidebar = document.getElementById('geocacheListSidebar');
if (sidebar) sidebar.classList.remove('open');
});
}
const geocacheAlert = document.getElementById('geocacheAlert');
if (geocacheAlert) {
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);
}
}
});
}
} catch (err) {
console.warn('Geocache listeners setup error:', err);
}
}
// Call it after a short delay to ensure DOM is ready
setTimeout(setupGeocacheListeners, 100);
// 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,
interactive: false // Don't capture touch events
}).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'));
// Close any open overlays
const editOverlay = document.querySelector('.edit-panel-overlay');
const adminOverlay = document.querySelector('.admin-panel-overlay');
if (editOverlay) editOverlay.classList.remove('active');
if (adminOverlay) adminOverlay.classList.remove('active');
if (tabName === 'edit') {
// Check if user is admin
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required', 'error');
return;
}
// Show edit overlay instead of tab content
editTab.classList.add('active');
const editOverlay = document.querySelector('.edit-panel-overlay');
if (editOverlay) {
editOverlay.classList.add('active');
}
navMode = false;
document.body.classList.remove('nav-mode');
// 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;
document.body.classList.add('nav-mode');
// 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 if user is admin
if (!currentUser || !currentUser.is_admin) {
updateStatus('Admin access required', 'error');
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 - initialize later after DOM is ready
let toolButtons = {};
// Initialize tool buttons after DOM elements exist
function initializeToolButtons() {
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')
};
// Tool button event listeners
Object.keys(toolButtons).forEach(tool => {
if (toolButtons[tool]) {
toolButtons[tool].addEventListener('click', () => setTool(tool));
} else {
console.warn(`Tool button not found: ${tool}`);
}
});
}
// Set active tool
function setTool(tool) {
currentTool = tool;
Object.keys(toolButtons).forEach(t => {
if (toolButtons[t]) {
toolButtons[t].classList.toggle('active', t === tool);
}
});
// Cancel any drawing in progress
if (tool !== 'draw' && isDrawing) {
cancelDrawing();
}
// Show/hide tool-specific controls
const reshapeControls = document.getElementById('reshapeControls');
const smoothControls = document.getElementById('smoothControls');
if (reshapeControls) reshapeControls.style.display = tool === 'reshape' ? 'block' : 'none';
if (smoothControls) 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 '';
}
}
// Initialize tool buttons when DOM is ready
setTimeout(() => {
initializeToolButtons();
}, 100);
// 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
const el_navConfirmYes = document.getElementById('navConfirmYes');
if (el_navConfirmYes) {
el_navConfirmYes.addEventListener('click', () => {
document.getElementById('navConfirmDialog').style.display = 'none';
if (pendingDestination) {
setDestination(pendingDestination.track, pendingDestination.index);
pendingDestination = null;
}
});
}
const el_navConfirmNo = document.getElementById('navConfirmNo');
if (el_navConfirmNo) {
el_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;
// Don't allow press-hold navigation when monsters are present
if (monsterEntourage.length > 0) 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;
ensurePopupInBody('navConfirmDialog');
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) {
// 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) {
e.preventDefault();
e.stopPropagation();
document.getElementById('pressHoldIndicator').style.display = 'none';
// Get latlng from touch position
const rect = mapContainer.getBoundingClientRect();
const x = currentTapLocation.x - rect.left;
const y = currentTapLocation.y - rect.top;
const containerPoint = L.point(x, y);
const latlng = map.containerPointToLatLng(containerPoint);
// If monsters are present, double-tap is for combat only
if (monsterEntourage.length > 0) {
const nearestMonster = findNearestMonster(latlng, 50);
if (nearestMonster) {
initiateCombat(nearestMonster.monster);
}
// Don't allow navigation when monsters are around
lastTapTime = 0;
lastTapLocation = null;
cancelPressHold();
return;
}
// No monsters - show navigation dialog if we have a destination
if (pendingDestination) {
const message = `Navigate to ${pendingDestination.track.name}?`;
document.getElementById('navConfirmMessage').textContent = message;
ensurePopupInBody('navConfirmDialog');
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) {
L.DomEvent.stopPropagation(e);
L.DomEvent.preventDefault(e);
// If monsters are present, double-tap is for combat only
if (monsterEntourage.length > 0) {
const nearestMonster = findNearestMonster(e.latlng, 50);
if (nearestMonster) {
initiateCombat(nearestMonster.monster);
}
// Don't allow navigation when monsters are around
return;
}
// No monsters - double-tap sets navigation destination
const nearest = findNearestTrackPoint(e.latlng);
if (nearest && nearest.distance < 50) {
pendingDestination = nearest;
const message = `Navigate to ${nearest.track.name}?`;
document.getElementById('navConfirmMessage').textContent = message;
ensurePopupInBody('navConfirmDialog');
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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// 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');
if (countEl) {
countEl.textContent = tracks.length;
}
if (listEl) {
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
const kmlFileEl = document.getElementById('kmlFile');
if (kmlFileEl) {
kmlFileEl.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);
});
}
const el_exportBtn = document.getElementById('exportBtn');
if (el_exportBtn) {
el_exportBtn.addEventListener('click', exportToKML);
}
const reloadBtn = document.getElementById('reloadBtn');
if (reloadBtn) {
reloadBtn.addEventListener('click', reloadTracks);
}
const el_saveServerBtn = document.getElementById('saveServerBtn');
if (el_saveServerBtn) {
el_saveServerBtn.addEventListener('click', saveToServer);
}
const gpsBtn = document.getElementById('gpsBtn');
if (gpsBtn) {
gpsBtn.addEventListener('click', toggleGPS);
}
const el_rotateMapBtn = document.getElementById('rotateMapBtn');
if (el_rotateMapBtn) {
el_rotateMapBtn.addEventListener('click', toggleRotateMap);
}
const autoCenterBtn = document.getElementById('autoCenterBtn');
if (autoCenterBtn) {
autoCenterBtn.addEventListener('click', toggleAutoCenter);
}
// Tab switching
const el_editTab = document.getElementById('editTab');
if (el_editTab) {
el_editTab.addEventListener('click', () => switchTab('edit'));
}
const navTab = document.getElementById('navTab');
if (navTab) {
navTab.addEventListener('click', () => switchTab('navigate'));
}
const el_adminTab = document.getElementById('adminTab');
if (el_adminTab) {
el_adminTab.addEventListener('click', () => switchTab('admin'));
}
// Edit overlay close button
setTimeout(() => {
const editCloseBtn = document.getElementById('editCloseBtn');
if (editCloseBtn) {
editCloseBtn.addEventListener('click', (e) => {
e.preventDefault();
e.stopPropagation();
const editOverlay = document.querySelector('.edit-panel-overlay');
if (editOverlay) {
editOverlay.classList.remove('active');
}
// Remove active class from edit tab
document.getElementById('editTab').classList.remove('active');
// Switch to navigate tab
switchTab('navigate');
});
}
}, 100);
// 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);
// Navigation
const clearNavBtn = document.getElementById('clearNavBtn');
if (clearNavBtn) clearNavBtn.addEventListener('click', clearDestination);
// Panel toggle - wrap to prevent error if element doesn't exist
const panelToggle = document.getElementById('panelToggle');
if (panelToggle) {
panelToggle.addEventListener('click', function() {
console.log('Hamburger clicked!'); // Debug
const panel = document.getElementById('controlPanel');
const toggleBtn = document.getElementById('panelToggle');
if (panel && toggleBtn) {
if (panel.style.display === 'none') {
panel.style.display = 'block';
toggleBtn.style.right = '300px';
} else {
panel.style.display = 'none';
toggleBtn.style.right = '10px';
}
}
});
} else {
console.error('panelToggle element not found!');
}
const undoBtn = document.getElementById('undoBtn');
if (undoBtn) undoBtn.addEventListener('click', undo);
const mergeConnectBtn = document.getElementById('mergeConnectBtn');
if (mergeConnectBtn) mergeConnectBtn.addEventListener('click', mergeConnect);
const selectAllBtn = document.getElementById('selectAllBtn');
if (selectAllBtn) selectAllBtn.addEventListener('click', selectAll);
const clearSelectionBtn = document.getElementById('clearSelectionBtn');
if (clearSelectionBtn) 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;
});
const el_remeshBtn = document.getElementById('remeshBtn');
if (el_remeshBtn) {
el_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}`;
ensurePopupInBody('remeshDialog');
document.getElementById('remeshDialog').style.display = 'flex';
});
}
const el_remeshYes = document.getElementById('remeshYes');
if (el_remeshYes) {
el_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();
});
}
const el_remeshNo = document.getElementById('remeshNo');
if (el_remeshNo) {
el_remeshNo.addEventListener('click', () => {
document.getElementById('remeshDialog').style.display = 'none';
});
}
// Preview system
const el_previewBtn = document.getElementById('previewBtn');
if (el_previewBtn) {
el_previewBtn.addEventListener('click', startPreview);
const applyMergeBtn = document.getElementById('applyMergeBtn');
if (applyMergeBtn) {
applyMergeBtn.addEventListener('click', applyMerge);
}
}
const el_cancelPreviewBtn = document.getElementById('cancelPreviewBtn');
if (el_cancelPreviewBtn) {
el_cancelPreviewBtn.addEventListener('click', cancelPreview);
}
// Live slider update during preview
const mergeThreshold = document.getElementById('mergeThreshold');
if (mergeThreshold) {
mergeThreshold.addEventListener('input', (e) => {
document.getElementById('thresholdValue').textContent = e.target.value;
if (previewMode) {
updatePreview(parseInt(e.target.value));
}
});
}
// Anchor distance slider update
const el_anchorDistance = document.getElementById('anchorDistance');
if (el_anchorDistance) {
el_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
const el_reshapeFalloff = document.getElementById('reshapeFalloff');
if (el_reshapeFalloff) {
el_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
const el_smoothBrushSize = document.getElementById('smoothBrushSize');
if (el_smoothBrushSize) {
el_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
const el_smoothStrength = document.getElementById('smoothStrength');
if (el_smoothStrength) {
el_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;
document.body.classList.add('nav-mode');
// 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
const el_resumeNavYes = document.getElementById('resumeNavYes');
if (el_resumeNavYes) {
el_resumeNavYes.addEventListener('click', () => {
document.getElementById('resumeNavDialog').style.display = 'none';
// Restore saved navigation
const savedNav = localStorage.getItem('navMode');
if (savedNav === 'true') {
switchTab('navigate');
restoreDestination();
}
});
}
const el_resumeNavNo = document.getElementById('resumeNavNo');
if (el_resumeNavNo) {
el_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
ensurePopupInBody('resumeNavDialog');
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();
}
// ============================================
// Authentication System
// ============================================
// Auth state
let currentUser = null;
let accessToken = localStorage.getItem('accessToken');
let refreshToken = localStorage.getItem('refreshToken');
// API helper with auth
async function authFetch(url, options = {}) {
if (!options.headers) options.headers = {};
if (accessToken) {
options.headers['Authorization'] = `Bearer ${accessToken}`;
}
let response = await fetch(url, options);
// If token expired, try to refresh
if (response.status === 401) {
const data = await response.json();
if (data.code === 'TOKEN_EXPIRED' && refreshToken) {
const refreshed = await refreshAccessToken();
if (refreshed) {
options.headers['Authorization'] = `Bearer ${accessToken}`;
response = await fetch(url, options);
}
}
}
return response;
}
async function refreshAccessToken() {
try {
const response = await fetch('/api/refresh', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
if (response.ok) {
const data = await response.json();
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
return true;
} else {
// Refresh failed, logout
logout();
return false;
}
} catch (err) {
console.error('Token refresh failed:', err);
return false;
}
}
function updateAuthUI() {
const profileSection = document.getElementById('userProfileSection');
const loginPrompt = document.getElementById('loginPromptSection');
const userName = document.getElementById('userName');
const userPoints = document.getElementById('userPoints');
const userFinds = document.getElementById('userFinds');
const userAvatar = document.getElementById('userAvatar');
const editTab = document.getElementById('editTab');
const adminTab = document.getElementById('adminTab');
if (currentUser) {
profileSection.style.display = 'block';
loginPrompt.style.display = 'none';
userName.textContent = currentUser.username;
userPoints.textContent = currentUser.total_points || 0;
userFinds.textContent = currentUser.finds_count || 0;
userAvatar.innerHTML = `<i class="mdi mdi-${currentUser.avatar_icon || 'account'}" style="color: ${currentUser.avatar_color || '#fff'}"></i>`;
// Show Edit/Admin tabs only for admins
if (currentUser.is_admin) {
editTab.style.display = '';
adminTab.style.display = '';
} else {
editTab.style.display = 'none';
adminTab.style.display = 'none';
}
} else {
profileSection.style.display = 'none';
loginPrompt.style.display = 'block';
editTab.style.display = 'none';
adminTab.style.display = 'none';
}
}
async function login(username, password) {
const response = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
if (response.ok) {
const data = await response.json();
currentUser = data.user;
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.error };
}
}
async function register(username, email, password) {
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, email, password })
});
if (response.ok) {
const data = await response.json();
currentUser = data.user;
accessToken = data.accessToken;
refreshToken = data.refreshToken;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
updateAuthUI();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
return { success: true };
} else {
const error = await response.json();
return { success: false, error: error.error };
}
}
async function logout() {
try {
if (accessToken) {
await authFetch('/api/logout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken })
});
}
} catch (err) {
console.error('Logout error:', err);
}
// Clean up RPG system
stopMonsterSpawning();
monsterEntourage.forEach(m => {
if (m.marker) m.marker.remove();
});
monsterEntourage = [];
playerStats = null;
document.getElementById('rpgHud').style.display = 'none';
currentUser = null;
accessToken = null;
refreshToken = null;
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
updateAuthUI();
}
async function loadCurrentUser() {
if (!accessToken) return;
try {
const response = await authFetch('/api/user/me');
if (response.ok) {
currentUser = await response.json();
updateAuthUI();
// Initialize RPG system for eligible users
await initializePlayerStats(currentUser.username);
} else {
// Token invalid
logout();
}
} catch (err) {
console.error('Failed to load user:', err);
}
}
// Auth modal handlers
function showAuthModal() {
document.getElementById('authModal').style.display = 'flex';
}
function hideAuthModal() {
document.getElementById('authModal').style.display = 'none';
document.getElementById('authError').classList.remove('visible');
}
function showAuthError(message) {
const errorEl = document.getElementById('authError');
errorEl.textContent = message;
errorEl.classList.add('visible');
}
// Auth tab switching
document.getElementById('loginTabBtn').addEventListener('click', () => {
document.getElementById('loginTabBtn').classList.add('active');
document.getElementById('registerTabBtn').classList.remove('active');
document.getElementById('loginForm').classList.add('active');
document.getElementById('registerForm').classList.remove('active');
document.getElementById('authError').classList.remove('visible');
});
document.getElementById('registerTabBtn').addEventListener('click', () => {
document.getElementById('registerTabBtn').classList.add('active');
document.getElementById('loginTabBtn').classList.remove('active');
document.getElementById('registerForm').classList.add('active');
document.getElementById('loginForm').classList.remove('active');
document.getElementById('authError').classList.remove('visible');
});
// Login form
document.getElementById('loginForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('loginSubmitBtn');
btn.disabled = true;
btn.textContent = 'Logging in...';
const username = document.getElementById('loginUsername').value;
const password = document.getElementById('loginPassword').value;
const result = await login(username, password);
btn.disabled = false;
btn.textContent = 'Login';
if (result.success) {
hideAuthModal();
updateStatus(`Welcome back, ${currentUser.username}!`, 'success');
} else {
showAuthError(result.error);
}
});
// Register form
document.getElementById('registerForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('registerSubmitBtn');
const username = document.getElementById('registerUsername').value;
const email = document.getElementById('registerEmail').value;
const password = document.getElementById('registerPassword').value;
const confirmPassword = document.getElementById('registerPasswordConfirm').value;
if (password !== confirmPassword) {
showAuthError('Passwords do not match');
return;
}
btn.disabled = true;
btn.textContent = 'Creating account...';
const result = await register(username, email, password);
btn.disabled = false;
btn.textContent = 'Create Account';
if (result.success) {
hideAuthModal();
updateStatus(`Welcome to HikeMap, ${currentUser.username}!`, 'success');
} else {
showAuthError(result.error);
}
});
// Open/close buttons
document.getElementById('openLoginBtn').addEventListener('click', showAuthModal);
document.getElementById('authCloseBtn').addEventListener('click', hideAuthModal);
document.getElementById('logoutBtn').addEventListener('click', logout);
// Guest mode button
document.getElementById('guestModeBtn').addEventListener('click', () => {
sessionStorage.setItem('guestMode', 'true');
hideAuthModal();
updateStatus('Continuing as guest - log in to save progress!', 'info');
});
// ==========================================
// CHARACTER CREATOR
// ==========================================
let charCreatorState = {
step: 1,
name: '',
race: null,
class: null
};
function showCharCreatorModal() {
// Reset state
charCreatorState = { step: 1, name: '', race: null, class: null };
document.getElementById('charNameInput').value = '';
// Populate race options
const raceGrid = document.getElementById('raceSelection');
raceGrid.innerHTML = Object.entries(RACES).map(([id, race]) => {
const bonusHtml = Object.entries(race.bonuses)
.filter(([stat, val]) => val !== 0)
.map(([stat, val]) => {
const statLabel = stat.toUpperCase();
const sign = val > 0 ? '+' : '';
const cls = val > 0 ? 'positive' : 'negative';
return `<span class="char-creator-stat ${cls}">${sign}${val} ${statLabel}</span>`;
}).join('');
return `
<div class="char-creator-option" data-race="${id}">
<div class="char-creator-option-icon">${race.icon}</div>
<div class="char-creator-option-name">${race.name}</div>
<div class="char-creator-option-desc">${race.description}</div>
<div class="char-creator-stats">${bonusHtml || '<span class="char-creator-stat neutral">Balanced</span>'}</div>
</div>
`;
}).join('');
// Add click handlers to race options
raceGrid.querySelectorAll('.char-creator-option').forEach(opt => {
opt.addEventListener('click', () => {
raceGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
charCreatorState.race = opt.dataset.race;
document.getElementById('charStep2Next').disabled = false;
});
});
// Populate class options
const classGrid = document.getElementById('classSelection');
classGrid.innerHTML = Object.entries(PLAYER_CLASSES).map(([id, cls]) => {
const disabled = !cls.available;
const stats = cls.baseStats;
return `
<div class="char-creator-option ${disabled ? 'disabled' : ''}" data-class="${id}">
<div class="char-creator-option-icon">${cls.icon}</div>
<div class="char-creator-option-name">${cls.name}</div>
<div class="char-creator-option-desc">${cls.description}</div>
${disabled ? '<div class="char-creator-option-badge">Coming Soon</div>' : `
<div class="char-creator-stats">
<span class="char-creator-stat neutral">${stats.hp} HP</span>
<span class="char-creator-stat neutral">${stats.mp} MP</span>
</div>
`}
</div>
`;
}).join('');
// Add click handlers to class options
classGrid.querySelectorAll('.char-creator-option:not(.disabled)').forEach(opt => {
opt.addEventListener('click', () => {
classGrid.querySelectorAll('.char-creator-option').forEach(o => o.classList.remove('selected'));
opt.classList.add('selected');
charCreatorState.class = opt.dataset.class;
document.getElementById('charStep3Next').disabled = false;
});
});
// Show modal and first step
goToCharStep(1);
document.getElementById('charCreatorModal').style.display = 'flex';
}
function hideCharCreatorModal() {
document.getElementById('charCreatorModal').style.display = 'none';
}
function goToCharStep(step) {
charCreatorState.step = step;
// Update step visibility
document.querySelectorAll('.char-creator-step').forEach(s => {
s.classList.toggle('active', parseInt(s.dataset.step) === step);
});
// Update step indicators
document.querySelectorAll('.char-step-dot').forEach(dot => {
const dotStep = parseInt(dot.dataset.step);
dot.classList.remove('active', 'completed');
if (dotStep === step) dot.classList.add('active');
else if (dotStep < step) dot.classList.add('completed');
});
// Update preview on step 4
if (step === 4) {
updateCharPreview();
}
}
function updateCharPreview() {
const race = RACES[charCreatorState.race];
const cls = PLAYER_CLASSES[charCreatorState.class];
if (!race || !cls) return;
// Calculate final stats (base + race bonuses)
const finalStats = {
hp: cls.baseStats.hp + (race.bonuses.hp || 0),
mp: cls.baseStats.mp + (race.bonuses.mp || 0),
atk: cls.baseStats.atk + (race.bonuses.atk || 0),
def: cls.baseStats.def + (race.bonuses.def || 0)
};
document.getElementById('charPreviewIcon').textContent = race.icon;
document.getElementById('charPreviewName').textContent = charCreatorState.name;
document.getElementById('charPreviewInfo').textContent = `${race.name} ${cls.name}`;
document.getElementById('charPreviewStats').innerHTML = `
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">❤️ HP</span>
<span class="char-creator-preview-stat-value">${finalStats.hp}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">💙 MP</span>
<span class="char-creator-preview-stat-value">${finalStats.mp}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">⚔️ ATK</span>
<span class="char-creator-preview-stat-value">${finalStats.atk}</span>
</div>
<div class="char-creator-preview-stat">
<span class="char-creator-preview-stat-label">🛡️ DEF</span>
<span class="char-creator-preview-stat-value">${finalStats.def}</span>
</div>
`;
}
async function createCharacter() {
const race = RACES[charCreatorState.race];
const cls = PLAYER_CLASSES[charCreatorState.class];
if (!race || !cls || !charCreatorState.name) {
updateStatus('Please complete all character creation steps', 'error');
return;
}
// Calculate final stats with race bonuses
const finalStats = {
hp: cls.baseStats.hp + (race.bonuses.hp || 0),
mp: cls.baseStats.mp + (race.bonuses.mp || 0),
atk: cls.baseStats.atk + (race.bonuses.atk || 0),
def: cls.baseStats.def + (race.bonuses.def || 0)
};
const characterData = {
name: charCreatorState.name,
race: charCreatorState.race,
class: charCreatorState.class,
level: 1,
xp: 0,
hp: finalStats.hp,
maxHp: finalStats.hp,
mp: finalStats.mp,
maxMp: finalStats.mp,
atk: finalStats.atk,
def: finalStats.def
};
try {
const token = localStorage.getItem('accessToken');
const response = await fetch('/api/user/character', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(characterData)
});
if (response.ok) {
hideCharCreatorModal();
updateStatus(`Welcome, ${charCreatorState.name}! Your adventure begins!`, 'success');
// Initialize RPG with the new character
playerStats = characterData;
savePlayerStats();
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
startMonsterSpawning();
} else {
const error = await response.json();
updateStatus(error.error || 'Failed to create character', 'error');
}
} catch (err) {
console.error('Character creation error:', err);
updateStatus('Failed to create character', 'error');
}
}
// Character creator event listeners
document.getElementById('charNameInput').addEventListener('input', (e) => {
charCreatorState.name = e.target.value.trim();
document.getElementById('charStep1Next').disabled = charCreatorState.name.length < 2;
});
document.getElementById('charStep1Next').addEventListener('click', () => goToCharStep(2));
document.getElementById('charStep2Back').addEventListener('click', () => goToCharStep(1));
document.getElementById('charStep2Next').addEventListener('click', () => goToCharStep(3));
document.getElementById('charStep3Back').addEventListener('click', () => goToCharStep(2));
document.getElementById('charStep3Next').addEventListener('click', () => goToCharStep(4));
document.getElementById('charStep4Back').addEventListener('click', () => goToCharStep(3));
document.getElementById('charCreateBtn').addEventListener('click', createCharacter);
// Character Sheet
function showCharacterSheet() {
if (!playerStats) return;
const race = RACES[playerStats.race] || RACES['human'];
const cls = PLAYER_CLASSES[playerStats.class] || PLAYER_CLASSES['trail_runner'];
// Update header
document.getElementById('charSheetIcon').textContent = race.icon;
document.getElementById('charSheetName').textContent = playerStats.name || 'Adventurer';
document.getElementById('charSheetInfo').textContent =
`${race.name} ${cls.name} - Level ${playerStats.level}`;
// Update stats grid
const hpPercent = (playerStats.hp / playerStats.maxHp) * 100;
const mpPercent = (playerStats.mp / playerStats.maxMp) * 100;
document.getElementById('charSheetStats').innerHTML = `
<div class="char-sheet-stat">
<span class="stat-label">❤️ HP</span>
<div class="stat-bar hp-bar">
<div class="stat-fill" style="width: ${hpPercent}%"></div>
</div>
<span class="stat-value">${playerStats.hp}/${playerStats.maxHp}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">💙 MP</span>
<div class="stat-bar mp-bar">
<div class="stat-fill" style="width: ${mpPercent}%"></div>
</div>
<span class="stat-value">${playerStats.mp}/${playerStats.maxMp}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">⚔️ ATK</span>
<span class="stat-value">${playerStats.atk}</span>
</div>
<div class="char-sheet-stat">
<span class="stat-label">🛡️ DEF</span>
<span class="stat-value">${playerStats.def}</span>
</div>
`;
// Update XP section
const xpNeeded = playerStats.level * 100;
const xpPercent = Math.min((playerStats.xp / xpNeeded) * 100, 100);
document.getElementById('charSheetXp').innerHTML = `
<div class="xp-bar-container">
<div class="xp-bar-fill" style="width: ${xpPercent}%"></div>
</div>
<div class="xp-text">${playerStats.xp}/${xpNeeded} XP</div>
<div class="xp-next">Next level: ${Math.max(0, xpNeeded - playerStats.xp)} XP needed</div>
`;
// Update skills
const classSkills = cls.skills || [];
document.getElementById('charSheetSkills').innerHTML = classSkills.map(skillId => {
const skill = SKILLS[skillId];
if (!skill) return '';
const locked = playerStats.level < skill.levelReq;
return `
<div class="char-sheet-skill ${locked ? 'locked' : ''}">
<span class="skill-icon">${locked ? '🔒' : skill.icon}</span>
<div class="skill-info">
<div class="skill-name">${skill.name} (Lv${skill.levelReq})</div>
<div class="skill-desc">${skill.description}</div>
${!locked ? `<div class="skill-cost">${skill.mpCost} MP</div>` : ''}
</div>
</div>
`;
}).join('');
document.getElementById('charSheetModal').style.display = 'flex';
}
function hideCharacterSheet() {
document.getElementById('charSheetModal').style.display = 'none';
}
// Character Sheet event listeners
document.getElementById('charSheetClose').addEventListener('click', hideCharacterSheet);
document.getElementById('charSheetModal').addEventListener('click', (e) => {
if (e.target.id === 'charSheetModal') {
hideCharacterSheet();
}
});
// Leaderboard
async function loadLeaderboard(period = 'all') {
try {
const response = await fetch(`/api/leaderboard?period=${period}`);
if (response.ok) {
const data = await response.json();
displayLeaderboard(data);
}
} catch (err) {
console.error('Failed to load leaderboard:', err);
}
}
function displayLeaderboard(entries) {
const list = document.getElementById('leaderboardList');
list.innerHTML = entries.map((entry, index) => {
const rankClass = index === 0 ? 'gold' : index === 1 ? 'silver' : index === 2 ? 'bronze' : '';
return `
<li class="leaderboard-item">
<span class="leaderboard-rank ${rankClass}">#${index + 1}</span>
<div class="leaderboard-avatar" style="background: ${entry.avatar_color || '#4CAF50'}">
<i class="mdi mdi-${entry.avatar_icon || 'account'}" style="color: white;"></i>
</div>
<div class="leaderboard-user">
<div class="leaderboard-username">${entry.username}</div>
<div class="leaderboard-finds">${entry.finds_count} finds</div>
</div>
<div class="leaderboard-points">${entry.total_points} pts</div>
</li>
`;
}).join('');
}
function showLeaderboard() {
document.getElementById('leaderboardModal').style.display = 'flex';
loadLeaderboard('all');
}
function hideLeaderboard() {
document.getElementById('leaderboardModal').style.display = 'none';
}
document.getElementById('openLeaderboardBtn').addEventListener('click', showLeaderboard);
document.getElementById('leaderboardCloseBtn').addEventListener('click', hideLeaderboard);
// Leaderboard tabs
document.querySelectorAll('.leaderboard-tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll('.leaderboard-tab').forEach(t => t.classList.remove('active'));
tab.classList.add('active');
loadLeaderboard(tab.dataset.period);
});
});
// "Found It" button for geocaches
async function claimGeocacheFind(geocacheId) {
if (!currentUser) {
showAuthModal();
return;
}
if (!gpsMarker) {
updateStatus('Please enable GPS to claim a find', 'error');
return;
}
const userPos = gpsMarker.getLatLng();
try {
const response = await authFetch('/api/geocaches/' + geocacheId + '/find', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
lat: userPos.lat,
lng: userPos.lng,
accuracy: lastGpsAccuracy || 10
})
});
if (response.ok) {
const result = await response.json();
// Update local user data
currentUser.total_points = result.total_points;
currentUser.finds_count = result.finds_count;
updateAuthUI();
// Show points popup
showPointsPopup(result.points_earned, result.is_first_finder);
// Update geocache dialog
updateGeocacheFoundButton(geocacheId, true);
} else {
const error = await response.json();
updateStatus(error.error, 'error');
}
} catch (err) {
console.error('Claim find error:', err);
updateStatus('Failed to claim find', 'error');
}
}
function showPointsPopup(points, isFirstFinder) {
const popup = document.createElement('div');
popup.className = 'points-popup';
popup.innerHTML = `+${points} pts` + (isFirstFinder ? '<span class="bonus">First Finder!</span>' : '');
document.body.appendChild(popup);
setTimeout(() => popup.remove(), 2500);
}
function updateGeocacheFoundButton(geocacheId, found) {
const foundBtn = document.getElementById('geocacheFoundBtn');
if (foundBtn) {
if (found) {
foundBtn.className = 'found-it-btn already-found';
foundBtn.textContent = 'Already Found!';
foundBtn.disabled = true;
}
}
}
// Track last GPS accuracy for find validation
let lastGpsAccuracy = null;
// ==========================================
// RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
// Initialize player stats for RPG system
async function initializePlayerStats(username) {
const token = localStorage.getItem('accessToken');
if (!token) {
playerStats = null;
document.getElementById('rpgHud').style.display = 'none';
return;
}
// Check if user has a character
try {
const hasCharResponse = await fetch('/api/user/has-character', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (hasCharResponse.ok) {
const { hasCharacter } = await hasCharResponse.json();
if (!hasCharacter) {
// No character - show character creator
console.log('No character found, showing character creator');
showCharCreatorModal();
return;
}
}
} catch (e) {
console.error('Failed to check character status:', e);
}
// Try to load character from server
try {
const response = await fetch('/api/user/rpg-stats', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const serverStats = await response.json();
if (serverStats && serverStats.name) {
playerStats = serverStats;
console.log('Loaded RPG stats from server:', playerStats);
// Show RPG HUD and start game
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
startMonsterSpawning();
console.log('RPG system initialized for', username);
return;
}
}
} catch (e) {
console.error('Failed to load RPG stats from server:', e);
}
// Fall back to localStorage if server didn't have valid stats
const saved = localStorage.getItem('hikemap_rpg_stats');
if (saved) {
try {
playerStats = JSON.parse(saved);
if (playerStats && playerStats.name) {
console.log('Loaded saved RPG stats from localStorage:', playerStats);
savePlayerStats(); // Sync to server
document.getElementById('rpgHud').style.display = 'flex';
updateRpgHud();
startMonsterSpawning();
return;
}
} catch (e) {
console.error('Failed to parse saved RPG stats:', e);
}
}
// No valid stats found - show character creator
console.log('No valid character data, showing character creator');
showCharCreatorModal();
}
// Save player stats to server (and localStorage as backup)
function savePlayerStats() {
if (!playerStats) return;
// Save to localStorage as backup
localStorage.setItem('hikemap_rpg_stats', JSON.stringify(playerStats));
// Save to server
const token = localStorage.getItem('accessToken');
if (token) {
fetch('/api/user/rpg-stats', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(playerStats)
}).catch(err => console.error('Failed to save RPG stats to server:', err));
}
}
// Update the RPG HUD display
function updateRpgHud() {
if (!playerStats) return;
document.getElementById('hudLevel').textContent = playerStats.level;
document.getElementById('hudHp').textContent = `${playerStats.hp}/${playerStats.maxHp}`;
document.getElementById('hudMp').textContent = `${playerStats.mp}/${playerStats.maxMp}`;
document.getElementById('hudMonsterCount').textContent = monsterEntourage.length;
document.getElementById('hudMonsterMax').textContent = getMaxMonsters();
// Update XP bar
const xpNeeded = playerStats.level * 100;
const xpPercent = Math.min(100, (playerStats.xp / xpNeeded) * 100);
document.getElementById('hudXpBar').style.width = xpPercent + '%';
document.getElementById('hudXpText').textContent = `${playerStats.xp}/${xpNeeded}`;
}
// Save monsters to server (debounced)
let monsterSaveTimeout = null;
function saveMonsters() {
if (monsterSaveTimeout) clearTimeout(monsterSaveTimeout);
monsterSaveTimeout = setTimeout(() => {
const token = localStorage.getItem('accessToken');
if (!token) return;
// Prepare monsters for saving (remove marker reference)
const monstersToSave = monsterEntourage.map(m => ({
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
atk: m.atk,
def: m.def,
position: m.position,
spawnTime: m.spawnTime,
lastDialogueTime: m.lastDialogueTime || 0
}));
fetch('/api/user/monsters', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(monstersToSave)
}).catch(err => console.error('Failed to save monsters:', err));
}, 1000); // Debounce 1 second
}
// Load monsters from server
async function loadMonsters() {
const token = localStorage.getItem('accessToken');
if (!token) return false;
try {
const response = await fetch('/api/user/monsters', {
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const savedMonsters = await response.json();
if (savedMonsters && savedMonsters.length > 0) {
console.log('Loading', savedMonsters.length, 'saved monsters');
// Clear any existing monsters first
monsterEntourage.forEach(m => {
if (m.marker) m.marker.remove();
});
monsterEntourage = [];
// Recreate each monster with markers
for (const m of savedMonsters) {
// If monster has no position, place near player or map center
let position = m.position;
if (!position || !position.lat || !position.lng) {
if (userLocation) {
// Random offset 30-50 meters from player
const angle = Math.random() * 2 * Math.PI;
const distance = 30 + Math.random() * 20;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
position = {
lat: userLocation.lat + (distance * Math.cos(angle)) / metersPerDegLat,
lng: userLocation.lng + (distance * Math.sin(angle)) / metersPerDegLng
};
} else {
// Default to map center
const center = map.getCenter();
position = { lat: center.lat, lng: center.lng };
}
}
const monster = {
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
atk: m.atk,
def: m.def,
position: position,
spawnTime: m.spawnTime,
lastDialogueTime: m.lastDialogueTime || 0,
marker: null
};
createMonsterMarker(monster);
monsterEntourage.push(monster);
}
updateRpgHud();
return true;
}
}
} catch (e) {
console.error('Failed to load monsters:', e);
}
return false;
}
// Start monster spawning timer
async function startMonsterSpawning() {
if (monsterSpawnTimer) clearInterval(monsterSpawnTimer);
if (monsterUpdateTimer) clearInterval(monsterUpdateTimer);
// First, try to load existing monsters
await loadMonsters();
// Spawn check every 20 seconds
monsterSpawnTimer = setInterval(() => {
if (Math.random() < 0.5) { // 50% chance each interval
spawnMonsterNearPlayer();
}
}, 20000);
// Update monster positions and dialogue every 2 seconds
monsterUpdateTimer = setInterval(() => {
updateMonsterPositions();
}, 2000);
// Only do initial spawn if no monsters were loaded
if (monsterEntourage.length === 0) {
setTimeout(() => {
spawnMonsterNearPlayer();
}, 5000);
}
}
// Stop monster spawning
function stopMonsterSpawning() {
if (monsterSpawnTimer) {
clearInterval(monsterSpawnTimer);
monsterSpawnTimer = null;
}
if (monsterUpdateTimer) {
clearInterval(monsterUpdateTimer);
monsterUpdateTimer = null;
}
}
// Spawn a monster near the player
function spawnMonsterNearPlayer() {
if (!userLocation || !playerStats) return;
if (monsterEntourage.length >= getMaxMonsters()) return;
// Random offset 30-60 meters from player
const angle = Math.random() * 2 * Math.PI;
const distance = 30 + Math.random() * 30; // 30-60 meters
// Convert meters to degrees (rough approximation)
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const offsetLat = (distance * Math.cos(angle)) / metersPerDegLat;
const offsetLng = (distance * Math.sin(angle)) / metersPerDegLng;
const monsterLevel = Math.max(1, playerStats.level + Math.floor(Math.random() * 3) - 1);
const monsterType = MONSTER_TYPES['discarded_gu'];
const monster = {
id: `monster_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
type: 'discarded_gu',
level: monsterLevel,
position: {
lat: userLocation.lat + offsetLat,
lng: userLocation.lng + offsetLng
},
spawnTime: Date.now(),
hp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp,
maxHp: monsterType.baseHp + (monsterLevel - 1) * monsterType.levelScale.hp,
atk: monsterType.baseAtk + (monsterLevel - 1) * monsterType.levelScale.atk,
def: monsterType.baseDef + (monsterLevel - 1) * monsterType.levelScale.def,
marker: null,
lastDialogueTime: 0
};
createMonsterMarker(monster);
monsterEntourage.push(monster);
updateRpgHud();
saveMonsters(); // Persist to server
console.log('Spawned monster:', monster.id, 'at level', monsterLevel);
}
// Create a Leaflet marker for a monster
function createMonsterMarker(monster) {
const monsterType = MONSTER_TYPES[monster.type];
const iconHtml = `
<div class="monster-marker" data-monster-id="${monster.id}">
<div class="monster-icon">${monsterType.icon}</div>
<div class="monster-dialogue-bubble" style="display: none;"></div>
</div>
`;
const divIcon = L.divIcon({
html: iconHtml,
className: 'monster-marker-container',
iconSize: [70, 70],
iconAnchor: [35, 35]
});
monster.marker = L.marker([monster.position.lat, monster.position.lng], {
icon: divIcon,
zIndexOffset: 2000 // High z-index so monsters are on top and easy to tap
}).addTo(map);
// Click to initiate combat
monster.marker.on('click', () => {
initiateCombat(monster);
});
}
// Update monster positions (follow player) and dialogue
function updateMonsterPositions() {
if (!userLocation || monsterEntourage.length === 0) return;
monsterEntourage.forEach(monster => {
// Calculate distance to player
const dx = userLocation.lng - monster.position.lng;
const dy = userLocation.lat - monster.position.lat;
const metersPerDegLat = 111320;
const metersPerDegLng = 111320 * Math.cos(userLocation.lat * Math.PI / 180);
const distanceMeters = Math.sqrt(
Math.pow(dy * metersPerDegLat, 2) +
Math.pow(dx * metersPerDegLng, 2)
);
// Follow if more than 40 meters away
if (distanceMeters > 40) {
const followSpeed = 0.00003; // degrees per update
const angle = Math.atan2(dy, dx);
monster.position.lat += Math.sin(angle) * followSpeed;
monster.position.lng += Math.cos(angle) * followSpeed;
monster.marker.setLatLng([monster.position.lat, monster.position.lng]);
}
// Update dialogue
updateMonsterDialogue(monster);
});
}
// Update monster dialogue based on time
function updateMonsterDialogue(monster) {
const now = Date.now();
const minutesSinceSpawn = (now - monster.spawnTime) / 60000;
// Only show dialogue every 15-30 seconds
if (now - monster.lastDialogueTime < 15000 + Math.random() * 15000) return;
// Determine phase
let phase = 'annoyed';
for (const p of DIALOGUE_PHASES) {
if (minutesSinceSpawn < p.maxMinutes) {
phase = p.phase;
break;
}
}
const dialogueSet = MONSTER_DIALOGUES[monster.type];
if (!dialogueSet || !dialogueSet[phase]) return;
const messages = dialogueSet[phase];
const message = messages[Math.floor(Math.random() * messages.length)];
showMonsterDialogue(monster, message);
monster.lastDialogueTime = now;
}
// Show dialogue bubble on monster
function showMonsterDialogue(monster, message) {
const markerEl = document.querySelector(`[data-monster-id="${monster.id}"]`);
if (!markerEl) return;
const bubble = markerEl.querySelector('.monster-dialogue-bubble');
if (!bubble) return;
bubble.textContent = message;
bubble.style.display = 'block';
// Hide after 4 seconds
setTimeout(() => {
bubble.style.display = 'none';
}, 4000);
}
// Remove a monster from the entourage
function removeMonster(monsterId) {
const idx = monsterEntourage.findIndex(m => m.id === monsterId);
if (idx !== -1) {
const monster = monsterEntourage[idx];
if (monster.marker) {
monster.marker.remove();
}
monsterEntourage.splice(idx, 1);
updateRpgHud();
saveMonsters(); // Persist removal to server
}
}
// ==========================================
// COMBAT SYSTEM
// ==========================================
// Initiate combat with a monster
function initiateCombat(clickedMonster) {
if (combatState) return; // Already in combat
if (!playerStats) return;
if (monsterEntourage.length === 0) return;
// Gather ALL monsters from entourage for multi-monster combat
const monstersInCombat = monsterEntourage.map(m => ({
id: m.id,
type: m.type,
level: m.level,
hp: m.hp,
maxHp: m.maxHp,
atk: m.atk,
def: m.def,
data: MONSTER_TYPES[m.type]
}));
// Find the clicked monster's index to make it the initial target
const clickedIndex = monstersInCombat.findIndex(m => m.id === clickedMonster.id);
combatState = {
player: {
hp: playerStats.hp,
maxHp: playerStats.maxHp,
mp: playerStats.mp,
maxMp: playerStats.maxMp,
atk: playerStats.atk,
def: playerStats.def
},
monsters: monstersInCombat,
selectedTargetIndex: clickedIndex >= 0 ? clickedIndex : 0,
turn: 'player',
currentMonsterTurn: 0,
log: []
};
showCombatUI();
}
// Show the combat UI
function showCombatUI() {
const overlay = document.getElementById('combatOverlay');
overlay.style.display = 'flex';
// Render monster list
renderMonsterList();
// Clear and repopulate combat log
const log = document.getElementById('combatLog');
const monsterCount = combatState.monsters.length;
log.innerHTML = `<div class="combat-log-entry">Combat begins! ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'} engaged!</div>`;
// Populate skills
const skillsContainer = document.getElementById('combatSkills');
skillsContainer.innerHTML = '';
const playerClass = PLAYER_CLASSES[playerStats.class];
playerClass.skills.forEach(skillId => {
const skill = SKILLS[skillId];
const levelReq = skill.levelReq || 1;
const isLocked = playerStats.level < levelReq;
const btn = document.createElement('button');
btn.className = 'skill-btn' + (isLocked ? ' skill-locked' : '');
btn.dataset.skillId = skillId;
if (isLocked) {
btn.innerHTML = `
<span class="skill-name">🔒 ${skill.name}</span>
<span class="skill-cost locked">Lv.${levelReq}</span>
`;
btn.disabled = true;
} else {
btn.innerHTML = `
<span class="skill-name">${skill.icon} ${skill.name}</span>
<span class="skill-cost ${skill.mpCost === 0 ? 'free' : ''}">${skill.mpCost > 0 ? skill.mpCost + ' MP' : 'Free'}</span>
`;
btn.onclick = () => executePlayerSkill(skillId);
}
skillsContainer.appendChild(btn);
});
// Set up flee button
document.getElementById('combatFleeBtn').onclick = fleeCombat;
updateCombatUI();
}
// Render the monster list in combat UI
function renderMonsterList() {
const container = document.getElementById('monsterList');
container.innerHTML = '';
combatState.monsters.forEach((monster, index) => {
const entry = document.createElement('div');
entry.className = 'monster-entry';
entry.dataset.index = index;
if (index === combatState.selectedTargetIndex) {
entry.classList.add('selected');
}
if (monster.hp <= 0) {
entry.classList.add('dead');
}
const hpPct = Math.max(0, (monster.hp / monster.maxHp) * 100);
entry.innerHTML = `
<div class="monster-entry-header">
${index === combatState.selectedTargetIndex ? '<span class="target-arrow">▶</span>' : ''}
<span class="monster-entry-icon">${monster.data.icon}</span>
<span class="monster-entry-name">${monster.data.name} Lv.${monster.level}</span>
</div>
<div class="monster-entry-hp">
<div class="hp-bar"><div class="hp-fill" style="width: ${hpPct}%;"></div></div>
<div class="stat-text">HP: ${Math.max(0, monster.hp)}/${monster.maxHp}</div>
</div>
`;
// Click to select target (only if alive and player's turn)
entry.onclick = () => selectTarget(index);
container.appendChild(entry);
});
}
// Select a monster as target
function selectTarget(index) {
if (!combatState || combatState.turn !== 'player') return;
if (combatState.monsters[index].hp <= 0) return; // Can't target dead monsters
combatState.selectedTargetIndex = index;
renderMonsterList();
addCombatLog(`Targeting ${combatState.monsters[index].data.name}!`);
}
// Update combat UI bars and text
function updateCombatUI() {
if (!combatState) return;
// Update turn indicator
const turnIndicator = document.getElementById('turnIndicator');
if (combatState.turn === 'player') {
turnIndicator.className = 'turn-indicator player-turn';
turnIndicator.textContent = '⚡ Your Turn';
} else {
const attackingMonster = combatState.monsters[combatState.currentMonsterTurn];
if (attackingMonster) {
turnIndicator.className = 'turn-indicator monster-turn';
turnIndicator.textContent = `🔥 ${attackingMonster.data.name}'s Turn`;
}
}
// Update player HP/MP bars
const playerHpPct = (combatState.player.hp / combatState.player.maxHp) * 100;
const playerMpPct = (combatState.player.mp / combatState.player.maxMp) * 100;
document.getElementById('playerHpBar').style.width = Math.max(0, playerHpPct) + '%';
document.getElementById('playerMpBar').style.width = Math.max(0, playerMpPct) + '%';
document.getElementById('playerHpText').textContent =
`${Math.max(0, combatState.player.hp)}/${combatState.player.maxHp}`;
document.getElementById('playerMpText').textContent =
`${Math.max(0, combatState.player.mp)}/${combatState.player.maxMp}`;
// Re-render monster list to update HP bars
renderMonsterList();
// Update skill button states
document.querySelectorAll('.skill-btn').forEach(btn => {
const skillId = btn.dataset.skillId;
const skill = SKILLS[skillId];
btn.disabled = combatState.player.mp < skill.mpCost || combatState.turn !== 'player';
});
// Disable flee button during monster turns
const fleeBtn = document.getElementById('combatFleeBtn');
if (fleeBtn) {
fleeBtn.disabled = combatState.turn !== 'player';
}
}
// Add entry to combat log
function addCombatLog(message, type = '') {
const log = document.getElementById('combatLog');
const entry = document.createElement('div');
entry.className = 'combat-log-entry';
if (type) entry.classList.add(`combat-log-${type}`);
entry.textContent = message;
log.appendChild(entry);
log.scrollTop = log.scrollHeight;
}
// Execute a player skill
function executePlayerSkill(skillId) {
if (!combatState || combatState.turn !== 'player') return;
const skill = SKILLS[skillId];
const levelReq = skill.levelReq || 1;
if (playerStats.level < levelReq) {
addCombatLog(`You need to be level ${levelReq} to use ${skill.name}!`);
return;
}
if (combatState.player.mp < skill.mpCost) {
addCombatLog("Not enough MP!");
return;
}
// Deduct MP
combatState.player.mp -= skill.mpCost;
// Get the targeted monster
const target = combatState.monsters[combatState.selectedTargetIndex];
if (skill.type === 'damage') {
const rawDamage = skill.calculate(combatState.player.atk);
const damage = Math.max(1, rawDamage - target.def);
target.hp -= damage;
addCombatLog(`You used ${skill.name} on ${target.data.name}! Dealt ${damage} damage!`, 'damage');
// Check if this monster died
if (target.hp <= 0) {
addCombatLog(`${target.data.name} was defeated!`, 'victory');
// Auto-retarget to next living monster if available
autoRetarget();
}
} else if (skill.type === 'heal') {
const healAmount = skill.calculate(combatState.player.maxHp);
combatState.player.hp = Math.min(combatState.player.maxHp, combatState.player.hp + healAmount);
addCombatLog(`You used ${skill.name}! Healed ${healAmount} HP!`, 'heal');
}
updateCombatUI();
// Check if ALL monsters are defeated
const livingMonsters = combatState.monsters.filter(m => m.hp > 0);
if (livingMonsters.length === 0) {
handleCombatVictory();
return;
}
// Start monster turns sequence
combatState.turn = 'monsters';
combatState.currentMonsterTurn = 0;
updateCombatUI();
setTimeout(executeMonsterTurns, 800);
}
// Auto-retarget to next living monster
function autoRetarget() {
const livingIndices = [];
combatState.monsters.forEach((m, i) => {
if (m.hp > 0) livingIndices.push(i);
});
if (livingIndices.length > 0) {
// Find next living monster (prefer one after current target)
let newTarget = livingIndices.find(i => i > combatState.selectedTargetIndex);
if (newTarget === undefined) {
newTarget = livingIndices[0]; // Wrap to first living monster
}
combatState.selectedTargetIndex = newTarget;
}
}
// Execute all monster turns sequentially
function executeMonsterTurns() {
if (!combatState) return;
// Find next living monster starting from currentMonsterTurn
while (combatState.currentMonsterTurn < combatState.monsters.length) {
const monster = combatState.monsters[combatState.currentMonsterTurn];
if (monster.hp > 0) {
// This monster is alive, execute its attack
executeOneMonsterAttack(combatState.currentMonsterTurn);
return; // Will continue in executeOneMonsterAttack
}
combatState.currentMonsterTurn++;
}
// All monsters have attacked, return to player turn
combatState.turn = 'player';
updateCombatUI();
}
// Execute one monster's attack
function executeOneMonsterAttack(monsterIndex) {
if (!combatState) return;
const monster = combatState.monsters[monsterIndex];
combatState.currentMonsterTurn = monsterIndex;
updateCombatUI();
const damage = Math.max(1, monster.atk - combatState.player.def);
combatState.player.hp -= damage;
addCombatLog(`${monster.data.name} attacks! You take ${damage} damage!`, 'damage');
updateCombatUI();
// Check for defeat
if (combatState.player.hp <= 0) {
handleCombatDefeat();
return;
}
// Move to next monster
combatState.currentMonsterTurn++;
setTimeout(executeMonsterTurns, 800);
}
// Handle combat victory
function handleCombatVictory() {
// Calculate total XP from all defeated monsters
let totalXp = 0;
const monsterIds = [];
combatState.monsters.forEach(monster => {
totalXp += monster.data.xpReward * monster.level;
monsterIds.push(monster.id);
});
const monsterCount = combatState.monsters.length;
addCombatLog(`Victory! Defeated ${monsterCount} ${monsterCount === 1 ? 'enemy' : 'enemies'}! Gained ${totalXp} XP!`, 'victory');
// Remove all monsters from entourage
monsterIds.forEach(id => removeMonster(id));
// Update player stats
playerStats.hp = combatState.player.hp;
playerStats.mp = combatState.player.mp;
playerStats.xp += totalXp;
// Check for level up
checkLevelUp();
savePlayerStats();
updateRpgHud();
setTimeout(closeCombatUI, 2500);
}
// Handle combat defeat
function handleCombatDefeat() {
const monsterCount = combatState.monsters.filter(m => m.hp > 0).length;
addCombatLog(`You were defeated! ${monsterCount} ${monsterCount === 1 ? 'enemy remains' : 'enemies remain'}. HP restored to 50%.`, 'damage');
// Restore HP to 50%
playerStats.hp = Math.floor(playerStats.maxHp * 0.5);
playerStats.mp = combatState.player.mp;
savePlayerStats();
updateRpgHud();
setTimeout(closeCombatUI, 2500);
}
// Flee from combat
function fleeCombat() {
addCombatLog("You fled from battle!");
// Save current HP/MP
playerStats.hp = combatState.player.hp;
playerStats.mp = combatState.player.mp;
savePlayerStats();
updateRpgHud();
setTimeout(closeCombatUI, 1000);
}
// Close combat UI
function closeCombatUI() {
document.getElementById('combatOverlay').style.display = 'none';
combatState = null;
}
// Check for level up
function checkLevelUp() {
const xpNeeded = playerStats.level * 100;
if (playerStats.xp >= xpNeeded) {
playerStats.level++;
playerStats.xp -= xpNeeded;
const classData = PLAYER_CLASSES[playerStats.class];
playerStats.maxHp += classData.hpPerLevel;
playerStats.hp = playerStats.maxHp; // Full heal on level up
playerStats.maxMp += classData.mpPerLevel;
playerStats.mp = playerStats.maxMp; // Full MP restore
playerStats.atk += classData.atkPerLevel;
playerStats.def += classData.defPerLevel;
addCombatLog(`LEVEL UP! Now level ${playerStats.level}!`, 'victory');
// Check for another level up (in case of huge XP gain)
checkLevelUp();
}
}
// ==========================================
// END RPG COMBAT SYSTEM FUNCTIONS
// ==========================================
// Initialize auth on load
loadCurrentUser();
// Show auth modal if not logged in (guest mode available)
if (!localStorage.getItem('accessToken') && !sessionStorage.getItem('guestMode')) {
setTimeout(() => {
showAuthModal();
}, 500);
}
</script>
</body>
</html>