signalk-parquet
Version:
Vessel data Parquet file archive with automated value and geospatial triggers. History API compliant with cloud backups and queries.
2,021 lines (1,777 loc) • 142 kB
HTML
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="origin" />
<title>SignalK Parquet Data Interface</title>
<link rel="icon" type="image/png" href="parquet.png" />
<script src="https://cdn.plot.ly/plotly-2.35.2.min.js"></script>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html {
overflow-x: hidden;
}
body {
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, sans-serif;
background-color: #f5f5f5;
color: #333;
line-height: 1.6;
overflow-x: hidden;
max-width: 100vw;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
overflow-x: hidden;
}
.header {
background: linear-gradient(135deg, #2bb5d6 0%, #013542 100%);
color: white;
padding: 20px 0;
margin-bottom: 20px;
border-radius: 10px;
text-align: center;
position: relative;
}
.header h1 {
font-size: 2.5rem;
margin-bottom: 10px;
margin: 0 0 10px 0;
}
.header p {
font-size: 1.1rem;
opacity: 0.9;
}
.section {
background: white;
padding: 20px;
margin-bottom: 20px;
border-radius: 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow-x: hidden;
max-width: 100%;
}
.section h2 {
color: #667eea;
margin-bottom: 15px;
font-size: 1.5rem;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #555;
}
input,
select,
textarea {
width: 100%;
padding: 10px;
border: 2px solid #ddd;
border-radius: 5px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus,
select:focus,
textarea:focus {
outline: none;
border-color: #667eea;
}
textarea {
resize: vertical;
min-height: 100px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
button {
background: #667eea;
color: white;
padding: 12px 20px;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
transition: background-color 0.3s;
margin-right: 10px;
margin-bottom: 10px;
}
button:hover {
background: #5a6fd8;
}
button:disabled {
background: #ccc;
cursor: not-allowed;
}
.btn-secondary {
background: #6c757d;
}
.btn-secondary:hover {
background: #5a6268;
}
.results {
margin-top: 20px;
}
.table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin-top: 15px;
max-width: 100%;
}
table {
width: 100%;
border-collapse: collapse;
table-layout: auto;
}
th,
td {
padding: 8px;
text-align: left;
border-bottom: 1px solid #ddd;
word-wrap: break-word;
word-break: break-word;
}
/* Code elements in tables should wrap */
td code {
word-break: break-all;
white-space: normal;
display: inline-block;
max-width: 100%;
}
th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
tr:hover {
background-color: #f8f9fa;
}
.edit-command-form-row td {
padding: 0;
background: transparent;
}
.loading {
text-align: center;
padding: 20px;
color: #666;
}
.error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
padding: 10px;
border-radius: 5px;
margin-top: 10px;
}
.path-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 15px;
margin-top: 15px;
}
.path-item {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
border-left: 4px solid #667eea;
}
.path-item h4 {
margin-bottom: 5px;
color: #495057;
}
.path-item p {
font-size: 0.9rem;
color: #666;
}
.path-item button {
margin-top: 10px;
padding: 5px 10px;
font-size: 12px;
}
.query-examples {
background: #f8f9fa;
padding: 15px;
border-radius: 5px;
margin-top: 15px;
}
.query-examples h4 {
margin-bottom: 10px;
color: #495057;
}
.query-examples ul {
list-style: none;
padding: 0;
}
.query-examples li {
margin-bottom: 10px;
padding: 10px;
background: white;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.3s;
}
.query-examples li:hover {
background: #e9ecef;
}
.query-examples code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 0.9rem;
}
.stats {
display: flex;
justify-content: space-between;
margin-bottom: 15px;
padding: 10px;
background: #f8f9fa;
border-radius: 5px;
}
.stat-item {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
.collapsible-header {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
margin: -20px -20px 15px -20px;
background: #f8f9fa;
border-radius: 10px 10px 0 0;
transition: background-color 0.3s;
}
.collapsible-header:hover {
background: #e9ecef;
}
.collapsible-header h2 {
margin: 0;
}
.collapse-icon {
font-size: 1.2rem;
transition: transform 0.3s;
}
.collapse-icon.collapsed {
transform: rotate(-90deg);
}
.collapsible-content {
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.collapsible-content.collapsed {
max-height: 0;
}
.collapsible-content.expanded {
max-height: 2000px;
}
/* Tab Navigation Styles */
.tab-navigation {
display: flex;
background: white;
border-radius: 10px 10px 0 0;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 0;
overflow-x: auto;
}
.tab-button {
background: #f8f9fa;
border: none;
padding: 15px 20px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
color: #666;
border-radius: 0;
transition: all 0.3s;
white-space: nowrap;
border-bottom: 3px solid transparent;
margin: 0;
}
.tab-button:first-child {
border-radius: 10px 0 0 0;
}
.tab-button:last-child {
border-radius: 0 10px 0 0;
}
.tab-button:hover {
background: #e9ecef;
color: #333;
}
.tab-button.active {
background: white;
color: #667eea;
border-bottom-color: #667eea;
}
.tab-content {
background: white;
border-radius: 0 0 10px 10px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
margin-bottom: 20px;
overflow-x: hidden;
max-width: 100%;
}
.tab-panel {
display: none;
}
.tab-panel.active {
display: block;
}
.tab-panel .section {
margin-bottom: 0;
box-shadow: none;
border-radius: 0 0 10px 10px;
}
/* Modal styles for responsiveness */
.modal-overlay {
display: flex;
align-items: flex-start;
justify-content: center;
padding: 20px;
}
.modal-content {
position: relative;
width: 100%;
}
/* Button groups for responsive layout */
.btn-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.btn-group button {
flex: 1 1 auto;
}
/* Responsive utility classes */
.hide-mobile {
display: block;
}
.show-mobile {
display: none;
}
.text-truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ============================================
RESPONSIVE DESIGN - Mobile First Approach
============================================ */
/* Extra small devices (phones, 480px and down) */
@media (max-width: 480px) {
.container {
padding: 8px;
}
.header {
padding: 15px 10px;
border-radius: 8px;
}
.header img {
position: static !important;
height: 50px !important;
margin-bottom: 10px;
}
.header h1 {
font-size: 1.4rem;
}
.header p {
font-size: 0.9rem;
}
.section {
padding: 12px;
border-radius: 8px;
}
.section h2 {
font-size: 1.2rem;
}
.stats {
flex-direction: column;
gap: 8px;
padding: 8px;
}
.stat-value {
font-size: 1.2rem;
}
.stat-label {
font-size: 0.8rem;
}
/* Tab navigation - horizontal scroll on very small screens */
.tab-navigation {
flex-wrap: nowrap;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.tab-button {
flex: 0 0 auto;
min-width: 100px;
padding: 10px 12px;
font-size: 12px;
}
.tab-button:first-child,
.tab-button:last-child {
border-radius: 0;
}
/* Buttons - full width on mobile */
button {
width: 100%;
margin-right: 0;
padding: 14px 16px;
font-size: 15px;
min-height: 48px; /* Touch-friendly */
}
.btn-group {
display: flex;
flex-direction: column;
gap: 8px;
}
/* Form inputs - larger touch targets */
input,
select,
textarea {
padding: 12px;
font-size: 16px; /* Prevents iOS zoom */
min-height: 48px;
}
/* Tables - responsive on mobile */
.table-container {
margin: 0 -8px;
padding: 0;
overflow-x: auto;
max-width: calc(100% + 16px);
}
table {
min-width: 500px;
font-size: 12px;
}
th,
td {
padding: 8px 6px;
font-size: 12px;
}
td code {
font-size: 11px;
}
/* Hide less important columns on mobile */
th:nth-child(4), td:nth-child(4), /* Source */
th:nth-child(5), td:nth-child(5), /* Context */
th:nth-child(6), td:nth-child(6) {
/* Exclude MMSI */
display: none;
}
/* Compact action buttons on mobile */
td button {
padding: 6px 8px !important;
font-size: 11px !important;
margin: 2px !important;
width: auto !important;
min-height: 32px !important;
}
/* Path list - single column */
.path-list {
grid-template-columns: 1fr;
gap: 10px;
}
.path-item {
padding: 12px;
}
/* Collapsible sections */
.collapsible-header {
padding: 12px 15px;
margin: -12px -12px 12px -12px;
}
/* Path tree */
.path-tree-container {
max-height: 300px;
}
.path-tree-item {
padding: 10px 8px;
min-height: 44px;
}
/* Query examples */
.query-examples {
padding: 10px;
}
.query-examples code {
font-size: 0.8rem;
word-break: break-all;
}
/* Utility class toggles for mobile */
.hide-mobile {
display: none !important;
}
.show-mobile {
display: block !important;
}
}
/* Small devices (large phones, tablets portrait - 481px to 768px) */
@media (min-width: 481px) and (max-width: 768px) {
.container {
padding: 12px;
}
.header {
padding: 15px;
}
.header img {
height: 60px !important;
left: 15px !important;
top: 15px !important;
}
.header h1 {
font-size: 1.8rem;
}
.section {
padding: 15px;
}
.stats {
flex-direction: column;
gap: 10px;
}
.tab-navigation {
flex-wrap: wrap;
}
.tab-button {
flex: 1 1 calc(25% - 2px);
min-width: 100px;
padding: 12px 15px;
font-size: 13px;
}
.tab-button:first-child,
.tab-button:last-child {
border-radius: 0;
}
/* Two column grid for path list */
.path-list {
grid-template-columns: repeat(2, 1fr);
}
/* Table responsive for tablets */
.table-container {
overflow-x: auto;
}
table {
min-width: 600px;
font-size: 13px;
}
th,
td {
padding: 8px;
}
/* Show more table columns on tablet */
th:nth-child(4),
td:nth-child(4) {
display: table-cell;
}
/* Hide context and exclude MMSI on tablet */
th:nth-child(5),
td:nth-child(5),
th:nth-child(6),
td:nth-child(6) {
display: none;
}
button {
min-height: 44px;
}
input,
select,
textarea {
font-size: 16px;
}
}
/* Medium devices (tablets landscape, small laptops - 769px to 1024px) */
@media (min-width: 769px) and (max-width: 1024px) {
.container {
padding: 15px;
}
.header h1 {
font-size: 2.2rem;
}
.stats {
flex-wrap: wrap;
justify-content: center;
gap: 15px;
}
.stat-item {
flex: 1 1 auto;
min-width: 100px;
}
.path-list {
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
}
/* Table on medium screens */
.table-container {
overflow-x: auto;
}
table {
min-width: 700px;
}
/* Hide exclude MMSI on medium screens */
th:nth-child(6),
td:nth-child(6) {
display: none;
}
}
/* Large devices (desktops - 1025px to 1440px) */
@media (min-width: 1025px) and (max-width: 1440px) {
.container {
padding: 20px;
}
}
/* Extra large devices (large desktops, TVs - 1441px and up) */
@media (min-width: 1441px) {
.container {
max-width: 1600px;
}
.header h1 {
font-size: 3rem;
}
.section h2 {
font-size: 1.8rem;
}
.stat-value {
font-size: 2rem;
}
.path-list {
grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
}
}
/* Landscape orientation for phones */
@media (max-height: 500px) and (orientation: landscape) {
.header {
padding: 10px;
}
.header img {
height: 40px !important;
}
.header h1 {
font-size: 1.5rem;
}
.header p {
display: none;
}
.tab-navigation {
position: sticky;
top: 0;
z-index: 100;
}
}
/* High DPI / Retina display optimizations */
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
.header img {
image-rendering: -webkit-optimize-contrast;
}
}
/* Print styles */
@media print {
.tab-navigation,
button,
.header img {
display: none;
}
.tab-panel {
display: block !important;
page-break-inside: avoid;
}
.section {
box-shadow: none;
border: 1px solid #ddd;
}
}
/* Responsive modal styles */
.modal-responsive {
margin: 20px !important;
max-width: calc(100% - 40px) !important;
max-height: calc(100vh - 40px) !important;
}
@media (max-width: 480px) {
.modal-responsive,
#contextPreviewModal > div,
#analysisHistoryModal > div,
#analysisViewModal > div,
#thresholdModal > div {
margin: 0 !important;
max-width: 100% !important;
width: 100% !important;
max-height: 100vh !important;
height: 100vh !important;
border-radius: 0 !important;
}
}
@media (max-width: 768px) {
#contextPreviewModal > div,
#analysisHistoryModal > div,
#analysisViewModal > div,
#thresholdModal > div {
margin: 10px !important;
max-width: calc(100% - 20px) !important;
max-height: calc(100vh - 20px) !important;
}
}
/* Touch-friendly scrollbars */
@media (pointer: coarse) {
.table-container,
.path-tree-container,
.tab-navigation {
scrollbar-width: thin;
}
.table-container::-webkit-scrollbar,
.path-tree-container::-webkit-scrollbar,
.tab-navigation::-webkit-scrollbar {
height: 6px;
width: 6px;
}
.table-container::-webkit-scrollbar-thumb,
.path-tree-container::-webkit-scrollbar-thumb,
.tab-navigation::-webkit-scrollbar-thumb {
background: #ccc;
border-radius: 3px;
}
}
/* Reduce motion for accessibility */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* Hierarchical Path Tree Styles */
.path-tree-container {
border: 1px solid #ddd;
border-radius: 4px;
background: white;
max-height: 400px;
overflow-y: auto;
padding: 8px;
}
.path-tree-item {
padding: 4px 8px;
cursor: pointer;
user-select: none;
display: flex;
align-items: center;
gap: 4px;
}
.path-tree-item:hover {
background: #f0f8ff;
}
.path-tree-item.selected {
background: #d0e8ff;
font-weight: 500;
}
.path-tree-item.has-value {
color: #0066cc;
}
.path-tree-toggle {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
flex-shrink: 0;
}
.path-tree-children {
margin-left: 20px;
display: none;
}
.path-tree-children.expanded {
display: block;
}
.path-tree-search {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
margin-bottom: 8px;
}
.path-tree-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ========== Map Explorer Styles ========== */
.me-layout {
display: flex;
gap: 12px;
margin-bottom: 10px;
}
.me-layout-map {
flex: 1;
min-width: 0;
}
.me-map-container {
height: 450px;
width: 100%;
border-radius: 8px;
border: 2px solid #ddd;
margin-bottom: 10px;
z-index: 1;
}
.me-toolbar {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
margin-bottom: 10px;
padding: 8px;
background: #f8f9fa;
border-radius: 6px;
}
.me-toolbar button {
padding: 6px 12px;
font-size: 13px;
margin: 0;
}
.me-toolbar button.me-active {
background: #9c27b0;
}
.me-status-text {
color: #666;
font-size: 13px;
margin-left: 8px;
}
.me-legend {
display: flex;
gap: 8px;
overflow-x: auto;
padding: 6px 0;
margin-bottom: 6px;
}
.me-legend-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: #f0f0f0;
border-radius: 16px;
font-size: 12px;
cursor: pointer;
white-space: nowrap;
border: 2px solid transparent;
user-select: none;
}
.me-legend-chip.me-hidden {
opacity: 0.4;
text-decoration: line-through;
}
.me-legend-chip.me-active-series {
border-color: #333;
}
.me-color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.me-summary {
padding: 6px 10px;
background: #e8eaf6;
border-radius: 4px;
font-size: 12px;
color: #333;
margin-bottom: 10px;
overflow-x: auto;
white-space: nowrap;
}
.me-query-panel {
padding: 8px 10px;
background: #fafafa;
overflow: hidden;
border: 1px solid #e0e0e0;
border-radius: 8px;
display: flex;
flex-direction: column;
flex: 1;
}
.me-query-panel h3 {
margin: 0 0 6px;
font-size: 1rem;
color: #555;
flex-shrink: 0;
}
.me-query-grid {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
}
.me-query-col label {
font-size: 12px;
margin-bottom: 2px;
}
.me-query-col select,
.me-query-col input {
font-size: 12px;
padding: 4px 6px;
}
.me-btn-group {
display: flex;
gap: 4px;
flex-wrap: wrap;
}
.me-btn-group button {
padding: 3px 8px;
font-size: 11px;
margin: 0;
background: #e0e0e0;
color: #333;
}
.me-btn-group button.me-active {
background: #667eea;
color: white;
}
.me-btn-group button:hover {
background: #bbb;
}
.me-btn-group button.me-active:hover {
background: #5a6fd8;
}
.me-date-range-picker {
display: flex;
align-items: center;
border: 1px solid #ccc;
border-radius: 4px;
overflow: hidden;
background: white;
}
.me-date-range-picker input[type="date"] {
border: none;
outline: none;
padding: 5px 6px;
font-size: 13px;
flex: 1;
min-width: 0;
background: transparent;
}
.me-date-sep {
color: #999;
font-size: 14px;
padding: 0 2px;
user-select: none;
}
.me-path-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 4px;
}
.me-path-chip {
display: inline-flex;
align-items: center;
gap: 4px;
background: #667eea;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
}
.me-path-chip button {
background: none;
border: none;
color: white;
cursor: pointer;
font-size: 14px;
padding: 0 2px;
line-height: 1;
}
.me-path-filter {
width: 100%;
box-sizing: border-box;
margin-bottom: 4px;
}
.me-path-list {
max-height: 90px;
overflow-y: auto;
border: 1px solid #ddd;
border-radius: 4px;
background: white;
}
.me-path-row {
display: flex;
align-items: center;
gap: 4px;
padding: 2px 6px;
font-size: 11px;
cursor: pointer;
font-weight: normal;
}
.me-path-row:hover {
background: #f0f0f0;
}
.me-path-row input[type="checkbox"],
.me-path-row input[type="radio"] {
width: auto;
margin: 0;
flex-shrink: 0;
}
.me-path-disabled {
opacity: 0.4;
cursor: default;
}
.me-loading {
text-align: center;
padding: 20px;
}
.me-results-panel {
margin-top: 10px;
}
.me-results-tabs {
display: flex;
gap: 4px;
margin-bottom: 8px;
}
.me-results-tab-btn {
padding: 6px 14px;
font-size: 13px;
margin: 0;
background: #e0e0e0;
color: #333;
}
.me-results-tab-btn.me-active {
background: #667eea;
color: white;
}
.me-all-points {
max-height: 350px;
overflow: auto;
}
.me-table-scroll {
overflow-x: auto;
}
.me-table-scroll table {
font-size: 13px;
}
.me-table-scroll thead {
position: sticky;
top: 0;
z-index: 1;
}
.me-selected-row {
background: #fff9c4 !important;
}
.me-table-scroll tr {
cursor: pointer;
}
.me-detail-header {
padding: 8px 0;
border-bottom: 1px solid #eee;
margin-bottom: 8px;
}
.me-detail-value-row {
display: flex;
align-items: center;
gap: 6px;
padding: 2px 0;
font-size: 14px;
}
.me-sparkline {
height: 80px;
margin-bottom: 6px;
}
.me-playback {
padding: 8px 0;
border-top: 1px solid #eee;
margin-top: 8px;
}
.me-playback-controls {
display: flex;
gap: 6px;
align-items: center;
margin-top: 6px;
}
.me-btn-sm {
padding: 4px 10px !important;
font-size: 12px !important;
margin: 0 !important;
}
.me-btn-danger {
background: #e53935 !important;
}
.me-export {
display: flex;
gap: 6px;
padding: 10px 0;
border-top: 1px solid #eee;
margin-top: 10px;
}
.me-modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 10000;
display: flex;
align-items: center;
justify-content: center;
}
.me-modal {
background: white;
border-radius: 10px;
padding: 24px;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
}
.me-saved-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
border-bottom: 1px solid #eee;
gap: 10px;
}
.me-badge {
display: inline-block;
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
color: white;
margin-left: 6px;
}
.me-badge-bbox {
background: #7b1fa2;
}
.me-badge-radius {
background: #0288d1;
}
.me-handle {
cursor: grab;
}
@media (max-width: 600px) {
.me-layout {
flex-direction: column;
}
.me-query-panel {
flex: none;
max-height: none;
}
.me-map-container {
height: 300px;
}
.me-toolbar {
flex-direction: column;
align-items: flex-start;
}
.me-summary {
font-size: 11px;
}
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<img
src="parquet.png"
alt="Parquet"
style="
height: 80px;
position: absolute;
left: 20px;
top: 20px;
border-radius: 5px;
"
/>
<h1 style="margin: 0 0 10px 0">SignalK Parquet Data Store</h1>
<p>
Export, query, and explore your SignalK data archived as Parquet files
</p>
</div>
<!-- Tab Navigation -->
<div class="tab-navigation">
<button class="tab-button active" onclick="showTab('pathConfig')">
⚙️ Path Configuration
</button>
<button class="tab-button" onclick="showTab('commandManager')">
🎮 Regimen/Commands Manager
</button>
<button
class="tab-button"
onclick="showTab('liveConnections')"
style="display: none"
>
🔴 Live Connections
</button>
<button
id="queryDatabaseTab"
class="tab-button"
onclick="showTab('dataPaths')"
>
🗄️ Query Database
</button>
<button
class="tab-button"
onclick="showTab('aiAnalysis')"
style="display: none"
>
🧠 AI Analysis <small>(EXPERIMENTAL)</small>
</button>
<button
class="tab-button"
onclick="showTab('dataValidation')"
style="display: none"
>
🔍 Data Validation
</button>
<button class="tab-button" onclick="showTab('mapExplorer')">
🗺️ Map Explorer
</button>
<button class="tab-button" onclick="showTab('migration')">
📊 Status
</button>
<button class="tab-button" onclick="showTab('s3Config')">
☁️ Cloud Status
</button>
</div>
<!-- Tab Content Panels -->
<div class="tab-content">
<!-- Path Configuration Tab -->
<div id="pathConfig" class="tab-panel active">
<div class="section">
<h2>⚙️ Path Configuration</h2>
<p>Manage which SignalK paths to collect data from:</p>
<!-- Home Port Configuration -->
<div
id="homePortConfig"
style="
margin: 15px 0 20px 0;
padding: 15px;
background: #e8f5e9;
border-radius: 8px;
border: 2px solid #4caf50;
"
>
<h3
style="
margin: 0 0 10px 0;
color: #2e7d32;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
"
onclick="toggleHomePortCollapse()"
>
<span id="homePortToggle">▼</span>
🏠 Home Port Location
<span style="font-size: 0.8em; font-weight: normal; color: #666"
>(Used for proximity-based thresholds)</span
>
</h3>
<div id="homePortDetails">
<div
style="
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 12px;
align-items: end;
"
>
<div>
<label
for="homePortLat"
style="
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
"
>Latitude:</label
>
<input
type="number"
id="homePortLat"
step="0.000001"
placeholder="e.g., 40.712800"
style="
width: 100%;
padding: 8px;
border: 1px solid #4caf50;
border-radius: 4px;
"
/>
</div>
<div>
<label
for="homePortLon"
style="
display: block;
margin-bottom: 5px;
font-weight: 500;
color: #333;
"
>Longitude:</label
>
<input
type="number"
id="homePortLon"
step="0.000001"
placeholder="e.g., -74.006000"
style="
width: 100%;
padding: 8px;
border: 1px solid #4caf50;
border-radius: 4px;
"
/>
</div>
<div>
<button
onclick="setCurrentLocationAsHomePort()"
style="
background: #2196f3;
padding: 8px 12px;
white-space: nowrap;
"
>
📍 Use Current Position
</button>
</div>
</div>
<div
style="
display: flex;
gap: 10px;
align-items: center;
margin-top: 12px;
"
>
<button
onclick="saveHomePort()"
style="background: #4caf50; padding: 8px 16px"
>
💾 Save Home Port
</button>
<div id="homePortStatus" style="font-size: 0.9em"></div>
</div>
</div>
</div>
<button onclick="loadPathConfigurations()">🔄 Refresh Paths</button>
<button onclick="showAddPathForm()">➕ Add New Path</button>
<button id="toggleCommandsBtn" onclick="toggleCommandPaths()">
👁️ Show Commands
</button>
<!-- Add Path Form (hidden by default) -->
<div
id="addPathForm"
style="
display: none;
margin: 20px 0;
padding: 20px;
background: #f8f9fa;
border-radius: 5px;
min-height: 400px;
overflow: visible;
"
>
<h4>Add New Path Configuration</h4>
<div class="form-group">
<label for="pathSignalK">SignalK Path:</label>
<div
style="
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 15px;
"
>
<label
style="
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
white-space: nowrap;
"
>
<span style="margin-right: 5px">🏠 Self paths</span>
<input
type="radio"
id="pathFilterSelf"
name="pathFilter"
value="self"
checked
onchange="updatePathFilter()"
/>
</label>
<label
style="
display: flex;
align-items: center;
font-weight: normal;
cursor: pointer;
white-space: nowrap;
"
>
<span style="margin-right: 5px">🚢 Other vessel paths</span>
<input
type="radio"
id="pathFilterOthers"
name="pathFilter"
value="others"
onchange="updatePathFilter()"
/>
</label>
</div>
<input
type="text"
id="pathSignalKSearch"
class="path-tree-search"
placeholder="🔍 Search paths..."
/>
<div id="pathSignalKTree" class="path-tree-container"></div>
<input type="hidden" id="pathSignalK" />
<button
type="button"
onclick="showCustomPathInput('pathSignalK')"
style="
margin-top: 5px;
padding: 6px 12px;
background: #666;
color: white;
border: none;
border-radius: 4px;
font-size: 0.9em;
"
>
🖊️ Enter Custom Path
</button>
<input
type="text"
id="pathSignalKCustom"
placeholder="Enter custom SignalK path (e.g., navigation.position)"
style="
width: 100%;
padding: 8px;
margin-top: 5px;
border: 1px solid #ddd;
border-radius: 4px;
display: none;
"
/>
</div>
<div class="form-group">
<label
for="pathEnabled"
style="
display: inline !important;
margin-bottom: 0 !important;
"
>Always Enabled:</label
>
<input
type="checkbox"
id="pathEnabled"
style="
display: inline !important;
width: auto !important;
margin-left: 10px;
"
/>
</div>
<div class="form-group">
<label for="pathRegimen">Regimen Control:</label>
<div
style="
border: 1px solid #ddd;
border-radius: 4px;
padding: 10px;
max-height: 150px;
overflow-y: auto;
background: white;
"
>
<div id="regimenCheckboxes">
<!-- Regimen checkboxes will be populated here -->
</div>
<div
style="
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid #eee;
"
>
<label
for="customRegimen"
style="font-size: 0.9em; color: #666"
>Custom Regimen:</label
>
<input
type="text"
id="customRegimen"
placeholder="Enter custom regimen name"
style="
width: 100%;
padding: 6px;
margin-top: 5px;
border: 1px solid #ddd;
border-radius: 3px;
"
/>
<button
type="button"
onclick="addCustomRegimen()"
style="
margin-top: 5px;
padding: 4px 8px;
background: #28a745;
color: white;
border: none;
border-radius: 3px;
font-size: 0.8em;
"
>
➕ Add
</button>
</div>
</div>
</div>
<div class="form-group">
<label for="pathContext">Context:</label>
<input
type="text"
id="pathContext"
placeholder="e.g., vessels.self or vessels.*"
value="vessels.self"
/>
</div>
<div class="form-group">
<label for="pathSource">Source Filter (optional):</label>
<input
type="text"
id="pathSource"
placeholder="e.g., mqtt-weatherflow-udp"
/>
<small style="color: #666; display: block; margin-top: 5px">
Filter by specific data source. Leave empty to accept all
sources.
</small>
</div>
<div class="form-group">
<label for="pathExcludeMMSI"
>Exclude MMSI (for vessels.* context):</label
>
<input
type="text"
id="pathExcludeMMSI"
placeholder="e.g., 123456789, 987654321 (comma-separated)"
/>
<small style="color: #666; display: block; margin-top: 5px">
Only applicable when context is "vessels.*". Leave empty to
include all vessels.
</small>
</div>
<div
style="
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #ddd;
min-height: 60px;
display: flex;
gap: 10px;
align-items: center;
"
>
<button onclick="addPathConfiguration()">✅ Add Path</button>
<button class="btn-secondary" onclick="hideAddPathForm()">
❌ Cancel
</button>
</div>
</div>
<div id="pathConfigContainer">
<div class="loading">Loading path configurations...</div>
</div>
</div>
</div>
<!-- Command Manager Tab -->
<div id="commandManager" class="tab-panel">
<div class="section">
<h2>🎮 Regimen/Commands Manager</h2>
<p>Register and manage SignalK commands for remote control:</p>
<button onclick="showAddCommandForm()">➕ Add New Command</button>
<!-- Add Command Form (hidden by default) -->
<div
id="addCommandForm"
style="
display: none;
margin: 20px 0;
padding: 20px;
background: #f8f9fa;
border-radius: 5px;
"
>
<h4>Register New Command</h4>
<div class="form-group">
<label for="commandName">Command Name:</label>
<input
type="text"
id="commandName"
placeholder="e.g., captureWeather, startLogging"
/>
</div>
<div class="form-group">
<label for="commandDescription">Description (optional):</label>
<input
type="text"
id="commandDescription"
placeholder="e.g., Start weather data capture"
/>
</div>
<div class="form-group">
<label for="commandKeywords"
>Keywords (for Claude context matching):</label
>
<input
type="text"
id="commandKeywords"
placeholder="e.g., anchor, anchoring, drag, swing"
/>
<small style="color: #666; font-size: 11px"
>Separate multiple keywords with commas</small
>
</div>
<!-- Default State is now hardcoded to OFF on server side -->
<!-- Threshold Configuration -->
<div
style="
margin-top: 20px;
padding: 15px;
background: #f0f8ff;
border: 1px solid #b3d9ff;
border-radius: 5px;
"
>
<h5 style="margin-bottom: 15px; color: #0066cc">
🎯 Threshold-Based Activation
</h5>
<div id="addCommandThresholdsList">
<!-- New thresholds will be populated here -->
</div>
<button
type="button"
id="addCommandThresholdButton"
onclick="addNewCommandThreshold()"
style="
background: #4caf50;
color: white;
border: none;
padding: 8px 12px;
border-radius: 4px;
margin-top: 10px;
"
>
➕ Add Threshold
</button>
<!-- Threshold form replaced by unified modal -->
</div>
<div
style="
margin-top: 20px;
padding-top: 15px;
border-top: 1px solid #ddd;
"
>
<button onclick="registerCommand()">✅ Register Command</button>
<button class="btn-secondary" onclick="hideAddCommandForm()">
❌ Cancel
</button>
</div>
</div>
<!-- Edit Command Form (hidden by default) -->
<div
id="editCommandForm"
style="
display: none;
margin: 20px 0;
padding: 20px;
background: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 5px;
width: 100%;
box-sizing: border-box;
"
>
<h4>Edit Command</h4>
<div class="form-group">
<label for="editCommandName">Command Name:</label>
<input
type="text"
id="editCommandName"
readonly
style="background: #f8f9fa"
/>
</div>
<div class="form-group">
<label for="editCommandDescription">Description:</label>
<input
type="text"
id="editCommandDescription"
placeholder="e.g., Start weather data capture"
/>
</div>
<div class="form-group">
<label for="editCommandKeywords"
>Keywords (for Claude context matching):</label
>
<input
type="text"
id="editCommandKeywords"
placeholder="e.g., anchor, anchoring, drag, swing"
/>
<small style="color: #666; font-size: 11px"
>Separate multiple keywords with commas</small
>
</div>
<!-- Default State is now hardcoded to OFF on server side -->
<!-- Threshold Configuration -->
<div
style="
margin-top: 20px;
padding: 15px;
background: #f0f8ff;
border: 1px solid #b3d9ff;
border-radius: 5px;
"
>
<h5 style="margin-bottom: 15px; color: #0066cc">
🎯 Threshold-Based Activation
</h5>
<div id="threshold