Warning, /firebird/packages/firebird-ng/display/src/lib/services/root-file.service.ts is written in an unsupported language. File is not indexed.
0001 /**
0002 * The app's facility for ROOT files the user points Firebird at: report what a
0003 * file holds, and convert podio events to DEX. Both run in a worker.
0004 *
0005 * Scope: this service moves BYTES and FACTS. It never decides what a file is
0006 * for - `probe()` returns the file's top-level keys and the routing control
0007 * (FileOpenRouterService) asks the registered loaders what to do with them. The
0008 * DEX conversion itself belongs to @dexvis/root2dex; this is only its
0009 * transport.
0010 *
0011 * Files are opened under handles (`createHandle()`): each client keeps its own
0012 * file open across conversions, so the "pick a file, then step through events"
0013 * loop pays the tree-metadata read once, and one client opening a file never
0014 * replaces another client's. Nothing here reads the whole file: a multi-GB
0015 * file is fine, only the baskets of the requested events cross the wire.
0016 *
0017 * Bundle note: the worker is created on the first use, so jsroot and the
0018 * converter are fetched when a user actually opens a file.
0019 */
0020
0021 import { Injectable, inject, signal } from '@angular/core';
0022 import type { FileContentProbe } from '@dexvis/firebird-core';
0023 import type {
0024 RootConvertOptions,
0025 RootFileRequest,
0026 RootFileResponse,
0027 RootFileSource,
0028 } from '@dexvis/firebird-ng/workers/root-file';
0029 import { FIREBIRD_WORKERS, UrlService } from '@dexvis/firebird-ng/api';
0030
0031 /** What was learned about a file when it was opened. */
0032 export interface OpenedRootFile {
0033 sourceName: string;
0034 model: string;
0035 entryCount: number;
0036 /**
0037 * The collection groups the model's conversion knows — what a UI offers as
0038 * conversion checkboxes. Names match `pyrobird convert --collections`.
0039 */
0040 collectionGroups: string[];
0041 }
0042
0043 /** Converted events, plus whatever the converter complained about. */
0044 export interface ConvertedEvents {
0045 entries: number[];
0046 dex: unknown;
0047 warnings: string[];
0048 }
0049
0050 interface PendingRequest {
0051 resolve: (value: never) => void;
0052 reject: (error: Error) => void;
0053 /** The handle the request belongs to; undefined for handle-free requests (probe). */
0054 handle?: number;
0055 }
0056
0057 /**
0058 * One client's view of the worker: a file opened under its own handle id, so
0059 * that the data selector's picker and the display's loads never replace each
0060 * other's open file. Close it when done; closing releases only this handle's
0061 * file, and the handle can open another file afterwards.
0062 */
0063 export class RootFileHandle {
0064 /** The file currently open under this handle, or null. */
0065 readonly openedFile = signal<OpenedRootFile | null>(null);
0066 /** True while an open or a conversion of this handle is in flight. */
0067 readonly busy = signal(false);
0068
0069 private inFlight = 0;
0070 /** Bumped by every open and by close: only the latest open sets `openedFile`. */
0071 private openToken = 0;
0072
0073 constructor(
0074 readonly id: number,
0075 private readonly service: RootFileService,
0076 ) {}
0077
0078 /**
0079 * Opens a ROOT file and reads its tree metadata, replacing what this handle
0080 * had open. When opens overlap, only the latest one sets `openedFile`.
0081 */
0082 async open(source: File | string): Promise<OpenedRootFile> {
0083 const token = ++this.openToken;
0084 this.openedFile.set(null);
0085 const opened = await this.track(this.service.request<OpenedRootFile>(this.id, requestId => ({
0086 type: 'open',
0087 requestId,
0088 handle: this.id,
0089 source: this.service.sourceOf(source),
0090 })));
0091 const file: OpenedRootFile = {
0092 sourceName: opened.sourceName,
0093 model: opened.model,
0094 entryCount: opened.entryCount,
0095 collectionGroups: opened.collectionGroups,
0096 };
0097 if (token === this.openToken) this.openedFile.set(file);
0098 return file;
0099 }
0100
0101 /**
0102 * Converts events of this handle's open file to one DEX document.
0103 *
0104 * @param entries Entry numbers as typed by the user: '1', '0,2,4-5'.
0105 * Entries outside the file are dropped with one warning in
0106 * `warnings`; a selection with no entry in the file is rejected.
0107 * @param options Collection groups to convert (names from the opened
0108 * file's `collectionGroups`; absent or empty means all groups) and the
0109 * sim hit collections kept out of MC-truth trajectories.
0110 */
0111 async convert(entries: string, options: RootConvertOptions = {}): Promise<ConvertedEvents> {
0112 const { collections, trajectoryExcludedCollections } = options;
0113 const result = await this.track(this.service.request<{ entries: number[]; dex: unknown; warnings: string[] }>(
0114 this.id,
0115 requestId => ({ type: 'convert', requestId, handle: this.id, entries, collections, trajectoryExcludedCollections }),
0116 ));
0117 return { entries: result.entries, dex: result.dex, warnings: result.warnings };
0118 }
0119
0120 /** Releases this handle's file in the worker; its in-flight requests reject. */
0121 close(): void {
0122 this.openToken++;
0123 this.openedFile.set(null);
0124 this.service.release(this.id);
0125 }
0126
0127 private async track<T>(request: Promise<T>): Promise<T> {
0128 this.inFlight++;
0129 this.busy.set(true);
0130 try {
0131 return await request;
0132 } finally {
0133 this.inFlight--;
0134 this.busy.set(this.inFlight > 0);
0135 }
0136 }
0137 }
0138
0139 @Injectable({ providedIn: 'root' })
0140 export class RootFileService {
0141 private readonly urls = inject(UrlService);
0142 /** The worker factories of the application (withWorkers). */
0143 private readonly workers = inject(FIREBIRD_WORKERS, { optional: true });
0144
0145 private worker: Worker | null = null;
0146 private readonly pending = new Map<string, PendingRequest>();
0147 private requestCounter = 0;
0148 private handleCounter = 0;
0149
0150 /** True when this browser can run the converter at all. */
0151 get isSupported(): boolean {
0152 return typeof Worker !== 'undefined';
0153 }
0154
0155 /** A new handle: one client's open file in the worker. */
0156 createHandle(): RootFileHandle {
0157 return new RootFileHandle(++this.handleCounter, this);
0158 }
0159
0160 /**
0161 * Reports the file's top-level keys with their ROOT class names. Cheap: only
0162 * the key directory is read, no event data and no geometry.
0163 */
0164 async probe(source: File | string): Promise<FileContentProbe> {
0165 const result = await this.request<{ probe: FileContentProbe }>(undefined, requestId => ({
0166 type: 'probe',
0167 requestId,
0168 source: this.sourceOf(source),
0169 }));
0170 return result.probe;
0171 }
0172
0173 /**
0174 * URL aliases (`asset://`, pack aliases) and server-relative paths are resolved
0175 * here, on the main thread: the worker's jsroot reader fetches whatever URL
0176 * it is handed and knows nothing about Firebird's schemes.
0177 */
0178 sourceOf(source: File | string): RootFileSource {
0179 return typeof source === 'string'
0180 ? { kind: 'url', url: this.urls.resolveDownloadUrl(source) }
0181 : { kind: 'file', file: source };
0182 }
0183
0184 /** Sends one request to the worker; `handle` ties it to a RootFileHandle. */
0185 request<T>(handle: number | undefined, build: (requestId: string) => RootFileRequest): Promise<T> {
0186 const worker = this.ensureWorker();
0187 const requestId = `root-file-${++this.requestCounter}`;
0188 return new Promise<T>((resolve, reject) => {
0189 this.pending.set(requestId, {
0190 resolve: resolve as (value: never) => void,
0191 reject,
0192 handle,
0193 });
0194 worker.postMessage(build(requestId));
0195 });
0196 }
0197
0198 /** Drops a handle's file in the worker and rejects its in-flight requests. */
0199 release(handle: number): void {
0200 for (const [requestId, request] of this.pending) {
0201 if (request.handle !== handle) continue;
0202 this.pending.delete(requestId);
0203 request.reject(new Error('The ROOT file was closed'));
0204 }
0205 if (!this.worker) return;
0206 const requestId = `root-file-${++this.requestCounter}`;
0207 this.worker.postMessage({ type: 'close', requestId, handle } satisfies RootFileRequest);
0208 }
0209
0210 private ensureWorker(): Worker {
0211 if (this.worker) return this.worker;
0212 if (!this.isSupported) {
0213 throw new Error('Web Workers are not available, cannot read ROOT files in this browser');
0214 }
0215 if (!this.workers) {
0216 throw new Error('No ROOT file worker: add withWorkers({ geometry, rootFile }) to provideFirebird()');
0217 }
0218 const worker = this.workers.rootFile();
0219 worker.onmessage = ({ data }: MessageEvent<RootFileResponse>) => this.onMessage(data);
0220 worker.onerror = error => {
0221 const message = `ROOT file worker error: ${error.message}`;
0222 console.error(`[RootFileService]: ${message}`);
0223 for (const request of this.pending.values()) request.reject(new Error(message));
0224 this.pending.clear();
0225 // A broken worker is dropped; the next request starts a fresh one
0226 worker.terminate();
0227 if (this.worker === worker) this.worker = null;
0228 };
0229 this.worker = worker;
0230 return worker;
0231 }
0232
0233 private onMessage(data: RootFileResponse): void {
0234 const request = this.pending.get(data.requestId);
0235 if (!request) return;
0236 this.pending.delete(data.requestId);
0237 if (data.type === 'error') {
0238 request.reject(new Error(data.error));
0239 return;
0240 }
0241 if (data.type === 'converted') {
0242 // The transfer delta is the structured-clone serialize + queue +
0243 // deserialize cost of shipping the DEX document out of the worker
0244 const transferMs = Date.now() - data.postedAtMs;
0245 if (data.convertMs > 100 || transferMs > 100) {
0246 console.log(`[load-timing] root worker: convert ${data.convertMs.toFixed(1)} ms, ` +
0247 `worker->main transfer ${transferMs} ms`);
0248 }
0249 }
0250 request.resolve(data as never);
0251 }
0252 }