diff --git a/phoenix/packages/phoenix-event-display/src/helpers/event-autoloader.ts b/phoenix/packages/phoenix-event-display/src/helpers/event-autoloader.ts new file mode 100644 index 000000000..189b3f61b --- /dev/null +++ b/phoenix/packages/phoenix-event-display/src/helpers/event-autoloader.ts @@ -0,0 +1,226 @@ +/** + * Strategies for auto-loading events. + * + * - `apache-listing`: Polls an Apache/nginx directory index page, parses the + * file listing (compatible with https://root.cern.ch/js/files/ style), and + * loads the newest file it hasn't seen yet. + * - `rest-endpoint`: Polls a REST endpoint that returns + * `{ url: string }` or `{ data: object }` pointing to the next event. + * - `sse`: Connects to a Server-Sent Events endpoint. The server pushes + * `data: ` messages whenever a new event is available. + */ +export type AutoloadSource = + | { type: 'apache-listing'; url: string; intervalMs?: number } + | { type: 'rest-endpoint'; url: string; intervalMs?: number } + | { type: 'sse'; url: string }; + +export interface AutoloadOptions { + source: AutoloadSource; + /** Called with the raw event data object when a new event arrives. */ + onEvent: (eventData: any) => void; + /** Called when an error occurs. Does not stop the autoloader. */ + onError?: (err: Error) => void; +} + +/** + * Experiment-agnostic event autoloader. + * + * Watches a directory or endpoint for new event files and calls `onEvent` + * whenever a new one arrives, leaving all rendering decisions to the caller. + * + * @example + * ```ts + * const autoloader = new EventAutoloader({ + * source: { type: 'apache-listing', url: 'https://my-server/events/', intervalMs: 5000 }, + * onEvent: (data) => eventDisplay.buildEventDataFromJSON(data), + * }); + * autoloader.start(); + * // later: + * autoloader.stop(); + * ``` + */ +export class EventAutoloader { + private options: AutoloadOptions; + private intervalId: ReturnType | null = null; + private eventSource: EventSource | null = null; + private seenFiles = new Set(); + private running = false; + + constructor(options: AutoloadOptions) { + this.options = options; + } + + /** Start watching for new events. Safe to call multiple times. */ + start() { + if (this.running) return; + this.running = true; + + const { source } = this.options; + + if (source.type === 'sse') { + this.startSSE(source.url); + } else { + const intervalMs = + (source as { intervalMs?: number }).intervalMs ?? 5000; + // Run once immediately, then on interval + this.poll(); + this.intervalId = setInterval(() => this.poll(), intervalMs); + } + } + + /** Stop watching. */ + stop() { + this.running = false; + if (this.intervalId !== null) { + clearInterval(this.intervalId); + this.intervalId = null; + } + if (this.eventSource) { + this.eventSource.close(); + this.eventSource = null; + } + } + + /** Whether the autoloader is currently active. */ + get isRunning() { + return this.running; + } + + private async poll() { + const { source } = this.options; + try { + if (source.type === 'apache-listing') { + await this.pollApacheListing(source.url); + } else if (source.type === 'rest-endpoint') { + await this.pollRestEndpoint(source.url); + } + } catch (err: any) { + this.options.onError?.(err instanceof Error ? err : new Error(String(err))); + } + } + + /** + * Fetches an Apache/nginx directory listing page, extracts all linked + * .json and .xml files, and loads any that haven't been seen yet. + * Compatible with the JSROOT file listing format at root.cern.ch/js/files/. + */ + private async pollApacheListing(url: string) { + const res = await fetch(url); + if (!res.ok) throw new Error(`Directory listing fetch failed: ${res.status}`); + const html = await res.text(); + + const newFiles = this.parseApacheListing(html, url); + + for (const fileUrl of newFiles) { + if (this.seenFiles.has(fileUrl)) continue; + this.seenFiles.add(fileUrl); + await this.fetchAndEmit(fileUrl); + } + } + + /** + * Parses an Apache/nginx autoindex HTML page and returns absolute URLs + * for all .json and .xml files listed. + */ + private parseApacheListing(html: string, baseUrl: string): string[] { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const anchors = Array.from(doc.querySelectorAll('a[href]')); + const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; + + return anchors + .map((a) => (a as HTMLAnchorElement).getAttribute('href') ?? '') + .filter((href) => /\.(json|xml)(\.zip)?$/.test(href)) + .map((href) => (href.startsWith('http') ? href : base + href)); + } + + /** + * Polls a REST endpoint. Expects a response of: + * - `{ url: string }` — fetches and emits the file at that URL + * - `{ data: object }` — emits the data directly + * - `{ events: object }` — emits the events object directly (Phoenix format) + */ + private async pollRestEndpoint(url: string) { + const res = await fetch(url); + if (!res.ok) throw new Error(`REST endpoint fetch failed: ${res.status}`); + const json = await res.json(); + + if (json?.url) { + const fileUrl: string = json.url; + if (this.seenFiles.has(fileUrl)) return; + this.seenFiles.add(fileUrl); + await this.fetchAndEmit(fileUrl); + } else if (json?.data) { + this.options.onEvent(json.data); + } else if (json?.events) { + this.options.onEvent(json.events); + } else { + // Treat the whole response as event data + this.options.onEvent(json); + } + } + + /** Fetches a .json or .xml(.zip) file and emits its parsed content. */ + private async fetchAndEmit(fileUrl: string) { + const res = await fetch(fileUrl); + if (!res.ok) throw new Error(`Event file fetch failed (${fileUrl}): ${res.status}`); + + const isZip = fileUrl.endsWith('.zip'); + const rawExt = fileUrl.replace(/\.zip$/, '').split('.').pop(); + + let text: string; + if (isZip) { + const buf = await res.arrayBuffer(); + text = await this.unzip(buf); + } else { + text = await res.text(); + } + + if (rawExt === 'json') { + this.options.onEvent(JSON.parse(text)); + } else if (rawExt === 'xml') { + // Emit raw XML string — caller's JiveXMLLoader handles parsing + this.options.onEvent({ __jivexml__: text }); + } + } + + /** Connects to an SSE endpoint and emits events as they arrive. */ + private startSSE(url: string) { + this.eventSource = new EventSource(url); + + this.eventSource.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + this.options.onEvent(data); + } catch (err: any) { + this.options.onError?.(new Error(`SSE parse error: ${err?.message}`)); + } + }; + + this.eventSource.onerror = () => { + this.options.onError?.(new Error('SSE connection error')); + }; + } + + /** Minimal zip extraction using the browser's DecompressionStream (Chrome 80+/FF 113+). */ + private async unzip(buffer: ArrayBuffer): Promise { + // Try native DecompressionStream first (no extra dependency) + if (typeof DecompressionStream !== 'undefined') { + try { + const ds = new DecompressionStream('deflate-raw'); + const writer = ds.writable.getWriter(); + writer.write(buffer); + writer.close(); + const out = await new Response(ds.readable).arrayBuffer(); + return new TextDecoder().decode(out); + } catch { + // fall through to JSZip path + } + } + // Fallback: dynamic import of JSZip (already a project dependency) + const JSZip = (await import('jszip')).default; + const zip = await JSZip.loadAsync(buffer); + const firstFile = Object.values(zip.files)[0]; + return firstFile.async('string'); + } +} diff --git a/phoenix/packages/phoenix-event-display/src/index.ts b/phoenix/packages/phoenix-event-display/src/index.ts new file mode 100644 index 000000000..09554f7cc --- /dev/null +++ b/phoenix/packages/phoenix-event-display/src/index.ts @@ -0,0 +1,51 @@ +// Event display +export * from './event-display'; + +// Three +export * from './managers/three-manager/index'; +export * from './managers/three-manager/animations-manager'; +export * from './managers/three-manager/controls-manager'; +export * from './managers/three-manager/effects-manager'; +export * from './managers/three-manager/export-manager'; +export * from './managers/three-manager/import-manager'; +export * from './managers/three-manager/renderer-manager'; +export * from './managers/three-manager/scene-manager'; +export * from './managers/three-manager/selection-manager'; +export * from './managers/three-manager/xr/xr-manager'; +export * from './managers/three-manager/xr/vr-manager'; +export * from './managers/three-manager/xr/ar-manager'; + +// UI +export * from './managers/ui-manager/index'; +export * from './managers/ui-manager/phoenix-menu/phoenix-menu-node'; + +// Extras +export * from './lib/types/configuration'; +export * from './lib/models/cut.model'; +export * from './lib/models/preset-view.model'; + +// Helpers +export * from './helpers/info-logger'; +export * from './helpers/rk-helper'; +export * from './helpers/runge-kutta'; +export * from './helpers/pretty-symbols'; +export * from './helpers/active-variable'; +export * from './helpers/zip'; +export * from './helpers/event-autoloader'; + +// Loaders +export * from './loaders/event-data-loader'; +export * from './loaders/cms-loader'; +export * from './loaders/jivexml-loader'; +export * from './loaders/jsroot-event-loader'; +export * from './loaders/phoenix-loader'; +export * from './loaders/edm4hep-json-loader'; +export * from './loaders/script-loader'; +export * from './loaders/trackml-loader'; +export * from './loaders/objects/cms-objects'; +export * from './loaders/objects/phoenix-objects'; + +// Managers +export * from './managers/state-manager'; +export * from './managers/loading-manager'; +export * from './managers/url-options-manager'; diff --git a/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/event-autoloader.service.ts b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/event-autoloader.service.ts new file mode 100644 index 000000000..335841e43 --- /dev/null +++ b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/event-autoloader.service.ts @@ -0,0 +1,108 @@ +import { Injectable, OnDestroy } from '@angular/core'; +import { EventAutoloader, type AutoloadSource } from 'phoenix-event-display'; +import { EventDisplayService } from './event-display.service'; +import { FileLoaderService } from './file-loader.service'; + +/** + * Angular service for auto-loading events from a directory or endpoint. + * + * Supports three source types: + * - `apache-listing`: polls an Apache/nginx directory index (e.g. JSROOT-style) + * - `rest-endpoint`: polls a REST API that returns the next event URL or data + * - `sse`: connects to a Server-Sent Events stream for push-based delivery + * + * @example — Apache directory (JSROOT-style): + * ```ts + * this.autoloaderService.start({ + * type: 'apache-listing', + * url: 'https://my-server/events/', + * intervalMs: 5000, + * }); + * ``` + * + * @example — AWS Lambda / REST endpoint: + * ```ts + * this.autoloaderService.start({ + * type: 'rest-endpoint', + * url: 'https://lambda-url.amazonaws.com/latest-event', + * intervalMs: 3000, + * }); + * ``` + * + * @example — Server-Sent Events (push): + * ```ts + * this.autoloaderService.start({ type: 'sse', url: '/api/events/stream' }); + * ``` + */ +@Injectable({ providedIn: 'root' }) +export class EventAutoloaderService implements OnDestroy { + private autoloader: EventAutoloader | null = null; + + constructor( + private eventDisplay: EventDisplayService, + private fileLoader: FileLoaderService, + ) {} + + /** + * Start auto-loading events from the given source. + * Stops any previously running autoloader first. + * @param source Source configuration. + * @param onError Optional error callback. + */ + start(source: AutoloadSource, onError?: (err: Error) => void) { + this.stop(); + + this.autoloader = new EventAutoloader({ + source, + onEvent: (data) => this.handleEvent(data), + onError: onError ?? ((err) => console.error('[EventAutoloader]', err)), + }); + + this.autoloader.start(); + } + + /** Stop the autoloader. */ + stop() { + this.autoloader?.stop(); + this.autoloader = null; + } + + get isRunning() { + return this.autoloader?.isRunning ?? false; + } + + ngOnDestroy() { + this.stop(); + } + + /** + * Routes incoming event data to the correct loader based on its shape. + * - `{ __jivexml__: string }` → JiveXMLLoader + * - plain object → buildEventDataFromJSON (Phoenix/JSON format) + */ + private handleEvent(data: any) { + if (data?.__jivexml__) { + this.fileLoader.loadJiveXMLEvent(data.__jivexml__, this.eventDisplay); + } else { + // Phoenix JSON format: may be a single event or a multi-event object + if (this.isMultiEventObject(data)) { + this.eventDisplay.parsePhoenixEvents(data); + } else { + this.eventDisplay.buildEventDataFromJSON(data); + } + } + } + + /** + * Heuristic: if the object's values are all objects (not arrays of physics + * objects), treat it as a multi-event Phoenix format. + */ + private isMultiEventObject(data: any): boolean { + if (typeof data !== 'object' || Array.isArray(data)) return false; + const values = Object.values(data); + return ( + values.length > 0 && + values.every((v) => typeof v === 'object' && !Array.isArray(v)) + ); + } +} diff --git a/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts new file mode 100644 index 000000000..39acd4a5e --- /dev/null +++ b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/services/index.ts @@ -0,0 +1,4 @@ +export * from './event-display.service'; +export * from './event-autoloader.service'; +export * from './extras/event-data-import'; +export * from './extras/attribute.pipe';