signalk-parquet
Version:
SignalK plugin and webapp that archives SK data to Parquet files with a regimen control system, advanced querying, Claude integrated AI analysis, spatial capabilities, and REST API.
1,430 lines (1,245 loc) β’ 64 kB
JavaScript
import { getPluginPath } from './utils.js';
let currentAnalysisId = null;
export async function testClaudeConnection() {
const btn = document.getElementById('testConnectionBtn');
const result = document.getElementById('claudeConnectionResult');
btn.disabled = true;
btn.innerHTML = 'π Testing...';
result.innerHTML = '';
try {
const response = await fetch(
`${getPluginPath()}/api/analyze/test-connection`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
const data = await response.json();
if (data.success) {
result.innerHTML = `<div style="color: green; padding: 10px; background: #d4edda; border-radius: 5px;">
β
Claude API connection successful!<br>
Model: ${data.model}<br>
Response time: ${data.responseTime}ms
</div>`;
} else {
result.innerHTML = `<div style="color: red; padding: 10px; background: #f8d7da; border-radius: 5px;">
β Connection failed: ${data.error}
</div>`;
}
} catch (error) {
result.innerHTML = `<div style="color: red; padding: 10px; background: #f8d7da; border-radius: 5px;">
β Error: ${error.message}
</div>`;
} finally {
btn.disabled = false;
btn.innerHTML = 'π Test Claude Connection';
}
}
// ===========================================
// VESSEL CONTEXT FUNCTIONS
// ===========================================
// Load vessel context when AI tab is initialized
export async function loadVesselContext() {
try {
console.log('Loading vessel context...');
const response = await fetch(`${getPluginPath()}/api/vessel-context`);
console.log('Vessel context response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Vessel context data:', data);
if (data.success && data.data) {
const context = data.data;
// Update auto-extracted vessel info display
displayVesselInfo(context.vesselInfo);
// Update custom context textarea
const customContextTextarea = document.getElementById(
'customVesselContext'
);
if (customContextTextarea) {
customContextTextarea.value = context.customContext || '';
}
// Show last updated info
updateVesselContextStatus(
`Last updated: ${new Date(context.lastUpdated).toLocaleString()}`
);
} else {
console.log('No vessel context data found, showing empty state');
displayVesselInfo({});
updateVesselContextStatus(
'No vessel context found - click Refresh to extract from SignalK'
);
}
} catch (error) {
console.error('Error loading vessel context:', error);
const autoInfoDiv = document.getElementById('autoVesselInfo');
if (autoInfoDiv) {
autoInfoDiv.innerHTML = `<div style="color: red;">Error loading vessel context: ${error.message}</div>`;
}
updateVesselContextStatus(
'Error loading vessel context: ' + error.message,
true
);
}
}
// Display vessel information in the UI
function displayVesselInfo(vesselInfo) {
const autoInfoDiv = document.getElementById('autoVesselInfo');
if (!autoInfoDiv) return;
if (!vesselInfo || Object.keys(vesselInfo).length === 0) {
autoInfoDiv.innerHTML = `
<div style="color: #666; font-style: italic; text-align: center; padding: 20px;">
No vessel information found.<br>
<small>Click "Refresh from SignalK" to extract vessel data automatically.</small>
</div>`;
return;
}
const sections = [];
// Basic identification
if (vesselInfo.name || vesselInfo.callsign || vesselInfo.mmsi) {
const items = [];
if (vesselInfo.name)
items.push(`<strong>Name:</strong> ${vesselInfo.name}`);
if (vesselInfo.callsign)
items.push(`<strong>Call Sign:</strong> ${vesselInfo.callsign}`);
if (vesselInfo.mmsi)
items.push(`<strong>MMSI:</strong> ${vesselInfo.mmsi}`);
if (vesselInfo.flag)
items.push(`<strong>Flag:</strong> ${vesselInfo.flag}`);
sections.push(
`<div><strong>π Identification:</strong> ${items.join(', ')}</div>`
);
}
// Physical characteristics
const physical = [];
// Handle length - could be number or object with overall property
if (vesselInfo.length) {
const lengthValue =
typeof vesselInfo.length === 'object' && vesselInfo.length.overall
? vesselInfo.length.overall
: vesselInfo.length;
if (lengthValue) physical.push(`${lengthValue}m LOA`);
}
if (vesselInfo.beam) physical.push(`${vesselInfo.beam}m beam`);
// Handle draft - could be number or object with maximum property
if (vesselInfo.draft) {
const draftValue =
typeof vesselInfo.draft === 'object' && vesselInfo.draft.maximum
? vesselInfo.draft.maximum
: vesselInfo.draft;
if (draftValue) physical.push(`${draftValue}m draft`);
}
if (vesselInfo.height) physical.push(`${vesselInfo.height}m height`);
if (vesselInfo.displacement)
physical.push(`${vesselInfo.displacement}t displacement`);
if (physical.length > 0) {
sections.push(
`<div><strong>π Physical:</strong> ${physical.join(', ')}</div>`
);
}
// Vessel type
if (vesselInfo.vesselType) {
sections.push(
`<div><strong>π’ Type:</strong> ${vesselInfo.vesselType}</div>`
);
}
// Technical specs
const technical = [];
if (vesselInfo.grossTonnage) technical.push(`${vesselInfo.grossTonnage} GT`);
if (vesselInfo.netTonnage) technical.push(`${vesselInfo.netTonnage} NT`);
if (vesselInfo.deadWeight) technical.push(`${vesselInfo.deadWeight}t DWT`);
if (technical.length > 0) {
sections.push(
`<div><strong>βοΈ Tonnage:</strong> ${technical.join(', ')}</div>`
);
}
// Build info
const build = [];
if (vesselInfo.builder) build.push(`Built by ${vesselInfo.builder}`);
if (vesselInfo.buildYear) build.push(`in ${vesselInfo.buildYear}`);
if (vesselInfo.hullNumber) build.push(`(Hull: ${vesselInfo.hullNumber})`);
if (build.length > 0) {
sections.push(`<div><strong>π¨ Build:</strong> ${build.join(' ')}</div>`);
}
if (sections.length === 0) {
autoInfoDiv.innerHTML = `<div style="color: #666; font-style: italic;">No vessel details available - try refreshing from SignalK</div>`;
} else {
autoInfoDiv.innerHTML = sections.join('<br style="margin: 8px 0;">');
}
}
// Refresh vessel information from SignalK
export async function refreshVesselInfo() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = 'π Refreshing...';
try {
console.log('Refreshing vessel info from SignalK...');
const response = await fetch(
`${getPluginPath()}/api/vessel-context/refresh`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
);
console.log('Refresh response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Refresh data:', data);
if (data.success && data.data) {
displayVesselInfo(data.data.vesselInfo);
updateVesselContextStatus(
'Vessel information refreshed from SignalK data'
);
} else {
console.error('Refresh failed:', data);
updateVesselContextStatus(
'Failed to refresh: ' + (data.error || 'Unknown error'),
true
);
}
} catch (error) {
console.error('Error refreshing vessel info:', error);
updateVesselContextStatus(
'Error refreshing vessel information: ' + error.message,
true
);
} finally {
btn.disabled = false;
btn.innerHTML = 'π Refresh from SignalK';
}
}
// Save vessel context (both auto-extracted and custom)
export async function saveVesselContext() {
const btn = event.target;
btn.disabled = true;
btn.innerHTML = 'πΎ Saving...';
try {
const customContext = document.getElementById('customVesselContext').value;
const response = await fetch(`${getPluginPath()}/api/vessel-context`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
customContext: customContext,
}),
});
const data = await response.json();
if (data.success) {
updateVesselContextStatus('Context saved successfully β
');
} else {
updateVesselContextStatus(
'Failed to save: ' + (data.error || 'Unknown error'),
true
);
}
} catch (error) {
console.error('Error saving vessel context:', error);
updateVesselContextStatus('Error saving context: ' + error.message, true);
} finally {
btn.disabled = false;
btn.innerHTML = 'πΎ Save Context';
}
}
// Preview Claude context
export async function previewClaudeContext() {
try {
console.log('Generating Claude context preview...');
const response = await fetch(
`${getPluginPath()}/api/vessel-context/claude-preview`
);
console.log('Preview response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
console.log('Preview data:', data);
if (data.success && data.data) {
const modal = document.getElementById('contextPreviewModal');
const content = document.getElementById('contextPreviewContent');
content.textContent = data.data.contextText;
modal.style.display = 'block';
} else {
console.error('Preview failed:', data);
updateVesselContextStatus(
'Failed to generate preview: ' + (data.error || 'Unknown error'),
true
);
}
} catch (error) {
console.error('Error previewing context:', error);
updateVesselContextStatus(
'Error generating preview: ' + error.message,
true
);
}
}
// Close context preview modal
export function closeContextPreview() {
const modal = document.getElementById('contextPreviewModal');
modal.style.display = 'none';
}
// Close modals when clicking outside
window.onclick = function (event) {
const contextModal = document.getElementById('contextPreviewModal');
const historyModal = document.getElementById('analysisHistoryModal');
const viewModal = document.getElementById('analysisViewModal');
if (event.target === contextModal) {
contextModal.style.display = 'none';
} else if (event.target === historyModal) {
historyModal.style.display = 'none';
} else if (event.target === viewModal) {
viewModal.style.display = 'none';
}
};
// Update vessel context status message
function updateVesselContextStatus(message, isError = false) {
const statusDiv = document.getElementById('vesselContextStatus');
if (statusDiv) {
statusDiv.innerHTML = `<span style="color: ${isError ? 'red' : 'green'};">${message}</span>`;
// Clear status after 5 seconds
setTimeout(() => {
statusDiv.innerHTML = '';
}, 5000);
}
}
// Toggle vessel context section
export function toggleVesselContext() {
const content = document.getElementById('vesselContextContent');
const icon = document.getElementById('vesselContextToggleIcon');
if (content.style.display === 'none') {
content.style.display = 'block';
icon.textContent = 'βΌ';
} else {
content.style.display = 'none';
icon.textContent = 'βΆ';
}
}
// ===========================================
// END VESSEL CONTEXT FUNCTIONS
// ===========================================
// Load analysis templates and populate UI
export async function loadAnalysisTemplates() {
try {
const response = await fetch(`${getPluginPath()}/api/analyze/templates`);
const data = await response.json();
if (data.success && data.templates) {
// Flatten template categories into a single array
const allTemplates = [];
data.templates.forEach(category => {
if (category.templates) {
allTemplates.push(...category.templates);
}
});
populateTemplateCards(allTemplates);
populateTemplateDropdown(allTemplates);
}
} catch (error) {
console.error('Error loading analysis templates:', error);
}
}
// Populate template dropdown
function populateTemplateDropdown(templates) {
const select = document.getElementById('analysisTemplate');
if (!select) {
console.warn(
'analysisTemplate select element not found; skipping dropdown population.'
);
return;
}
const templateLookup = new Map();
select.innerHTML = '<option value="">Select a template...</option>';
templates.forEach(template => {
const option = document.createElement('option');
option.value = template.id;
option.textContent = `${template.icon} ${template.name}`;
templateLookup.set(template.id, template);
select.appendChild(option);
});
select.onchange = event => {
const templateId = event.target.value;
if (!templateId) {
return;
}
const selected = templateLookup.get(templateId);
if (!selected) {
return;
}
const defaultPath = selected.defaultPath || selected.path || '';
const promptField = document.getElementById('customPrompt');
if (promptField && selected.prompt) {
promptField.value = selected.prompt;
}
if (defaultPath) {
runQuickAnalysis(defaultPath);
} else {
alert(
'This template does not define a default data path. Select paths manually, adjust the prompt if needed, then run a custom analysis.'
);
}
};
}
function populateTemplateCards(templates) {
const container = document.getElementById('analysisTemplateCards');
if (!container) {
return;
}
if (!Array.isArray(templates) || templates.length === 0) {
container.innerHTML =
'<div style="color: #666; font-style: italic;">No templates available yet.</div>';
return;
}
container.innerHTML = templates
.map(template => {
const defaultPath = template.defaultPath || template.path || '';
const buttonHtml = defaultPath
? `
<button type="button" onclick="runQuickAnalysis('${defaultPath}')" style="background: #2196F3; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
π Run Template
</button>
`
: '';
return `
<div style="background: #f8f9fa; border: 1px solid #e0e0e0; border-radius: 8px; padding: 15px; margin-bottom: 15px;">
<div style="display: flex; align-items: center; margin-bottom: 10px;">
<span style="font-size: 1.5em; margin-right: 10px;">${template.icon || 'π'}</span>
<h4 style="margin: 0; font-size: 1.1em;">${template.name}</h4>
</div>
<p style="margin: 0 0 10px 0; color: #555; font-size: 0.95em;">${template.description || 'Run this analysis to explore your data.'}</p>
${buttonHtml}
</div>
`;
})
.join('');
}
// Run quick analysis WITHOUT templates - just analyze data directly
export async function runQuickAnalysis(dataPath) {
const result = document.getElementById('analysisResults');
const content = document.getElementById('analysisContent');
result.style.display = 'block';
content.innerHTML =
'<div style="text-align: center; padding: 20px;">π Running direct analysis...</div>';
try {
// Skip templates entirely - use custom analysis with generic prompt
const analysisRequest = {
dataPath,
analysisType: 'custom',
customPrompt: `Analyze this maritime sensor data and provide insights. Focus on:
1. Data patterns and trends over time
2. Any anomalies or unusual readings
3. Statistical summary of the data
4. Practical insights for maritime operations
5. Data quality assessment
Provide actionable insights based on what you observe in the data.`,
timeRange: {
start: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), // Last 7 days
end: new Date().toISOString(),
},
};
console.log(`π Running template-free analysis for: ${dataPath}`);
const response = await fetch(`${getPluginPath()}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(analysisRequest),
});
const data = await response.json();
if (data.success && data.data) {
displayAnalysisResult(data.data);
} else {
content.innerHTML = `<div style="color: red;">β Analysis failed: ${data.error}</div>`;
}
} catch (error) {
content.innerHTML = `<div style="color: red;">β Error: ${error.message}</div>`;
}
}
// Get selected data paths
export function getSelectedDataPaths() {
const checkboxes = document.querySelectorAll(
'#analysisDataPathContainer input[type="checkbox"]:checked'
);
return Array.from(checkboxes).map(checkbox => checkbox.value);
}
// Update selected path count
export function updateSelectedPathCount() {
const selectedPaths = getSelectedDataPaths();
document.getElementById('selectedPathCount').textContent =
selectedPaths.length;
}
// Handle path checkbox change
export function handlePathCheckboxChange() {
updateSelectedPathCount();
}
// Select all paths
export function selectAllPaths() {
const checkboxes = document.querySelectorAll(
'#analysisDataPathContainer input[type="checkbox"]'
);
checkboxes.forEach(checkbox => (checkbox.checked = true));
updateSelectedPathCount();
}
// Clear all paths
export function clearAllPaths() {
const checkboxes = document.querySelectorAll(
'#analysisDataPathContainer input[type="checkbox"]'
);
checkboxes.forEach(checkbox => (checkbox.checked = false));
updateSelectedPathCount();
}
// Get path icon based on path name
function getPathIcon(path) {
let icon = 'π';
if (path.includes('wind')) icon = 'π¨';
else if (path.includes('navigation')) icon = 'π§';
else if (path.includes('position')) icon = 'π';
else if (path.includes('temperature')) icon = 'π‘οΈ';
else if (path.includes('battery') || path.includes('electrical')) icon = 'π';
else if (path.includes('command')) icon = 'βοΈ';
return icon;
}
// Populate analysis path checkboxes
export function populateAnalysisPathCheckboxes(paths) {
const container = document.getElementById('analysisDataPathContainer');
if (!paths || paths.length === 0) {
container.innerHTML =
'<div style="color: #666; font-style: italic;">No data paths available</div>';
return;
}
container.innerHTML = ''; // Clear loading message
paths.forEach((pathInfo, _index) => {
const checkboxDiv = document.createElement('div');
checkboxDiv.className = 'path-checkbox-row';
const pathInfoDiv = document.createElement('div');
pathInfoDiv.className = 'path-info';
const iconSpan = document.createElement('span');
iconSpan.className = 'path-icon';
iconSpan.textContent = getPathIcon(pathInfo.path);
const label = document.createElement('label');
label.htmlFor = `path_${pathInfo.path.replace(/[^a-zA-Z0-9]/g, '_')}`;
label.textContent = `${pathInfo.path} (${pathInfo.fileCount} files)`;
label.style.cssText = 'cursor: pointer; margin: 0;';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.value = pathInfo.path;
checkbox.id = `path_${pathInfo.path.replace(/[^a-zA-Z0-9]/g, '_')}`;
checkbox.onchange = handlePathCheckboxChange;
checkbox.className = 'path-checkbox';
pathInfoDiv.appendChild(iconSpan);
pathInfoDiv.appendChild(label);
checkboxDiv.appendChild(pathInfoDiv);
checkboxDiv.appendChild(checkbox);
container.appendChild(checkboxDiv);
});
updateSelectedPathCount();
}
// Global variable to track current analysis request
let currentAnalysisController = null;
// Cancel current analysis
export function cancelAnalysis() {
if (currentAnalysisController) {
currentAnalysisController.abort();
currentAnalysisController = null;
}
}
// Run custom analysis
export async function runCustomAnalysis() {
const runButton =
document.getElementById('runAnalysisBtn') ||
document.querySelector('button[onclick="runCustomAnalysis()"]');
// If already running, cancel the current analysis
if (currentAnalysisController) {
cancelAnalysis();
return;
}
const selectedPaths = getSelectedDataPaths();
const customPrompt = document.getElementById('customPrompt').value;
const startDate = document.getElementById('analysisStartDate').value;
const endDate = document.getElementById('analysisEndDate').value;
const aggregationMethod = document.getElementById('aggregationMethod').value;
const resolution = document.getElementById('resolution').value;
const claudeModel = document.getElementById('claudeModelMain').value;
const enableDatabaseAccess = document.getElementById(
'enableDatabaseAccess'
).checked;
// Skip path validation in database access mode - Claude can access all data
if (!enableDatabaseAccess && (!selectedPaths || selectedPaths.length === 0)) {
alert('Please select at least one data path');
return;
}
const result = document.getElementById('analysisResults');
const content = document.getElementById('analysisContent');
result.style.display = 'block';
// Start timer
const startTime = new Date();
const timerElement = document.createElement('div');
timerElement.id = 'analysisTimer';
timerElement.style.cssText =
'background: #e3f2fd; border: 1px solid #2196f3; border-radius: 5px; padding: 10px; margin-bottom: 15px; text-align: center; font-family: monospace;';
content.innerHTML = '';
content.appendChild(timerElement);
const loadingElement = document.createElement('div');
loadingElement.style.cssText = 'text-align: center; padding: 20px;';
loadingElement.innerHTML = ``;
content.appendChild(loadingElement);
// Update timer every 100ms
const timerInterval = setInterval(() => {
const elapsed = (new Date() - startTime) / 1000;
timerElement.innerHTML = `
<strong>β±οΈ Analysis Timer</strong><br>
Prompt sent: ${startTime.toLocaleTimeString()}<br>
Elapsed: ${elapsed.toFixed(1)}s
`;
}, 100);
// Update button to show running state
const originalButtonText = runButton.textContent;
runButton.textContent = 'βΈοΈ Running Analysis (click to cancel)';
runButton.style.background = '#ffc107';
try {
// Create abort controller for cancellation
currentAnalysisController = new AbortController();
const analysisRequest = {
dataPath: enableDatabaseAccess
? 'database_access_mode'
: selectedPaths.join(','), // REST API supports comma-separated paths
analysisType: 'custom',
customPrompt:
customPrompt ||
`Analyze this maritime sensor data and provide insights. Focus on:
1. Data patterns and trends over time
2. Any anomalies or unusual readings
3. Statistical summary of the data
4. Practical insights for maritime operations
5. Data quality assessment
Provide actionable insights based on what you observe in the data.`,
timeRange: {
start: startDate
? new Date(startDate).toISOString()
: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(),
end: endDate
? new Date(endDate).toISOString()
: new Date().toISOString(),
},
aggregationMethod: aggregationMethod || 'average',
resolution: resolution || '', // Empty string = Auto
claudeModel: claudeModel || 'claude-sonnet-4-20250514',
useDatabaseAccess: enableDatabaseAccess,
};
console.log(
`π Running custom analysis for paths: ${selectedPaths.join(', ')}`
);
const response = await fetch(`${getPluginPath()}/api/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(analysisRequest),
signal: currentAnalysisController.signal,
});
const data = await response.json();
// Stop timer and show final time
clearInterval(timerInterval);
const endTime = new Date();
const totalTime = (endTime - startTime) / 1000;
// Debug what we're actually getting
console.log('Full response data:', JSON.stringify(data, null, 2));
// Get token usage if available
let tokenInfo = '';
if (data.usage) {
const usage = data.usage;
tokenInfo = `<br><strong>Tokens: ${usage.input_tokens || 0} in + ${usage.output_tokens || 0} out = ${(usage.input_tokens || 0) + (usage.output_tokens || 0)} total</strong>`;
} else {
tokenInfo = `<br><small style="color: #666;">No token usage found in response</small>`;
}
timerElement.innerHTML = `
<strong>β±οΈ Analysis Complete</strong><br>
Started: ${startTime.toLocaleTimeString()}<br>
Completed: ${endTime.toLocaleTimeString()}<br>
<strong>Total Time: ${totalTime.toFixed(1)}s</strong>${tokenInfo}
`;
timerElement.style.background = data.success ? '#e8f5e8' : '#ffeaea';
timerElement.style.borderColor = data.success ? '#4caf50' : '#f44336';
// Remove loading message and clear controller
loadingElement.remove();
currentAnalysisController = null;
// Restore button
runButton.textContent = originalButtonText;
runButton.style.background = '';
if (data.success && data.data) {
displayAnalysisResult(data.data);
} else {
const errorElement = document.createElement('div');
errorElement.style.color = 'red';
errorElement.innerHTML = `β Analysis failed: ${data.error}`;
content.appendChild(errorElement);
}
} catch (error) {
// Stop timer on error
clearInterval(timerInterval);
const endTime = new Date();
const totalTime = (endTime - startTime) / 1000;
// Check if it was cancelled
if (error.name === 'AbortError') {
timerElement.innerHTML = `
<strong>β Analysis Cancelled</strong><br>
Started: ${startTime.toLocaleTimeString()}<br>
Cancelled: ${endTime.toLocaleTimeString()}<br>
<strong>Time: ${totalTime.toFixed(1)}s</strong>
`;
timerElement.style.background = '#fff3cd';
timerElement.style.borderColor = '#ffc107';
} else {
timerElement.innerHTML = `
<strong>β±οΈ Analysis Failed</strong><br>
Started: ${startTime.toLocaleTimeString()}<br>
Failed: ${endTime.toLocaleTimeString()}<br>
<strong>Total Time: ${totalTime.toFixed(1)}s</strong>
`;
timerElement.style.background = '#ffeaea';
timerElement.style.borderColor = '#f44336';
}
loadingElement.remove();
currentAnalysisController = null;
// Restore button
runButton.textContent = originalButtonText;
runButton.style.background = '';
const errorElement = document.createElement('div');
errorElement.style.color = 'red';
errorElement.innerHTML = `β Error: ${error.message}`;
content.appendChild(errorElement);
}
}
// Convert MMSI numbers to MarineTraffic links
function convertMMSIToLinks(container) {
const mmsiRegex = /(MMSI:?\s?)(\d{8,9})/gi;
// Function to recursively process text nodes
function processTextNodes(node) {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.textContent;
if (mmsiRegex.test(text)) {
const newText = text.replace(mmsiRegex, (match, prefix, mmsi) => {
return `${prefix}<a href="https://www.marinetraffic.com/en/ais/details/ships/mmsi:${mmsi}" target="_blank" style="color: #1976d2; text-decoration: underline;" title="View ${mmsi} on MarineTraffic">${mmsi}</a>`;
});
// Replace text node with HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = newText;
// Replace the text node with new nodes
const fragment = document.createDocumentFragment();
while (tempDiv.firstChild) {
fragment.appendChild(tempDiv.firstChild);
}
node.parentNode.replaceChild(fragment, node);
}
} else if (node.nodeType === Node.ELEMENT_NODE) {
// Process child nodes (but skip existing links)
if (node.tagName.toLowerCase() !== 'a') {
const children = Array.from(node.childNodes);
children.forEach(child => processTextNodes(child));
}
}
}
processTextNodes(container);
}
// Process and embed charts from Claude responses
function processChartRequests(container) {
console.log('Processing chart requests in container:', container);
// Look for JSON chart specifications in the text - improved pattern to handle nested objects
const chartRegex = /```json\s*([\s\S]*?"type":\s*"chart"[\s\S]*?)\s*```/gi;
// First try processing the entire innerHTML
const fullText = container.innerHTML || container.textContent || '';
console.log('Full container text:', fullText.substring(0, 500));
const fullMatches = [...fullText.matchAll(chartRegex)];
console.log('Matches in full text:', fullMatches.length);
// Process all matches found in full text
let processedCharts = false;
if (fullMatches.length > 0) {
let newHTML = fullText;
fullMatches.forEach((match, index) => {
let validJsonString = ''; // Declare in outer scope for error handling
try {
let jsonString = match[1].trim();
jsonString = jsonString
.replace(/"/g, '"')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>');
// Extract only valid JSON by finding the first complete JSON object
validJsonString = jsonString;
try {
// Find the end of the first complete JSON object
let braceCount = 0;
let inString = false;
let escaped = false;
let jsonEndIndex = -1;
for (let i = 0; i < jsonString.length; i++) {
const char = jsonString[i];
if (escaped) {
escaped = false;
continue;
}
if (char === '\\' && inString) {
escaped = true;
continue;
}
if (char === '"' && !escaped) {
inString = !inString;
continue;
}
if (!inString) {
if (char === '{') {
braceCount++;
} else if (char === '}') {
braceCount--;
if (braceCount === 0) {
jsonEndIndex = i + 1;
break;
}
}
}
}
if (jsonEndIndex > 0) {
validJsonString = jsonString.substring(0, jsonEndIndex);
console.log(
'π§ FRONTEND - Extracted valid JSON, length:',
validJsonString.length,
'vs original:',
jsonString.length
);
}
} catch (extractError) {
console.warn(
'π§ JSON extraction failed, using original:',
extractError
);
}
console.log(
'π FRONTEND - Processing JSON length:',
validJsonString.length
);
const chartSpec = JSON.parse(validJsonString);
console.log('Parsed chart spec:', chartSpec);
if (chartSpec.type === 'chart') {
const chartId = `chart-${Date.now()}-${Math.random().toString(36).substr(2, 9)}-${index}`;
const chartHTML = `
<div style="margin: 20px 0; padding: 15px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #2196F3;">
<h4 style="margin: 0 0 15px 0; color: #333;">π ${chartSpec.title || 'Chart'}</h4>
<div id="${chartId}" style="width: 100%; height: 400px;"></div>
</div>
`;
newHTML = newHTML.replace(match[0], chartHTML);
processedCharts = true;
// Queue chart rendering for after DOM update
setTimeout(() => renderChart(chartId, chartSpec), 300);
}
} catch (e) {
console.error('Failed to parse chart specification:', e);
console.error('Raw JSON that failed to parse:', validJsonString);
// Show the problematic area around the error position
if (e.message.includes('position')) {
const match = e.message.match(/position (\d+)/);
if (match) {
const errorPos = parseInt(match[1]);
const start = Math.max(0, errorPos - 100);
const end = Math.min(validJsonString.length, errorPos + 100);
console.error(
'π¨ PROBLEM AREA around position',
errorPos,
':',
validJsonString.substring(start, end)
);
console.error(
'π¨ CHARACTER AT ERROR:',
validJsonString[errorPos],
'ASCII:',
validJsonString.charCodeAt(errorPos)
);
}
}
}
});
if (processedCharts) {
container.innerHTML = newHTML;
}
}
// Processing complete - legacy text node processing disabled
// Charts are now processed using direct HTML replacement above
}
// Render chart using Plotly.js (native format)
function renderChart(chartId, chartSpec) {
console.log('Attempting to render Plotly chart:', chartId, chartSpec);
const chartDiv = document.getElementById(chartId);
if (!chartDiv) {
console.error('Chart div not found:', chartId);
return;
}
console.log('Chart div found:', chartDiv);
// Handle both new native Plotly format and legacy Chart.js format
let plotlyData, plotlyLayout, plotlyConfig;
if (chartSpec.data && chartSpec.layout) {
// Native Plotly format - use directly
console.log('Using native Plotly format');
plotlyData = chartSpec.data;
plotlyLayout = chartSpec.layout;
plotlyConfig = chartSpec.config || {};
} else if (chartSpec.datasets) {
// Legacy Chart.js format - convert to Plotly
console.log('Converting Chart.js format to Plotly');
plotlyData = chartSpec.datasets.map(dataset => ({
x: chartSpec.labels || [],
y: dataset.data || [],
name: dataset.label || '',
type: 'scatter',
mode: dataset.borderDash ? 'lines' : 'lines+markers',
line: {
color: dataset.borderColor || dataset.backgroundColor || '#2196F3',
width: dataset.borderWidth || 2,
dash: dataset.borderDash ? 'dash' : 'solid',
},
marker: {
size: 4,
color: dataset.borderColor || dataset.backgroundColor || '#2196F3',
},
fill: dataset.fill ? 'tonexty' : 'none',
fillcolor: dataset.backgroundColor || 'rgba(33, 150, 243, 0.1)',
}));
plotlyLayout = {
title: {
text: chartSpec.title || '',
font: { size: 16 },
},
xaxis: {
title: chartSpec.xAxisLabel || '',
showgrid: true,
zeroline: false,
},
yaxis: {
title: chartSpec.yAxisLabel || '',
showgrid: true,
zeroline: false,
},
showlegend: true,
hovermode: 'x unified',
margin: { l: 60, r: 30, t: 50, b: 50 },
plot_bgcolor: 'rgba(0,0,0,0)',
paper_bgcolor: 'rgba(0,0,0,0)',
};
} else {
console.error(
'Invalid chart specification - no data or datasets found:',
chartSpec
);
return;
}
// Default Plotly config
const defaultConfig = {
responsive: true,
displayModeBar: true,
modeBarButtonsToRemove: ['pan2d', 'select2d', 'lasso2d', 'resetScale2d'],
displaylogo: false,
};
plotlyConfig = { ...defaultConfig, ...plotlyConfig };
console.log(
'Creating Plotly chart with data:',
plotlyData,
'layout:',
plotlyLayout
);
try {
Plotly.newPlot(chartId, plotlyData, plotlyLayout, plotlyConfig);
console.log('Plotly chart created successfully:', chartId);
return true;
} catch (error) {
console.error('Failed to create Plotly chart:', chartId, error);
}
}
// Display analysis results
function displayAnalysisResult(analysisResult) {
const content = document.getElementById('analysisContent');
let html = `
<div style="border-left: 4px solid #667eea; padding-left: 15px; margin-bottom: 20px;">
<h4 style="color: #667eea; margin: 0 0 10px 0;">π Analysis Summary</h4>
<div style="white-space: pre-wrap; line-height: 1.6;">${analysisResult.analysis}</div>
</div>
`;
if (analysisResult.insights && analysisResult.insights.length > 0) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #28a745; margin: 0 0 10px 0;">π‘ Key Insights</h4>
<ul style="padding-left: 20px;">
${analysisResult.insights.map(insight => `<li style="margin-bottom: 5px;">${insight}</li>`).join('')}
</ul>
</div>
`;
}
if (
analysisResult.recommendations &&
analysisResult.recommendations.length > 0
) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #ffc107; margin: 0 0 10px 0;">π― Recommendations</h4>
<ul style="padding-left: 20px;">
${analysisResult.recommendations.map(rec => `<li style="margin-bottom: 5px;">${rec}</li>`).join('')}
</ul>
</div>
`;
}
if (analysisResult.anomalies && analysisResult.anomalies.length > 0) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #dc3545; margin: 0 0 10px 0;">β οΈ Anomalies Detected</h4>
<div style="background: #f8f9fa; padding: 10px; border-radius: 5px;">
${analysisResult.anomalies
.map(
anomaly => `
<div style="margin-bottom: 8px; padding: 8px; background: white; border-radius: 4px;">
<strong>${anomaly.severity.toUpperCase()}</strong> - ${anomaly.description}
<br><small style="color: #666;">Timestamp: ${new Date(anomaly.timestamp).toLocaleString()}</small>
</div>
`
)
.join('')}
</div>
</div>
`;
}
html += `
<div style="margin-top: 20px; padding-top: 15px; border-top: 1px solid #dee2e6; font-size: 12px; color: #6c757d;">
<strong>Analysis Metadata:</strong><br>
Data Path: ${analysisResult.metadata.dataPath}<br>
Records Analyzed: ${analysisResult.metadata.recordCount}<br>
Confidence: ${(analysisResult.confidence * 100).toFixed(1)}%<br>
Data Quality: ${analysisResult.dataQuality}<br>
Timestamp: ${new Date(analysisResult.timestamp).toLocaleString()}
</div>
`;
// Always preserve existing charts and timers, never use innerHTML = which destroys charts
const resultsDiv = document.createElement('div');
resultsDiv.innerHTML = html;
// Clear content but preserve timer and any existing charts
const children = Array.from(content.children);
children.forEach(child => {
// Preserve timer and any Plotly chart containers
if (
child.id === 'analysisTimer' ||
child.querySelector('div[id^="chart-"]') ||
(child.id && child.id.startsWith('chart-')) ||
child.querySelector('.plotly-graph-div')
) {
// Keep this element
} else {
child.remove();
}
});
content.appendChild(resultsDiv);
// Process chart requests FIRST (before MMSI link conversion which would break JSON)
processChartRequests(resultsDiv);
// Convert MMSI numbers to MarineTraffic links in the new results (after charts are processed)
convertMMSIToLinks(resultsDiv);
// Show follow-up section for database access mode and store analysis ID
if (
analysisResult.metadata &&
analysisResult.metadata.useDatabaseAccess &&
analysisResult.id
) {
currentAnalysisId = analysisResult.id;
const followUpSection = document.getElementById('followUpSection');
if (followUpSection) {
followUpSection.style.display = 'block';
}
} else {
currentAnalysisId = null;
const followUpSection = document.getElementById('followUpSection');
if (followUpSection) {
followUpSection.style.display = 'none';
}
}
}
// Open analysis history modal
export async function openAnalysisHistoryModal() {
const modal = document.getElementById('analysisHistoryModal');
const content = document.getElementById('analysisHistoryModalContent');
// Show modal immediately
modal.style.display = 'block';
content.innerHTML =
'<div style="text-align: center; padding: 20px;"><div>π Loading analysis history...</div></div>';
try {
const response = await fetch(
`${getPluginPath()}/api/analyze/history?limit=20`
);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const data = await response.json();
if (data.success && data.data && data.data.length > 0) {
let html = '<div>';
data.data.forEach((analysis, _index) => {
const date = new Date(analysis.timestamp);
const isRecent = Date.now() - date.getTime() < 24 * 60 * 60 * 1000; // Less than 24 hours
html += `
<div style="background: ${isRecent ? '#f0f8ff' : 'white'}; border: 1px solid #ddd; border-radius: 8px; padding: 20px; margin-bottom: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1);">
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 15px;">
<div>
<h4 style="margin: 0 0 5px 0; color: #333; font-size: 1.1em;">${analysis.metadata.analysisType || 'Custom Analysis'}</h4>
<div style="font-size: 0.9em; color: #666; margin-bottom: 5px;">
<strong>Path:</strong> ${analysis.metadata.dataPath}
</div>
<div style="font-size: 0.85em; color: #888;">
${date.toLocaleString()} ${isRecent ? '(Recent)' : ''}
</div>
</div>
<div style="display: flex; gap: 8px;">
<button onclick="viewAnalysis('${analysis.id}')" style="background: #2196F3; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
ποΈ View
</button>
<button onclick="deleteAnalysis('${analysis.id}')" style="background: #f44336; color: white; border: none; padding: 8px 12px; border-radius: 4px; cursor: pointer; font-size: 0.9em;">
ποΈ Delete
</button>
</div>
</div>
<div style="font-size: 0.9em; color: #555; line-height: 1.4; max-height: 80px; overflow: hidden; position: relative;">
${analysis.analysis.substring(0, 300)}${analysis.analysis.length > 300 ? '...' : ''}
</div>
${
analysis.insights && analysis.insights.length > 0
? `
<div style="margin-top: 10px; font-size: 0.85em; color: #666;">
<strong>Key Insights:</strong> ${analysis.insights.slice(0, 2).join(', ')}${analysis.insights.length > 2 ? '...' : ''}
</div>
`
: ''
}
</div>
`;
});
html += '</div>';
content.innerHTML = html;
} else {
content.innerHTML = `
<div style="text-align: center; padding: 40px; color: #666;">
<div style="font-size: 3em; margin-bottom: 20px;">π</div>
<h3>No Analysis History Found</h3>
<p>Your Claude AI analyses will appear here once you start running them.</p>
</div>
`;
}
} catch (error) {
content.innerHTML = `
<div style="text-align: center; padding: 40px; color: #d32f2f;">
<div style="font-size: 3em; margin-bottom: 20px;">β οΈ</div>
<h3>Error Loading History</h3>
<p>${error.message}</p>
</div>
`;
}
}
// Close analysis history modal
export function closeAnalysisHistoryModal() {
document.getElementById('analysisHistoryModal').style.display = 'none';
}
// View individual analysis
export async function viewAnalysis(analysisId) {
const modal = document.getElementById('analysisViewModal');
const content = document.getElementById('analysisViewContent');
// Show modal
modal.style.display = 'block';
content.innerHTML =
'<div style="text-align: center; padding: 20px;">π Loading analysis...</div>';
try {
// Find the analysis in the current data (could also fetch individually)
const historyResponse = await fetch(
`${getPluginPath()}/api/analyze/history`
);
const historyData = await historyResponse.json();
if (historyData.success && historyData.data) {
const analysis = historyData.data.find(a => a.id === analysisId);
if (analysis) {
displayFullAnalysis(analysis);
} else {
content.innerHTML =
'<div style="text-align: center; padding: 20px; color: #d32f2f;">Analysis not found.</div>';
}
}
} catch (error) {
content.innerHTML = `<div style="text-align: center; padding: 20px; color: #d32f2f;">Error loading analysis: ${error.message}</div>`;
}
}
// Display full analysis in modal
function displayFullAnalysis(analysis) {
const content = document.getElementById('analysisViewContent');
const title = document.getElementById('analysisViewTitle');
title.textContent = `π§ ${analysis.metadata.analysisType || 'Analysis'} - ${new Date(analysis.timestamp).toLocaleDateString()}`;
let html = `
<div style="margin-bottom: 20px; padding: 15px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #2196F3;">
<h4 style="margin: 0 0 10px 0; color: #333;">Analysis Metadata</h4>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px; font-size: 0.9em;">
<div><strong>Data Path:</strong> ${analysis.metadata.dataPath}</div>
<div><strong>Analysis Type:</strong> ${analysis.metadata.analysisType || 'Custom'}</div>
<div><strong>Date:</strong> ${new Date(analysis.timestamp).toLocaleString()}</div>
<div><strong>Record Count:</strong> ${analysis.metadata.recordCount || 'N/A'}</div>
<div><strong>Confidence:</strong> ${Math.round((analysis.confidence || 0) * 100)}%</div>
<div><strong>Data Quality:</strong> ${analysis.dataQuality || 'N/A'}</div>
</div>
</div>
<div style="margin-bottom: 20px;">
<h4 style="color: #333; border-bottom: 2px solid #2196F3; padding-bottom: 5px;">π§ Main Analysis</h4>
<div style="background: white; padding: 20px; border-radius: 8px; border: 1px solid #ddd; line-height: 1.6; white-space: pre-wrap;">${analysis.analysis}</div>
</div>
`;
if (analysis.insights && analysis.insights.length > 0) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #333; border-bottom: 2px solid #4CAF50; padding-bottom: 5px;">π‘ Key Insights</h4>
<ul style="background: white; padding: 20px; border-radius: 8px; border: 1px solid #ddd; line-height: 1.6;">
`;
analysis.insights.forEach(insight => {
html += `<li style="margin-bottom: 8px;">${insight}</li>`;
});
html += `</ul></div>`;
}
if (analysis.recommendations && analysis.recommendations.length > 0) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #333; border-bottom: 2px solid #FF9800; padding-bottom: 5px;">π― Recommendations</h4>
<ul style="background: white; padding: 20px; border-radius: 8px; border: 1px solid #ddd; line-height: 1.6;">
`;
analysis.recommendations.forEach(rec => {
html += `<li style="margin-bottom: 8px;">${rec}</li>`;
});
html += `</ul></div>`;
}
if (analysis.anomalies && analysis.anomalies.length > 0) {
html += `
<div style="margin-bottom: 20px;">
<h4 style="color: #333; border-bottom: 2px solid #f44336; padding-bottom: 5px;">β οΈ Anomalies Detected</h4>
<div style="background: white; padding: 20px; border-radius: 8px; border: 1px solid #ddd;">
`;
analysis.anomalies.forEach(anomaly => {
html += `
<div style="margin-bottom: 15px; padding: 10px; background: #fff3e0; border-left: 4px solid #ff9800; border-radius: 4px;">
<div style="font-weight: bold; margin-bottom: 5px;">${new Date(anomaly.timestamp).toLocaleString()}</div>
<div style="margin-bottom: 5px;"><strong>Value:</strong> ${anomaly.value} (Expected: ${anomaly.expectedRange.min} - ${anomaly.expectedRange.max})</div>
<div style="margin-bottom: 5px;"><strong>Severity:</strong> ${anomaly.severity} (Confidence: ${Math.round(anomaly.confidence * 100)}%)</div>
<div>${anomaly.description}</div>
</div>
`;
});
html += `</div></div>`;
}
// Add copy button
html += `
<div style="text-align: center; margin-top: 20px; padding-top: 20px; border-top: 1px solid #ddd;">
<button onclick="deleteAnalysis('${analysis.id}', true)" style="background: #f44336; color: white; border: none; padding: 12px 24px; border-radius: 6px; cursor: pointer;">
ποΈ Delete Analysis
</button>
</div>
`;
content.innerHTML = html;
// Process chart requests FIRST (before MMSI link conversion which would break JSON)
processChartRequests(content);
// Convert MMSI numbers to MarineTraffic links in the modal content (after charts are processed)
convertMMSIToLinks(content);
}
// Close analysis view modal
export function closeAnalysisViewModal() {
document.getElementById('analysisViewModal').style.display = 'none';
}
// Delete analysis
export async function deleteAnal