Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
226 changes: 226 additions & 0 deletions phoenix/packages/phoenix-event-display/src/helpers/event-autoloader.ts
Original file line number Diff line number Diff line change
@@ -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: <json-string>` 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<typeof setInterval> | null = null;
private eventSource: EventSource | null = null;
private seenFiles = new Set<string>();
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<string> {
// 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');
}
}
51 changes: 51 additions & 0 deletions phoenix/packages/phoenix-event-display/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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))
);
}
}
Original file line number Diff line number Diff line change
@@ -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';
Loading