Visualizer work
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

445 lines
17 KiB

// In src/dataExplorer.js
import { appState } from './state.js';
import { throttle } from './utils.js';
import {
canvasContainer,
explorerBtn,
mainContent
} from './dom.js'; // Import the DOM elements we need to listen to
// --- DOM Elements (Internal to this module) ---
const panel = document.getElementById('data-explorer-panel');
const closeBtn = document.getElementById('close-explorer-btn');
const footer = document.getElementById('explorer-footer');
const plotBtn = document.getElementById('plot-selected-btn');
const tabs = {
tree: { btn: document.getElementById('tab-btn-tree'), panel: document.getElementById('tab-panel-tree') },
grid: { btn: document.getElementById('tab-btn-grid'), panel: document.getElementById('tab-panel-grid') },
trackGrid: { btn: document.getElementById('tab-btn-track-grid'), panel: document.getElementById('tab-panel-track-grid') },
plot: { btn: document.getElementById('tab-btn-plot'), panel: document.getElementById('tab-panel-plot') },
};
const gridDiv = document.getElementById('data-grid');
const trackGridDiv = document.getElementById('track-data-grid');
const chartCanvas = document.getElementById('data-chart');
// --- Module-Local State ---
let gridApi = null;
let trackGridApi = null;
let chartInstance = null;
let currentGridData = null;
// --- AG Grid Configuration ---
const gridOptions = {
rowData: [],
columnDefs: [],
defaultColDef: {
sortable: true,
filter: true,
resizable: true,
// --- START: Set a default width for all columns ---
width: 100, // Keep width at 100 as requested
// --- END: Set a default width ---
},
// --- START: Define a specific column type for numbers ---
// This allows us to apply special formatting only to numeric columns.
columnTypes: {
numberColumn: {
// --- START: Add column-specific number formatting ---
// This formatter checks the column ID and applies the correct precision.
valueFormatter: params => {
const { value, colDef } = params;
// Do nothing if value is not a number or is an integer (like 'index')
if (typeof value !== 'number' || value === null || Number.isInteger(value)) {
return value;
}
// Apply formatting based on the column's field name
switch (colDef.field) {
case 'snr':
return value.toFixed(2); // SNR gets 2 decimal places
default:
return value.toFixed(4); // All other numbers get 4 decimal places
}
}
// --- END: Add column-specific number formatting ---
}
}
// --- END: Define a specific column type for numbers ---
};
const trackGridOptions = {
...gridOptions
};
// --- Chart.js Configuration ---
function createChart(data, label) {
if (chartInstance) {
chartInstance.destroy();
}
chartInstance = new Chart(chartCanvas, {
type: 'line',
data: {
labels: data.map((_, i) => i),
datasets: [{
label: label,
data: data,
borderColor: 'rgba(75, 192, 192, 1)',
tension: 0.1,
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
}
});
}
// --- Core Functions (Internal) ---
function showExplorer() {
panel.classList.remove('hidden');
updateExplorer();
}
function hideExplorer() {
panel.classList.add('hidden');
}
function switchTab(targetTab) {
Object.values(tabs).forEach(tab => {
tab.panel.classList.add('hidden');
// Remove active classes (border and color)
tab.btn.classList.remove('border-b-2', 'border-blue-500');
// Add inactive classes (gray text)
tab.btn.classList.add('text-gray-500', 'dark:text-gray-400');
});
tabs[targetTab].panel.classList.remove('hidden');
// Add active classes (border and color)
tabs[targetTab].btn.classList.add('border-b-2', 'border-blue-500');
// Remove inactive classes (gray text)
tabs[targetTab].btn.classList.remove('text-gray-500', 'dark:text-gray-400');
footer.classList.toggle('hidden', targetTab !== 'grid');
}
function createTreeView(data) {
const pre = document.createElement('pre');
pre.textContent = JSON.stringify(data, (key, value) => {
if (key.startsWith('_')) return undefined;
return value;
}, 2);
return pre;
}
function updateExplorer() {
if (panel.classList.contains('hidden') || !appState.vizData) return;
const frame = appState.vizData.radarFrames[appState.currentFrame];
if (!frame) return;
// --- START: Correctly gather track data for the current frame ---
// We iterate through all tracks and find the history log entry for the current frame.
const tracksForCurrentFrame = appState.vizData.tracks
.map(track => {
const log = track.historyLog.find(log => log.frameIdx === appState.currentFrame);
// Return a new object combining track ID with its log for this frame, if it exists.
return log ? { trackId: track.id, ...log } : null;
})
.filter(Boolean); // Filter out any null entries for tracks not present in this frame.
// --- END: Correctly gather track data for the current frame ---
tabs.tree.panel.innerHTML = '';
tabs.tree.panel.appendChild(createTreeView({
currentFrame: appState.currentFrame + 1,
frameData: frame,
trackData: tracksForCurrentFrame // Use the newly created array
}));
// --- START: Auto-update Point Cloud Grid ---
displayInGrid(frame.pointCloud, `${appState.currentFrame + 1}`);
// --- END: Auto-update Point Cloud Grid ---
displayTracksInGrid(tracksForCurrentFrame);
}
function displayInGrid(data, title) {
if (!Array.isArray(data) || data.length === 0 || !gridApi) return;
// --- START: Add index and prepare data ---
// We map the original data to a new array, adding an 'index' property to each object.
const indexedData = data.map((row, index) => ({
index: index,
...row
}));
currentGridData = indexedData; // Store the new indexed data
// --- END: Add index and prepare data ---
// --- START: More robust column generation ---
// Auto-generate columns, and assign a 'type' if the data is numeric.
const columns = Object.keys(indexedData[0]).map(key => ({
field: key,
headerName: key, // Set header name
// If the first row's value for this key is a number, assign our custom number type.
type: typeof indexedData[0][key] === 'number' ? 'numberColumn' : undefined
}));
// --- END: More robust column generation ---
gridApi.setGridOption('columnDefs', columns);
gridApi.setGridOption('rowData', indexedData);
// --- START: Apply default sort ---
// After setting the data, apply a sort model to sort by the 'index' column ascending.
gridApi.applyColumnState({ state: [{ colId: 'index', sort: 'asc' }] });
// --- END: Apply default sort ---
tabs.grid.btn.textContent = `Point Cloud: Frame ${title}`;
}
function displayTracksInGrid(trackData) {
if (!trackGridApi || !Array.isArray(trackData) || trackData.length === 0) {
if (trackGridApi) trackGridApi.setGridOption('rowData', []);
return;
}
// --- START: Update Track Grid Title ---
tabs.trackGrid.btn.textContent = `Track Grid: Frame ${appState.currentFrame + 1}`;
// --- END: Update Track Grid Title ---
// --- START: Build a complete set of columns from ALL tracks ---
// Create a Set of all unique keys from all track objects in the current frame.
// This prevents missing columns if the first track has fewer properties than others.
const allKeys = new Set();
trackData.forEach(track => {
Object.keys(track).forEach(key => allKeys.add(key));
});
// --- START: Fix for column type detection ---
const columns = Array.from(allKeys).map(key => ({
field: key,
headerName: key,
// Find the first track that has a non-null value for this key to determine its type.
// This is much more reliable than only checking the first track in the list.
type: typeof trackData.find(t => t[key] !== null && t[key] !== undefined)?.[key] === 'number' ? 'numberColumn' : undefined,
valueFormatter: params => {
if (Array.isArray(params.value)) {
// --- START: Robust Array Formatting ---
// Check if the item 'v' is a number before calling toFixed.
// If it's not a number (e.g., it's another array), stringify it.
return `[${params.value.map(v => (typeof v === 'number' && v !== null) ? v.toFixed(3) : JSON.stringify(v)).join(', ')}]`;
// --- END: Robust Array Formatting ---
}
return params.value;
}
}));
// --- END: Fix for column type detection ---
// --- END: Build a complete set of columns from ALL tracks ---
trackGridApi.setGridOption('columnDefs', columns);
trackGridApi.setGridOption('rowData', trackData);
}
// --- START: New Robust Update Logic ---
let throttleTimer = null;
let debounceTimer = null;
export function throttledUpdateExplorer() {
// Clear any pending final update, as a new call has come in.
clearTimeout(debounceTimer);
// If we are not currently in a "cool-down" period from a throttled call...
if (!throttleTimer) {
updateExplorer(); // ...execute the update immediately.
// Then, set a cool-down timer to prevent another immediate execution.
throttleTimer = setTimeout(() => { throttleTimer = null; }, 100); // 100ms throttle for snappy display
}
// Schedule a final, debounced update for after the interactions stop.
debounceTimer = setTimeout(() => { updateExplorer(); }, 500); // 500ms debounce
}
// --- END: New Robust Update Logic ---
// --- START: Resizable and Draggable Panel Logic ---
function makePanelInteractive(panel) {
const header = document.getElementById('data-explorer-header');
const resizers = panel.querySelectorAll('.resizer');
const minWidth = 400;
const minHeight = 300;
let original_width = 0;
let original_height = 0;
let original_x = 0;
let original_y = 0;
let original_mouse_x = 0;
let original_mouse_y = 0;
// --- Dragging Logic ---
header.addEventListener('mousedown', (e) => {
e.preventDefault();
original_x = panel.offsetLeft;
original_y = panel.offsetTop;
original_mouse_x = e.pageX;
original_mouse_y = e.pageY;
document.body.classList.add('dragging');
window.addEventListener('mousemove', dragPanel);
window.addEventListener('mouseup', stopDrag);
});
function dragPanel(e) {
const dx = e.pageX - original_mouse_x;
const dy = e.pageY - original_mouse_y;
panel.style.left = `${original_x + dx}px`;
panel.style.top = `${original_y + dy}px`;
}
function stopDrag() {
document.body.classList.remove('dragging');
window.removeEventListener('mousemove', dragPanel);
window.removeEventListener('mouseup', stopDrag);
}
// --- Resizing Logic ---
resizers.forEach(resizer => {
resizer.addEventListener('mousedown', (e) => {
e.preventDefault();
original_width = parseFloat(getComputedStyle(panel, null).getPropertyValue('width').replace('px', ''));
original_height = parseFloat(getComputedStyle(panel, null).getPropertyValue('height').replace('px', ''));
original_x = panel.getBoundingClientRect().left;
original_y = panel.getBoundingClientRect().top;
original_mouse_x = e.pageX;
original_mouse_y = e.pageY;
const resizeFunc = (event) => resizePanel(event, resizer.classList);
document.body.classList.add('resizing');
window.addEventListener('mousemove', resizeFunc);
window.addEventListener('mouseup', () => {
document.body.classList.remove('resizing');
window.removeEventListener('mousemove', resizeFunc);
});
});
});
function resizePanel(e, direction) {
// --- START: Fix for Resizing Logic ---
// The logic is updated to handle corners correctly by checking for 't', 'b', 'l', 'r' substrings.
// This allows a corner like 'resizer-br' to trigger both bottom and right resizing logic.
if (direction.toString().includes('r')) { // Check for right edge
const width = original_width + (e.pageX - original_mouse_x);
if (width > minWidth) panel.style.width = `${width}px`;
}
if (direction.toString().includes('b')) { // Check for bottom edge
const height = original_height + (e.pageY - original_mouse_y);
if (height > minHeight) panel.style.height = `${height}px`;
}
if (direction.toString().includes('l')) { // Check for left edge
const newWidth = original_width - (e.pageX - original_mouse_x);
if (newWidth > minWidth) {
panel.style.width = `${newWidth}px`;
panel.style.left = `${original_x + (e.pageX - original_mouse_x)}px`;
}
}
if (direction.toString().includes('t')) { // Check for top edge
const newHeight = original_height - (e.pageY - original_mouse_y);
if (newHeight > minHeight) {
panel.style.height = `${newHeight}px`;
panel.style.top = `${original_y + (e.pageY - original_mouse_y)}px`;
}
}
// --- END: Fix for Resizing Logic ---
}
}
function initializePanelPosition(panel) {
// Remove Tailwind classes that conflict with dynamic positioning/sizing
panel.classList.remove('bottom-24', 'right-4', 'w-full', 'max-w-2xl', 'h-1/2');
// Set initial size and position with inline styles
const initialWidth = 896; // Corresponds to max-w-2xl
const initialHeight = window.innerHeight / 2;
panel.style.width = `${initialWidth}px`;
panel.style.height = `${initialHeight}px`;
panel.style.top = `${window.innerHeight - initialHeight - 96}px`; // 96px is roughly bottom-24
panel.style.left = `${window.innerWidth - initialWidth - 16}px`; // 16px is right-4
}
// --- END: Resizable and Draggable Panel Logic ---
// --- Initialization Function (The file's only export) ---
export function initializeDataExplorer() {
// Initialize the grid
if (!gridApi) {
gridApi = agGrid.createGrid(gridDiv, gridOptions);
}
if (!trackGridApi) {
trackGridApi = agGrid.createGrid(trackGridDiv, trackGridOptions);
}
// --- START: Make panel interactive ---
initializePanelPosition(panel);
makePanelInteractive(panel);
// --- END: Make panel interactive ---
// --- Wire up all event listeners ---
// Toggle panel visibility
explorerBtn.addEventListener('click', () => {
if (panel.classList.contains('hidden')) {
showExplorer();
} else {
hideExplorer();
}
});
closeBtn.addEventListener('click', hideExplorer);
// Tab switching
Object.keys(tabs).forEach(key => {
tabs[key].btn.addEventListener('click', () => switchTab(key));
});
// Plot button
plotBtn.addEventListener('click', () => {
// Fix: Use onColumnHeaderClicked or get column from focused cell
const focusedCell = gridApi.getFocusedCell();
if (!focusedCell) {
alert("Please click a cell in the column you wish to plot.");
return;
}
const colId = focusedCell.column.getColId();
const plotData = currentGridData.map(row => row[colId]).filter(val => typeof val === 'number');
if (plotData.length > 0) {
createChart(plotData, colId);
switchTab('plot');
} else {
alert("The selected column contains no numeric data to plot.");
}
});
// Keyboard shortcut listener
document.addEventListener("keydown", (event) => {
// Ignore if typing in an input
const isTextInputFocused =
event.target.tagName === "INPUT" &&
(event.target.type === "text" || event.target.type === "number");
if (isTextInputFocused) {
return;
}
// Toggle explorer with 'i' key
if (event.key === "i") {
event.preventDefault();
if (panel.classList.contains("hidden")) {
showExplorer();
} else {
hideExplorer();
}
}
});
}