Implement a complete self-improving localization system that uses BLE RSSI triangulation as ground truth to learn per-link, per-zone weights for the Fresnel zone fusion engine. Key components: - Ground truth sample collection with confidence > 0.7 and distance < 0.5m gates - Spatial weight learner using online SGD with L2 regularization - Validation gate that rejects updates without 5% improvement on holdout set - Bilinear interpolation for smooth weight transitions between zones - SQLite persistence for weights with 10,000 sample cap per person - Position accuracy trend visualization in dashboard Accuracy panel Backend (mothership/internal/localization/): - groundtruth.go: BLE trilateration provider with Gauss-Newton optimization - groundtruth_store.go: SQLite storage with weekly accuracy rollups - spatial_weights.go: SpatialWeightLearner with SGD, validation, interpolation - weightlearner.go: WeightLearner with error history tracking - weightstore.go: Weight persistence to SQLite Frontend (dashboard/js/accuracy.js): - fetchPositionAccuracy/fetchPositionHistory functions - drawPositionSparkline for weekly median error trend - Position accuracy section with median error, trend indicator, sample count Tests cover: - Sample collection gates (confidence/distance thresholds) - SGD weight updates after 100 samples - Validation gate rejection of adversarial samples - Bilinear interpolation smoothness - SQLite sample cap enforcement - Weight persistence across restarts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
988 lines
37 KiB
JavaScript
988 lines
37 KiB
JavaScript
/**
|
|
* Accuracy Panel for Detection Quality Metrics
|
|
* Displays precision, recall, F1 scores, and improvement trends
|
|
*/
|
|
(function() {
|
|
'use strict';
|
|
|
|
var Accuracy = {
|
|
// State
|
|
panelVisible: false,
|
|
currentData: null,
|
|
historyData: null,
|
|
improvementData: null,
|
|
positionData: null,
|
|
positionHistory: null,
|
|
|
|
// Config
|
|
config: {
|
|
pollIntervalMs: 60000, // 1 minute
|
|
historyWeeks: 8
|
|
},
|
|
|
|
/**
|
|
* Initialize the accuracy panel
|
|
*/
|
|
init: function() {
|
|
this.createPanel();
|
|
this.addStyles();
|
|
this.startPolling();
|
|
console.log('[Accuracy] Module initialized');
|
|
},
|
|
|
|
/**
|
|
* Create the accuracy panel
|
|
*/
|
|
createPanel: function() {
|
|
var panel = document.createElement('div');
|
|
panel.id = 'accuracy-panel';
|
|
panel.className = 'accuracy-panel';
|
|
panel.style.display = 'none';
|
|
panel.innerHTML = '\
|
|
<div class="accuracy-header">\
|
|
<h3>Detection Accuracy</h3>\
|
|
<button class="accuracy-close" onclick="Accuracy.togglePanel()">×</button>\
|
|
</div>\
|
|
<div class="accuracy-content">\
|
|
<div class="accuracy-gauge-section">\
|
|
<div class="accuracy-gauge-container">\
|
|
<svg class="accuracy-gauge" viewBox="0 0 100 100">\
|
|
<circle class="gauge-bg" cx="50" cy="50" r="40"/>\
|
|
<circle class="gauge-fill" cx="50" cy="50" r="40"/>\
|
|
</svg>\
|
|
<div class="gauge-value">\
|
|
<span class="gauge-number">--</span>\
|
|
<span class="gauge-label">F1 Score</span>\
|
|
</div>\
|
|
</div>\
|
|
</div>\
|
|
<div class="accuracy-metrics">\
|
|
<div class="metric-item">\
|
|
<span class="metric-label">Precision</span>\
|
|
<span class="metric-value" id="accuracy-precision">--</span>\
|
|
</div>\
|
|
<div class="metric-item">\
|
|
<span class="metric-label">Recall</span>\
|
|
<span class="metric-value" id="accuracy-recall">--</span>\
|
|
</div>\
|
|
<div class="metric-item">\
|
|
<span class="metric-label">F1 Score</span>\
|
|
<span class="metric-value" id="accuracy-f1">--</span>\
|
|
</div>\
|
|
</div>\
|
|
<div class="accuracy-motivation">\
|
|
<div class="motivation-text">\
|
|
You\'ve provided <span id="feedback-count">0</span> corrections.\
|
|
</div>\
|
|
<div class="motivation-improvement" id="improvement-text"></div>\
|
|
</div>\
|
|
<div class="accuracy-trend-section">\
|
|
<div class="trend-header">\
|
|
<span>F1 Score Trend (8 weeks)</span>\
|
|
</div>\
|
|
<canvas id="accuracy-sparkline" width="240" height="60"></canvas>\
|
|
</div>\
|
|
<div class="position-accuracy-section">\
|
|
<div class="position-header">\
|
|
<span>Position Accuracy</span>\
|
|
<span class="position-badge" id="position-badge" style="display:none">Improving</span>\
|
|
</div>\
|
|
<div class="position-metrics">\
|
|
<div class="position-metric">\
|
|
<span class="position-label">Median error</span>\
|
|
<span class="position-value" id="position-median">-- m</span>\
|
|
</div>\
|
|
<div class="position-metric">\
|
|
<span class="position-label">Trend</span>\
|
|
<span class="position-trend" id="position-trend">--</span>\
|
|
</div>\
|
|
</div>\
|
|
<div class="position-sparkline-container">\
|
|
<canvas id="position-sparkline" width="240" height="40"></canvas>\
|
|
</div>\
|
|
<div class="position-samples">\
|
|
<span id="position-sample-text">No position samples yet</span>\
|
|
</div>\
|
|
</div>\
|
|
<div class="accuracy-breakdown">\
|
|
<div class="breakdown-header">Per-Zone Breakdown</div>\
|
|
<div id="zone-breakdown" class="zone-breakdown">\
|
|
<div class="loading-text">Loading...</div>\
|
|
</div>\
|
|
</div>\
|
|
<div class="accuracy-stats">\
|
|
<div class="stats-row">\
|
|
<span>Pending corrections</span>\
|
|
<span id="unprocessed-count">0</span>\
|
|
</div>\
|
|
<div class="stats-row">\
|
|
<span>Processed corrections</span>\
|
|
<span id="processed-count">0</span>\
|
|
</div>\
|
|
</div>\
|
|
</div>';
|
|
document.body.appendChild(panel);
|
|
|
|
// Add toggle button to status bar
|
|
this.addToggleButton();
|
|
},
|
|
|
|
/**
|
|
* Add toggle button to status bar
|
|
*/
|
|
addToggleButton: function() {
|
|
var statusBar = document.getElementById('status-bar');
|
|
if (!statusBar) return;
|
|
|
|
var btn = document.createElement('div');
|
|
btn.className = 'status-item accuracy-toggle';
|
|
btn.id = 'accuracy-toggle';
|
|
btn.innerHTML = '\
|
|
<div class="accuracy-mini-gauge">\
|
|
<svg viewBox="0 0 32 32">\
|
|
<circle cx="16" cy="16" r="12" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="3"/>\
|
|
<circle class="mini-gauge-fill" cx="16" cy="16" r="12" fill="none" stroke="#66bb6a" stroke-width="3"\
|
|
stroke-dasharray="0 75.4" stroke-linecap="round" transform="rotate(-90 16 16)"/>\
|
|
</svg>\
|
|
<span class="mini-gauge-value">--</span>\
|
|
</div>\
|
|
<span class="accuracy-label">Accuracy</span>';
|
|
btn.onclick = function() { Accuracy.togglePanel(); };
|
|
btn.style.cursor = 'pointer';
|
|
|
|
// Insert after detection-quality
|
|
var qualityItem = document.getElementById('detection-quality');
|
|
if (qualityItem && qualityItem.nextSibling) {
|
|
statusBar.insertBefore(btn, qualityItem.nextSibling);
|
|
} else {
|
|
statusBar.appendChild(btn);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Toggle panel visibility
|
|
*/
|
|
togglePanel: function() {
|
|
var panel = document.getElementById('accuracy-panel');
|
|
if (!panel) return;
|
|
|
|
if (this.panelVisible) {
|
|
panel.style.display = 'none';
|
|
this.panelVisible = false;
|
|
} else {
|
|
panel.style.display = 'block';
|
|
this.panelVisible = true;
|
|
this.refresh();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Start polling for accuracy updates
|
|
*/
|
|
startPolling: function() {
|
|
var self = this;
|
|
this.refresh();
|
|
|
|
setInterval(function() {
|
|
self.refresh();
|
|
}, this.config.pollIntervalMs);
|
|
},
|
|
|
|
/**
|
|
* Refresh all accuracy data
|
|
*/
|
|
refresh: function() {
|
|
this.fetchAccuracy();
|
|
this.fetchHistory();
|
|
this.fetchImprovement();
|
|
this.fetchStats();
|
|
this.fetchZoneBreakdown();
|
|
this.fetchPositionAccuracy();
|
|
this.fetchPositionHistory();
|
|
},
|
|
|
|
/**
|
|
* Fetch current accuracy metrics
|
|
*/
|
|
fetchAccuracy: function() {
|
|
var self = this;
|
|
fetch('/api/learning/accuracy')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.currentData = data;
|
|
self.updateDisplay();
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch accuracy:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetch accuracy history for sparkline
|
|
*/
|
|
fetchHistory: function() {
|
|
var self = this;
|
|
fetch('/api/learning/accuracy/history?weeks=' + this.config.historyWeeks)
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.historyData = data;
|
|
self.drawSparkline();
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch history:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetch improvement statistics
|
|
*/
|
|
fetchImprovement: function() {
|
|
var self = this;
|
|
fetch('/api/learning/accuracy/improvement')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.improvementData = data;
|
|
self.updateMotivation();
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch improvement:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetch feedback stats
|
|
*/
|
|
fetchStats: function() {
|
|
fetch('/api/learning/stats')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
document.getElementById('unprocessed-count').textContent = data.unprocessed_count || 0;
|
|
document.getElementById('processed-count').textContent = data.processed_count || 0;
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch stats:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetch per-zone accuracy breakdown
|
|
*/
|
|
fetchZoneBreakdown: function() {
|
|
var self = this;
|
|
var week = this.getCurrentWeek();
|
|
|
|
fetch('/api/learning/accuracy/history?scope_type=zone&weeks=1')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.renderZoneBreakdown(data);
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch zone breakdown:', err);
|
|
self.renderZoneBreakdown([]);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Get current week string
|
|
*/
|
|
getCurrentWeek: function() {
|
|
var now = new Date();
|
|
var start = new Date(now.getFullYear(), 0, 1);
|
|
var diff = now - start;
|
|
var oneWeek = 604800000; // ms in a week
|
|
var weekNum = Math.ceil((diff + start.getDay() * 86400000) / oneWeek);
|
|
return now.getFullYear() + '-W' + (weekNum < 10 ? '0' : '') + weekNum;
|
|
},
|
|
|
|
/**
|
|
* Render zone breakdown
|
|
*/
|
|
renderZoneBreakdown: function(zones) {
|
|
var container = document.getElementById('zone-breakdown');
|
|
if (!container) return;
|
|
|
|
if (!zones || zones.length === 0) {
|
|
container.innerHTML = '<div class="no-data-text">No zone data yet</div>';
|
|
return;
|
|
}
|
|
|
|
var self = this;
|
|
var html = '';
|
|
zones.sort(function(a, b) { return (b.f1 || 0) - (a.f1 || 0); });
|
|
|
|
zones.forEach(function(zone) {
|
|
var f1 = zone.f1 !== null ? (zone.f1 * 100).toFixed(0) + '%' : '--';
|
|
var color = zone.f1 >= 0.8 ? '#66bb6a' : (zone.f1 >= 0.6 ? '#ffa726' : '#ef5350');
|
|
|
|
html += '<div class="zone-item" data-zone-id="' + zone.scope_id + '">' +
|
|
'<span class="zone-name">' + self.formatZoneName(zone.scope_id) + '</span>' +
|
|
'<span class="zone-score" style="color:' + color + '">' + f1 + '</span>' +
|
|
'</div>';
|
|
});
|
|
|
|
container.innerHTML = html;
|
|
|
|
// Add click handlers to focus on zone
|
|
container.querySelectorAll('.zone-item').forEach(function(item) {
|
|
item.onclick = function() {
|
|
var zoneId = this.getAttribute('data-zone-id');
|
|
if (window.Viz3D && window.Viz3D.focusOnZone) {
|
|
window.Viz3D.focusOnZone(zoneId);
|
|
}
|
|
};
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Format zone name for display
|
|
*/
|
|
formatZoneName: function(zoneId) {
|
|
if (!zoneId) return 'Unknown';
|
|
// Convert zone-xxx to Xxx
|
|
return zoneId.replace(/^zone-/, '').replace(/-/g, ' ')
|
|
.replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
|
},
|
|
|
|
/**
|
|
* Update display with current data
|
|
*/
|
|
updateDisplay: function() {
|
|
if (!this.currentData) return;
|
|
|
|
var precision = this.currentData.precision;
|
|
var recall = this.currentData.recall;
|
|
var f1 = this.currentData.f1;
|
|
|
|
// Update metrics
|
|
document.getElementById('accuracy-precision').textContent = this.formatPercent(precision);
|
|
document.getElementById('accuracy-recall').textContent = this.formatPercent(recall);
|
|
document.getElementById('accuracy-f1').textContent = this.formatPercent(f1);
|
|
|
|
// Update gauge
|
|
var gaugeValue = document.querySelector('.gauge-number');
|
|
if (gaugeValue) {
|
|
gaugeValue.textContent = this.formatPercent(f1);
|
|
}
|
|
|
|
// Update gauge fill
|
|
var gaugeFill = document.querySelector('.gauge-fill');
|
|
if (gaugeFill && f1 !== null) {
|
|
var circumference = 2 * Math.PI * 40;
|
|
var offset = circumference * (1 - f1);
|
|
gaugeFill.style.strokeDasharray = (circumference - offset) + ' ' + circumference;
|
|
gaugeFill.style.stroke = this.getColorForScore(f1);
|
|
}
|
|
|
|
// Update mini gauge in status bar
|
|
this.updateMiniGauge(f1);
|
|
},
|
|
|
|
/**
|
|
* Update mini gauge in status bar
|
|
*/
|
|
updateMiniGauge: function(f1) {
|
|
var miniFill = document.querySelector('.mini-gauge-fill');
|
|
var miniValue = document.querySelector('.mini-gauge-value');
|
|
|
|
if (miniFill && f1 !== null) {
|
|
var circumference = 2 * Math.PI * 12;
|
|
var offset = circumference * (1 - f1);
|
|
miniFill.style.strokeDasharray = (circumference - offset) + ' ' + circumference;
|
|
miniFill.style.stroke = this.getColorForScore(f1);
|
|
}
|
|
|
|
if (miniValue) {
|
|
miniValue.textContent = this.formatPercent(f1);
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Update motivation section
|
|
*/
|
|
updateMotivation: function() {
|
|
if (!this.improvementData) return;
|
|
|
|
var feedbackCount = document.getElementById('feedback-count');
|
|
var improvementText = document.getElementById('improvement-text');
|
|
|
|
if (feedbackCount) {
|
|
feedbackCount.textContent = this.improvementData.total_feedback || 0;
|
|
}
|
|
|
|
if (improvementText) {
|
|
var improvement = this.improvementData.improvement_pct || 0;
|
|
if (improvement > 0) {
|
|
improvementText.innerHTML = '<span class="improvement-positive">Accuracy improved ' +
|
|
improvement.toFixed(0) + '% this week!</span>';
|
|
} else if (improvement < 0) {
|
|
improvementText.innerHTML = '<span class="improvement-negative">Accuracy decreased ' +
|
|
Math.abs(improvement).toFixed(0) + '% this week.</span>';
|
|
} else {
|
|
improvementText.innerHTML = '<span class="improvement-neutral">Keep providing feedback to improve accuracy!</span>';
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Draw sparkline for accuracy history
|
|
*/
|
|
drawSparkline: function() {
|
|
var canvas = document.getElementById('accuracy-sparkline');
|
|
if (!canvas || !this.historyData || this.historyData.length === 0) return;
|
|
|
|
var ctx = canvas.getContext('2d');
|
|
var width = canvas.width;
|
|
var height = canvas.height;
|
|
var padding = 4;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Sort by week
|
|
var data = this.historyData.slice().sort(function(a, b) {
|
|
return a.week.localeCompare(b.week);
|
|
});
|
|
|
|
if (data.length < 2) {
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Need more data...', width / 2, height / 2);
|
|
return;
|
|
}
|
|
|
|
// Get F1 values
|
|
var values = data.map(function(d) { return d.f1 || 0; });
|
|
var min = Math.min.apply(null, values);
|
|
var max = Math.max.apply(null, values);
|
|
if (max === min) max = min + 0.1;
|
|
|
|
// Draw line
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#4fc3f7';
|
|
ctx.lineWidth = 2;
|
|
|
|
var stepX = (width - padding * 2) / (data.length - 1);
|
|
|
|
for (var i = 0; i < data.length; i++) {
|
|
var x = padding + i * stepX;
|
|
var y = height - padding - ((values[i] - min) / (max - min)) * (height - padding * 2);
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
ctx.stroke();
|
|
|
|
// Draw points
|
|
ctx.fillStyle = '#4fc3f7';
|
|
for (var i = 0; i < data.length; i++) {
|
|
var x = padding + i * stepX;
|
|
var y = height - padding - ((values[i] - min) / (max - min)) * (height - padding * 2);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Fetch position accuracy from ground truth samples
|
|
*/
|
|
fetchPositionAccuracy: function() {
|
|
var self = this;
|
|
fetch('/api/accuracy/position')
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.positionData = data;
|
|
self.updatePositionDisplay();
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch position accuracy:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Fetch position accuracy history for sparkline
|
|
*/
|
|
fetchPositionHistory: function() {
|
|
var self = this;
|
|
fetch('/api/accuracy/position/history?weeks=' + this.config.historyWeeks)
|
|
.then(function(res) { return res.json(); })
|
|
.then(function(data) {
|
|
self.positionHistory = data;
|
|
self.drawPositionSparkline();
|
|
})
|
|
.catch(function(err) {
|
|
console.error('[Accuracy] Failed to fetch position history:', err);
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Update position accuracy display
|
|
*/
|
|
updatePositionDisplay: function() {
|
|
if (!this.positionData) return;
|
|
|
|
var data = this.positionData;
|
|
|
|
// Update median error
|
|
var medianEl = document.getElementById('position-median');
|
|
if (medianEl) {
|
|
if (data.current_median_m !== undefined) {
|
|
medianEl.textContent = data.current_median_m.toFixed(2) + ' m';
|
|
} else {
|
|
medianEl.textContent = '-- m';
|
|
}
|
|
}
|
|
|
|
// Update trend indicator
|
|
var trendEl = document.getElementById('position-trend');
|
|
var badgeEl = document.getElementById('position-badge');
|
|
if (trendEl && data.trend) {
|
|
var improvement = data.improvement_pct || 0;
|
|
if (data.trend === 'improving') {
|
|
trendEl.innerHTML = '<span style="color:#66bb6a">↓ ' + Math.abs(improvement).toFixed(0) + '%</span>';
|
|
if (badgeEl) {
|
|
badgeEl.style.display = 'inline';
|
|
badgeEl.textContent = 'Improving';
|
|
badgeEl.className = 'position-badge improving';
|
|
}
|
|
} else if (data.trend === 'degrading') {
|
|
trendEl.innerHTML = '<span style="color:#ef5350">↑ ' + Math.abs(improvement).toFixed(0) + '%</span>';
|
|
if (badgeEl) {
|
|
badgeEl.style.display = 'none';
|
|
}
|
|
} else {
|
|
trendEl.innerHTML = '<span style="color:#888">stable</span>';
|
|
if (badgeEl) {
|
|
badgeEl.style.display = 'none';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Update sample count text
|
|
var sampleTextEl = document.getElementById('position-sample-text');
|
|
if (sampleTextEl) {
|
|
var totalSamples = data.total_samples || 0;
|
|
var todaySamples = data.today_samples || 0;
|
|
var personCount = data.current_persons || Object.keys(data.samples_by_person || {}).length;
|
|
|
|
if (totalSamples > 0) {
|
|
sampleTextEl.textContent = 'Based on ' + totalSamples + ' position measurements' +
|
|
(personCount > 0 ? ' from ' + personCount + ' people' : '') +
|
|
' (' + todaySamples + ' today)';
|
|
} else {
|
|
sampleTextEl.textContent = 'No position samples yet. Walk around with your phone to collect ground truth.';
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Draw position accuracy sparkline (lower is better)
|
|
*/
|
|
drawPositionSparkline: function() {
|
|
var canvas = document.getElementById('position-sparkline');
|
|
if (!canvas || !this.positionHistory || this.positionHistory.length === 0) return;
|
|
|
|
var ctx = canvas.getContext('2d');
|
|
var width = canvas.width;
|
|
var height = canvas.height;
|
|
var padding = 4;
|
|
|
|
ctx.clearRect(0, 0, width, height);
|
|
|
|
// Sort by week
|
|
var data = this.positionHistory.slice().sort(function(a, b) {
|
|
return a.week.localeCompare(b.week);
|
|
});
|
|
|
|
if (data.length < 2) {
|
|
ctx.fillStyle = '#666';
|
|
ctx.font = '11px sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.fillText('Need more data...', width / 2, height / 2);
|
|
return;
|
|
}
|
|
|
|
// Get median error values (lower is better)
|
|
var values = data.map(function(d) { return d.median_error || 0; });
|
|
var min = Math.min.apply(null, values);
|
|
var max = Math.max.apply(null, values);
|
|
if (max === min) max = min + 0.1;
|
|
|
|
// Draw line (inverted Y since lower is better)
|
|
ctx.beginPath();
|
|
ctx.strokeStyle = '#66bb6a'; // Green since lower error is good
|
|
ctx.lineWidth = 2;
|
|
|
|
var stepX = (width - padding * 2) / (data.length - 1);
|
|
|
|
for (var i = 0; i < data.length; i++) {
|
|
var x = padding + i * stepX;
|
|
// Invert: higher values go lower on the chart
|
|
var y = padding + ((values[i] - min) / (max - min)) * (height - padding * 2);
|
|
|
|
if (i === 0) {
|
|
ctx.moveTo(x, y);
|
|
} else {
|
|
ctx.lineTo(x, y);
|
|
}
|
|
}
|
|
|
|
ctx.stroke();
|
|
|
|
// Draw points
|
|
ctx.fillStyle = '#66bb6a';
|
|
for (var i = 0; i < data.length; i++) {
|
|
var x = padding + i * stepX;
|
|
var y = padding + ((values[i] - min) / (max - min)) * (height - padding * 2);
|
|
ctx.beginPath();
|
|
ctx.arc(x, y, 3, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
|
|
// Draw arrow indicator for trend
|
|
if (data.length >= 2) {
|
|
var lastTwo = values.slice(-2);
|
|
var trend = lastTwo[1] - lastTwo[0];
|
|
var arrowX = width - 15;
|
|
var arrowY = 10;
|
|
|
|
ctx.font = 'bold 12px sans-serif';
|
|
if (trend < 0) {
|
|
// Error decreased (improving)
|
|
ctx.fillStyle = '#66bb6a';
|
|
ctx.fillText('↓', arrowX, arrowY + 4);
|
|
} else if (trend > 0) {
|
|
// Error increased (degrading)
|
|
ctx.fillStyle = '#ef5350';
|
|
ctx.fillText('↑', arrowX, arrowY + 4);
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Format a decimal as percentage
|
|
*/
|
|
formatPercent: function(value) {
|
|
if (value === null || value === undefined) return '--';
|
|
return (value * 100).toFixed(0) + '%';
|
|
},
|
|
|
|
/**
|
|
* Get color for a score (0-1)
|
|
*/
|
|
getColorForScore: function(score) {
|
|
if (score >= 0.8) return '#66bb6a';
|
|
if (score >= 0.6) return '#ffa726';
|
|
return '#ef5350';
|
|
},
|
|
|
|
/**
|
|
* Add CSS styles
|
|
*/
|
|
addStyles: function() {
|
|
if (document.getElementById('accuracy-styles')) return;
|
|
|
|
var style = document.createElement('style');
|
|
style.id = 'accuracy-styles';
|
|
style.textContent = '\
|
|
.accuracy-panel {\
|
|
position: fixed;\
|
|
top: 60px;\
|
|
right: 20px;\
|
|
width: 300px;\
|
|
max-height: calc(100vh - 80px);\
|
|
background: rgba(0, 0, 0, 0.9);\
|
|
border-radius: 8px;\
|
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);\
|
|
z-index: 150;\
|
|
overflow-y: auto;\
|
|
}\
|
|
.accuracy-header {\
|
|
display: flex;\
|
|
justify-content: space-between;\
|
|
align-items: center;\
|
|
padding: 12px 16px;\
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);\
|
|
}\
|
|
.accuracy-header h3 {\
|
|
font-size: 14px;\
|
|
color: #888;\
|
|
text-transform: uppercase;\
|
|
letter-spacing: 1px;\
|
|
margin: 0;\
|
|
}\
|
|
.accuracy-close {\
|
|
background: none;\
|
|
border: none;\
|
|
color: #888;\
|
|
font-size: 20px;\
|
|
cursor: pointer;\
|
|
}\
|
|
.accuracy-close:hover { color: #fff; }\
|
|
.accuracy-content {\
|
|
padding: 16px;\
|
|
}\
|
|
.accuracy-gauge-section {\
|
|
display: flex;\
|
|
justify-content: center;\
|
|
margin-bottom: 16px;\
|
|
}\
|
|
.accuracy-gauge-container {\
|
|
position: relative;\
|
|
width: 120px;\
|
|
height: 120px;\
|
|
}\
|
|
.accuracy-gauge {\
|
|
width: 100%;\
|
|
height: 100%;\
|
|
transform: rotate(-90deg);\
|
|
}\
|
|
.gauge-bg {\
|
|
fill: none;\
|
|
stroke: rgba(255, 255, 255, 0.1);\
|
|
stroke-width: 8;\
|
|
}\
|
|
.gauge-fill {\
|
|
fill: none;\
|
|
stroke: #66bb6a;\
|
|
stroke-width: 8;\
|
|
stroke-linecap: round;\
|
|
stroke-dasharray: 0 251;\
|
|
transition: stroke-dasharray 0.5s, stroke 0.3s;\
|
|
}\
|
|
.gauge-value {\
|
|
position: absolute;\
|
|
top: 50%;\
|
|
left: 50%;\
|
|
transform: translate(-50%, -50%);\
|
|
text-align: center;\
|
|
}\
|
|
.gauge-number {\
|
|
display: block;\
|
|
font-size: 24px;\
|
|
font-weight: 600;\
|
|
color: #fff;\
|
|
}\
|
|
.gauge-label {\
|
|
display: block;\
|
|
font-size: 10px;\
|
|
color: #888;\
|
|
text-transform: uppercase;\
|
|
}\
|
|
.accuracy-metrics {\
|
|
display: flex;\
|
|
justify-content: space-around;\
|
|
padding: 12px 0;\
|
|
border-top: 1px solid rgba(255, 255, 255, 0.1);\
|
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);\
|
|
margin-bottom: 12px;\
|
|
}\
|
|
.metric-item {\
|
|
text-align: center;\
|
|
}\
|
|
.metric-label {\
|
|
display: block;\
|
|
font-size: 10px;\
|
|
color: #888;\
|
|
margin-bottom: 2px;\
|
|
}\
|
|
.metric-value {\
|
|
font-size: 16px;\
|
|
font-weight: 600;\
|
|
color: #eee;\
|
|
}\
|
|
.accuracy-motivation {\
|
|
background: rgba(79, 195, 247, 0.1);\
|
|
border-radius: 6px;\
|
|
padding: 10px 12px;\
|
|
margin-bottom: 12px;\
|
|
text-align: center;\
|
|
}\
|
|
.motivation-text {\
|
|
font-size: 12px;\
|
|
color: #bbb;\
|
|
}\
|
|
.motivation-text span {\
|
|
font-weight: 600;\
|
|
color: #4fc3f7;\
|
|
}\
|
|
.motivation-improvement {\
|
|
font-size: 11px;\
|
|
margin-top: 4px;\
|
|
}\
|
|
.improvement-positive { color: #66bb6a; }\
|
|
.improvement-negative { color: #ef5350; }\
|
|
.improvement-neutral { color: #888; }\
|
|
.accuracy-trend-section {\
|
|
margin-bottom: 12px;\
|
|
}\
|
|
.trend-header {\
|
|
font-size: 11px;\
|
|
color: #888;\
|
|
margin-bottom: 8px;\
|
|
}\
|
|
#accuracy-sparkline {\
|
|
width: 100%;\
|
|
background: rgba(255, 255, 255, 0.03);\
|
|
border-radius: 4px;\
|
|
}\
|
|
.accuracy-breakdown {\
|
|
margin-bottom: 12px;\
|
|
}\
|
|
.breakdown-header {\
|
|
font-size: 11px;\
|
|
color: #888;\
|
|
margin-bottom: 8px;\
|
|
}\
|
|
.zone-breakdown {\
|
|
display: flex;\
|
|
flex-direction: column;\
|
|
gap: 4px;\
|
|
}\
|
|
.zone-item {\
|
|
display: flex;\
|
|
justify-content: space-between;\
|
|
align-items: center;\
|
|
font-size: 11px;\
|
|
padding: 4px 8px;\
|
|
background: rgba(255, 255, 255, 0.03);\
|
|
border-radius: 3px;\
|
|
cursor: pointer;\
|
|
transition: background 0.2s;\
|
|
}\
|
|
.zone-item:hover {\
|
|
background: rgba(255, 255, 255, 0.08);\
|
|
}\
|
|
.zone-name {\
|
|
color: #bbb;\
|
|
}\
|
|
.zone-score {\
|
|
font-weight: 500;\
|
|
}\
|
|
.no-data-text {\
|
|
color: #666;\
|
|
font-size: 11px;\
|
|
text-align: center;\
|
|
padding: 8px;\
|
|
}\
|
|
.accuracy-stats {\
|
|
font-size: 11px;\
|
|
}\
|
|
.stats-row {\
|
|
display: flex;\
|
|
justify-content: space-between;\
|
|
padding: 4px 0;\
|
|
color: #888;\
|
|
}\
|
|
.stats-row span:last-child {\
|
|
color: #ccc;\
|
|
}\
|
|
.position-accuracy-section {\
|
|
background: rgba(102, 187, 106, 0.1);\
|
|
border-radius: 6px;\
|
|
padding: 10px 12px;\
|
|
margin-bottom: 12px;\
|
|
}\
|
|
.position-header {\
|
|
display: flex;\
|
|
justify-content: space-between;\
|
|
align-items: center;\
|
|
font-size: 11px;\
|
|
color: #888;\
|
|
margin-bottom: 8px;\
|
|
}\
|
|
.position-badge {\
|
|
font-size: 9px;\
|
|
padding: 2px 6px;\
|
|
border-radius: 3px;\
|
|
text-transform: uppercase;\
|
|
font-weight: 600;\
|
|
}\
|
|
.position-badge.improving {\
|
|
background: rgba(102, 187, 106, 0.3);\
|
|
color: #66bb6a;\
|
|
}\
|
|
.position-metrics {\
|
|
display: flex;\
|
|
justify-content: space-around;\
|
|
margin-bottom: 8px;\
|
|
}\
|
|
.position-metric {\
|
|
text-align: center;\
|
|
}\
|
|
.position-label {\
|
|
display: block;\
|
|
font-size: 10px;\
|
|
color: #888;\
|
|
margin-bottom: 2px;\
|
|
}\
|
|
.position-value {\
|
|
font-size: 16px;\
|
|
font-weight: 600;\
|
|
color: #eee;\
|
|
}\
|
|
.position-trend {\
|
|
font-size: 14px;\
|
|
font-weight: 500;\
|
|
}\
|
|
.position-sparkline-container {\
|
|
margin-bottom: 8px;\
|
|
}\
|
|
#position-sparkline {\
|
|
width: 100%;\
|
|
background: rgba(255, 255, 255, 0.03);\
|
|
border-radius: 4px;\
|
|
}\
|
|
.position-samples {\
|
|
font-size: 10px;\
|
|
color: #888;\
|
|
text-align: center;\
|
|
}\
|
|
.accuracy-toggle {\
|
|
padding: 2px 10px;\
|
|
background: rgba(255, 255, 255, 0.05);\
|
|
border-radius: 4px;\
|
|
}\
|
|
.accuracy-toggle:hover {\
|
|
background: rgba(255, 255, 255, 0.1);\
|
|
}\
|
|
.accuracy-mini-gauge {\
|
|
position: relative;\
|
|
width: 32px;\
|
|
height: 32px;\
|
|
}\
|
|
.accuracy-mini-gauge svg {\
|
|
width: 32px;\
|
|
height: 32px;\
|
|
}\
|
|
.mini-gauge-value {\
|
|
position: absolute;\
|
|
top: 50%;\
|
|
left: 50%;\
|
|
transform: translate(-50%, -50%);\
|
|
font-size: 8px;\
|
|
font-weight: 600;\
|
|
color: #ccc;\
|
|
}\
|
|
.accuracy-label {\
|
|
font-size: 11px;\
|
|
color: #888;\
|
|
}';
|
|
document.head.appendChild(style);
|
|
}
|
|
};
|
|
|
|
// Expose globally
|
|
window.Accuracy = Accuracy;
|
|
|
|
// Initialize when DOM is ready
|
|
if (document.readyState === 'loading') {
|
|
document.addEventListener('DOMContentLoaded', function() { Accuracy.init(); });
|
|
} else {
|
|
Accuracy.init();
|
|
}
|
|
})();
|