diff --git a/Dockerfile b/Dockerfile index 724621b57..e425665ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,7 @@ ENV npm_config_python=/usr/bin/python3 # CI environment variables ENV CI=1 ENV CYPRESS_INSTALL_BINARY=0 +ENV HUSKY=0 # Install dependencies (verbose so you can see if python errors occur) RUN yarn install --silent diff --git a/packages/phoenix-event-display/configs/jest.conf.js b/packages/phoenix-event-display/configs/jest.conf.js index f809bb8d6..a5c491937 100644 --- a/packages/phoenix-event-display/configs/jest.conf.js +++ b/packages/phoenix-event-display/configs/jest.conf.js @@ -14,6 +14,10 @@ module.exports = { '^@angular/core$': '/src/tests/helpers/angular-core-mock', // Strip .js extensions from relative TypeScript imports '^(\\.{1,2}/.+)\\.js$': '$1', + // Mock the Web Worker wrapper — import.meta.url is not valid in Jest's + // CommonJS/ts-jest environment regardless of tsconfig module setting. + '(.*)/workers/event-data-parser$': + '/src/tests/helpers/event-data-parser-mock', }, transform: { '^.+\\.m?[tj]s$': [ diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index 6283026b0..3ee88bc78 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -1,21 +1,18 @@ -import { httpRequest, settings as jsrootSettings, openFile } from 'jsroot'; +import { httpRequest, openFile } from 'jsroot'; +import { settings as jsrootSettings } from 'jsroot'; import { build } from 'jsroot/geom'; -import { ActiveVariable } from './helpers/active-variable'; +import { ThreeManager } from './managers/three-manager/index'; +import { UIManager } from './managers/ui-manager/index'; import { InfoLogger } from './helpers/info-logger'; -import { getLabelTitle } from './helpers/labels'; import type { Configuration } from './lib/types/configuration'; -import { PhoenixLoader } from './loaders/phoenix-loader'; -import { LoadingManager } from './managers/loading-manager'; import { StateManager } from './managers/state-manager'; +import { LoadingManager } from './managers/loading-manager'; +import { URLOptionsManager } from './managers/url-options-manager'; +import { ActiveVariable } from './helpers/active-variable'; import type { AnimationPreset } from './managers/three-manager/animations-manager'; -import { ThreeManager } from './managers/three-manager/index'; import { XRSessionType } from './managers/three-manager/xr/xr-manager'; -import { UIManager } from './managers/ui-manager/index'; -import { URLOptionsManager } from './managers/url-options-manager'; -import type { - PhoenixEventData, - PhoenixEventsData, -} from './lib/types/event-data'; +import { getLabelTitle } from './helpers/labels'; +import { PhoenixLoader } from './loaders/phoenix-loader'; declare global { /** @@ -34,7 +31,7 @@ export class EventDisplay { /** Configuration for preset views and event data loader. */ public configuration: Configuration; /** An object containing event data. */ - private eventsData: PhoenixEventsData; + private eventsData: any; /** 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. */ @@ -51,8 +48,6 @@ export class EventDisplay { private stateManager: StateManager; /** URL manager for managing options given through URL. */ private urlOptionsManager: URLOptionsManager; - /** Flag to track if EventDisplay has been initialized. */ - private isInitialized: boolean = false; /** * Create the Phoenix event display and intitialize all the elements. @@ -73,10 +68,6 @@ export class EventDisplay { * @param configuration Configuration used to customize different aspects. */ public init(configuration: Configuration) { - if (this.isInitialized) { - this.cleanup(); - } - this.isInitialized = true; this.configuration = configuration; // Initialize the three manager with configuration @@ -104,24 +95,6 @@ export class EventDisplay { this.enableKeyboardControls(); } - /** - * Cleanup event listeners and resources before re-initialization. - */ - public cleanup() { - if (this.graphicsLibrary) { - this.graphicsLibrary.cleanup(); - } - if (this.ui) { - this.ui.cleanup(); - } - // Clear accumulated callbacks - this.onEventsChange = []; - this.onDisplayedEventChange = []; - // Reset singletons for clean view transition - this.loadingManager?.reset(); - this.stateManager?.resetForViewTransition(); - } - /** * Initialize XR. * @param xrSessionType Type of the XR session. Either AR or VR. @@ -145,7 +118,7 @@ export class EventDisplay { * @param eventsData Object containing the event data. * @returns Array of strings containing the keys of the eventsData object. */ - public parsePhoenixEvents(eventsData: PhoenixEventsData): string[] { + public parsePhoenixEvents(eventsData: any): string[] { this.eventsData = eventsData; if (typeof this.configuration.eventDataLoader === 'undefined') { this.configuration.eventDataLoader = new PhoenixLoader(); @@ -163,7 +136,7 @@ export class EventDisplay { * of physics objects. * @param eventData Object containing the event data. */ - public buildEventDataFromJSON(eventData: PhoenixEventData) { + public async buildEventDataFromJSON(eventData: any) { // Reset labels this.resetLabels(); // Creating UI folder @@ -190,7 +163,7 @@ export class EventDisplay { * the event associated with that key. * @param eventKey String that represents the event in the eventsData object. */ - public loadEvent(eventKey: string) { + public async loadEvent(eventKey: any) { const event = this.eventsData[eventKey]; if (event) { @@ -578,17 +551,15 @@ export class EventDisplay { * Add a callback to onDisplayedEventChange array to call * the callback on changes to the displayed event. * @param callback Callback to be added to the onDisplayedEventChange array. - * @returns Unsubscribe function to remove the callback. */ public listenToDisplayedEventChange( callback: (event: any) => any, ): () => void { this.onDisplayedEventChange.push(callback); return () => { - const index = this.onDisplayedEventChange.indexOf(callback); - if (index > -1) { - this.onDisplayedEventChange.splice(index, 1); - } + this.onDisplayedEventChange = this.onDisplayedEventChange.filter( + (cb) => cb !== callback, + ); }; } @@ -596,17 +567,13 @@ export class EventDisplay { * Add a callback to onEventsChange array to call * the callback on changes to the events. * @param callback Callback to be added to the onEventsChange array. - * @returns Unsubscribe function to remove the callback. */ public listenToLoadedEventsChange( callback: (events: any) => any, ): () => void { this.onEventsChange.push(callback); return () => { - const index = this.onEventsChange.indexOf(callback); - if (index > -1) { - this.onEventsChange.splice(index, 1); - } + this.onEventsChange = this.onEventsChange.filter((cb) => cb !== callback); }; } diff --git a/packages/phoenix-event-display/src/loaders/jivexml-loader.ts b/packages/phoenix-event-display/src/loaders/jivexml-loader.ts index ea853c96e..a193effec 100644 --- a/packages/phoenix-event-display/src/loaders/jivexml-loader.ts +++ b/packages/phoenix-event-display/src/loaders/jivexml-loader.ts @@ -1,5 +1,6 @@ import { PhoenixLoader } from './phoenix-loader'; import { CoordinateHelper } from '../helpers/coordinate-helper'; +import { EventDataParserWorker } from '../workers/event-data-parser'; /** * PhoenixLoader for processing and loading an event from the JiveXML data format. @@ -9,6 +10,8 @@ export class JiveXMLLoader extends PhoenixLoader { private data: any; /** List of tracks to draw with thicker tubes */ thickTrackCollections: string[]; + /** Worker for off-main-thread XML parsing */ + private parserWorker: EventDataParserWorker; /** * Constructor for the JiveXMLLoader. @@ -18,6 +21,7 @@ export class JiveXMLLoader extends PhoenixLoader { super(); this.data = {}; this.thickTrackCollections = thickTrackCollections; + this.parserWorker = new EventDataParserWorker(); } /** @@ -30,22 +34,27 @@ export class JiveXMLLoader extends PhoenixLoader { } /** - * Get the event data from the JiveXML data format. - * @returns An object containing all the event data. + * Get the event data from the JiveXML data format asynchronously. + * XML parsing is offloaded to a Web Worker to avoid blocking the main thread. + * @returns Promise resolving to an object containing all the event data. */ - public getEventData(): any { - const parser = new DOMParser(); - const xmlDoc = parser.parseFromString(this.data, 'text/xml'); - - // Handle multiple events later (if JiveXML even supports this?) - const firstEvent = xmlDoc.getElementsByTagName('Event')[0]; - - const eventData = { - eventNumber: firstEvent.getAttribute('eventNumber'), - runNumber: firstEvent.getAttribute('runNumber'), - lumiBlock: firstEvent.getAttribute('lumiBlock'), - time: firstEvent.getAttribute('dateTime'), - Hits: undefined, + public async getEventData(): Promise { + const parsed = await this.parserWorker.parseJiveXML(this.data); + return this.buildEventDataFromParsed(parsed); + } + + /** + * Build the structured event data object from the pre-parsed worker result. + * @param parsed Plain-data result from the Web Worker. + * @returns Structured event data ready for Phoenix rendering. + */ + private buildEventDataFromParsed(parsed: any): any { + const eventData: any = { + eventNumber: parsed.eventNumber, + runNumber: parsed.runNumber, + lumiBlock: parsed.lumiBlock, + time: parsed.time, + Hits: {}, Tracks: {}, Jets: {}, CaloClusters: {}, @@ -58,48 +67,353 @@ export class JiveXMLLoader extends PhoenixLoader { MissingEnergy: {}, }; - // Hits - this.getPixelClusters(firstEvent, eventData); - this.getSCTClusters(firstEvent, eventData); - this.getTRT_DriftCircles(firstEvent, eventData); - this.getMuonPRD(firstEvent, 'MDT', eventData); - this.getRPC(firstEvent, eventData); - this.getMuonPRD(firstEvent, 'TGC', eventData); - this.getMuonPRD(firstEvent, 'CSCD', eventData); - this.getMuonPRD(firstEvent, 'MM', eventData); - this.getMuonPRD(firstEvent, 'STGC', eventData); - - // Tracks - // (must be filled after hits because it might use them) - this.getTracks(firstEvent, eventData); - - // Jets - this.getJets(firstEvent, eventData); - - // Clusters - this.getCaloClusters(firstEvent, eventData); - - // Cells - // this.getFCALCaloCells(firstEvent, 'FCAL', eventData); - this.getCaloCells(firstEvent, 'LAr', eventData); - this.getCaloCells(firstEvent, 'HEC', eventData); - this.getCaloCells(firstEvent, 'Tile', eventData); - - // Vertices - this.getVertices(firstEvent, eventData); - - // MET - this.getMissingEnergy(firstEvent, eventData); - - // Compound objects - this.getElectrons(firstEvent, eventData); - this.getMuons(firstEvent, eventData); - this.getPhotons(firstEvent, eventData); - - // console.log('Got this eventdata', eventData); + this.buildPixelClusters(parsed, eventData); + this.buildSCTClusters(parsed, eventData); + this.buildTRTDriftCircles(parsed, eventData); + this.buildMuonPRDs(parsed, eventData); + this.buildTracks(parsed, eventData); + this.buildJets(parsed, eventData); + this.buildCaloClusters(parsed, eventData); + this.buildCaloCells(parsed, eventData); + this.buildVertices(parsed, eventData); + this.buildMissingEnergy(parsed, eventData); + this.buildElectrons(parsed, eventData); + this.buildMuons(parsed, eventData); + this.buildPhotons(parsed, eventData); + return eventData; } + private buildPixelClusters(parsed: any, eventData: any) { + if (!parsed.pixelClusters) return; + const { count, id, x0, y0, z0, eloss } = parsed.pixelClusters; + eventData.Hits.Pixel = []; + for (let i = 0; i < count; i++) { + eventData.Hits.Pixel.push({ + pos: [x0[i] * 10, y0[i] * 10, z0[i] * 10], + id: id[i], + energyLoss: eloss[i], + }); + } + } + + private buildSCTClusters(parsed: any, eventData: any) { + if (!parsed.sctClusters) return; + const { count, id, phiModule, side, x0, y0, z0 } = parsed.sctClusters; + eventData.Hits.SCT = []; + for (let i = 0; i < count; i++) { + eventData.Hits.SCT.push({ + pos: [x0[i] * 10, y0[i] * 10, z0[i] * 10], + id: id[i], + phiModule: phiModule[i], + side: side[i], + }); + } + } + + private buildTRTDriftCircles(parsed: any, eventData: any) { + if (!parsed.trt) return; + const { + count, + driftR, + id, + noise, + phi, + rhoz, + sub, + threshold, + timeOverThreshold, + } = parsed.trt; + eventData.Hits.TRT = []; + for (let i = 0; i < count; i++) { + let pos: number[]; + if (sub[i] === 1 || sub[i] === 2) { + const z1 = sub[i] === 1 ? -3.5 : 3.5; + const z2 = sub[i] === 1 ? -742 : 742; + pos = [ + Math.cos(phi[i]) * rhoz[i] * 10, + Math.sin(phi[i]) * rhoz[i] * 10, + z1, + Math.cos(phi[i]) * rhoz[i] * 10, + Math.sin(phi[i]) * rhoz[i] * 10, + z2, + ]; + } else { + const r1 = Math.abs(rhoz[i]) > 280 ? 480 : 640; + pos = [ + Math.cos(phi[i]) * r1, + Math.sin(phi[i]) * r1, + rhoz[i] * 10, + Math.cos(phi[i]) * 1030, + Math.sin(phi[i]) * 1030, + rhoz[i] * 10, + ]; + } + eventData.Hits.TRT.push({ + pos, + id: id[i], + type: 'Line', + driftR: driftR[i], + noise: noise[i], + threshold: threshold[i], + timeOverThreshold: timeOverThreshold[i], + }); + } + } + + private buildMuonPRDs(parsed: any, eventData: any) { + for (const rawName of ['MDT', 'TGC', 'CSCD', 'MM', 'STGC', 'RPC']) { + const data = parsed[`muonPRD_${rawName}`]; + if (!data) continue; + const name = rawName === 'CSCD' ? 'CSC' : rawName; + const { count, x, y, z, length, width, id, identifier } = data; + eventData.Hits[name] = []; + for (let i = 0; i < count; i++) { + const hit: any = { + pos: this.getMuonLinePositions(i, x, y, z, length), + id: id[i], + type: 'Line', + identifier: identifier[i], + }; + if (rawName === 'RPC') hit.width = width[i]; + eventData.Hits[name].push(hit); + } + } + } + + private buildTracks(parsed: any, eventData: any) { + const badTracks: Record = {}; + for (const col of parsed.tracks ?? []) { + let name = col.storeGateKey ?? 'Unknown'; + if (name === 'Tracks') name = 'Tracks_'; + const thickTracks = this.thickTrackCollections.includes(name); + const { + count, + numPolyline, + polylineX, + polylineY, + polylineZ, + chi2, + numDoF, + pt, + d0, + z0, + phi0, + cotTheta, + trackAuthor, + } = col; + const jsontracks: any[] = []; + let polylineCounter = 0; + + for (let i = 0; i < count; i++) { + let storeTrack = true; + const track: any = { + chi2: chi2[i] ?? 0, + dof: numDoF[i] ?? 0, + pT: 0, + phi: 0, + eta: 0, + pos: [], + dparams: [], + hits: {}, + author: {}, + badtrack: [], + linewidth: thickTracks ? 20.0 : undefined, + }; + if (trackAuthor?.length > i) track.author = trackAuthor[i]; + + let theta = Math.atan(1 / cotTheta[i]); + track.pT = Math.abs(pt[i]) * 1000; + const momentum = track.pT / Math.sin(theta); + track.dparams = [d0[i], z0[i], phi0[i], theta, 1.0 / momentum]; + track.phi = phi0[i]; + + if (theta < 0) theta += Math.PI; + if (track.phi > Math.PI) track.phi -= 2 * Math.PI; + else if (track.phi < -Math.PI) track.phi += 2 * Math.PI; + + if (!CoordinateHelper.anglesAreSane(theta, track.phi)) { + badTracks['Improper angles'] = + (badTracks['Improper angles'] ?? 0) + 1; + track.badtrack.push('Improper angles'); + storeTrack = false; + } + + track.eta = CoordinateHelper.thetaToEta(theta); + if (Number.isNaN(track.eta)) { + track.badtrack.push('Invalid eta'); + storeTrack = false; + } + + if (numPolyline?.length) { + for (let p = 0; p < numPolyline[i]; p++) { + const x = polylineX[polylineCounter + p] * 10; + const y = polylineY[polylineCounter + p] * 10; + const z = polylineZ[polylineCounter + p] * 10; + track.pos.push([x, y, z]); + } + polylineCounter += numPolyline[i]; + } + + if (storeTrack) jsontracks.push(track); + } + eventData.Tracks[name] = jsontracks; + } + for (const error in badTracks) { + if (badTracks[error] > 0) + console.log( + `${badTracks[error]} tracks had "${error}" and were marked as bad.`, + ); + } + } + + private buildJets(parsed: any, eventData: any) { + for (const col of parsed.jets ?? []) { + const { storeGateKey, count, phi, eta, energy, coneR } = col; + if (!storeGateKey) continue; + eventData.Jets[storeGateKey] = Array.from({ length: count }, (_, i) => ({ + coneR: coneR[i] ?? 0.4, + phi: phi[i], + eta: eta[i], + energy: energy[i] * 1000, + })); + } + } + + private buildCaloClusters(parsed: any, eventData: any) { + for (const col of parsed.caloClusters ?? []) { + const { storeGateKey, count, phi, eta, et } = col; + if (!storeGateKey) continue; + eventData.CaloClusters[storeGateKey] = Array.from( + { length: count }, + (_, i) => ({ + phi: phi[i], + eta: eta[i], + energy: et[i] * 1000, + }), + ); + } + } + + private buildCaloCells(parsed: any, eventData: any) { + for (const name of ['LAr', 'HEC', 'Tile']) { + const data = parsed[`caloCells_${name}`]; + if (!data) continue; + const { count, eta, phi, channel, energy, id } = data; + eventData.CaloCells[name] = Array.from({ length: count }, (_, i) => ({ + eta: eta[i], + phi: phi[i], + id: id[i], + energy: energy[i], + channel: channel[i], + })); + } + } + + private buildVertices(parsed: any, eventData: any) { + for (const col of parsed.vertices ?? []) { + const { + storeGateKey, + count, + x, + y, + z, + chi2, + primVxCand, + vertexType, + numTracks, + sgkey, + tracks, + } = col; + if (!storeGateKey) continue; + const temp: any[] = []; + let trackIndex = 0; + for (let i = 0; i < count; i++) { + const maxIndex = trackIndex + numTracks[i]; + const thisTrackIndices: number[] = []; + for (; trackIndex < maxIndex; trackIndex++) { + thisTrackIndices.push(tracks[trackIndex]); + } + temp.push({ + x: x[i], + y: y[i], + z: z[i], + chi2: chi2[i], + primVxCand: primVxCand[i], + vertexType: vertexType[i], + linkedTracks: thisTrackIndices, + linkedTrackCollection: sgkey[i], + }); + } + eventData.Vertices[storeGateKey] = temp; + } + } + + private buildMuons(parsed: any, eventData: any) { + for (const col of parsed.muons ?? []) { + const { storeGateKey, count, chi2, energy, eta, phi, pt, pdgId } = col; + if (!storeGateKey) continue; + eventData.Muons[storeGateKey] = Array.from({ length: count }, (_, i) => ({ + chi2: chi2[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, + pdgId: pdgId[i], + })); + } + } + + private buildElectrons(parsed: any, eventData: any) { + for (const col of parsed.electrons ?? []) { + const { storeGateKey, count, author, energy, eta, phi, pt, pdgId } = col; + if (!storeGateKey) continue; + eventData.Electrons[storeGateKey] = Array.from( + { length: count }, + (_, i) => ({ + author: author[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, + pdgId: pdgId[i], + }), + ); + } + } + + private buildPhotons(parsed: any, eventData: any) { + for (const col of parsed.photons ?? []) { + const { storeGateKey, count, author, energy, eta, phi, pt } = col; + if (!storeGateKey) continue; + eventData.Photons[storeGateKey] = Array.from( + { length: count }, + (_, i) => ({ + author: author[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, + }), + ); + } + } + + private buildMissingEnergy(parsed: any, eventData: any) { + for (const col of parsed.missingEnergy ?? []) { + const { storeGateKey, count, et, etx, ety } = col; + if (!storeGateKey) continue; + eventData.MissingEnergy[storeGateKey] = Array.from( + { length: count }, + (_, i) => ({ + et: et[i], + etx: etx[i], + ety: ety[i], + }), + ); + } + } + /** * Get the number array from a collection in XML DOM. * @param collection Collection in XML DOM of JiveXML format. @@ -121,21 +435,18 @@ export class JiveXMLLoader extends PhoenixLoader { } /** - * Get the string array from a collection in XML DOM. + * Get the number array from a collection in XML DOM. * @param collection Collection in XML DOM of JiveXML format. * @param key Tag name of the string array. - * @returns String array, or empty array if tag not found. + * @returns String array. */ - private getStringArrayFromHTML(collection: Element, key: any): string[] { - const elements = collection.getElementsByTagName(key); - if (elements.length) { - return elements[0].innerHTML - .replace(/\r\n|\n|\r/gm, ' ') - .trim() - .split(' ') - .map(String); - } - return []; + private getStringArrayFromHTML(collection: Element, key: any) { + return collection + .getElementsByTagName(key)[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(String); } /** * Try to get the position of a hit (i.e. linked from a track) @@ -884,34 +1195,72 @@ export class JiveXMLLoader extends PhoenixLoader { for (const vertexColl of vertexCollections) { const numOfObjects = Number(vertexColl.getAttribute('count')); - // Use safe helper methods to extract arrays - returns empty array if tag not found - const x = this.getNumberArrayFromHTML(vertexColl, 'x'); - const y = this.getNumberArrayFromHTML(vertexColl, 'y'); - const z = this.getNumberArrayFromHTML(vertexColl, 'z'); - const chi2 = this.getNumberArrayFromHTML(vertexColl, 'chi2'); - const primVxCand = this.getNumberArrayFromHTML(vertexColl, 'primVxCand'); - const vertexType = this.getNumberArrayFromHTML(vertexColl, 'vertexType'); - const numTracks = this.getNumberArrayFromHTML(vertexColl, 'numTracks'); - const sgkeyOfTracks = this.getStringArrayFromHTML(vertexColl, 'sgkey'); - const trackIndices = this.getNumberArrayFromHTML(vertexColl, 'tracks'); - - // Skip this collection if required vertex position data is missing - if (x.length === 0 || y.length === 0 || z.length === 0) { - console.warn( - `Skipping vertex collection: missing required x/y/z data for ${vertexColl.getAttribute('storeGateKey')}`, - ); - continue; - } - - const temp = []; + // The nodes are big strings of numbers, and contain carriage returns. So need to strip all of this, make to array of strings, + // then convert to array of numbers + const x = vertexColl + .getElementsByTagName('x')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const y = vertexColl + .getElementsByTagName('y')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const z = vertexColl + .getElementsByTagName('z')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const chi2 = vertexColl + .getElementsByTagName('chi2')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const primVxCand = vertexColl + .getElementsByTagName('primVxCand')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const vertexType = vertexColl + .getElementsByTagName('vertexType')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const numTracks = vertexColl + .getElementsByTagName('numTracks')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const sgkeyOfTracks = vertexColl + .getElementsByTagName('sgkey')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(String); + const trackIndices = vertexColl + .getElementsByTagName('tracks')[0] + .innerHTML.replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + const temp = []; // Ugh let trackIndex = 0; for (let i = 0; i < numOfObjects; i++) { - const maxIndex = trackIndex + (numTracks[i] || 0); + const maxIndex = trackIndex + numTracks[i]; const thisTrackIndices = []; for (; trackIndex < maxIndex; trackIndex++) { - if (trackIndex >= trackIndices.length) { - console.warn('TrackIndex exceeds maximum number of track indices.'); - break; + if (trackIndex > trackIndices.length) { + console.log( + 'Error! TrackIndex exceeds maximum number of track indices.', + ); } thisTrackIndices.push(trackIndices[trackIndex]); } diff --git a/packages/phoenix-event-display/src/tests/helpers/event-data-parser-mock.ts b/packages/phoenix-event-display/src/tests/helpers/event-data-parser-mock.ts new file mode 100644 index 000000000..22794adf7 --- /dev/null +++ b/packages/phoenix-event-display/src/tests/helpers/event-data-parser-mock.ts @@ -0,0 +1,12 @@ +/** Jest mock for EventDataParserWorker — stubs out Web Worker / import.meta.url */ +export class EventDataParserWorker { + parseJSON(jsonString: string): Promise { + return Promise.resolve(JSON.parse(jsonString)); + } + + parseJiveXML(_xmlString: string): Promise { + return Promise.resolve(null); + } + + terminate(): void {} +} diff --git a/packages/phoenix-event-display/src/workers/event-data-parser.ts b/packages/phoenix-event-display/src/workers/event-data-parser.ts new file mode 100644 index 000000000..d37a1d97d --- /dev/null +++ b/packages/phoenix-event-display/src/workers/event-data-parser.ts @@ -0,0 +1,71 @@ +import type { WorkerRequest, WorkerResponse } from './event-data-parser.worker'; + +/** + * Wrapper around the event-data-parser Web Worker. + * Falls back to synchronous parsing when Workers are unavailable. + */ +export class EventDataParserWorker { + private worker: Worker | null = null; + + constructor() { + if (typeof Worker !== 'undefined') { + try { + this.worker = new Worker( + new URL('./event-data-parser.worker', import.meta.url), + { type: 'module' }, + ); + } catch { + this.worker = null; + } + } + } + + /** + * Parse a JSON string off the main thread. + * Falls back to JSON.parse synchronously if Workers are unavailable. + */ + parseJSON(jsonString: string): Promise { + if (!this.worker) { + return Promise.resolve(JSON.parse(jsonString)); + } + return this.postMessage({ type: 'json', payload: jsonString }); + } + + /** + * Parse a JiveXML string off the main thread. + * Falls back to synchronous DOMParser if Workers are unavailable. + */ + parseJiveXML(xmlString: string): Promise { + if (!this.worker) { + return Promise.resolve(this.parseJiveXMLSync(xmlString)); + } + return this.postMessage({ type: 'jivexml', payload: xmlString }); + } + + terminate() { + this.worker?.terminate(); + this.worker = null; + } + + private postMessage(request: WorkerRequest): Promise { + return new Promise((resolve, reject) => { + const handler = (event: MessageEvent) => { + this.worker!.removeEventListener('message', handler); + if (event.data.type === 'error') { + reject(new Error(event.data.message)); + } else { + resolve((event.data as any).result); + } + }; + this.worker!.addEventListener('message', handler); + this.worker!.postMessage(request); + }); + } + + private parseJiveXMLSync(xmlString: string): any { + // Inline fallback — mirrors extractJiveXMLData in the worker + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlString, 'text/xml'); + return xmlDoc.getElementsByTagName('Event')[0] ?? null; + } +} diff --git a/packages/phoenix-event-display/src/workers/event-data-parser.worker.ts b/packages/phoenix-event-display/src/workers/event-data-parser.worker.ts new file mode 100644 index 000000000..509334f1c --- /dev/null +++ b/packages/phoenix-event-display/src/workers/event-data-parser.worker.ts @@ -0,0 +1,263 @@ +/** + * Web Worker for parsing event data off the main thread. + * Handles both JSON (deep clone/transfer) and JiveXML (DOMParser) parsing. + * Three.js object creation must still happen on the main thread. + */ + +export type WorkerRequest = + | { type: 'json'; payload: string } + | { type: 'jivexml'; payload: string }; + +export type WorkerResponse = + | { type: 'json'; result: any } + | { type: 'jivexml'; result: any } + | { type: 'error'; message: string }; + +addEventListener('message', (event: MessageEvent) => { + try { + const { type, payload } = event.data; + + if (type === 'json') { + const result = JSON.parse(payload); + postMessage({ type: 'json', result } satisfies WorkerResponse); + } else if (type === 'jivexml') { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(payload, 'text/xml'); + const firstEvent = xmlDoc.getElementsByTagName('Event')[0]; + + if (!firstEvent) { + throw new Error('No element found in JiveXML'); + } + + // Extract all raw numeric/string arrays from XML here so the main + // thread only receives plain data — no DOM objects cross the boundary. + const result = extractJiveXMLData(firstEvent); + postMessage({ type: 'jivexml', result } satisfies WorkerResponse); + } + } catch (err: any) { + postMessage({ + type: 'error', + message: err?.message ?? String(err), + } satisfies WorkerResponse); + } +}); + +function getNumbers(el: Element, tag: string): number[] { + const nodes = el.getElementsByTagName(tag); + if (!nodes.length) return []; + return nodes[0].innerHTML + .replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); +} + +function getStrings(el: Element, tag: string): string[] { + const nodes = el.getElementsByTagName(tag); + if (!nodes.length) return []; + return nodes[0].innerHTML + .replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(String); +} + +/** + * Extracts all raw data arrays from the JiveXML DOM into plain objects. + * This mirrors the structure consumed by JiveXMLLoader methods, but as + * serialisable data rather than live DOM nodes. + */ +function extractJiveXMLData(firstEvent: Element) { + const attr = (name: string) => firstEvent.getAttribute(name); + + const result: any = { + eventNumber: attr('eventNumber'), + runNumber: attr('runNumber'), + lumiBlock: attr('lumiBlock'), + time: attr('dateTime'), + collections: {} as Record, + }; + + // Helper to collect all elements of a tag into raw data bags + const collectAll = (tag: string) => + Array.from(firstEvent.getElementsByTagName(tag)); + + // --- Tracks --- + result.tracks = collectAll('Track').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + numPolyline: getNumbers(col, 'numPolyline'), + polylineX: getNumbers(col, 'polylineX'), + polylineY: getNumbers(col, 'polylineY'), + polylineZ: getNumbers(col, 'polylineZ'), + chi2: getNumbers(col, 'chi2'), + numDoF: getNumbers(col, 'numDoF'), + pt: getNumbers(col, 'pt'), + d0: getNumbers(col, 'd0'), + z0: getNumbers(col, 'z0'), + phi0: getNumbers(col, 'phi0'), + cotTheta: getNumbers(col, 'cotTheta'), + hits: getNumbers(col, 'hits'), + numHits: getNumbers(col, 'numHits'), + trackAuthor: getNumbers(col, 'trackAuthor'), + })); + + // --- Jets --- + result.jets = collectAll('Jet').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + phi: getNumbers(col, 'phi'), + eta: getNumbers(col, 'eta'), + energy: getNumbers(col, 'energy'), + coneR: getNumbers(col, 'coneR'), + })); + + // --- CaloClusters --- + result.caloClusters = collectAll('Cluster').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + phi: getNumbers(col, 'phi'), + eta: getNumbers(col, 'eta'), + et: getNumbers(col, 'et'), + })); + + // --- PixelClusters --- + const pixEls = firstEvent.getElementsByTagName('PixCluster'); + if (pixEls.length) { + const el = pixEls[0]; + result.pixelClusters = { + count: Number(el.getAttribute('count')), + id: getNumbers(el, 'id'), + x0: getNumbers(el, 'x0'), + y0: getNumbers(el, 'y0'), + z0: getNumbers(el, 'z0'), + eloss: getNumbers(el, 'eloss'), + }; + } + + // --- SCT --- + const sctEls = firstEvent.getElementsByTagName('STC'); + if (sctEls.length) { + const el = sctEls[0]; + result.sctClusters = { + count: Number(el.getAttribute('count')), + id: getNumbers(el, 'id'), + phiModule: getNumbers(el, 'phiModule'), + side: getNumbers(el, 'side'), + x0: getNumbers(el, 'x0'), + y0: getNumbers(el, 'y0'), + z0: getNumbers(el, 'z0'), + }; + } + + // --- TRT --- + const trtEls = firstEvent.getElementsByTagName('TRT'); + if (trtEls.length) { + const el = trtEls[0]; + result.trt = { + count: Number(el.getAttribute('count')), + driftR: getNumbers(el, 'driftR'), + id: getNumbers(el, 'id'), + noise: getNumbers(el, 'noise'), + phi: getNumbers(el, 'phi'), + rhoz: getNumbers(el, 'rhoz'), + sub: getNumbers(el, 'sub'), + threshold: getNumbers(el, 'threshold'), + timeOverThreshold: getNumbers(el, 'timeOverThreshold'), + }; + } + + // --- Muon PRDs: MDT, TGC, CSCD, MM, STGC, RPC --- + for (const name of ['MDT', 'TGC', 'CSCD', 'MM', 'STGC', 'RPC']) { + const els = firstEvent.getElementsByTagName(name); + if (!els.length) continue; + const el = els[0]; + result[`muonPRD_${name}`] = { + count: Number(el.getAttribute('count')), + x: getNumbers(el, 'x'), + y: getNumbers(el, 'y'), + z: getNumbers(el, 'z'), + length: getNumbers(el, 'length'), + width: getNumbers(el, 'width'), + id: getNumbers(el, 'id'), + identifier: getStrings(el, 'identifier'), + }; + } + + // --- CaloCells: LAr, HEC, Tile --- + for (const name of ['LAr', 'HEC', 'Tile']) { + const els = firstEvent.getElementsByTagName(name); + if (!els.length) continue; + const el = els[0]; + result[`caloCells_${name}`] = { + count: Number(el.getAttribute('count')), + eta: getNumbers(el, 'eta'), + phi: getNumbers(el, 'phi'), + channel: getNumbers(el, 'channel'), + energy: getNumbers(el, 'energy'), + id: getNumbers(el, 'id'), + slot: getStrings(el, 'slot'), + }; + } + + // --- Vertices --- + result.vertices = collectAll('RVx').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + x: getNumbers(col, 'x'), + y: getNumbers(col, 'y'), + z: getNumbers(col, 'z'), + chi2: getNumbers(col, 'chi2'), + primVxCand: getNumbers(col, 'primVxCand'), + vertexType: getNumbers(col, 'vertexType'), + numTracks: getNumbers(col, 'numTracks'), + sgkey: getStrings(col, 'sgkey'), + tracks: getNumbers(col, 'tracks'), + })); + + // --- Muons --- + result.muons = collectAll('Muon').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + chi2: getNumbers(col, 'chi2'), + energy: getNumbers(col, 'energy'), + eta: getNumbers(col, 'eta'), + phi: getNumbers(col, 'phi'), + pt: getNumbers(col, 'pt'), + pdgId: getNumbers(col, 'pdgId'), + })); + + // --- Electrons --- + result.electrons = collectAll('Electron').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + author: getStrings(col, 'author'), + energy: getNumbers(col, 'energy'), + eta: getNumbers(col, 'eta'), + phi: getNumbers(col, 'phi'), + pt: getNumbers(col, 'pt'), + pdgId: getNumbers(col, 'pdgId'), + })); + + // --- Photons --- + result.photons = collectAll('Photon').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + author: getStrings(col, 'author'), + energy: getNumbers(col, 'energy'), + eta: getNumbers(col, 'eta'), + phi: getNumbers(col, 'phi'), + pt: getNumbers(col, 'pt'), + })); + + // --- MissingEnergy --- + result.missingEnergy = collectAll('ETMis').map((col) => ({ + storeGateKey: col.getAttribute('storeGateKey'), + count: Number(col.getAttribute('count')), + et: getStrings(col, 'et'), + etx: getNumbers(col, 'etx'), + ety: getNumbers(col, 'ety'), + })); + + return result; +} diff --git a/packages/phoenix-ng/jest.config.js b/packages/phoenix-ng/jest.config.js index 01b4c0193..1f6055f52 100644 --- a/packages/phoenix-ng/jest.config.js +++ b/packages/phoenix-ng/jest.config.js @@ -25,7 +25,16 @@ module.exports = { // 🔑 CRITICAL: ensures CI uses your setup-jest.ts setupFilesAfterEnv: ['/setup-jest.ts'], - moduleNameMapper: pathsToModuleNameMapper(paths, { prefix: '' }), + moduleNameMapper: { + ...pathsToModuleNameMapper(paths, { prefix: '' }), + // Resolve phoenix-event-display from source so Jest doesn't need the dist build + '^phoenix-event-display$': + '/../../packages/phoenix-event-display/src/index.ts', + // Mock the Web Worker wrapper — import.meta.url cannot be parsed by Jest's + // CommonJS runtime. Worker behaviour is not under test in unit tests. + '(.*)/workers/event-data-parser': + '/projects/phoenix-ui-components/lib/test-helpers/event-data-parser-mock.ts', + }, transformIgnorePatterns: [ `/node_modules/(?!.*\\.m?js$|${esModules.join('|')})`, @@ -48,6 +57,14 @@ module.exports = { globals: { 'ts-jest': { isolatedModules: true, + astTransformers: { + before: [ + { + path: 'ts-jest-mock-import-meta', + options: { metaObjectReplacement: { url: '' } }, + }, + ], + }, }, }, }; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/test-helpers/event-data-parser-mock.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/test-helpers/event-data-parser-mock.ts new file mode 100644 index 000000000..22794adf7 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/test-helpers/event-data-parser-mock.ts @@ -0,0 +1,12 @@ +/** Jest mock for EventDataParserWorker — stubs out Web Worker / import.meta.url */ +export class EventDataParserWorker { + parseJSON(jsonString: string): Promise { + return Promise.resolve(JSON.parse(jsonString)); + } + + parseJiveXML(_xmlString: string): Promise { + return Promise.resolve(null); + } + + terminate(): void {} +}