|
@@ -0,0 +1,1646 @@
|
|
|
|
|
+<!DOCTYPE html>
|
|
|
|
|
+<html lang="en">
|
|
|
|
|
+<head>
|
|
|
|
|
+ <meta charset="UTF-8">
|
|
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
|
|
+ <title>GPX Visualizer</title>
|
|
|
|
|
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
|
|
|
|
+ <style>
|
|
|
|
|
+ * {
|
|
|
|
|
+ box-sizing: border-box;
|
|
|
|
|
+ margin: 0;
|
|
|
|
|
+ padding: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ body {
|
|
|
|
|
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ height: 100vh;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #map-container {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ transition: margin-right 0.3s ease;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #map {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #drop-overlay {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ left: 0;
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ bottom: 0;
|
|
|
|
|
+ background: rgba(52, 152, 219, 0.9);
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ z-index: 1000;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #drop-overlay.active {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #drop-overlay-content {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #drop-overlay-content .icon {
|
|
|
|
|
+ font-size: 64px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #drop-overlay-content p {
|
|
|
|
|
+ font-size: 24px;
|
|
|
|
|
+ font-weight: 500;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-prompt {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ left: 50%;
|
|
|
|
|
+ transform: translate(-50%, -50%);
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ z-index: 500;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ padding: 40px;
|
|
|
|
|
+ border-radius: 12px;
|
|
|
|
|
+ box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-prompt h2 {
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ color: #2c3e50;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-prompt p {
|
|
|
|
|
+ color: #7f8c8d;
|
|
|
|
|
+ margin-bottom: 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-input-label {
|
|
|
|
|
+ display: inline-block;
|
|
|
|
|
+ padding: 12px 24px;
|
|
|
|
|
+ background: #3498db;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-input-label:hover {
|
|
|
|
|
+ background: #2980b9;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #file-input {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar {
|
|
|
|
|
+ width: 380px;
|
|
|
|
|
+ background: #f8f9fa;
|
|
|
|
|
+ border-left: 1px solid #dee2e6;
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ flex-direction: column;
|
|
|
|
|
+ transition: transform 0.3s ease, width 0.3s ease;
|
|
|
|
|
+ overflow: hidden;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar.collapsed {
|
|
|
|
|
+ width: 0;
|
|
|
|
|
+ border-left: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar-toggle {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ right: 380px;
|
|
|
|
|
+ top: 50%;
|
|
|
|
|
+ transform: translateY(-50%);
|
|
|
|
|
+ z-index: 1000;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ border: 1px solid #dee2e6;
|
|
|
|
|
+ border-right: none;
|
|
|
|
|
+ border-radius: 6px 0 0 6px;
|
|
|
|
|
+ padding: 12px 8px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ transition: right 0.3s ease;
|
|
|
|
|
+ box-shadow: -2px 0 5px rgba(0,0,0,0.1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar-toggle.collapsed {
|
|
|
|
|
+ right: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar-toggle:hover {
|
|
|
|
|
+ background: #f0f0f0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #sidebar-content {
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ overflow-y: auto;
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ min-width: 380px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .section {
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ border-radius: 8px;
|
|
|
|
|
+ padding: 16px;
|
|
|
|
|
+ margin-bottom: 16px;
|
|
|
|
|
+ box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .section h3 {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ color: #7f8c8d;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+ letter-spacing: 0.5px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-grid {
|
|
|
|
|
+ display: grid;
|
|
|
|
|
+ grid-template-columns: 1fr 1fr;
|
|
|
|
|
+ gap: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-item {
|
|
|
|
|
+ padding: 8px;
|
|
|
|
|
+ background: #f8f9fa;
|
|
|
|
|
+ border-radius: 6px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-item.full-width {
|
|
|
|
|
+ grid-column: 1 / -1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-label {
|
|
|
|
|
+ font-size: 11px;
|
|
|
|
|
+ color: #95a5a6;
|
|
|
|
|
+ text-transform: uppercase;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-value {
|
|
|
|
|
+ font-size: 18px;
|
|
|
|
|
+ font-weight: 600;
|
|
|
|
|
+ color: #2c3e50;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .stat-value.small {
|
|
|
|
|
+ font-size: 14px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #elevation-chart-container {
|
|
|
|
|
+ position: relative;
|
|
|
|
|
+ height: 150px;
|
|
|
|
|
+ margin-top: 8px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #elevation-chart {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ height: 100%;
|
|
|
|
|
+ cursor: crosshair;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #chart-tooltip {
|
|
|
|
|
+ position: absolute;
|
|
|
|
|
+ background: rgba(44, 62, 80, 0.95);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ pointer-events: none;
|
|
|
|
|
+ opacity: 0;
|
|
|
|
|
+ transition: opacity 0.15s;
|
|
|
|
|
+ z-index: 100;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #chart-tooltip.visible {
|
|
|
|
|
+ opacity: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row {
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row:last-child {
|
|
|
|
|
+ margin-bottom: 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row label {
|
|
|
|
|
+ display: block;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #7f8c8d;
|
|
|
|
|
+ margin-bottom: 4px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row select,
|
|
|
|
|
+ .settings-row input[type="text"] {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ padding: 8px 10px;
|
|
|
|
|
+ border: 1px solid #dee2e6;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row select:focus,
|
|
|
|
|
+ .settings-row input[type="text"]:focus {
|
|
|
|
|
+ outline: none;
|
|
|
|
|
+ border-color: #3498db;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn {
|
|
|
|
|
+ padding: 8px 16px;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ transition: background 0.2s;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-primary {
|
|
|
|
|
+ background: #3498db;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-primary:hover {
|
|
|
|
|
+ background: #2980b9;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-secondary {
|
|
|
|
|
+ background: #95a5a6;
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-secondary:hover {
|
|
|
|
|
+ background: #7f8c8d;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .btn-block {
|
|
|
|
|
+ width: 100%;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #track-info {
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ color: #7f8c8d;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .loading-indicator {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ justify-content: center;
|
|
|
|
|
+ padding: 20px;
|
|
|
|
|
+ color: #7f8c8d;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .loading-indicator.active {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .spinner {
|
|
|
|
|
+ width: 20px;
|
|
|
|
|
+ height: 20px;
|
|
|
|
|
+ border: 2px solid #dee2e6;
|
|
|
|
|
+ border-top-color: #3498db;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ animation: spin 0.8s linear infinite;
|
|
|
|
|
+ margin-right: 10px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @keyframes spin {
|
|
|
|
|
+ to { transform: rotate(360deg); }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .elevation-warning {
|
|
|
|
|
+ background: #fff3cd;
|
|
|
|
|
+ border: 1px solid #ffc107;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ color: #856404;
|
|
|
|
|
+ margin-bottom: 12px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .track-color-picker {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .track-color-picker input[type="color"] {
|
|
|
|
|
+ width: 40px;
|
|
|
|
|
+ height: 32px;
|
|
|
|
|
+ border: 1px solid #dee2e6;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ cursor: pointer;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .track-width-slider {
|
|
|
|
|
+ display: flex;
|
|
|
|
|
+ align-items: center;
|
|
|
|
|
+ gap: 10px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .track-width-slider input[type="range"] {
|
|
|
|
|
+ flex: 1;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .track-width-slider span {
|
|
|
|
|
+ min-width: 30px;
|
|
|
|
|
+ text-align: right;
|
|
|
|
|
+ font-size: 13px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* Map marker tooltip */
|
|
|
|
|
+ .map-tooltip {
|
|
|
|
|
+ background: rgba(44, 62, 80, 0.95);
|
|
|
|
|
+ color: white;
|
|
|
|
|
+ padding: 8px 12px;
|
|
|
|
|
+ border-radius: 4px;
|
|
|
|
|
+ font-size: 12px;
|
|
|
|
|
+ white-space: nowrap;
|
|
|
|
|
+ border: none;
|
|
|
|
|
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .map-tooltip::before {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .leaflet-tooltip-left.map-tooltip::before,
|
|
|
|
|
+ .leaflet-tooltip-right.map-tooltip::before {
|
|
|
|
|
+ display: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /* Hover marker */
|
|
|
|
|
+ .hover-marker {
|
|
|
|
|
+ background: #e74c3c;
|
|
|
|
|
+ border: 3px solid white;
|
|
|
|
|
+ border-radius: 50%;
|
|
|
|
|
+ box-shadow: 0 2px 5px rgba(0,0,0,0.3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ #no-track-message {
|
|
|
|
|
+ text-align: center;
|
|
|
|
|
+ color: #95a5a6;
|
|
|
|
|
+ padding: 40px 20px;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .hidden {
|
|
|
|
|
+ display: none !important;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row a {
|
|
|
|
|
+ color: #3498db;
|
|
|
|
|
+ text-decoration: none;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ .settings-row a:hover {
|
|
|
|
|
+ text-decoration: underline;
|
|
|
|
|
+ }
|
|
|
|
|
+ </style>
|
|
|
|
|
+</head>
|
|
|
|
|
+<body>
|
|
|
|
|
+ <div id="map-container">
|
|
|
|
|
+ <div id="map"></div>
|
|
|
|
|
+ <div id="drop-overlay">
|
|
|
|
|
+ <div id="drop-overlay-content">
|
|
|
|
|
+ <div class="icon">🗺</div>
|
|
|
|
|
+ <p>Drop GPX file here</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="file-prompt">
|
|
|
|
|
+ <h2>GPX Visualizer</h2>
|
|
|
|
|
+ <p>Drag and drop a GPX file onto the map<br>or click below to select</p>
|
|
|
|
|
+ <label id="file-input-label">
|
|
|
|
|
+ Select GPX File
|
|
|
|
|
+ <input type="file" id="file-input" accept=".gpx">
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <button id="sidebar-toggle" title="Toggle sidebar">◀</button>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="sidebar">
|
|
|
|
|
+ <div id="sidebar-content">
|
|
|
|
|
+ <div id="no-track-message">
|
|
|
|
|
+ <p>No track loaded</p>
|
|
|
|
|
+ <p style="font-size: 12px; margin-top: 8px;">Load a GPX file to see statistics</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div id="track-data" class="hidden">
|
|
|
|
|
+ <div class="section">
|
|
|
|
|
+ <h3>Track Information</h3>
|
|
|
|
|
+ <div id="track-info">-</div>
|
|
|
|
|
+ <div class="stat-grid">
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Distance</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-distance">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Duration</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-duration">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Avg Speed</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-avg-speed">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Max Speed</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-max-speed">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Elevation Gain</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-elev-gain">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Elevation Loss</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-elev-loss">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Min Elevation</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-elev-min">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item">
|
|
|
|
|
+ <div class="stat-label">Max Elevation</div>
|
|
|
|
|
+ <div class="stat-value" id="stat-elev-max">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="stat-item full-width">
|
|
|
|
|
+ <div class="stat-label">Time Range</div>
|
|
|
|
|
+ <div class="stat-value small" id="stat-time-range">-</div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="section">
|
|
|
|
|
+ <h3>Elevation Profile</h3>
|
|
|
|
|
+ <div id="elevation-warning" class="elevation-warning hidden">
|
|
|
|
|
+ Elevation data missing. <button class="btn btn-primary" id="fetch-elevation-btn" style="padding: 4px 8px; font-size: 11px;">Fetch from API</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="loading-indicator" id="elevation-loading">
|
|
|
|
|
+ <div class="spinner"></div>
|
|
|
|
|
+ <span>Fetching elevation data...</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div id="elevation-chart-container">
|
|
|
|
|
+ <canvas id="elevation-chart"></canvas>
|
|
|
|
|
+ <div id="chart-tooltip"></div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="section">
|
|
|
|
|
+ <h3>Track Style</h3>
|
|
|
|
|
+ <div class="settings-row">
|
|
|
|
|
+ <label>Track Color</label>
|
|
|
|
|
+ <div class="track-color-picker">
|
|
|
|
|
+ <input type="color" id="track-color" value="#3498db">
|
|
|
|
|
+ <span id="track-color-value">#3498db</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="settings-row">
|
|
|
|
|
+ <label>Track Width</label>
|
|
|
|
|
+ <div class="track-width-slider">
|
|
|
|
|
+ <input type="range" id="track-width" min="1" max="10" value="3">
|
|
|
|
|
+ <span id="track-width-value">3px</span>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="section">
|
|
|
|
|
+ <h3>Map Settings</h3>
|
|
|
|
|
+ <div class="settings-row">
|
|
|
|
|
+ <label>Tile Server</label>
|
|
|
|
|
+ <select id="tile-server">
|
|
|
|
|
+ <option value="freemap">Freemap.sk</option>
|
|
|
|
|
+ <option value="osm">OpenStreetMap</option>
|
|
|
|
|
+ <option value="osmde">OpenStreetMap DE</option>
|
|
|
|
|
+ <option value="topo">OpenTopoMap</option>
|
|
|
|
|
+ <option value="cycle">CyclOSM</option>
|
|
|
|
|
+ <option value="satellite">ESRI Satellite</option>
|
|
|
|
|
+ <optgroup label="Mapy.cz (requires API key)">
|
|
|
|
|
+ <option value="mapy-outdoor">Mapy.cz Outdoor</option>
|
|
|
|
|
+ <option value="mapy-basic">Mapy.cz Basic</option>
|
|
|
|
|
+ <option value="mapy-aerial">Mapy.cz Aerial</option>
|
|
|
|
|
+ <option value="mapy-winter">Mapy.cz Winter</option>
|
|
|
|
|
+ </optgroup>
|
|
|
|
|
+ <option value="custom">Custom...</option>
|
|
|
|
|
+ </select>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="settings-row hidden" id="mapy-apikey-row">
|
|
|
|
|
+ <label>Mapy.cz API Key (<a href="https://developer.mapy.com/rest-api-mapy-cz/how-to-start/" target="_blank">get free key</a>)</label>
|
|
|
|
|
+ <input type="text" id="mapy-apikey" placeholder="Enter your API key">
|
|
|
|
|
+ <button class="btn btn-primary btn-block" id="apply-mapy-apikey" style="margin-top: 8px;">Apply</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <div class="settings-row hidden" id="custom-tile-row">
|
|
|
|
|
+ <label>Custom Tile URL</label>
|
|
|
|
|
+ <input type="text" id="custom-tile-url" placeholder="https://{s}.tile.example.com/{z}/{x}/{y}.png">
|
|
|
|
|
+ <button class="btn btn-primary btn-block" id="apply-custom-tile" style="margin-top: 8px;">Apply</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div class="section">
|
|
|
|
|
+ <h3>Actions</h3>
|
|
|
|
|
+ <button class="btn btn-secondary btn-block" id="fit-bounds-btn">Fit Track to View</button>
|
|
|
|
|
+ <button class="btn btn-secondary btn-block" id="load-another-btn" style="margin-top: 8px;">Load Another File</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
|
|
|
|
+ <script>
|
|
|
|
|
+ // ==================== Configuration ====================
|
|
|
|
|
+ const TILE_SERVERS = {
|
|
|
|
|
+ freemap: {
|
|
|
|
|
+ url: 'https://{s}.freemap.sk/T/{z}/{x}/{y}.jpeg',
|
|
|
|
|
+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, <a href="https://freemap.sk">Freemap.sk</a> CC-By-SA 2.0',
|
|
|
|
|
+ subdomains: 'abcd',
|
|
|
|
|
+ minZoom: 8,
|
|
|
|
|
+ maxZoom: 16
|
|
|
|
|
+ },
|
|
|
|
|
+ osm: {
|
|
|
|
|
+ url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
|
|
|
+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
|
|
|
+ },
|
|
|
|
|
+ osmde: {
|
|
|
|
|
+ url: 'https://{s}.tile.openstreetmap.de/{z}/{x}/{y}.png',
|
|
|
|
|
+ attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
|
|
|
|
+ },
|
|
|
|
|
+ topo: {
|
|
|
|
|
+ url: 'https://{s}.tile.opentopomap.org/{z}/{x}/{y}.png',
|
|
|
|
|
+ attribution: '© <a href="https://opentopomap.org">OpenTopoMap</a> (CC-BY-SA)'
|
|
|
|
|
+ },
|
|
|
|
|
+ cycle: {
|
|
|
|
|
+ url: 'https://{s}.tile-cyclosm.openstreetmap.fr/cyclosm/{z}/{x}/{y}.png',
|
|
|
|
|
+ attribution: '© <a href="https://www.cyclosm.org">CyclOSM</a>'
|
|
|
|
|
+ },
|
|
|
|
|
+ satellite: {
|
|
|
|
|
+ url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
|
|
|
+ attribution: '© Esri',
|
|
|
|
|
+ subdomains: []
|
|
|
|
|
+ },
|
|
|
|
|
+ 'mapy-outdoor': {
|
|
|
|
|
+ url: 'https://api.mapy.cz/v1/maptiles/outdoor/256/{z}/{x}/{y}?apikey={apikey}',
|
|
|
|
|
+ attribution: '© <a href="https://mapy.cz">Mapy.cz</a>',
|
|
|
|
|
+ requiresApiKey: true
|
|
|
|
|
+ },
|
|
|
|
|
+ 'mapy-basic': {
|
|
|
|
|
+ url: 'https://api.mapy.cz/v1/maptiles/basic/256/{z}/{x}/{y}?apikey={apikey}',
|
|
|
|
|
+ attribution: '© <a href="https://mapy.cz">Mapy.cz</a>',
|
|
|
|
|
+ requiresApiKey: true
|
|
|
|
|
+ },
|
|
|
|
|
+ 'mapy-aerial': {
|
|
|
|
|
+ url: 'https://api.mapy.cz/v1/maptiles/aerial/256/{z}/{x}/{y}?apikey={apikey}',
|
|
|
|
|
+ attribution: '© <a href="https://mapy.cz">Mapy.cz</a>',
|
|
|
|
|
+ requiresApiKey: true
|
|
|
|
|
+ },
|
|
|
|
|
+ 'mapy-winter': {
|
|
|
|
|
+ url: 'https://api.mapy.cz/v1/maptiles/winter/256/{z}/{x}/{y}?apikey={apikey}',
|
|
|
|
|
+ attribution: '© <a href="https://mapy.cz">Mapy.cz</a>',
|
|
|
|
|
+ requiresApiKey: true
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const ELEVATION_API = 'https://api.open-elevation.com/api/v1/lookup';
|
|
|
|
|
+ const ELEVATION_BATCH_SIZE = 100; // Points per API request
|
|
|
|
|
+ const CHART_MAX_POINTS = 500; // Downsample for chart rendering
|
|
|
|
|
+ const TRACK_HOVER_TOLERANCE_PX = 20; // Pixels distance for track hover detection
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== State ====================
|
|
|
|
|
+ let map = null;
|
|
|
|
|
+ let tileLayer = null;
|
|
|
|
|
+ let trackLayer = null;
|
|
|
|
|
+ let hoverMarker = null;
|
|
|
|
|
+ let stickyPoint = null; // Non-null when tooltip is pinned by click
|
|
|
|
|
+ let trackData = null; // { points: [], stats: {} }
|
|
|
|
|
+ let chartData = null; // Downsampled for chart
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== DOM Elements ====================
|
|
|
|
|
+ const elements = {
|
|
|
|
|
+ map: document.getElementById('map'),
|
|
|
|
|
+ dropOverlay: document.getElementById('drop-overlay'),
|
|
|
|
|
+ filePrompt: document.getElementById('file-prompt'),
|
|
|
|
|
+ fileInput: document.getElementById('file-input'),
|
|
|
|
|
+ sidebar: document.getElementById('sidebar'),
|
|
|
|
|
+ sidebarToggle: document.getElementById('sidebar-toggle'),
|
|
|
|
|
+ noTrackMessage: document.getElementById('no-track-message'),
|
|
|
|
|
+ trackDataSection: document.getElementById('track-data'),
|
|
|
|
|
+ elevationChart: document.getElementById('elevation-chart'),
|
|
|
|
|
+ chartTooltip: document.getElementById('chart-tooltip'),
|
|
|
|
|
+ elevationWarning: document.getElementById('elevation-warning'),
|
|
|
|
|
+ elevationLoading: document.getElementById('elevation-loading'),
|
|
|
|
|
+ fetchElevationBtn: document.getElementById('fetch-elevation-btn'),
|
|
|
|
|
+ trackColor: document.getElementById('track-color'),
|
|
|
|
|
+ trackColorValue: document.getElementById('track-color-value'),
|
|
|
|
|
+ trackWidth: document.getElementById('track-width'),
|
|
|
|
|
+ trackWidthValue: document.getElementById('track-width-value'),
|
|
|
|
|
+ tileServer: document.getElementById('tile-server'),
|
|
|
|
|
+ customTileRow: document.getElementById('custom-tile-row'),
|
|
|
|
|
+ customTileUrl: document.getElementById('custom-tile-url'),
|
|
|
|
|
+ applyCustomTile: document.getElementById('apply-custom-tile'),
|
|
|
|
|
+ mapyApikeyRow: document.getElementById('mapy-apikey-row'),
|
|
|
|
|
+ mapyApikey: document.getElementById('mapy-apikey'),
|
|
|
|
|
+ applyMapyApikey: document.getElementById('apply-mapy-apikey'),
|
|
|
|
|
+ fitBoundsBtn: document.getElementById('fit-bounds-btn'),
|
|
|
|
|
+ loadAnotherBtn: document.getElementById('load-another-btn'),
|
|
|
|
|
+ stats: {
|
|
|
|
|
+ distance: document.getElementById('stat-distance'),
|
|
|
|
|
+ duration: document.getElementById('stat-duration'),
|
|
|
|
|
+ avgSpeed: document.getElementById('stat-avg-speed'),
|
|
|
|
|
+ maxSpeed: document.getElementById('stat-max-speed'),
|
|
|
|
|
+ elevGain: document.getElementById('stat-elev-gain'),
|
|
|
|
|
+ elevLoss: document.getElementById('stat-elev-loss'),
|
|
|
|
|
+ elevMin: document.getElementById('stat-elev-min'),
|
|
|
|
|
+ elevMax: document.getElementById('stat-elev-max'),
|
|
|
|
|
+ timeRange: document.getElementById('stat-time-range')
|
|
|
|
|
+ },
|
|
|
|
|
+ trackInfo: document.getElementById('track-info')
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Initialization ====================
|
|
|
|
|
+ function init() {
|
|
|
|
|
+ initMap();
|
|
|
|
|
+ initEventListeners();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function initMap() {
|
|
|
|
|
+ map = L.map('map').setView([50.6616, 16.2814], 14); // Ruprechtický Špičák
|
|
|
|
|
+ setTileServer('freemap');
|
|
|
|
|
+
|
|
|
|
|
+ // Create hover marker (hidden initially)
|
|
|
|
|
+ const hoverIcon = L.divIcon({
|
|
|
|
|
+ className: 'hover-marker',
|
|
|
|
|
+ iconSize: [14, 14],
|
|
|
|
|
+ iconAnchor: [7, 7]
|
|
|
|
|
+ });
|
|
|
|
|
+ hoverMarker = L.marker([0, 0], { icon: hoverIcon, interactive: false });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function initEventListeners() {
|
|
|
|
|
+ // File input
|
|
|
|
|
+ elements.fileInput.addEventListener('change', handleFileSelect);
|
|
|
|
|
+
|
|
|
|
|
+ // Drag and drop
|
|
|
|
|
+ document.addEventListener('dragover', handleDragOver);
|
|
|
|
|
+ document.addEventListener('dragleave', handleDragLeave);
|
|
|
|
|
+ document.addEventListener('drop', handleDrop);
|
|
|
|
|
+
|
|
|
|
|
+ // Sidebar toggle
|
|
|
|
|
+ elements.sidebarToggle.addEventListener('click', toggleSidebar);
|
|
|
|
|
+
|
|
|
|
|
+ // Track style
|
|
|
|
|
+ elements.trackColor.addEventListener('input', updateTrackStyle);
|
|
|
|
|
+ elements.trackWidth.addEventListener('input', updateTrackStyle);
|
|
|
|
|
+
|
|
|
|
|
+ // Tile server
|
|
|
|
|
+ elements.tileServer.addEventListener('change', handleTileServerChange);
|
|
|
|
|
+ elements.applyCustomTile.addEventListener('click', applyCustomTileServer);
|
|
|
|
|
+ elements.applyMapyApikey.addEventListener('click', applyMapyApikey);
|
|
|
|
|
+
|
|
|
|
|
+ // Actions
|
|
|
|
|
+ elements.fitBoundsBtn.addEventListener('click', fitTrackBounds);
|
|
|
|
|
+ elements.loadAnotherBtn.addEventListener('click', () => elements.fileInput.click());
|
|
|
|
|
+ elements.fetchElevationBtn.addEventListener('click', fetchElevationData);
|
|
|
|
|
+
|
|
|
|
|
+ // Elevation chart interaction
|
|
|
|
|
+ elements.elevationChart.addEventListener('mousemove', handleChartHover);
|
|
|
|
|
+ elements.elevationChart.addEventListener('mouseleave', handleChartLeave);
|
|
|
|
|
+
|
|
|
|
|
+ // Map hover for track (with tolerance)
|
|
|
|
|
+ map.on('mousemove', handleMapHover);
|
|
|
|
|
+ map.on('mouseout', handleMapLeave);
|
|
|
|
|
+ map.on('click', handleMapClick);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== File Handling ====================
|
|
|
|
|
+ function handleDragOver(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ elements.dropOverlay.classList.add('active');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleDragLeave(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ if (e.target === elements.dropOverlay || !elements.dropOverlay.contains(e.relatedTarget)) {
|
|
|
|
|
+ elements.dropOverlay.classList.remove('active');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleDrop(e) {
|
|
|
|
|
+ e.preventDefault();
|
|
|
|
|
+ e.stopPropagation();
|
|
|
|
|
+ elements.dropOverlay.classList.remove('active');
|
|
|
|
|
+
|
|
|
|
|
+ const files = e.dataTransfer.files;
|
|
|
|
|
+ if (files.length > 0 && files[0].name.toLowerCase().endsWith('.gpx')) {
|
|
|
|
|
+ loadGPXFile(files[0]);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleFileSelect(e) {
|
|
|
|
|
+ const file = e.target.files[0];
|
|
|
|
|
+ if (file) {
|
|
|
|
|
+ loadGPXFile(file);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function loadGPXFile(file) {
|
|
|
|
|
+ const reader = new FileReader();
|
|
|
|
|
+ reader.onload = (e) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ parseGPX(e.target.result, file.name);
|
|
|
|
|
+ elements.filePrompt.classList.add('hidden');
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ alert('Error parsing GPX file: ' + err.message);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+ reader.readAsText(file);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== GPX Parsing ====================
|
|
|
|
|
+ function parseGPX(xmlString, fileName) {
|
|
|
|
|
+ const parser = new DOMParser();
|
|
|
|
|
+ const doc = parser.parseFromString(xmlString, 'application/xml');
|
|
|
|
|
+
|
|
|
|
|
+ const parseError = doc.querySelector('parsererror');
|
|
|
|
|
+ if (parseError) {
|
|
|
|
|
+ throw new Error('Invalid XML format');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Extract track points - memory efficient approach
|
|
|
|
|
+ const points = [];
|
|
|
|
|
+ const trkpts = doc.querySelectorAll('trkpt');
|
|
|
|
|
+ const rtepts = doc.querySelectorAll('rtept');
|
|
|
|
|
+ const wpts = doc.querySelectorAll('wpt');
|
|
|
|
|
+
|
|
|
|
|
+ // Process track points
|
|
|
|
|
+ const allPoints = [...trkpts, ...rtepts];
|
|
|
|
|
+
|
|
|
|
|
+ if (allPoints.length === 0 && wpts.length === 0) {
|
|
|
|
|
+ throw new Error('No track points found in GPX file');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let hasElevation = false;
|
|
|
|
|
+ let hasTime = false;
|
|
|
|
|
+
|
|
|
|
|
+ for (const pt of allPoints) {
|
|
|
|
|
+ const lat = parseFloat(pt.getAttribute('lat'));
|
|
|
|
|
+ const lon = parseFloat(pt.getAttribute('lon'));
|
|
|
|
|
+
|
|
|
|
|
+ if (isNaN(lat) || isNaN(lon)) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const eleNode = pt.querySelector('ele');
|
|
|
|
|
+ const timeNode = pt.querySelector('time');
|
|
|
|
|
+
|
|
|
|
|
+ const point = {
|
|
|
|
|
+ lat,
|
|
|
|
|
+ lon,
|
|
|
|
|
+ ele: eleNode ? parseFloat(eleNode.textContent) : null,
|
|
|
|
|
+ time: timeNode ? new Date(timeNode.textContent) : null
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ if (point.ele !== null && !isNaN(point.ele)) hasElevation = true;
|
|
|
|
|
+ if (point.time !== null) hasTime = true;
|
|
|
|
|
+
|
|
|
|
|
+ points.push(point);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (points.length === 0) {
|
|
|
|
|
+ throw new Error('No valid track points found');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Get track name
|
|
|
|
|
+ const nameNode = doc.querySelector('trk > name') || doc.querySelector('rte > name') || doc.querySelector('metadata > name');
|
|
|
|
|
+ const trackName = nameNode ? nameNode.textContent : fileName.replace('.gpx', '');
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate derived data (distance from start for each point)
|
|
|
|
|
+ let totalDist = 0;
|
|
|
|
|
+ for (let i = 0; i < points.length; i++) {
|
|
|
|
|
+ if (i === 0) {
|
|
|
|
|
+ points[i].dist = 0;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const d = haversineDistance(
|
|
|
|
|
+ points[i - 1].lat, points[i - 1].lon,
|
|
|
|
|
+ points[i].lat, points[i].lon
|
|
|
|
|
+ );
|
|
|
|
|
+ totalDist += d;
|
|
|
|
|
+ points[i].dist = totalDist;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Store track data
|
|
|
|
|
+ trackData = {
|
|
|
|
|
+ name: trackName,
|
|
|
|
|
+ points,
|
|
|
|
|
+ hasElevation,
|
|
|
|
|
+ hasTime,
|
|
|
|
|
+ stats: calculateStats(points, hasElevation, hasTime)
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Downsample for chart
|
|
|
|
|
+ chartData = downsamplePoints(points, CHART_MAX_POINTS);
|
|
|
|
|
+
|
|
|
|
|
+ // Update UI
|
|
|
|
|
+ displayTrack();
|
|
|
|
|
+ displayStats();
|
|
|
|
|
+
|
|
|
|
|
+ // Delay chart drawing to ensure container is visible and has dimensions
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ drawElevationChart();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // Show elevation warning if no elevation data
|
|
|
|
|
+ if (!hasElevation) {
|
|
|
|
|
+ elements.elevationWarning.classList.remove('hidden');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.elevationWarning.classList.add('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Show track data section
|
|
|
|
|
+ elements.noTrackMessage.classList.add('hidden');
|
|
|
|
|
+ elements.trackDataSection.classList.remove('hidden');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Calculations ====================
|
|
|
|
|
+ function haversineDistance(lat1, lon1, lat2, lon2) {
|
|
|
|
|
+ const R = 6371000; // Earth's radius in meters
|
|
|
|
|
+ const dLat = (lat2 - lat1) * Math.PI / 180;
|
|
|
|
|
+ const dLon = (lon2 - lon1) * Math.PI / 180;
|
|
|
|
|
+ const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
|
|
|
|
|
+ Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
|
|
|
|
|
+ Math.sin(dLon / 2) * Math.sin(dLon / 2);
|
|
|
|
|
+ const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
|
|
|
|
+ return R * c;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function calculateStats(points, hasElevation, hasTime) {
|
|
|
|
|
+ const stats = {
|
|
|
|
|
+ distance: points[points.length - 1].dist,
|
|
|
|
|
+ duration: null,
|
|
|
|
|
+ avgSpeed: null,
|
|
|
|
|
+ maxSpeed: null,
|
|
|
|
|
+ elevGain: null,
|
|
|
|
|
+ elevLoss: null,
|
|
|
|
|
+ elevMin: null,
|
|
|
|
|
+ elevMax: null,
|
|
|
|
|
+ startTime: null,
|
|
|
|
|
+ endTime: null
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // Time-based stats
|
|
|
|
|
+ if (hasTime) {
|
|
|
|
|
+ const validTimes = points.filter(p => p.time !== null);
|
|
|
|
|
+ if (validTimes.length >= 2) {
|
|
|
|
|
+ stats.startTime = validTimes[0].time;
|
|
|
|
|
+ stats.endTime = validTimes[validTimes.length - 1].time;
|
|
|
|
|
+ stats.duration = (stats.endTime - stats.startTime) / 1000; // seconds
|
|
|
|
|
+
|
|
|
|
|
+ if (stats.duration > 0) {
|
|
|
|
|
+ stats.avgSpeed = stats.distance / stats.duration; // m/s
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate max speed (with smoothing)
|
|
|
|
|
+ let maxSpeed = 0;
|
|
|
|
|
+ for (let i = 1; i < validTimes.length; i++) {
|
|
|
|
|
+ const dt = (validTimes[i].time - validTimes[i - 1].time) / 1000;
|
|
|
|
|
+ if (dt > 0) {
|
|
|
|
|
+ const dist = validTimes[i].dist - validTimes[i - 1].dist;
|
|
|
|
|
+ const speed = dist / dt;
|
|
|
|
|
+ if (speed > maxSpeed && speed < 100) { // Filter unrealistic speeds
|
|
|
|
|
+ maxSpeed = speed;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ stats.maxSpeed = maxSpeed;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Elevation stats
|
|
|
|
|
+ if (hasElevation) {
|
|
|
|
|
+ let gain = 0, loss = 0;
|
|
|
|
|
+ let min = Infinity, max = -Infinity;
|
|
|
|
|
+ let prevEle = null;
|
|
|
|
|
+
|
|
|
|
|
+ // Use smoothing for elevation gain/loss calculation
|
|
|
|
|
+ const smoothedEle = smoothElevation(points);
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < points.length; i++) {
|
|
|
|
|
+ const ele = smoothedEle[i];
|
|
|
|
|
+ if (ele === null) continue;
|
|
|
|
|
+
|
|
|
|
|
+ if (ele < min) min = ele;
|
|
|
|
|
+ if (ele > max) max = ele;
|
|
|
|
|
+
|
|
|
|
|
+ if (prevEle !== null) {
|
|
|
|
|
+ const diff = ele - prevEle;
|
|
|
|
|
+ if (diff > 0) gain += diff;
|
|
|
|
|
+ else loss -= diff;
|
|
|
|
|
+ }
|
|
|
|
|
+ prevEle = ele;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ stats.elevGain = gain;
|
|
|
|
|
+ stats.elevLoss = loss;
|
|
|
|
|
+ stats.elevMin = min === Infinity ? null : min;
|
|
|
|
|
+ stats.elevMax = max === -Infinity ? null : max;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return stats;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function smoothElevation(points, windowSize = 5) {
|
|
|
|
|
+ // Simple moving average smoothing
|
|
|
|
|
+ const result = [];
|
|
|
|
|
+ for (let i = 0; i < points.length; i++) {
|
|
|
|
|
+ if (points[i].ele === null) {
|
|
|
|
|
+ result.push(null);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let sum = 0, count = 0;
|
|
|
|
|
+ for (let j = Math.max(0, i - windowSize); j <= Math.min(points.length - 1, i + windowSize); j++) {
|
|
|
|
|
+ if (points[j].ele !== null) {
|
|
|
|
|
+ sum += points[j].ele;
|
|
|
|
|
+ count++;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ result.push(count > 0 ? sum / count : null);
|
|
|
|
|
+ }
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function downsamplePoints(points, maxPoints) {
|
|
|
|
|
+ if (points.length <= maxPoints) {
|
|
|
|
|
+ return points.map((p, i) => ({ ...p, originalIndex: i }));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const result = [];
|
|
|
|
|
+ const step = (points.length - 1) / (maxPoints - 1);
|
|
|
|
|
+
|
|
|
|
|
+ for (let i = 0; i < maxPoints; i++) {
|
|
|
|
|
+ const idx = Math.round(i * step);
|
|
|
|
|
+ result.push({ ...points[idx], originalIndex: idx });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ return result;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Map Display ====================
|
|
|
|
|
+ function displayTrack() {
|
|
|
|
|
+ // Remove existing track
|
|
|
|
|
+ if (trackLayer) {
|
|
|
|
|
+ map.removeLayer(trackLayer);
|
|
|
|
|
+ }
|
|
|
|
|
+ // Clear sticky state when loading a new track
|
|
|
|
|
+ stickyPoint = null;
|
|
|
|
|
+ hideHoverPoint(true);
|
|
|
|
|
+
|
|
|
|
|
+ // Create polyline from points
|
|
|
|
|
+ const latLngs = trackData.points.map(p => [p.lat, p.lon]);
|
|
|
|
|
+
|
|
|
|
|
+ trackLayer = L.polyline(latLngs, {
|
|
|
|
|
+ color: elements.trackColor.value,
|
|
|
|
|
+ weight: parseInt(elements.trackWidth.value),
|
|
|
|
|
+ opacity: 0.8,
|
|
|
|
|
+ lineJoin: 'round'
|
|
|
|
|
+ }).addTo(map);
|
|
|
|
|
+
|
|
|
|
|
+ // Track hover is handled at map level for better tolerance
|
|
|
|
|
+
|
|
|
|
|
+ // Fit map to track bounds
|
|
|
|
|
+ fitTrackBounds();
|
|
|
|
|
+
|
|
|
|
|
+ // Update track info
|
|
|
|
|
+ elements.trackInfo.textContent = `${trackData.name} (${trackData.points.length.toLocaleString()} points)`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function fitTrackBounds() {
|
|
|
|
|
+ if (trackLayer) {
|
|
|
|
|
+ map.fitBounds(trackLayer.getBounds(), { padding: [50, 50] });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function updateTrackStyle() {
|
|
|
|
|
+ const color = elements.trackColor.value;
|
|
|
|
|
+ const width = parseInt(elements.trackWidth.value);
|
|
|
|
|
+
|
|
|
|
|
+ elements.trackColorValue.textContent = color;
|
|
|
|
|
+ elements.trackWidthValue.textContent = width + 'px';
|
|
|
|
|
+
|
|
|
|
|
+ if (trackLayer) {
|
|
|
|
|
+ trackLayer.setStyle({
|
|
|
|
|
+ color: color,
|
|
|
|
|
+ weight: width
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Tile Server ====================
|
|
|
|
|
+ function setTileServer(serverId, customUrl = null, apikey = null) {
|
|
|
|
|
+ if (tileLayer) {
|
|
|
|
|
+ map.removeLayer(tileLayer);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ let url, attribution, options = {};
|
|
|
|
|
+
|
|
|
|
|
+ if (serverId === 'custom' && customUrl) {
|
|
|
|
|
+ url = customUrl;
|
|
|
|
|
+ attribution = 'Custom tiles';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ const server = TILE_SERVERS[serverId];
|
|
|
|
|
+ if (!server) return;
|
|
|
|
|
+
|
|
|
|
|
+ url = server.url;
|
|
|
|
|
+ attribution = server.attribution;
|
|
|
|
|
+
|
|
|
|
|
+ // Handle API key substitution
|
|
|
|
|
+ if (server.requiresApiKey && apikey) {
|
|
|
|
|
+ url = url.replace('{apikey}', apikey);
|
|
|
|
|
+ } else if (server.requiresApiKey && !apikey) {
|
|
|
|
|
+ // Don't set tile layer without API key
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (server.subdomains !== undefined) {
|
|
|
|
|
+ options.subdomains = server.subdomains;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (server.minZoom !== undefined) {
|
|
|
|
|
+ options.minZoom = server.minZoom;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (server.maxZoom !== undefined) {
|
|
|
|
|
+ options.maxZoom = server.maxZoom;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ tileLayer = L.tileLayer(url, {
|
|
|
|
|
+ attribution,
|
|
|
|
|
+ maxZoom: options.maxZoom || 19,
|
|
|
|
|
+ ...options
|
|
|
|
|
+ }).addTo(map);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleTileServerChange() {
|
|
|
|
|
+ const value = elements.tileServer.value;
|
|
|
|
|
+
|
|
|
|
|
+ // Hide all extra rows first
|
|
|
|
|
+ elements.customTileRow.classList.add('hidden');
|
|
|
|
|
+ elements.mapyApikeyRow.classList.add('hidden');
|
|
|
|
|
+
|
|
|
|
|
+ if (value === 'custom') {
|
|
|
|
|
+ elements.customTileRow.classList.remove('hidden');
|
|
|
|
|
+ } else if (value.startsWith('mapy-')) {
|
|
|
|
|
+ const server = TILE_SERVERS[value];
|
|
|
|
|
+ if (server && server.requiresApiKey) {
|
|
|
|
|
+ elements.mapyApikeyRow.classList.remove('hidden');
|
|
|
|
|
+ // If we already have an API key, apply immediately
|
|
|
|
|
+ const apikey = elements.mapyApikey.value.trim();
|
|
|
|
|
+ if (apikey) {
|
|
|
|
|
+ setTileServer(value, null, apikey);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ setTileServer(value);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function applyCustomTileServer() {
|
|
|
|
|
+ const url = elements.customTileUrl.value.trim();
|
|
|
|
|
+ if (url) {
|
|
|
|
|
+ setTileServer('custom', url);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function applyMapyApikey() {
|
|
|
|
|
+ const apikey = elements.mapyApikey.value.trim();
|
|
|
|
|
+ const serverId = elements.tileServer.value;
|
|
|
|
|
+ if (apikey && serverId.startsWith('mapy-')) {
|
|
|
|
|
+ setTileServer(serverId, null, apikey);
|
|
|
|
|
+ } else if (!apikey) {
|
|
|
|
|
+ alert('Please enter your Mapy.cz API key');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Statistics Display ====================
|
|
|
|
|
+ function displayStats() {
|
|
|
|
|
+ const stats = trackData.stats;
|
|
|
|
|
+
|
|
|
|
|
+ // Distance
|
|
|
|
|
+ if (stats.distance >= 1000) {
|
|
|
|
|
+ elements.stats.distance.textContent = (stats.distance / 1000).toFixed(2) + ' km';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.distance.textContent = Math.round(stats.distance) + ' m';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Duration
|
|
|
|
|
+ if (stats.duration !== null) {
|
|
|
|
|
+ elements.stats.duration.textContent = formatDuration(stats.duration);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.duration.textContent = '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Average speed
|
|
|
|
|
+ if (stats.avgSpeed !== null) {
|
|
|
|
|
+ elements.stats.avgSpeed.textContent = (stats.avgSpeed * 3.6).toFixed(1) + ' km/h';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.avgSpeed.textContent = '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Max speed
|
|
|
|
|
+ if (stats.maxSpeed !== null) {
|
|
|
|
|
+ elements.stats.maxSpeed.textContent = (stats.maxSpeed * 3.6).toFixed(1) + ' km/h';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.maxSpeed.textContent = '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Elevation
|
|
|
|
|
+ if (stats.elevGain !== null) {
|
|
|
|
|
+ elements.stats.elevGain.textContent = Math.round(stats.elevGain) + ' m';
|
|
|
|
|
+ elements.stats.elevLoss.textContent = Math.round(stats.elevLoss) + ' m';
|
|
|
|
|
+ elements.stats.elevMin.textContent = Math.round(stats.elevMin) + ' m';
|
|
|
|
|
+ elements.stats.elevMax.textContent = Math.round(stats.elevMax) + ' m';
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.elevGain.textContent = '-';
|
|
|
|
|
+ elements.stats.elevLoss.textContent = '-';
|
|
|
|
|
+ elements.stats.elevMin.textContent = '-';
|
|
|
|
|
+ elements.stats.elevMax.textContent = '-';
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Time range
|
|
|
|
|
+ if (stats.startTime && stats.endTime) {
|
|
|
|
|
+ const options = {
|
|
|
|
|
+ year: 'numeric', month: 'short', day: 'numeric',
|
|
|
|
|
+ hour: '2-digit', minute: '2-digit'
|
|
|
|
|
+ };
|
|
|
|
|
+ elements.stats.timeRange.textContent =
|
|
|
|
|
+ stats.startTime.toLocaleDateString(undefined, options);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ elements.stats.timeRange.textContent = '-';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function formatDuration(seconds) {
|
|
|
|
|
+ const h = Math.floor(seconds / 3600);
|
|
|
|
|
+ const m = Math.floor((seconds % 3600) / 60);
|
|
|
|
|
+ const s = Math.floor(seconds % 60);
|
|
|
|
|
+
|
|
|
|
|
+ if (h > 0) {
|
|
|
|
|
+ return `${h}h ${m}m`;
|
|
|
|
|
+ } else if (m > 0) {
|
|
|
|
|
+ return `${m}m ${s}s`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ return `${s}s`;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Elevation Chart ====================
|
|
|
|
|
+ function drawElevationChart() {
|
|
|
|
|
+ const canvas = elements.elevationChart;
|
|
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
|
|
+
|
|
|
|
|
+ // Set canvas size for high DPI displays
|
|
|
|
|
+ const rect = canvas.getBoundingClientRect();
|
|
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
+ canvas.width = rect.width * dpr;
|
|
|
|
|
+ canvas.height = rect.height * dpr;
|
|
|
|
|
+ ctx.scale(dpr, dpr);
|
|
|
|
|
+
|
|
|
|
|
+ // Clear canvas
|
|
|
|
|
+ ctx.clearRect(0, 0, rect.width, rect.height);
|
|
|
|
|
+
|
|
|
|
|
+ if (!chartData || chartData.length === 0) return;
|
|
|
|
|
+
|
|
|
|
|
+ // Check if we have elevation data
|
|
|
|
|
+ const hasElevation = chartData.some(p => p.ele !== null);
|
|
|
|
|
+ if (!hasElevation) {
|
|
|
|
|
+ ctx.fillStyle = '#95a5a6';
|
|
|
|
|
+ ctx.font = '14px sans-serif';
|
|
|
|
|
+ ctx.textAlign = 'center';
|
|
|
|
|
+ ctx.fillText('No elevation data', rect.width / 2, rect.height / 2);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate bounds
|
|
|
|
|
+ const padding = { top: 20, right: 20, bottom: 30, left: 50 };
|
|
|
|
|
+ const chartWidth = rect.width - padding.left - padding.right;
|
|
|
|
|
+ const chartHeight = rect.height - padding.top - padding.bottom;
|
|
|
|
|
+
|
|
|
|
|
+ const elevations = chartData.map(p => p.ele).filter(e => e !== null);
|
|
|
|
|
+ const minEle = Math.min(...elevations);
|
|
|
|
|
+ const maxEle = Math.max(...elevations);
|
|
|
|
|
+ const eleRange = maxEle - minEle || 1;
|
|
|
|
|
+ const totalDist = chartData[chartData.length - 1].dist;
|
|
|
|
|
+
|
|
|
|
|
+ // Draw grid lines
|
|
|
|
|
+ ctx.strokeStyle = '#eee';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+
|
|
|
|
|
+ // Horizontal grid lines
|
|
|
|
|
+ const eleSteps = 4;
|
|
|
|
|
+ for (let i = 0; i <= eleSteps; i++) {
|
|
|
|
|
+ const y = padding.top + (chartHeight * i / eleSteps);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(padding.left, y);
|
|
|
|
|
+ ctx.lineTo(rect.width - padding.right, y);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+
|
|
|
|
|
+ // Elevation labels
|
|
|
|
|
+ const ele = maxEle - (eleRange * i / eleSteps);
|
|
|
|
|
+ ctx.fillStyle = '#95a5a6';
|
|
|
|
|
+ ctx.font = '10px sans-serif';
|
|
|
|
|
+ ctx.textAlign = 'right';
|
|
|
|
|
+ ctx.fillText(Math.round(ele) + 'm', padding.left - 5, y + 3);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Distance labels
|
|
|
|
|
+ ctx.textAlign = 'center';
|
|
|
|
|
+ const distSteps = 5;
|
|
|
|
|
+ for (let i = 0; i <= distSteps; i++) {
|
|
|
|
|
+ const x = padding.left + (chartWidth * i / distSteps);
|
|
|
|
|
+ const dist = totalDist * i / distSteps;
|
|
|
|
|
+ const label = dist >= 1000 ? (dist / 1000).toFixed(1) + 'km' : Math.round(dist) + 'm';
|
|
|
|
|
+ ctx.fillText(label, x, rect.height - 10);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Draw elevation profile
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(padding.left, padding.top + chartHeight);
|
|
|
|
|
+
|
|
|
|
|
+ let firstPoint = true;
|
|
|
|
|
+ for (const point of chartData) {
|
|
|
|
|
+ if (point.ele === null) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const x = padding.left + (point.dist / totalDist) * chartWidth;
|
|
|
|
|
+ const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
|
|
|
|
|
+
|
|
|
|
|
+ if (firstPoint) {
|
|
|
|
|
+ ctx.moveTo(x, y);
|
|
|
|
|
+ firstPoint = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.lineTo(x, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Close path for fill
|
|
|
|
|
+ const lastPoint = chartData[chartData.length - 1];
|
|
|
|
|
+ const lastX = padding.left + chartWidth;
|
|
|
|
|
+ ctx.lineTo(lastX, padding.top + chartHeight);
|
|
|
|
|
+ ctx.lineTo(padding.left, padding.top + chartHeight);
|
|
|
|
|
+ ctx.closePath();
|
|
|
|
|
+
|
|
|
|
|
+ // Fill
|
|
|
|
|
+ const gradient = ctx.createLinearGradient(0, padding.top, 0, rect.height - padding.bottom);
|
|
|
|
|
+ gradient.addColorStop(0, 'rgba(52, 152, 219, 0.6)');
|
|
|
|
|
+ gradient.addColorStop(1, 'rgba(52, 152, 219, 0.1)');
|
|
|
|
|
+ ctx.fillStyle = gradient;
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+
|
|
|
|
|
+ // Stroke
|
|
|
|
|
+ ctx.strokeStyle = '#3498db';
|
|
|
|
|
+ ctx.lineWidth = 2;
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ firstPoint = true;
|
|
|
|
|
+ for (const point of chartData) {
|
|
|
|
|
+ if (point.ele === null) continue;
|
|
|
|
|
+
|
|
|
|
|
+ const x = padding.left + (point.dist / totalDist) * chartWidth;
|
|
|
|
|
+ const y = padding.top + chartHeight - ((point.ele - minEle) / eleRange) * chartHeight;
|
|
|
|
|
+
|
|
|
|
|
+ if (firstPoint) {
|
|
|
|
|
+ ctx.moveTo(x, y);
|
|
|
|
|
+ firstPoint = false;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ ctx.lineTo(x, y);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+
|
|
|
|
|
+ // Store chart bounds for hover detection
|
|
|
|
|
+ canvas.chartBounds = {
|
|
|
|
|
+ padding,
|
|
|
|
|
+ chartWidth,
|
|
|
|
|
+ chartHeight,
|
|
|
|
|
+ minEle,
|
|
|
|
|
+ maxEle,
|
|
|
|
|
+ eleRange,
|
|
|
|
|
+ totalDist
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Hover Interactions ====================
|
|
|
|
|
+ function handleChartHover(e) {
|
|
|
|
|
+ const canvas = elements.elevationChart;
|
|
|
|
|
+ const rect = canvas.getBoundingClientRect();
|
|
|
|
|
+ const bounds = canvas.chartBounds;
|
|
|
|
|
+
|
|
|
|
|
+ if (!bounds || !chartData) return;
|
|
|
|
|
+
|
|
|
|
|
+ const x = e.clientX - rect.left;
|
|
|
|
|
+ const y = e.clientY - rect.top;
|
|
|
|
|
+
|
|
|
|
|
+ // Check if within chart area
|
|
|
|
|
+ if (x < bounds.padding.left || x > bounds.padding.left + bounds.chartWidth) {
|
|
|
|
|
+ handleChartLeave();
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Calculate distance at cursor
|
|
|
|
|
+ const distRatio = (x - bounds.padding.left) / bounds.chartWidth;
|
|
|
|
|
+ const dist = distRatio * bounds.totalDist;
|
|
|
|
|
+
|
|
|
|
|
+ // Find nearest point
|
|
|
|
|
+ let nearestPoint = null;
|
|
|
|
|
+ let nearestDist = Infinity;
|
|
|
|
|
+ for (const point of chartData) {
|
|
|
|
|
+ const d = Math.abs(point.dist - dist);
|
|
|
|
|
+ if (d < nearestDist) {
|
|
|
|
|
+ nearestDist = d;
|
|
|
|
|
+ nearestPoint = point;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (nearestPoint) {
|
|
|
|
|
+ showHoverPoint(nearestPoint, 'chart');
|
|
|
|
|
+
|
|
|
|
|
+ // Show tooltip
|
|
|
|
|
+ const tooltip = elements.chartTooltip;
|
|
|
|
|
+ tooltip.innerHTML = formatPointTooltip(nearestPoint);
|
|
|
|
|
+ tooltip.classList.add('visible');
|
|
|
|
|
+
|
|
|
|
|
+ // Position tooltip
|
|
|
|
|
+ const pointX = bounds.padding.left + (nearestPoint.dist / bounds.totalDist) * bounds.chartWidth;
|
|
|
|
|
+ const pointY = nearestPoint.ele !== null ?
|
|
|
|
|
+ bounds.padding.top + bounds.chartHeight - ((nearestPoint.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
|
|
|
|
|
+ bounds.padding.top + bounds.chartHeight / 2;
|
|
|
|
|
+
|
|
|
|
|
+ tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
|
|
|
|
|
+ tooltip.style.top = Math.max(10, pointY - tooltip.offsetHeight / 2) + 'px';
|
|
|
|
|
+
|
|
|
|
|
+ // Draw hover indicator on chart
|
|
|
|
|
+ drawChartHoverIndicator(pointX, pointY, bounds);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleChartLeave() {
|
|
|
|
|
+ hideHoverPoint();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleMapHover(e) {
|
|
|
|
|
+ if (stickyPoint) return; // Don't update while sticky
|
|
|
|
|
+ if (!trackData || !trackData.points.length) return;
|
|
|
|
|
+
|
|
|
|
|
+ const latlng = e.latlng;
|
|
|
|
|
+ const mousePoint = map.latLngToContainerPoint(latlng);
|
|
|
|
|
+
|
|
|
|
|
+ // Find nearest point within pixel tolerance
|
|
|
|
|
+ let nearestPoint = null;
|
|
|
|
|
+ let nearestPixelDist = Infinity;
|
|
|
|
|
+
|
|
|
|
|
+ for (const point of trackData.points) {
|
|
|
|
|
+ const pointLatLng = L.latLng(point.lat, point.lon);
|
|
|
|
|
+ const pointPixel = map.latLngToContainerPoint(pointLatLng);
|
|
|
|
|
+ const pixelDist = mousePoint.distanceTo(pointPixel);
|
|
|
|
|
+
|
|
|
|
|
+ if (pixelDist < nearestPixelDist) {
|
|
|
|
|
+ nearestPixelDist = pixelDist;
|
|
|
|
|
+ nearestPoint = point;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ if (nearestPoint && nearestPixelDist <= TRACK_HOVER_TOLERANCE_PX) {
|
|
|
|
|
+ showHoverPoint(nearestPoint, 'map');
|
|
|
|
|
+ } else {
|
|
|
|
|
+ hideHoverPoint();
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleMapLeave() {
|
|
|
|
|
+ if (stickyPoint) return; // Keep sticky tooltip visible
|
|
|
|
|
+ hideHoverPoint();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function handleMapClick() {
|
|
|
|
|
+ if (stickyPoint) {
|
|
|
|
|
+ // Second click: un-stick and hide
|
|
|
|
|
+ stickyPoint = null;
|
|
|
|
|
+ hideHoverPoint(true);
|
|
|
|
|
+ } else if (hoverMarker && map.hasLayer(hoverMarker)) {
|
|
|
|
|
+ // Tooltip is showing: make it sticky
|
|
|
|
|
+ stickyPoint = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function hideHoverPoint(force) {
|
|
|
|
|
+ if (stickyPoint && !force) return; // Don't hide while sticky
|
|
|
|
|
+ if (hoverMarker && map.hasLayer(hoverMarker)) {
|
|
|
|
|
+ elements.chartTooltip.classList.remove('visible');
|
|
|
|
|
+ hoverMarker.remove();
|
|
|
|
|
+ if (chartData && chartData.length > 0) {
|
|
|
|
|
+ drawElevationChart(); // Redraw to clear hover indicator
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function showHoverPoint(point, source) {
|
|
|
|
|
+ // Show marker on map
|
|
|
|
|
+ hoverMarker.setLatLng([point.lat, point.lon]);
|
|
|
|
|
+ hoverMarker.addTo(map);
|
|
|
|
|
+
|
|
|
|
|
+ // Show tooltip on map
|
|
|
|
|
+ hoverMarker.bindTooltip(formatPointTooltip(point), {
|
|
|
|
|
+ permanent: true,
|
|
|
|
|
+ direction: 'top',
|
|
|
|
|
+ className: 'map-tooltip',
|
|
|
|
|
+ offset: [0, -10]
|
|
|
|
|
+ }).openTooltip();
|
|
|
|
|
+
|
|
|
|
|
+ // Highlight on chart if source is map
|
|
|
|
|
+ if (source === 'map' && elements.elevationChart.chartBounds) {
|
|
|
|
|
+ const bounds = elements.elevationChart.chartBounds;
|
|
|
|
|
+ const pointX = bounds.padding.left + (point.dist / bounds.totalDist) * bounds.chartWidth;
|
|
|
|
|
+ const pointY = point.ele !== null ?
|
|
|
|
|
+ bounds.padding.top + bounds.chartHeight - ((point.ele - bounds.minEle) / bounds.eleRange) * bounds.chartHeight :
|
|
|
|
|
+ bounds.padding.top + bounds.chartHeight / 2;
|
|
|
|
|
+
|
|
|
|
|
+ drawChartHoverIndicator(pointX, pointY, bounds);
|
|
|
|
|
+
|
|
|
|
|
+ // Show chart tooltip
|
|
|
|
|
+ const tooltip = elements.chartTooltip;
|
|
|
|
|
+ const rect = elements.elevationChart.getBoundingClientRect();
|
|
|
|
|
+ tooltip.innerHTML = formatPointTooltip(point);
|
|
|
|
|
+ tooltip.classList.add('visible');
|
|
|
|
|
+ tooltip.style.left = Math.min(pointX + 10, rect.width - tooltip.offsetWidth - 10) + 'px';
|
|
|
|
|
+ tooltip.style.top = Math.max(10, pointY - 20) + 'px';
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function drawChartHoverIndicator(x, y, bounds) {
|
|
|
|
|
+ const canvas = elements.elevationChart;
|
|
|
|
|
+ const ctx = canvas.getContext('2d');
|
|
|
|
|
+ const dpr = window.devicePixelRatio || 1;
|
|
|
|
|
+
|
|
|
|
|
+ // Redraw chart first
|
|
|
|
|
+ drawElevationChart();
|
|
|
|
|
+
|
|
|
|
|
+ // Draw vertical line
|
|
|
|
|
+ ctx.strokeStyle = 'rgba(231, 76, 60, 0.5)';
|
|
|
|
|
+ ctx.lineWidth = 1;
|
|
|
|
|
+ ctx.setLineDash([5, 5]);
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.moveTo(x * dpr, bounds.padding.top * dpr);
|
|
|
|
|
+ ctx.lineTo(x * dpr, (bounds.padding.top + bounds.chartHeight) * dpr);
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ ctx.setLineDash([]);
|
|
|
|
|
+
|
|
|
|
|
+ // Draw point marker
|
|
|
|
|
+ ctx.fillStyle = '#e74c3c';
|
|
|
|
|
+ ctx.beginPath();
|
|
|
|
|
+ ctx.arc(x * dpr, y * dpr, 5 * dpr, 0, Math.PI * 2);
|
|
|
|
|
+ ctx.fill();
|
|
|
|
|
+ ctx.strokeStyle = 'white';
|
|
|
|
|
+ ctx.lineWidth = 2 * dpr;
|
|
|
|
|
+ ctx.stroke();
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ function formatPointTooltip(point) {
|
|
|
|
|
+ let html = '';
|
|
|
|
|
+
|
|
|
|
|
+ // Distance
|
|
|
|
|
+ if (point.dist >= 1000) {
|
|
|
|
|
+ html += `<div><strong>Distance:</strong> ${(point.dist / 1000).toFixed(2)} km</div>`;
|
|
|
|
|
+ } else {
|
|
|
|
|
+ html += `<div><strong>Distance:</strong> ${Math.round(point.dist)} m</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Elevation
|
|
|
|
|
+ if (point.ele !== null) {
|
|
|
|
|
+ html += `<div><strong>Elevation:</strong> ${Math.round(point.ele)} m</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Time
|
|
|
|
|
+ if (point.time) {
|
|
|
|
|
+ const timeStr = point.time.toLocaleTimeString(undefined, {
|
|
|
|
|
+ hour: '2-digit',
|
|
|
|
|
+ minute: '2-digit',
|
|
|
|
|
+ second: '2-digit'
|
|
|
|
|
+ });
|
|
|
|
|
+ html += `<div><strong>Time:</strong> ${timeStr}</div>`;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Coordinates
|
|
|
|
|
+ html += `<div><strong>Lat:</strong> ${point.lat.toFixed(5)}</div>`;
|
|
|
|
|
+ html += `<div><strong>Lon:</strong> ${point.lon.toFixed(5)}</div>`;
|
|
|
|
|
+
|
|
|
|
|
+ return html;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Elevation API ====================
|
|
|
|
|
+ async function fetchElevationData() {
|
|
|
|
|
+ if (!trackData) return;
|
|
|
|
|
+
|
|
|
|
|
+ elements.fetchElevationBtn.disabled = true;
|
|
|
|
|
+ elements.elevationLoading.classList.add('active');
|
|
|
|
|
+ elements.elevationWarning.classList.add('hidden');
|
|
|
|
|
+
|
|
|
|
|
+ try {
|
|
|
|
|
+ const points = trackData.points;
|
|
|
|
|
+ const batches = [];
|
|
|
|
|
+
|
|
|
|
|
+ // Create batches
|
|
|
|
|
+ for (let i = 0; i < points.length; i += ELEVATION_BATCH_SIZE) {
|
|
|
|
|
+ batches.push(points.slice(i, i + ELEVATION_BATCH_SIZE));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Fetch elevation for each batch
|
|
|
|
|
+ for (let i = 0; i < batches.length; i++) {
|
|
|
|
|
+ const batch = batches[i];
|
|
|
|
|
+ const locations = batch.map(p => ({
|
|
|
|
|
+ latitude: p.lat,
|
|
|
|
|
+ longitude: p.lon
|
|
|
|
|
+ }));
|
|
|
|
|
+
|
|
|
|
|
+ const response = await fetch(ELEVATION_API, {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ locations })
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ if (!response.ok) {
|
|
|
|
|
+ throw new Error(`API error: ${response.status}`);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ // Update points with elevation data
|
|
|
|
|
+ for (let j = 0; j < data.results.length; j++) {
|
|
|
|
|
+ const pointIndex = i * ELEVATION_BATCH_SIZE + j;
|
|
|
|
|
+ if (pointIndex < points.length) {
|
|
|
|
|
+ points[pointIndex].ele = data.results[j].elevation;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Small delay between batches to be nice to the API
|
|
|
|
|
+ if (i < batches.length - 1) {
|
|
|
|
|
+ await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Update stats and chart
|
|
|
|
|
+ trackData.hasElevation = true;
|
|
|
|
|
+ trackData.stats = calculateStats(points, true, trackData.hasTime);
|
|
|
|
|
+ chartData = downsamplePoints(points, CHART_MAX_POINTS);
|
|
|
|
|
+
|
|
|
|
|
+ displayStats();
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ drawElevationChart();
|
|
|
|
|
+ });
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ alert('Failed to fetch elevation data: ' + err.message);
|
|
|
|
|
+ elements.elevationWarning.classList.remove('hidden');
|
|
|
|
|
+ } finally {
|
|
|
|
|
+ elements.fetchElevationBtn.disabled = false;
|
|
|
|
|
+ elements.elevationLoading.classList.remove('active');
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Sidebar ====================
|
|
|
|
|
+ function toggleSidebar() {
|
|
|
|
|
+ const isCollapsed = elements.sidebar.classList.toggle('collapsed');
|
|
|
|
|
+ elements.sidebarToggle.classList.toggle('collapsed', isCollapsed);
|
|
|
|
|
+ elements.sidebarToggle.innerHTML = isCollapsed ? '▶' : '◀';
|
|
|
|
|
+
|
|
|
|
|
+ // Invalidate map size after animation
|
|
|
|
|
+ setTimeout(() => {
|
|
|
|
|
+ map.invalidateSize();
|
|
|
|
|
+ }, 350);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Window Resize ====================
|
|
|
|
|
+ window.addEventListener('resize', () => {
|
|
|
|
|
+ if (trackData) {
|
|
|
|
|
+ requestAnimationFrame(() => {
|
|
|
|
|
+ drawElevationChart();
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ==================== Start ====================
|
|
|
|
|
+ document.addEventListener('DOMContentLoaded', init);
|
|
|
|
|
+ </script>
|
|
|
|
|
+</body>
|
|
|
|
|
+</html>
|