diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index 6283026b0..05c88b5c8 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -16,6 +16,10 @@ import type { PhoenixEventData, PhoenixEventsData, } from './lib/types/event-data'; +import { + buildEventSummaries, + type EventSummary, +} from './helpers/event-summary'; declare global { /** @@ -35,6 +39,8 @@ export class EventDisplay { public configuration: Configuration; /** An object containing event data. */ private eventsData: PhoenixEventsData; + /** Currently displayed event key. */ + private currentEventKey: string | null = null; /** Array containing callbacks to be called when events change. */ private onEventsChange: ((events: any) => void)[] = []; /** Array containing callbacks to be called when the displayed event changes. */ @@ -53,6 +59,8 @@ export class EventDisplay { private urlOptionsManager: URLOptionsManager; /** Flag to track if EventDisplay has been initialized. */ private isInitialized: boolean = false; + /** Stored keydown handler for event navigation shortcuts. */ + private eventNavKeydownHandler: ((e: KeyboardEvent) => void) | null = null; /** * Create the Phoenix event display and intitialize all the elements. @@ -114,6 +122,11 @@ export class EventDisplay { if (this.ui) { this.ui.cleanup(); } + // Clean up event navigation keyboard handler + if (this.eventNavKeydownHandler) { + document.removeEventListener('keydown', this.eventNavKeydownHandler); + this.eventNavKeydownHandler = null; + } // Clear accumulated callbacks this.onEventsChange = []; this.onDisplayedEventChange = []; @@ -194,10 +207,65 @@ export class EventDisplay { const event = this.eventsData[eventKey]; if (event) { + this.currentEventKey = eventKey; this.buildEventDataFromJSON(event); } } + /** + * Get the currently displayed event key. + */ + public getCurrentEventKey(): string | null { + return this.currentEventKey; + } + + /** + * Load the next event in the event list. + * Wraps around to the first event after the last. + */ + public nextEvent() { + if (!this.eventsData) return; + const keys = Object.keys(this.eventsData); + if (keys.length === 0) return; + const currentIndex = this.currentEventKey + ? keys.indexOf(this.currentEventKey) + : -1; + const nextIndex = (currentIndex + 1) % keys.length; + this.loadEvent(keys[nextIndex]); + } + + /** + * Load the previous event in the event list. + * Wraps around to the last event before the first. + */ + public previousEvent() { + if (!this.eventsData) return; + const keys = Object.keys(this.eventsData); + if (keys.length === 0) return; + const currentIndex = this.currentEventKey + ? keys.indexOf(this.currentEventKey) + : -1; + const prevIndex = currentIndex <= 0 ? keys.length - 1 : currentIndex - 1; + this.loadEvent(keys[prevIndex]); + } + + /** + * Get all loaded events data. + * @returns The events data object, or undefined if no events loaded. + */ + public getEventsData(): PhoenixEventsData | undefined { + return this.eventsData; + } + + /** + * Build summaries of all loaded events for the event browser. + * @returns Array of event summaries with collection counts and metadata. + */ + public getEventSummaries(): EventSummary[] { + if (!this.eventsData) return []; + return buildEventSummaries(this.eventsData); + } + /** * Get the three manager responsible for three.js functions. * @returns The three.js manager. @@ -751,6 +819,30 @@ export class EventDisplay { public enableKeyboardControls() { this.ui.enableKeyboardControls(); this.graphicsLibrary.enableKeyboardControls(); + + // Remove previous event navigation listener if exists + if (this.eventNavKeydownHandler) { + document.removeEventListener('keydown', this.eventNavKeydownHandler); + } + + // Shift+ArrowRight = next event, Shift+ArrowLeft = previous event + this.eventNavKeydownHandler = (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + const isTyping = ['input', 'textarea', 'select'].includes( + target?.tagName.toLowerCase(), + ); + const hasFocusableContent = target?.hasAttribute('tabindex'); + if (isTyping || hasFocusableContent || !e.shiftKey) return; + + if (e.code === 'ArrowRight') { + e.preventDefault(); + this.nextEvent(); + } else if (e.code === 'ArrowLeft') { + e.preventDefault(); + this.previousEvent(); + } + }; + document.addEventListener('keydown', this.eventNavKeydownHandler); } /** diff --git a/packages/phoenix-event-display/src/helpers/event-summary.ts b/packages/phoenix-event-display/src/helpers/event-summary.ts new file mode 100644 index 000000000..ac954538f --- /dev/null +++ b/packages/phoenix-event-display/src/helpers/event-summary.ts @@ -0,0 +1,174 @@ +import type { + PhoenixEventsData, + PhoenixEventData, + MissingEnergyParams, +} from '../lib/types/event-data'; + +/** Summary of a single event for browsing/filtering. */ +export interface EventSummary { + /** The key used to look up this event in PhoenixEventsData. */ + eventKey: string; + /** Event number from metadata (if available). */ + eventNumber: string | number | undefined; + /** Run number from metadata (if available). */ + runNumber: string | number | undefined; + /** Total number of physics objects across all collections. */ + totalObjects: number; + /** Count of objects per collection type (e.g. "Tracks": 42, "Jets": 5). */ + collectionCounts: { [typeName: string]: number }; + /** Missing transverse energy magnitude in MeV (NaN if no MET collection). */ + met: number; +} + +/** Known collection types that hold arrays of physics objects. */ +const OBJECT_COLLECTION_TYPES = [ + 'Tracks', + 'Jets', + 'Hits', + 'CaloClusters', + 'CaloCells', + 'Muons', + 'Photons', + 'Electrons', + 'Vertices', + 'MissingEnergy', + 'PlanarCaloCells', + 'IrregularCaloCells', +] as const; + +/** + * Count all physics objects in a single event. + * Each collection type (Tracks, Jets, etc.) can have multiple named sub-collections. + */ +function countCollections(eventData: PhoenixEventData): { + counts: { [typeName: string]: number }; + total: number; +} { + const counts: { [typeName: string]: number } = {}; + let total = 0; + + for (const typeName of OBJECT_COLLECTION_TYPES) { + const typeData = eventData[typeName]; + if (!typeData || typeof typeData !== 'object') continue; + + let typeCount = 0; + + if (typeName === 'PlanarCaloCells') { + // PlanarCaloCells has a special structure: { collName: { plane: [...], cells: [...] } } + for (const collName of Object.keys(typeData)) { + const coll = (typeData as any)[collName]; + if (coll?.cells && Array.isArray(coll.cells)) { + typeCount += coll.cells.length; + } + } + } else { + // Standard structure: { collName: objectArray } + for (const collName of Object.keys(typeData)) { + const coll = (typeData as any)[collName]; + if (Array.isArray(coll)) { + typeCount += coll.length; + } + } + } + + if (typeCount > 0) { + counts[typeName] = typeCount; + total += typeCount; + } + } + + return { counts, total }; +} + +/** + * Compute missing transverse energy magnitude from MET collections. + * Returns NaN if no MET data is present. + */ +function computeMET(eventData: PhoenixEventData): number { + const metCollections = eventData.MissingEnergy; + if (!metCollections || typeof metCollections !== 'object') return NaN; + + // Use the first MET collection found, take its first entry + for (const collName of Object.keys(metCollections)) { + const metArray = metCollections[collName]; + if (Array.isArray(metArray) && metArray.length > 0) { + const met = metArray[0] as MissingEnergyParams; + if (met.etx !== undefined && met.ety !== undefined) { + return Math.sqrt(met.etx * met.etx + met.ety * met.ety); + } + } + } + + return NaN; +} + +/** + * Build a summary for a single event. + */ +export function summarizeEvent( + eventKey: string, + eventData: PhoenixEventData, +): EventSummary { + const { counts, total } = countCollections(eventData); + const met = computeMET(eventData); + + return { + eventKey, + eventNumber: + eventData['event number'] ?? eventData.eventNumber ?? undefined, + runNumber: eventData['run number'] ?? eventData.runNumber ?? undefined, + totalObjects: total, + collectionCounts: counts, + met, + }; +} + +/** + * Pre-scan all events and build a summary index. + * This is the main entry point for the event browser. + */ +export function buildEventSummaries( + eventsData: PhoenixEventsData, +): EventSummary[] { + const summaries: EventSummary[] = []; + + for (const eventKey of Object.keys(eventsData)) { + const eventData = eventsData[eventKey]; + if (eventData && typeof eventData === 'object') { + summaries.push(summarizeEvent(eventKey, eventData)); + } + } + + return summaries; +} + +/** Detector-level types that are fixed per detector, not per event. */ +const DETECTOR_LEVEL_TYPES = new Set([ + 'CaloCells', + 'Hits', + 'PlanarCaloCells', + 'IrregularCaloCells', +]); + +/** + * Get all unique collection type names across all events. + * Reconstructed physics objects are listed first, detector-level types last. + */ +export function getAvailableColumns(summaries: EventSummary[]): string[] { + const columnSet = new Set(); + for (const summary of summaries) { + for (const typeName of Object.keys(summary.collectionCounts)) { + columnSet.add(typeName); + } + } + const reco: string[] = []; + const detector: string[] = []; + for (const col of Array.from(columnSet).sort()) { + if (DETECTOR_LEVEL_TYPES.has(col)) { + detector.push(col); + } else { + reco.push(col); + } + } + return [...reco, ...detector]; +} diff --git a/packages/phoenix-event-display/src/index.ts b/packages/phoenix-event-display/src/index.ts index 98ddeae4a..006b71b4d 100644 --- a/packages/phoenix-event-display/src/index.ts +++ b/packages/phoenix-event-display/src/index.ts @@ -32,6 +32,7 @@ export * from './helpers/runge-kutta'; export * from './helpers/pretty-symbols'; export * from './helpers/active-variable'; export * from './helpers/zip'; +export * from './helpers/event-summary'; // Loaders export * from './loaders/event-data-loader'; diff --git a/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/event-browser.svg b/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/event-browser.svg new file mode 100644 index 000000000..f8ff9ed10 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/event-browser.svg @@ -0,0 +1,13 @@ + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/event-browser.svg b/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/event-browser.svg new file mode 100644 index 000000000..f8ff9ed10 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/event-browser.svg @@ -0,0 +1,13 @@ + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts index 7b96687c6..e75c28847 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts @@ -65,6 +65,8 @@ import { EventDataExplorerComponent, EventDataExplorerDialogComponent, CycleEventsComponent, + EventBrowserComponent, + EventBrowserOverlayComponent, } from './ui-menu'; import { AttributePipe } from '../services/extras/attribute.pipe'; @@ -127,6 +129,8 @@ const PHOENIX_COMPONENTS: Type[] = [ FileExplorerComponent, RingLoaderComponent, CycleEventsComponent, + EventBrowserComponent, + EventBrowserOverlayComponent, ]; @NgModule({ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.html new file mode 100644 index 000000000..fea5ff22d --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.html @@ -0,0 +1,228 @@ + +
+ + + + +
+
+ Filter: + + + + + | + MET >= + + +
+ +
+ + {{ getFilterLabel(filter) }} + + +
+
+ + +
+ {{ displayedSummaries.length }} of {{ allSummaries.length }} events +
+ + +
+ + + + + + + + + + + + + + + + + + + +
Event +
+
{{ col }}
+
+ + +
+
+
+
+
+ Reco +
+
+ + +
+
+
+
+
MET
+
+ + +
+
+
+
{{ getEventLabel(summary) }}
+
+ {{ getRunLabel(summary) }} +
+
+ {{ formatNumber(getCount(summary, col)) }} + + {{ formatNumber(getRecoTotal(summary)) }} + {{ formatMET(summary.met) }} + +
+

+ Load event data to browse events here. +

+
+ + +
+ Arrow keys to navigate, Enter to load, Shift+Left/Right for prev/next +
+
+
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.scss new file mode 100644 index 000000000..93994a56e --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.scss @@ -0,0 +1,266 @@ +.event-browser { + height: 95%; + + .search-box { + .search-input { + flex: 1; + padding: 4px 8px; + font-size: 12px; + border: 1px solid rgba(88, 88, 88, 0.15); + box-shadow: var(--phoenix-icon-shadow); + background-color: var(--phoenix-background-color-tertiary); + color: var(--phoenix-text-color-secondary); + border-radius: 4px; + + &::placeholder { + opacity: 0.5; + } + } + + .search-hint { + font-size: 10px; + color: var(--phoenix-text-color-secondary); + opacity: 0.5; + margin-left: 0.4rem; + white-space: nowrap; + } + } + + .filter-controls { + .filter-select, + .filter-input { + padding: 3px 6px; + font-size: 11px; + border: 1px solid rgba(88, 88, 88, 0.08); + box-shadow: var(--phoenix-icon-shadow); + background-color: var(--phoenix-background-color-tertiary); + color: var(--phoenix-text-color-secondary); + border-radius: 3px; + } + + .filter-select { + width: 7rem; + } + + .filter-input { + width: 4rem; + } + + .btn-filter { + padding: 3px 8px; + font-size: 11px; + color: var(--phoenix-text-color-secondary); + border: 1px solid rgba(88, 88, 88, 0.2); + border-radius: 3px; + cursor: pointer; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } + + .filter-separator { + color: var(--phoenix-text-color-secondary); + opacity: 0.4; + margin: 0 0.2rem; + } + + .filter-label { + font-size: 11px; + color: var(--phoenix-text-color-secondary); + } + + .gap-1 { + gap: 0.3rem; + } + } + + .active-filters { + .filter-tag { + display: inline-flex; + align-items: center; + padding: 1px 6px; + margin-right: 0.3rem; + font-size: 10px; + color: var(--phoenix-text-color-secondary); + background: rgba(255, 255, 255, 0.08); + border-radius: 3px; + + .filter-remove { + margin-left: 4px; + font-size: 12px; + line-height: 1; + cursor: pointer; + color: var(--phoenix-text-color-secondary); + opacity: 0.6; + + &:hover { + opacity: 1; + } + } + } + } + + .summary-count { + font-size: 11px; + color: var(--phoenix-text-color-secondary); + opacity: 0.7; + } +} + +.boxBody { + height: 80%; + overflow: auto; + outline: none; + + p.emptyBox { + max-width: 21em; + color: var(--phoenix-text-color-secondary); + } +} + +.event-browser table { + position: relative; + color: var(--phoenix-text-color-secondary); + font-size: 12px; + + thead tr th { + position: sticky; + top: 0; + z-index: 100; + background: var(--phoenix-background-color-secondary); + white-space: nowrap; + font-size: 11px; + + .head-wrapper { + display: flex; + align-items: center; + + .sort-options { + display: flex; + flex-direction: row; + + .icon-wrapper { + display: flex; + width: 0.85rem; + height: 0.85rem; + padding: 0.15rem; + opacity: 0.5; + transition: opacity 0.15s; + + &:hover, + &.sort-active { + opacity: 1; + } + + &.up { + transform: rotate(180deg); + } + + svg { + width: 100%; + height: 100%; + } + } + } + } + } + + tr * { + padding-right: 0.8rem; + + &:last-child { + padding-right: 0; + } + } + + tbody tr { + cursor: pointer; + transition: background 0.1s; + + &:hover { + background: rgba(255, 255, 255, 0.05); + } + + &.selected-event { + color: var(--phoenix-background-color); + background: var(--phoenix-text-color); + box-shadow: 0 0 10px var(--phoenix-text-color); + } + + &.loaded-event:not(.selected-event) { + background: rgba(255, 255, 255, 0.03); + + .event-label { + font-weight: 600; + } + } + } + + td { + min-height: 20px; + white-space: nowrap; + + &.dim-zero { + opacity: 0.25; + } + } + + .event-cell { + .event-label { + font-size: 12px; + line-height: 1.3; + } + + .run-label { + font-size: 10px; + opacity: 0.55; + line-height: 1.2; + } + } + + .btn-load { + position: relative; + width: 1.4rem; + height: 1.4rem; + text-align: center; + background-color: var(--phoenix-options-icon-bg); + border-radius: 8px; + cursor: pointer; + + &:hover { + border: 1px solid var(--phoenix-options-icon-path); + } + + .load-icon { + position: absolute; + top: 0; + left: 0; + padding: 0.3rem; + width: 100%; + height: 100%; + } + + .loading-spinner { + display: inline-block; + width: 10px; + height: 10px; + border: 2px solid rgba(255, 255, 255, 0.2); + border-top-color: var(--phoenix-text-color-secondary); + border-radius: 50%; + animation: spin 0.7s linear infinite; + } + } +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.keyboard-hint { + font-size: 10px; + color: var(--phoenix-text-color-secondary); + opacity: 0.5; +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.ts new file mode 100644 index 000000000..1b3fa15c3 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser-overlay/event-browser-overlay.component.ts @@ -0,0 +1,364 @@ +import { Component, Input, type OnInit, type OnDestroy } from '@angular/core'; +import { type EventSummary, getAvailableColumns } from 'phoenix-event-display'; +import { EventDisplayService } from '../../../../services/event-display.service'; + +/** Sort state for a column. */ +interface SortState { + column: string; + direction: 'asc' | 'desc'; +} + +/** Filter for a collection count column. */ +interface ColumnFilter { + column: string; + operator: '>=' | '<=' | '='; + value: number; +} + +/** Detector-level types (fixed per detector, not per event). */ +const DETECTOR_LEVEL_TYPES = new Set([ + 'CaloCells', + 'Hits', + 'PlanarCaloCells', + 'IrregularCaloCells', +]); + +@Component({ + standalone: false, + selector: 'app-event-browser-overlay', + templateUrl: './event-browser-overlay.component.html', + styleUrls: ['./event-browser-overlay.component.scss'], +}) +export class EventBrowserOverlayComponent implements OnInit, OnDestroy { + @Input() showEventBrowser: boolean; + + /** All event summaries from the pre-scan. */ + allSummaries: EventSummary[] = []; + /** Filtered and sorted summaries for display. */ + displayedSummaries: EventSummary[] = []; + /** Collection type columns available across all events. */ + columns: string[] = []; + /** Currently active sort. */ + currentSort: SortState | null = null; + /** Currently selected event key (row highlight). */ + selectedEventKey: string | null = null; + /** Currently loaded event key (displayed in 3D). */ + loadedEventKey: string | null = null; + /** Event key currently being loaded (for spinner). */ + loadingEventKey: string | null = null; + /** Active filters. */ + filters: ColumnFilter[] = []; + /** MET filter minimum value. */ + metFilterMin: number | null = null; + /** Search query for event number/key. */ + searchQuery: string = ''; + + /** Filter input state for adding new filters. */ + filterColumn: string = ''; + filterOperator: '>=' | '<=' | '=' = '>='; + filterValue: number | null = null; + + private unsubscribes: (() => void)[] = []; + + constructor(private eventDisplay: EventDisplayService) {} + + ngOnInit() { + // Track the currently loaded event + this.loadedEventKey = this.eventDisplay.getCurrentEventKey(); + + // Catch events already loaded before this component initialized + const existing = this.eventDisplay.getEventSummaries(); + if (existing.length > 0) { + this.buildSummaries(); + } + + this.unsubscribes.push( + this.eventDisplay.listenToLoadedEventsChange(() => { + this.buildSummaries(); + }), + ); + + // Update loaded/loading state when any event finishes rendering + this.unsubscribes.push( + this.eventDisplay.listenToDisplayedEventChange(() => { + this.loadedEventKey = this.eventDisplay.getCurrentEventKey(); + this.loadingEventKey = null; + }), + ); + } + + ngOnDestroy() { + this.unsubscribes.forEach((fn) => fn?.()); + } + + /** Build summaries from loaded events data. */ + buildSummaries() { + this.allSummaries = this.eventDisplay.getEventSummaries(); + this.columns = getAvailableColumns(this.allSummaries); + this.filters = []; + this.metFilterMin = null; + this.currentSort = null; + this.applyFiltersAndSort(); + } + + /** Get the count for a collection type in an event summary. */ + getCount(summary: EventSummary, column: string): number { + return summary.collectionCounts[column] ?? 0; + } + + /** Format a number with comma separators. */ + formatNumber(value: number): string { + return value.toLocaleString(); + } + + /** Compute reconstructed objects total (excluding detector-level types). */ + getRecoTotal(summary: EventSummary): number { + let total = 0; + for (const [typeName, count] of Object.entries(summary.collectionCounts)) { + if (!DETECTOR_LEVEL_TYPES.has(typeName)) { + total += count; + } + } + return total; + } + + /** Format MET for display. */ + formatMET(met: number): string { + if (isNaN(met)) return '-'; + return (met / 1000).toFixed(1) + ' GeV'; + } + + /** Sort by a column. Clicking same column+direction resets sort. */ + sort(column: string, direction: 'asc' | 'desc') { + if ( + this.currentSort?.column === column && + this.currentSort?.direction === direction + ) { + this.currentSort = null; + } else { + this.currentSort = { column, direction }; + } + this.applyFiltersAndSort(); + } + + /** Sort by MET. */ + sortByMET(direction: 'asc' | 'desc') { + if ( + this.currentSort?.column === '_met' && + this.currentSort?.direction === direction + ) { + this.currentSort = null; + } else { + this.currentSort = { column: '_met', direction }; + } + this.applyFiltersAndSort(); + } + + /** Sort by reco total. */ + sortByReco(direction: 'asc' | 'desc') { + if ( + this.currentSort?.column === '_reco' && + this.currentSort?.direction === direction + ) { + this.currentSort = null; + } else { + this.currentSort = { column: '_reco', direction }; + } + this.applyFiltersAndSort(); + } + + /** Add a filter from the UI controls. */ + addFilter() { + if (!this.filterColumn || this.filterValue === null) return; + + // Remove existing filter on same column + this.filters = this.filters.filter((f) => f.column !== this.filterColumn); + this.filters.push({ + column: this.filterColumn, + operator: this.filterOperator, + value: this.filterValue, + }); + this.applyFiltersAndSort(); + } + + /** Remove a filter. */ + removeFilter(index: number) { + this.filters.splice(index, 1); + this.applyFiltersAndSort(); + } + + /** Update MET minimum filter. */ + updateMETFilter(value: string) { + const parsed = parseFloat(value); + this.metFilterMin = isNaN(parsed) ? null : parsed * 1000; // Convert GeV to MeV + this.applyFiltersAndSort(); + } + + /** Update search query and re-filter. */ + updateSearch(query: string) { + this.searchQuery = query.trim(); + this.applyFiltersAndSort(); + } + + /** Jump to an event by search - selects and loads the first match. */ + jumpToSearch() { + if (this.displayedSummaries.length > 0) { + this.selectEvent(this.displayedSummaries[0]); + } + } + + /** Clear all filters. */ + clearFilters() { + this.filters = []; + this.metFilterMin = null; + this.searchQuery = ''; + this.applyFiltersAndSort(); + } + + /** Apply all active filters and current sort to produce displayedSummaries. */ + applyFiltersAndSort() { + let result = [...this.allSummaries]; + + // Apply search query (matches event number or event key) + if (this.searchQuery) { + const query = this.searchQuery.toLowerCase(); + result = result.filter( + (s) => + s.eventKey.toLowerCase().includes(query) || + (s.eventNumber !== undefined && + String(s.eventNumber).toLowerCase().includes(query)), + ); + } + + // Apply column filters + for (const filter of this.filters) { + result = result.filter((s) => { + const value = s.collectionCounts[filter.column] ?? 0; + switch (filter.operator) { + case '>=': + return value >= filter.value; + case '<=': + return value <= filter.value; + case '=': + return value === filter.value; + default: + return true; + } + }); + } + + // Apply MET filter + if (this.metFilterMin !== null) { + result = result.filter( + (s) => !isNaN(s.met) && s.met >= this.metFilterMin, + ); + } + + // Apply sort + if (this.currentSort) { + const { column, direction } = this.currentSort; + const multiplier = direction === 'asc' ? 1 : -1; + + result.sort((a, b) => { + let valA: number, valB: number; + if (column === '_met') { + valA = isNaN(a.met) ? -1 : a.met; + valB = isNaN(b.met) ? -1 : b.met; + } else if (column === '_reco') { + valA = this.getRecoTotal(a); + valB = this.getRecoTotal(b); + } else { + valA = a.collectionCounts[column] ?? 0; + valB = b.collectionCounts[column] ?? 0; + } + return (valA - valB) * multiplier; + }); + } + + this.displayedSummaries = result; + } + + /** Load the selected event. */ + selectEvent(summary: EventSummary) { + this.selectedEventKey = summary.eventKey; + this.loadingEventKey = summary.eventKey; + this.eventDisplay.loadEvent(summary.eventKey); + } + + /** Handle keyboard navigation on the table. */ + onTableKeydown(event: KeyboardEvent) { + if (!this.displayedSummaries.length) return; + + const currentIndex = this.selectedEventKey + ? this.displayedSummaries.findIndex( + (s) => s.eventKey === this.selectedEventKey, + ) + : -1; + + let newIndex = currentIndex; + + if (event.key === 'ArrowDown') { + event.preventDefault(); + newIndex = Math.min(currentIndex + 1, this.displayedSummaries.length - 1); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + newIndex = Math.max(currentIndex - 1, 0); + } else if (event.key === 'Enter' && currentIndex >= 0) { + event.preventDefault(); + this.selectEvent(this.displayedSummaries[currentIndex]); + return; + } else { + return; + } + + if (newIndex !== currentIndex && newIndex >= 0) { + this.selectedEventKey = this.displayedSummaries[newIndex].eventKey; + const row = document.getElementById('event-row-' + this.selectedEventKey); + if (row) { + row.scrollIntoView({ block: 'nearest' }); + } + } + } + + /** Get display label for an event (event number + run number). */ + getEventLabel(summary: EventSummary): string { + const parts: string[] = []; + if (summary.eventNumber !== undefined) { + parts.push(String(summary.eventNumber)); + } else { + parts.push(summary.eventKey); + } + return parts.join(''); + } + + /** Get run number display. */ + getRunLabel(summary: EventSummary): string { + if (summary.runNumber !== undefined) { + return 'Run ' + String(summary.runNumber); + } + return ''; + } + + /** Get a filter description for display. */ + getFilterLabel(filter: ColumnFilter): string { + return `${filter.column} ${filter.operator} ${filter.value}`; + } + + /** Check if the sort indicator should be active. */ + isSortActive(column: string, direction: 'asc' | 'desc'): boolean { + return ( + this.currentSort?.column === column && + this.currentSort?.direction === direction + ); + } + + /** Check if a row is the currently loaded event. */ + isLoadedEvent(summary: EventSummary): boolean { + return this.loadedEventKey === summary.eventKey; + } + + /** Check if a row is currently loading. */ + isLoadingEvent(summary: EventSummary): boolean { + return this.loadingEventKey === summary.eventKey; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.html new file mode 100644 index 000000000..831574c5d --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.html @@ -0,0 +1,7 @@ + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.scss new file mode 100644 index 000000000..e69de29bb diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.ts new file mode 100644 index 000000000..c92245977 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/event-browser/event-browser.component.ts @@ -0,0 +1,38 @@ +import { + Component, + type OnInit, + ComponentRef, + type OnDestroy, +} from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { EventBrowserOverlayComponent } from './event-browser-overlay/event-browser-overlay.component'; + +@Component({ + standalone: false, + selector: 'app-event-browser', + templateUrl: './event-browser.component.html', + styleUrls: ['./event-browser.component.scss'], +}) +export class EventBrowserComponent implements OnInit, OnDestroy { + showEventBrowser = false; + overlayWindow: ComponentRef; + + constructor(private overlay: Overlay) {} + + ngOnInit() { + const overlayRef = this.overlay.create(); + const overlayPortal = new ComponentPortal(EventBrowserOverlayComponent); + this.overlayWindow = overlayRef.attach(overlayPortal); + this.overlayWindow.instance.showEventBrowser = this.showEventBrowser; + } + + ngOnDestroy(): void { + this.overlayWindow?.destroy(); + } + + toggleOverlay() { + this.showEventBrowser = !this.showEventBrowser; + this.overlayWindow.instance.showEventBrowser = this.showEventBrowser; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts index 50b4566a0..5167ce627 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts @@ -36,4 +36,6 @@ export * from './share-link/share-link-dialog/share-link-dialog.component'; export * from './event-data-explorer/event-data-explorer.component'; export * from './event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component'; export * from './cycle-events/cycle-events.component'; +export * from './event-browser/event-browser.component'; +export * from './event-browser/event-browser-overlay/event-browser-overlay.component'; export * from './ui-menu-wrapper/ui-menu-wrapper.component'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html index a3126156c..c58328379 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html @@ -40,6 +40,9 @@ [animationPresets]="animationPresets" > + + +