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:
parent
75f792044a
commit
54cab1e89d
7 changed files with 360 additions and 32 deletions
File diff suppressed because one or more lines are too long
|
|
@ -1 +1 @@
|
|||
7584e1777b5e000bb982b1dcd20a04f68afbb5fe
|
||||
357459e0b4ced2fe6d13cf8a328e09756564fa58
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue