wip: various improvements

- Visualization improvements in 3D view
- Event API enhancements
- Replay system refinements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jedarden 2026-04-09 13:50:42 -04:00
parent 75f792044a
commit 54cab1e89d
7 changed files with 360 additions and 32 deletions

File diff suppressed because one or more lines are too long

View file

@ -1 +1 @@
7584e1777b5e000bb982b1dcd20a04f68afbb5fe
357459e0b4ced2fe6d13cf8a328e09756564fa58

View file

@ -16,7 +16,12 @@
initialLoadLimit: 200,
fetchSinceHours: 24,
debounceMs: 300,
replaySeekWindowSec: 5 // seconds before/after event timestamp
replaySeekWindowSec: 5, // seconds before/after event timestamp
virtualization: {
enabled: true,
rootMargin: '200px', // load items 200px before they enter viewport
threshold: 0.01 // trigger when 1% of item is visible
}
};
// ============================================

View file

@ -2107,6 +2107,270 @@ const Viz3D = (function () {
});
}
// ── GDOP Overlay Visualization ───────────────────────────────────────────────────
// GDOP overlay state
let _gdopOverlayVisible = false;
let _gdopMesh = null; // THREE.Mesh with GDOP texture
let _gdopTexture = null; // THREE.DataTexture with GDOP data
let _gdopData = null; // Cached GDOP heatmap data
let _gdopLegendVisible = false;
let _gdopLegendSprites = []; // Array of THREE.Sprite for legend
/**
* Set visibility of GDOP overlay layer.
* @param {boolean} visible - Whether to show GDOP overlay
*/
function setGDOPOverlayVisible(visible) {
_gdopOverlayVisible = visible;
if (_gdopMesh) {
_gdopMesh.visible = visible;
}
if (_gdopLegendVisible) {
_gdopLegendSprites.forEach(function(sprite) {
sprite.visible = visible;
});
}
if (visible && !_gdopData) {
fetchGDOPData();
}
}
/**
* Fetch GDOP heatmap data from API.
*/
function fetchGDOPData() {
fetch('/api/simulator/gdop/compute', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cell_size: 0.2,
max_zone: 3,
threshold: 0.02
})
})
.then(function(response) { return response.json(); })
.then(function(data) {
_gdopData = data;
updateGDOPOverlay(data);
})
.catch(function(err) {
console.error('[Viz3D] Failed to fetch GDOP data:', err);
});
}
/**
* Update the GDOP overlay with new data.
* @param {Object} data - GDOP computation results
*/
function updateGDOPOverlay(data) {
if (!data || !data.gdop_heatmap) {
console.warn('[Viz3D] No GDOP heatmap data in response');
return;
}
var heatmap = data.gdop_heatmap;
var width = heatmap.width;
var depth = heatmap.depth;
var cellSize = heatmap.cell_size;
var originX = heatmap.origin_x;
var originY = heatmap.origin_y;
// Create texture from GDOP data
var gdopValues = new Float32Array(heatmap.gdop_values);
var colors = new Uint8Array(heatmap.colors.flat());
// Create data texture
if (_gdopTexture) {
_gdopTexture.dispose();
}
_gdopTexture = new THREE.DataTexture(colors, width, depth, THREE.RGBFormat);
_gdopTexture.needsUpdate = true;
// Create or update mesh
if (!_gdopMesh) {
var geo = new THREE.PlaneGeometry(
width * cellSize,
depth * cellSize
);
var mat = new THREE.MeshBasicMaterial({
map: _gdopTexture,
transparent: true,
opacity: 0.6,
side: THREE.DoubleSide,
depthWrite: false
});
_gdopMesh = new THREE.Mesh(geo, mat);
_gdopMesh.rotation.x = -Math.PI / 2;
_gdopMesh.position.set(
originX + (width * cellSize) / 2,
0.01, // Slightly above floor
originY + (depth * cellSize) / 2
);
_scene.add(_gdopMesh);
_gdopMesh.visible = _gdopOverlayVisible;
} else {
// Update existing mesh dimensions
_gdopMesh.geometry.dispose();
_gdopMesh.geometry = new THREE.PlaneGeometry(
width * cellSize,
depth * cellSize
);
_gdopMesh.position.set(
originX + (width * cellSize) / 2,
0.01,
originY + (depth * cellSize) / 2
);
_gdopMesh.material.map = _gdopTexture;
}
// Update or create legend
updateGDOPLegend(data.coverage_score);
console.log('[Viz3D] GDOP overlay updated:', data.coverage_score.toFixed(1) + '% coverage');
}
/**
* Update or create the GDOP legend.
* @param {number} coverageScore - Coverage percentage (0-100)
*/
function updateGDOPLegend(coverageScore) {
// Clear existing legend sprites
_gdopLegendSprites.forEach(function(sprite) {
_scene.remove(sprite);
});
_gdopLegendSprites = [];
if (!_gdopOverlayVisible) {
return;
}
// Create legend sprites
var legendItems = [
{ color: [34, 197, 94], label: 'Excellent', gdop: '< 2' },
{ color: [255, 193, 7], label: 'Good', gdop: '2-4' },
{ color: [255, 146, 0], label: 'Fair', gdop: '4-8' },
{ color: [220, 53, 69], label: 'Poor', gdop: '> 8' },
{ color: [80, 80, 80], label: 'None', gdop: '∞' }
];
var startY = 1.5;
var spacing = 0.15;
legendItems.forEach(function(item, index) {
var canvas = document.createElement('canvas');
canvas.width = 256;
canvas.height = 64;
var ctx = canvas.getContext('2d');
// Draw color box
ctx.fillStyle = 'rgb(' + item.color.join(',') + ')';
ctx.fillRect(10, 16, 32, 32);
// Draw border
ctx.strokeStyle = 'rgba(255, 255, 255, 0.5)';
ctx.lineWidth = 2;
ctx.strokeRect(10, 16, 32, 32);
// Draw label
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 24px Arial, sans-serif';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(item.label + ' (GDOP ' + item.gdop + ')', 50, 32);
// Create texture
var texture = new THREE.CanvasTexture(canvas);
texture.needsUpdate = true;
// Create sprite
var material = new THREE.SpriteMaterial({
map: texture,
transparent: true,
depthTest: false
});
var sprite = new THREE.Sprite(material);
sprite.scale.set(1.5, 0.4, 1);
sprite.position.set(
(_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6),
startY - index * spacing,
(_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5)
);
_scene.add(sprite);
_gdopLegendSprites.push(sprite);
});
// Add coverage score sprite
var scoreCanvas = document.createElement('canvas');
scoreCanvas.width = 256;
scoreCanvas.height = 64;
var scoreCtx = scoreCanvas.getContext('2d');
scoreCtx.fillStyle = '#ffffff';
scoreCtx.font = 'bold 28px Arial, sans-serif';
scoreCtx.textAlign = 'center';
scoreCtx.textBaseline = 'middle';
scoreCtx.fillText('Coverage: ' + coverageScore.toFixed(1) + '%', 128, 32);
var scoreTexture = new THREE.CanvasTexture(scoreCanvas);
scoreTexture.needsUpdate = true;
var scoreSprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: scoreTexture,
transparent: true,
depthTest: false
})
);
scoreSprite.scale.set(2, 0.5, 1);
scoreSprite.position.set(
(_room ? (_room.origin_x || 0) + _room.width + 0.5 : 6),
startY - legendItems.length * spacing - 0.2,
(_room ? (_room.origin_z || 0) + _room.depth / 2 : 2.5)
);
_scene.add(scoreSprite);
_gdopLegendSprites.push(scoreSprite);
_gdopLegendVisible = true;
}
/**
* Clear the GDOP overlay.
*/
function clearGDOPOverlay() {
if (_gdopMesh) {
_scene.remove(_gdopMesh);
_gdopMesh.geometry.dispose();
_gdopMesh.material.dispose();
_gdopMesh = null;
}
if (_gdopTexture) {
_gdopTexture.dispose();
_gdopTexture = null;
}
_gdopData = null;
}
/**
* Get current GDOP overlay state.
* @returns {Object} State object
*/
function getGDOPState() {
return {
visible: _gdopOverlayVisible,
hasData: _gdopData !== null,
coverageScore: _gdopData ? _gdopData.coverage_score : null
};
}
/**
* Focus the camera on a specific zone.
* @param {string} zoneID - The zone ID to focus on
@ -2611,6 +2875,10 @@ const Viz3D = (function () {
enterReplayMode: enterReplayMode,
exitReplayMode: exitReplayMode,
updateReplayBlobs: updateReplayBlobs,
// GDOP overlay support
setGDOPOverlayVisible: setGDOPOverlayVisible,
clearGDOPOverlay: clearGDOPOverlay,
getGDOPState: getGDOPState,
};
// ── Replay Mode Support ─────────────────────────────────────────────────────
// Store live blob states for replay mode restoration

View file

@ -184,7 +184,7 @@ func isValidEventType(t string) bool {
// GET /api/events — paginated event list with FTS5 search and keyset cursor pagination.
//
// Query params: limit (default 50, max 500), before (timestamp_ms cursor),
// after (ISO8601), type, zone, person, q (FTS5 query).
// after (ISO8601), type, zone, person, q (FTS5 query), mode (expert|simple).
//
// GET /api/events/{id} — single event by ID.
func (e *EventsHandler) RegisterRoutes(r chi.Router) {
@ -259,6 +259,7 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
zone := r.URL.Query().Get("zone")
person := r.URL.Query().Get("person")
afterStr := r.URL.Query().Get("after")
mode := r.URL.Query().Get("mode") // "expert" or "simple" (default: simple)
// Validate event type
if eventType != "" && !isValidEventType(eventType) {
@ -277,6 +278,22 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
afterTS = t.UnixNano() / 1e6
}
// In simple mode, filter out system-only event types
// Simple mode shows: zone_entry, zone_exit, portal_crossing, fall_alert, anomaly, security_alert, learning_milestone
// Simple mode hides: node_online, node_offline, ota_update, baseline_changed, system
simpleModeTypes := map[string]bool{
"zone_entry": true,
"zone_exit": true,
"portal_crossing": true,
"fall_alert": true,
"anomaly": true,
"security_alert": true,
"learning_milestone": true,
"presence_transition": true,
"stationary_detected": true,
}
isSimpleMode := mode != "expert"
// Prepare FTS5 query with prefix matching
if q != "" {
q = prepareFTSQuery(q)
@ -306,32 +323,29 @@ func (e *EventsHandler) listEvents(w http.ResponseWriter, r *http.Request) {
baseArgs = []interface{}{}
}
// Collect filter conditions (excludes before cursor — that's pagination, not filtering)
type cond struct {
sql string
arg interface{}
}
var filters []cond
if eventType != "" {
filters = append(filters, cond{p + "type = ?", eventType})
}
if zone != "" {
filters = append(filters, cond{p + "zone = ?", zone})
}
if person != "" {
filters = append(filters, cond{p + "person = ?", person})
}
if afterTS > 0 {
filters = append(filters, cond{p + "timestamp_ms >= ?", afterTS})
}
// Build WHERE clause with all filters (no before, no LIMIT)
// Build WHERE clause with filters
whereSQL := baseWhere
whereArgs := append([]interface{}{}, baseArgs...)
for _, f := range filters {
whereSQL += " AND " + f.sql
whereArgs = append(whereArgs, f.arg)
if eventType != "" {
whereSQL += " AND " + p + "type = ?"
whereArgs = append(whereArgs, eventType)
} else if isSimpleMode {
// In simple mode with no explicit type filter, exclude system event types
whereSQL += " AND " + p + "type NOT IN (?, ?, ?, ?, ?)"
whereArgs = append(whereArgs, "node_online", "node_offline", "ota_update", "baseline_changed", "system")
}
if zone != "" {
whereSQL += " AND " + p + "zone = ?"
whereArgs = append(whereArgs, zone)
}
if person != "" {
whereSQL += " AND " + p + "person = ?"
whereArgs = append(whereArgs, person)
}
if afterTS > 0 {
whereSQL += " AND " + p + "timestamp_ms >= ?"
whereArgs = append(whereArgs, afterTS)
}
// COUNT for total_filtered

View file

@ -547,6 +547,29 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request)
progress = float64(session.CurrentMS-session.FromMS) / float64(duration)
}
// Convert replay blobs to API format
blobs := make([]map[string]interface{}, 0, len(session.LastBlobs))
for _, b := range session.LastBlobs {
blob := map[string]interface{}{
"id": b.ID,
"x": b.X,
"y": b.Y,
"z": b.Z,
"vx": b.VX,
"vy": b.VY,
"vz": b.VZ,
"weight": b.Weight,
"posture": b.Posture,
"person_id": b.PersonID,
"person_label": b.PersonLabel,
"person_color": b.PersonColor,
"identity_confidence": b.IdentityConfidence,
"identity_source": b.IdentitySource,
"trail": b.Trail,
}
blobs = append(blobs, blob)
}
// Build response with session state and blobs
response := map[string]interface{}{
"session_id": sessionID,
@ -557,7 +580,8 @@ func (h *ReplayHandler) getSessionState(w http.ResponseWriter, r *http.Request)
"speed": session.Speed,
"progress": progress,
"params": session.Params,
"blobs": []interface{}{}, // TODO: populate with actual blob data
"blobs": blobs,
"timestamp_ms": session.LastBlobTime,
}
writeJSON(w, http.StatusOK, response)

View file

@ -32,6 +32,10 @@ type ReplaySession struct {
// Pipeline state for this session
baselineState map[string]*signal.BaselineState // per-link baseline
// Most recent blobs from replay fusion
LastBlobs []BlobUpdate
LastBlobTime int64 // timestamp_ms of the last blob update
}
// FusionEngine is the interface required for replay blob generation.
@ -253,8 +257,12 @@ func (w *Worker) processSession(s *ReplaySession) {
// Run fusion to generate blobs if we have a fusion engine
if w.fusionEngine != nil {
blobs := w.runFusion()
s.LastBlobs = blobs
s.LastBlobTime = frameTimeNS / 1e6
w.broadcaster.BroadcastReplayBlobs(blobs, frameTimeNS/1e6)
} else {
s.LastBlobs = []BlobUpdate{}
s.LastBlobTime = frameTimeNS / 1e6
w.broadcaster.BroadcastReplayBlobs([]BlobUpdate{}, frameTimeNS/1e6)
}
}
@ -343,6 +351,8 @@ func (w *Worker) StartSession(fromMS, toMS int64, speed int) (string, error) {
Params: make(map[string]interface{}),
CreatedAt: time.Now(),
baselineState: make(map[string]*signal.BaselineState),
LastBlobs: []BlobUpdate{},
LastBlobTime: fromMS,
}
w.sessions[id] = s