diff --git a/phoenix/packages/phoenix-event-display/src/event-display.ts b/phoenix/packages/phoenix-event-display/src/event-display.ts new file mode 100644 index 000000000..04efb1f0a --- /dev/null +++ b/phoenix/packages/phoenix-event-display/src/event-display.ts @@ -0,0 +1,841 @@ +import { httpRequest, openFile } from 'jsroot'; +import { settings as jsrootSettings } from 'jsroot'; +import { build } from 'jsroot/geom'; +import { ThreeManager } from './managers/three-manager/index'; +import { UIManager } from './managers/ui-manager/index'; +import { InfoLogger } from './helpers/info-logger'; +import type { Configuration } from './lib/types/configuration'; +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 { XRSessionType } from './managers/three-manager/xr/xr-manager'; +import { getLabelTitle } from './helpers/labels'; +import { PhoenixLoader } from './loaders/phoenix-loader'; + +declare global { + /** + * Window interface for adding objects to the window object. + */ + interface Window { + /** EventDisplay object containing event display related functions. */ + EventDisplay: any; + } +} + +/** + * Phoenix event display class for managing detector geometries and event data. + */ +export class EventDisplay { + /** Configuration for preset views and event data loader. */ + public configuration: Configuration; + /** An object containing event data. */ + 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. */ + private onDisplayedEventChange: ((nowDisplayingEvent: any) => void)[] = []; + /** Three manager for three.js operations. */ + private graphicsLibrary: ThreeManager; + /** Info logger for storing event display logs. */ + private infoLogger: InfoLogger; + /** UI manager for UI menu. */ + private ui: UIManager; + /** Loading manager for loadable resources */ + private loadingManager: LoadingManager; + /** State manager for managing event display state. */ + private stateManager: StateManager; + /** URL manager for managing options given through URL. */ + private urlOptionsManager: URLOptionsManager; + + /** + * Create the Phoenix event display and intitialize all the elements. + * @param configuration Configuration used to customize different aspects. + */ + constructor(configuration?: Configuration) { + this.loadingManager = new LoadingManager(); + this.infoLogger = new InfoLogger(); + this.graphicsLibrary = new ThreeManager(this.infoLogger); + this.ui = new UIManager(this.graphicsLibrary); + if (configuration) { + this.init(configuration); + } + } + + /** + * Initialize all the Phoenix event display elements. + * @param configuration Configuration used to customize different aspects. + */ + public init(configuration: Configuration) { + this.configuration = configuration; + + // Initialize the three manager with configuration + this.graphicsLibrary.init(configuration); + // Initialize the UI with configuration + this.ui.init(configuration); + // Set up for the state manager + this.getStateManager().setEventDisplay(this); + + // Animate loop + const uiLoop = () => { + this.ui.updateUI(); + }; + this.graphicsLibrary.setAnimationLoop(uiLoop); + + // Process and apply URL options + this.urlOptionsManager = new URLOptionsManager(this, configuration); + if (configuration.allowUrlOptions !== false) { + this.urlOptionsManager.applyOptions(); + } + + // Allow adding elements through console + this.enableEventDisplayConsole(); + // Allow keyboard controls + this.enableKeyboardControls(); + } + + /** + * Initialize XR. + * @param xrSessionType Type of the XR session. Either AR or VR. + * @param onSessionEnded Callback when the XR session ends. + */ + public initXR(xrSessionType: XRSessionType, onSessionEnded?: () => void) { + this.graphicsLibrary.initXRSession(xrSessionType, onSessionEnded); + } + + /** + * End VR and remove VR settings. + * @param xrSessionType Type of the XR session. Either AR or VR. + */ + public endXR(xrSessionType: XRSessionType) { + this.graphicsLibrary.endXRSession(xrSessionType); + } + + /** + * Receives an object containing all the eventKeys and saves it. + * Then it loads by default the first event. + * @param eventsData Object containing the event data. + * @returns Array of strings containing the keys of the eventsData object. + */ + public parsePhoenixEvents(eventsData: any): string[] { + this.eventsData = eventsData; + if (typeof this.configuration.eventDataLoader === 'undefined') { + this.configuration.eventDataLoader = new PhoenixLoader(); + } + const eventKeys = + this.configuration.eventDataLoader.getEventsList(eventsData); + this.loadEvent(eventKeys[0]); + this.onEventsChange.forEach((callback) => callback(eventKeys)); + + return eventKeys; + } + + /** + * Receives an object containing one event and builds the different collections + * of physics objects. + * @param eventData Object containing the event data. + */ + public async buildEventDataFromJSON(eventData: any) { + // Reset labels + this.resetLabels(); + // Creating UI folder + this.ui.addEventDataFolder(); + this.ui.addLabelsFolder(); + // Clearing existing event data + this.graphicsLibrary.clearEventData(); + // Build data and add to scene + if (this.configuration.eventDataLoader) { + this.configuration.eventDataLoader.buildEventData( + eventData, + this.graphicsLibrary, + this.ui, + this.infoLogger, + ); + } + this.onDisplayedEventChange.forEach((callback) => callback(eventData)); + // Reload the event data state in Phoenix menu + this.ui.loadEventFolderPhoenixMenuState(); + } + + /** + * Receives a string representing the key of an event and loads + * the event associated with that key. + * @param eventKey String that represents the event in the eventsData object. + */ + public async loadEvent(eventKey: any) { + const event = this.eventsData[eventKey]; + + if (event) { + this.buildEventDataFromJSON(event); + } + } + + /** + * Get the three manager responsible for three.js functions. + * @returns The three.js manager. + */ + public getThreeManager() { + return this.graphicsLibrary; + } + + /** + * Get the UI manager responsible for UI related functions. + * @returns The UI manager. + */ + public getUIManager() { + return this.ui; + } + + /** + * Get the info logger containing event display logs. + * @returns The info logger instance being used by the event display. + */ + public getInfoLogger() { + return this.infoLogger; + } + + /** + * Get the loading manager for managing loadable items. + * @returns The loading manager. + */ + public getLoadingManager() { + return this.loadingManager; + } + + /** + * Get the state manager that manages event display's state. + * @returns The state manager. + */ + public getStateManager() { + if (!this.stateManager) { + this.stateManager = new StateManager(); + } + + return this.stateManager; + } + + /** + * Get the URL options manager that manages options given through URL. + * @returns The URL options manager. + */ + public getURLOptionsManager() { + return this.urlOptionsManager; + } + + // ********************** + // * LOADING GEOMETRIES * + // ********************** + + /** + * Loads an OBJ (.obj) geometry from the given filename + * and adds it to the dat.GUI menu. + * @param filename Path to the geometry. + * @param name Name given to the geometry. + * @param color Color to initialize the geometry. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param doubleSided If true, render both sides of the material. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + * @param setFlat Whether object should be flat-shaded or not. Default `true`. + * @returns Promise for loading the geometry. + */ + public async loadOBJGeometry( + filename: string, + name: string, + color: any, + menuNodeName: string, + doubleSided: boolean, + initiallyVisible: boolean = true, + setFlat: boolean = true, + ): Promise { + this.loadingManager.addLoadableItem(`obj_geom_${name}`); + + const { object } = await this.graphicsLibrary.loadOBJGeometry( + filename, + name, + color, + doubleSided, + initiallyVisible, + setFlat, + ); + this.ui.addGeometry(object, menuNodeName); + + this.loadingManager.itemLoaded(`obj_geom_${name}`); + this.infoLogger.add(name, 'Loaded OBJ geometry'); + } + + /** + * Parses and loads an OBJ geometry from the given content + * and adds it to the dat.GUI menu. + * @param content Content of the OBJ geometry. + * @param name Name given to the geometry. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + */ + public parseOBJGeometry( + content: string, + name: string, + menuNodeName?: string, + initiallyVisible: boolean = true, + ) { + this.loadingManager.addLoadableItem(`parse_obj_${name}`); + const { object } = this.graphicsLibrary.parseOBJGeometry( + content, + name, + initiallyVisible, + ); + this.ui.addGeometry(object, menuNodeName); + this.loadingManager.itemLoaded(`parse_obj_${name}`); + } + + /** + * Exports scene to OBJ file format. + */ + public exportToOBJ() { + this.graphicsLibrary.exportSceneToOBJ(); + this.infoLogger.add('Exported scene to OBJ'); + } + + /** + * Parse and load an event from the Phoenix file format (.phnx). + * @param input Content containing the JSON with event data + * and other configuration. + * @returns Promise for loading the geometry. + */ + public async parsePhoenixDisplay(input: any): Promise { + const phoenixScene = JSON.parse(input); + + if (phoenixScene.sceneConfiguration && phoenixScene.scene) { + // Creating UI folder + this.ui.addEventDataFolder(); + this.ui.addLabelsFolder(); + // Clearing existing event data + this.graphicsLibrary.clearEventData(); + // Add to scene + this.loadSceneConfiguration(phoenixScene.sceneConfiguration); + + this.loadingManager.addLoadableItem(`parse_phnx_${name}`); + await this.graphicsLibrary.parsePhnxScene(phoenixScene.scene); + this.loadingManager.itemLoaded(`parse_phnx_${name}`); + } + } + + /** + * Exports scene as phoenix format, allowing to load it later and recover the saved configuration. + */ + public exportPhoenixDisplay() { + this.graphicsLibrary.exportPhoenixScene(); + } + + /** + * Parses and loads a geometry in GLTF (.gltf or .glb) format. + * also supports zip files of the above + * @param file Geometry file in GLTF (.gltf or .glb) format. + * @returns Promise for loading the geometry. + */ + public async parseGLTFGeometry(file: File): Promise { + const name = file.name.split('/').pop(); + this.loadingManager.addLoadableItem(`parse_gltf_${name}`); + + const allGeometriesUIParameters = + await this.graphicsLibrary.parseGLTFGeometry(file); + for (const { object, menuNodeName } of allGeometriesUIParameters) { + this.ui.addGeometry(object, menuNodeName); + } + + this.loadingManager.itemLoaded(`parse_gltf_${name}`); + } + + /** + * Loads a GLTF (.gltf) scene/geometry from the given URL. + * and adds it to the dat.GUI menu. + * @param url URL to the GLTF (.gltf) file. + * @param name Name of the loaded scene/geometry. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param scale Scale of the geometry. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + * @returns Promise for loading the geometry. + */ + public async loadGLTFGeometry( + url: any, + name: string, + menuNodeName?: string | undefined, + scale: number = 1.0, + initiallyVisible: boolean = true, + ): Promise { + this.loadingManager.addLoadableItem(`gltf_geom_${name}`); + + const allGeometriesUIParameters = + await this.graphicsLibrary.loadGLTFGeometry( + url, + name, + menuNodeName, + scale, + initiallyVisible, + ); + for (const { object, menuNodeName } of allGeometriesUIParameters) { + this.ui.addGeometry(object, menuNodeName); + } + + this.loadingManager.itemLoaded(`gltf_geom_${name}`); + } + + /** + * Loads geometries from JSON. + * @param json JSON or URL to JSON file of the geometry. + * @param name Name of the geometry or group of geometries. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param scale Scale of the geometry. + * @param doubleSided Renders both sides of the material. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + * @returns Promise for loading the geometry. + */ + public async loadJSONGeometry( + json: string | { [key: string]: any }, + name: string, + menuNodeName?: string, + scale?: number, + doubleSided?: boolean, + initiallyVisible: boolean = true, + ): Promise { + this.loadingManager.addLoadableItem(`json_geom_${name}`); + + const { object } = await this.graphicsLibrary.loadJSONGeometry( + json, + name, + scale, + doubleSided, + initiallyVisible, + ); + this.ui.addGeometry(object, menuNodeName); + + this.loadingManager.itemLoaded(`json_geom_${name}`); + this.infoLogger.add(name, 'Loaded JSON geometry'); + } + + /** + * Load JSON geometry from JSRoot. + * @param url URL of the JSRoot geometry file. + * @param name Name of the geometry. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param scale Scale of the geometry. + * @param doubleSided Renders both sides of the material. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + */ + public async loadRootJSONGeometry( + url: string, + name: string, + menuNodeName?: string, + scale?: number, + doubleSided?: boolean, + initiallyVisible: boolean = true, + ) { + this.loadingManager.addLoadableItem('root_json_geom'); + + const object = await httpRequest(url, 'object'); + await this.loadJSONGeometry( + build(object, { dflt_colors: true }).toJSON(), + name, + menuNodeName, + scale, + doubleSided, + initiallyVisible, + ); + + this.loadingManager.itemLoaded('root_json_geom'); + } + + /** + * Load ROOT geometry from JSRoot. + * @param url URL of the JSRoot file. + * @param objectName Name of the object inside the ".root" file. + * @param name Name of the geometry. + * @param menuNodeName Name of the node in Phoenix menu to add the geometry to. Use > as a separator for specifying the hierarchy for sub-folders. + * @param scale Scale of the geometry. + * @param doubleSided Renders both sides of the material. + * @param initiallyVisible Whether the geometry is initially visible or not. Default `true`. + */ + public async loadRootGeometry( + url: string, + objectName: string, + name: string, + menuNodeName?: string, + scale?: number, + doubleSided?: boolean, + initiallyVisible: boolean = true, + ) { + this.loadingManager.addLoadableItem('root_geom'); + // See https://github.com/root-project/jsroot/blob/19ce116b68701ab45e0a092c673119bf97ede0c2/modules/core.mjs#L241. + jsrootSettings.UseStamp = false; + + const file = await openFile(url); + const obj = await file.readObject(objectName); + + await this.loadJSONGeometry( + build(obj, { dflt_colors: true }).toJSON(), + name, + menuNodeName, + scale, + doubleSided, + initiallyVisible, + ); + + this.loadingManager.itemLoaded('root_geom'); + } + + /** + * Build Geometry from thr passed parameters, where + * @param parameters + */ + public buildGeometryFromParameters(parameters: any): void { + this.graphicsLibrary.addGeometryFromParameters(parameters); + } + + /** + * Zoom all the cameras by a specific zoom factor. + * The factor may either be greater (zoom in) or smaller (zoom out) than 1. + * @param zoomFactor The factor to zoom by. + * @param zoomTime The time it takes for a zoom animation to complete. + */ + public zoomTo(zoomFactor: number, zoomTime: number) { + this.graphicsLibrary.zoomTo(zoomFactor, zoomTime); + } + + /** + * Processes event data and geometry for Loading the scene + * from Phoenix file format (.phnx). + * @param sceneConfiguration Scene configuration containingevent data and detector geometry. + */ + private loadSceneConfiguration(sceneConfiguration: { + eventData: { [key: string]: any }; + geometries: any[]; + }) { + for (const objectType of Object.keys(sceneConfiguration.eventData)) { + this.ui.addEventDataTypeFolder(objectType); + + const collections = sceneConfiguration.eventData[objectType]; + for (const collection of collections) { + this.ui.addCollection(objectType, collection); + } + } + + for (const geom of sceneConfiguration.geometries) { + this.ui.addGeometry(geom, '#ffffff'); + } + } + + /** + * Get all the objects inside a collection. + * @param collectionName Key of the collection that will be retrieved. + * @returns Object containing all physics objects from the desired collection. + */ + public getCollection(collectionName: string) { + if (this.configuration.eventDataLoader) { + return this.configuration.eventDataLoader.getCollection(collectionName); + } + return {}; + } + + /** + * Get the different collections for the current stored event. + * @returns List of strings, each representing a collection of the event displayed. + */ + public getCollections(): string[] { + if (this.configuration.eventDataLoader) { + return this.configuration.eventDataLoader.getCollections(); + } + return []; + } + + /** + * 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. + */ + public listenToDisplayedEventChange(callback: (event: any) => any) { + this.onDisplayedEventChange.push(callback); + } + + /** + * 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. + */ + public listenToLoadedEventsChange(callback: (events: any) => any) { + this.onEventsChange.push(callback); + } + + /** + * Get metadata associated to the displayed event (experiment info, time, run, event...). + * @returns Metadata of the displayed event. + */ + public getEventMetadata(): any[] { + if (this.configuration.eventDataLoader) { + return this.configuration.eventDataLoader.getEventMetadata(); + } + return []; + } + + /** + * Enables calling specified event display methods in console. + */ + private enableEventDisplayConsole() { + // Defining an EventDisplay object in window to access methods through console + window.EventDisplay = { + loadGLTFGeometry: (sceneUrl: string, name: string) => { + this.loadGLTFGeometry(sceneUrl, name); + }, + loadOBJGeometry: ( + filename: string, + name: string, + colour: any, + menuNodeName: string, + doubleSided: boolean, + ) => { + this.loadOBJGeometry(filename, name, colour, menuNodeName, doubleSided); + }, + loadJSONGeometry: ( + json: string | { [key: string]: any }, + name: string, + menuNodeName: string, + scale?: number, + doubleSided?: boolean, + initiallyVisible: boolean = true, + ) => { + this.loadJSONGeometry( + json, + name, + menuNodeName, + scale, + doubleSided, + initiallyVisible, + ); + }, + buildGeometryFromParameters: (parameters: { [key: string]: any }) => + this.buildGeometryFromParameters(parameters), + scene: this.getThreeManager().getSceneManager().getScene(), + }; + } + + /** + * Sets the renderer to be used to render the event display on the overlayed canvas. + * @param overlayCanvas An HTML canvas on which the overlay renderer is to be set. + */ + public setOverlayRenderer(overlayCanvas: HTMLCanvasElement) { + this.graphicsLibrary.setOverlayRenderer(overlayCanvas); + } + + /** + * Initializes the object which will show information of the selected geometry/event data. + * @param selectedObject Object to display the data. + */ + public allowSelection(selectedObject: { name: string; attributes: any[] }) { + this.graphicsLibrary.setSelectedObjectDisplay(selectedObject); + } + + /** + * Toggles the ability of selecting geometries/event data by clicking on the screen. + * @param enable Value to enable or disable the functionality. + */ + public enableSelecting(enable: boolean) { + this.graphicsLibrary.enableSelecting(enable); + } + + /** + * Fixes the camera position of the overlay view. + * @param fixed Whether the overlay view is to be fixed or not. + */ + public fixOverlayView(fixed: boolean) { + this.graphicsLibrary.fixOverlayView(fixed); + } + + /** + * Get the uuid of the currently selected object. + * @returns uuid of the currently selected object. + */ + public getActiveObjectId(): ActiveVariable { + return this.graphicsLibrary.getActiveObjectId(); + } + + /** + * Move the camera to look at the object with the given uuid + * and highlight it. + * @param uuid uuid of the object. + * @param detector whether the function is for detector objects or event data. + */ + public lookAtObject(uuid: string, detector: boolean = false) { + if (detector == true) { + this.graphicsLibrary.lookAtObject(uuid, true); + this.graphicsLibrary.highlightObject(uuid, true); + } else { + this.graphicsLibrary.lookAtObject(uuid); + this.graphicsLibrary.highlightObject(uuid); + } + } + + /** + * Highlight the object with the given uuid by giving it an outline. + * @param uuid uuid of the object. + * @param detector whether the function is for detector objects or event data. + */ + public highlightObject(uuid: string, detector: boolean = false) { + if (detector == true) { + this.graphicsLibrary.highlightObject(uuid, true); + } else { + this.graphicsLibrary.highlightObject(uuid, false); + } + } + + /** + * Enable highlighting of the objects. + */ + public enableHighlighting() { + this.graphicsLibrary.enableHighlighting(); + } + + /** + * Disable highlighting of the objects. + */ + public disableHighlighting() { + this.graphicsLibrary.disableHighlighting(); + } + + /** + * Enable keyboard controls for the event display. + */ + public enableKeyboardControls() { + this.ui.enableKeyboardControls(); + this.graphicsLibrary.enableKeyboardControls(); + } + + /** + * Animate the camera through the event scene. + * @param startPos Start position of the translation animation. + * @param tweenDuration Duration of each tween in the translation animation. + * @param onAnimationEnd Callback when the last animation ends. + */ + public animateThroughEvent( + startPos: number[], + tweenDuration: number, + onAnimationEnd?: () => void, + ) { + this.graphicsLibrary.animateThroughEvent( + startPos, + tweenDuration, + onAnimationEnd, + ); + } + + /** + * Animate scene by animating camera through the scene and animating event collision. + * @param animationPreset Preset for animation including positions to go through and + * event collision animation options. + * @param onEnd Function to call when the animation ends. + */ + public animatePreset(animationPreset: AnimationPreset, onEnd?: () => void) { + this.graphicsLibrary.animatePreset(animationPreset, onEnd); + } + + /** + * Enter screenshot (fullscreen) mode and run a named preset animation. + * Exits screenshot mode automatically when the animation completes. + * Designed for recording with OBS or similar screen capture software. + * Can also be triggered via URL: ?ss=preset-name + * @param presetName Name of the animation preset to run. + * @param presets Map of preset name to AnimationPreset. Required to resolve the preset. + */ + public animatePresetInSSMode( + presetName: string, + presets?: { [key: string]: AnimationPreset }, + ) { + const preset = presets?.[presetName]; + if (!preset) { + console.warn( + `[Phoenix] animatePresetInSSMode: preset "${presetName}" not found.`, + ); + return; + } + document.body.classList.add('ss-mode'); + document.documentElement.requestFullscreen?.().catch(() => {}); + this.graphicsLibrary.animatePreset(preset, () => { + document.body.classList.remove('ss-mode'); + document.exitFullscreen?.().catch(() => {}); + }); + } + + /** + * Animate the propagation and generation of event data with particle collison. + * @param tweenDuration Duration of the animation tween. + * @param onEnd Function to call when all animations have ended. + */ + public animateEventWithCollision(tweenDuration: number, onEnd?: () => void) { + this.graphicsLibrary.animateEventWithCollision(tweenDuration, onEnd); + } + + /** + * Animate the propagation and generation of event data + * using clipping planes after particle collison. + * @param tweenDuration Duration of the animation tween. + * @param onEnd Function to call when all animations have ended. + */ + public animateClippingWithCollision( + tweenDuration: number, + onEnd?: () => void, + ) { + this.graphicsLibrary.animateClippingWithCollision(tweenDuration, onEnd); + } + + /** + * Add label to a 3D object. + * @param label Label to add to the event object. + * @param collection Collection the event object is a part of. + * @param indexInCollection Event object's index in collection. + * @param uuid UUID of the three.js object. + */ + public addLabelToObject( + label: string, + collection: string, + indexInCollection: number, + uuid: string, + ) { + if (!this.configuration.eventDataLoader) { + return; + } + + const labelId = this.configuration.eventDataLoader.addLabelToEventObject( + label, + collection, + indexInCollection, + ); + + // Remove the label if the string is empty + if (!label) { + this.ui.removeLabel(labelId, true); + return; + } + + this.ui.addLabel(labelId); + this.graphicsLibrary.addLabelToObject(label, uuid, labelId); + } + + /** + * Reset/remove all labels. + */ + public resetLabels() { + // labelsObject[EventDataType][Collection][Index] + if (!this.configuration.eventDataLoader) { + return; + } + const labelsObject = this.configuration.eventDataLoader.getLabelsObject(); + for (const eventDataType in labelsObject) { + for (const collection in labelsObject[eventDataType]) { + for (const index in labelsObject[eventDataType][collection]) { + const labelId = getLabelTitle(eventDataType, collection, index); + this.ui.removeLabel(labelId, true); + + delete labelsObject[eventDataType][collection][index]; + } + } + } + } +} diff --git a/phoenix/packages/phoenix-event-display/src/loaders/jivexml-loader.ts b/phoenix/packages/phoenix-event-display/src/loaders/jivexml-loader.ts new file mode 100644 index 000000000..ea853c96e --- /dev/null +++ b/phoenix/packages/phoenix-event-display/src/loaders/jivexml-loader.ts @@ -0,0 +1,1059 @@ +import { PhoenixLoader } from './phoenix-loader'; +import { CoordinateHelper } from '../helpers/coordinate-helper'; + +/** + * PhoenixLoader for processing and loading an event from the JiveXML data format. + */ +export class JiveXMLLoader extends PhoenixLoader { + /** Event data in JiveXML data format */ + private data: any; + /** List of tracks to draw with thicker tubes */ + thickTrackCollections: string[]; + + /** + * Constructor for the JiveXMLLoader. + * @param thickTrackCollections A list of names of track collections which should be drawn thicker + */ + constructor(thickTrackCollections: string[] = []) { + super(); + this.data = {}; + this.thickTrackCollections = thickTrackCollections; + } + + /** + * Process JiveXML data to be used by the class. + * @param data Event data in JiveXML data format. + */ + public process(data: any) { + console.log('Processing JiveXML event data'); + this.data = data; + } + + /** + * Get the event data from the JiveXML data format. + * @returns 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, + Tracks: {}, + Jets: {}, + CaloClusters: {}, + CaloCells: {}, + PlanarCaloCells: {}, + Vertices: {}, + Electrons: {}, + Muons: {}, + Photons: {}, + 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); + return eventData; + } + + /** + * 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 number array. + * @returns Number array. + */ + private getNumberArrayFromHTML(collection: Element, key: any) { + // console.log(collection); + let array = []; + const elements = collection.getElementsByTagName(key); + if (elements.length) { + array = elements[0].innerHTML + .replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + } + return array; + } + + /** + * Get the string 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. + */ + 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 []; + } + /** + * Try to get the position of a hit (i.e. linked from a track) + * @param hitIdentifier The unique identifier of this hit. + * @param eventData The complete eventData, which must contain hits. + * @returns [found, x, y, z]. + */ + private getPositionOfHit( + hitIdentifier: number, + eventData: { Hits: [[{ id: number; pos: any }]] }, + ) { + for (const hitcollection in eventData.Hits) { + for (const hit of eventData.Hits[hitcollection]) { + if (hit == null) { + console.log('Empty hit'); + } else { + if ('id' in hit && hit.id == hitIdentifier) + return [true, hit.pos[0], hit.pos[1], hit.pos[2]]; + } + } + } + return [false, 0, 0, 0]; + } + + /** + * Extract Tracks from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Tracks. + */ + public getTracks( + firstEvent: Element, + eventData: { Tracks: any; Hits: any }, + ): void { + const tracksHTML = firstEvent.getElementsByTagName('Track'); + const trackCollections = Array.from(tracksHTML); + const badTracks: { [key: string]: any } = {}; + + for (const collection of trackCollections) { + let trackCollectionName = + collection.getAttribute('storeGateKey') ?? 'Unknown'; + if (trackCollectionName === 'Tracks') { + trackCollectionName = 'Tracks_'; //We have problems if the name of the collection is a type + } + + let thickTracks = false; + if ( + this.thickTrackCollections.find( + (collection) => collection === trackCollectionName, + ) + ) { + thickTracks = true; + } + + // if (!trackCollectionName.includes('MuonSpectrometer')) continue; + const numOfTracks = Number(collection.getAttribute('count')); + const jsontracks = []; + + // 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 tmp = collection.getElementsByTagName('numPolyline'); + + let polylineX: number[] = []; + let polylineY: number[] = []; + let polylineZ: number[] = []; + let numPolyline: number[] = []; + if (tmp.length === 0) { + console.log( + 'WARNING the track collection ' + + trackCollectionName + + ' has no line information. Will rely on Phoenix to extrapolate.', + ); + } else { + numPolyline = this.getNumberArrayFromHTML(collection, 'numPolyline'); + + const polyLineXHTML = collection.getElementsByTagName('polylineX'); + + if (polyLineXHTML.length > 0) { + polylineX = polyLineXHTML[0].innerHTML + .replace(/\r\n|\n|\r/gm, ' ') + .trim() + .split(' ') + .map(Number); + // Assume the rest are okay. + polylineY = this.getNumberArrayFromHTML(collection, 'polylineY'); + polylineZ = this.getNumberArrayFromHTML(collection, 'polylineZ'); + } else { + // unset numPolyline so check later is simple (it will all be zeros anyway) + numPolyline = []; + polylineX = []; + polylineY = []; + polylineZ = []; + } + } + + const chi2 = this.getNumberArrayFromHTML(collection, 'chi2'); + const numDoF = this.getNumberArrayFromHTML(collection, 'numDoF'); + const pT = this.getNumberArrayFromHTML(collection, 'pt'); + const d0 = this.getNumberArrayFromHTML(collection, 'd0'); + const z0 = this.getNumberArrayFromHTML(collection, 'z0'); + const phi0 = this.getNumberArrayFromHTML(collection, 'phi0'); + const cotTheta = this.getNumberArrayFromHTML(collection, 'cotTheta'); + const hits = this.getNumberArrayFromHTML(collection, 'hits'); + const numHits = this.getNumberArrayFromHTML(collection, 'numHits'); + + // Sanity check of some required quantities + if ( + numOfTracks != pT.length || + numOfTracks != d0.length || + numOfTracks != z0.length || + numOfTracks != cotTheta.length + ) { + console.log( + 'ERROR: Wrong number of some track variables. Corrupted JiveXML?', + ); + } + + let trackAuthor; + if (collection.getElementsByTagName('trackAuthor').length) { + trackAuthor = this.getNumberArrayFromHTML(collection, 'trackAuthor'); + } + + let polylineCounter = 0, + hitsCounter = 0; // Both of these need to persist throughout the track collection. + // Sanity check: + if (numPolyline && numPolyline.length != numOfTracks) + console.log( + 'numPolyline ', + numPolyline.length, + 'numOfTracks', + numOfTracks, + ); + for (let i = 0; i < numOfTracks; i++) { + let storeTrack = true; // Need to do this because we need to retrieve all info so counters don't go wrong. + const debugTrack = false; + const track = { + chi2: 0.0, + dof: 0.0, + pT: 0.0, + phi: 0.0, + eta: 0.0, + pos: [] as number[][], + dparams: [] as number[], // Explicitly define the type as number[] + hits: {}, + author: {}, + badtrack: [] as string[], + linewidth: thickTracks ? 20.0 : undefined, + }; + if (chi2.length >= i) track.chi2 = chi2[i]; + if (numDoF.length >= i) track.dof = numDoF[i]; + if (trackAuthor?.length >= i) track.author = trackAuthor[i]; + + let theta = Math.atan(1 / cotTheta[i]); + + track.pT = Math.abs(pT[i]) * 1000; // JiveXML uses GeV + const momentum = track.pT / Math.sin(theta); + track.dparams = [d0[i], z0[i], phi0[i], theta, 1.0 / momentum]; + track.phi = phi0[i]; + + // if (track.phi == 1.37786) { + // if (i === 0) { + // console.log('Cuplrit found! Index = ', i); + // debugTrack = true; + // storeTrack = true; + // } + + if (theta < 0) { + theta += Math.PI; + // TODO - check if we need to flip phi here? + } + // FIXME - should probably handle this better ... what if phi = 4PI for example? + if (track.phi > Math.PI) { + track.phi -= 2.0 * Math.PI; + } else if (track.phi < -Math.PI) { + track.phi += 2.0 * Math.PI; + } + + if (!CoordinateHelper.anglesAreSane(theta, track.phi)) { + badTracks['Improper angles']++; + track.badtrack.push('Improper angles'); + storeTrack = false; + } + + track.eta = CoordinateHelper.thetaToEta(theta); + + if (Number.isNaN(track.eta)) { + track.badtrack.push('Invalid eta'); + storeTrack = false; + } + + const pos = [], + listOfHits = []; + let maxR = 0.0, + radius = 0.0, + x = 0.0, + y = 0.0, + z = 0.0; + if (numPolyline) { + // Sanity check + if ( + polylineCounter + numPolyline[i] > polylineX.length || + polylineCounter + numPolyline[i] > polylineY.length || + polylineCounter + numPolyline[i] > polylineZ.length + ) { + console.log( + 'ERROR: not enough points left for this track. Corrupted JiveXML?', + ); + } + for (let p = 0; p < numPolyline[i]; p++) { + x = polylineX[polylineCounter + p] * 10.0; + y = polylineY[polylineCounter + p] * 10.0; + z = polylineZ[polylineCounter + p] * 10.0; + pos.push([x, y, z]); + radius = Math.sqrt(x * x + y * y + z * z); + if (radius < maxR) { + console.log( + 'WARNING: track positions do not seem to be sorted radially', + ); + badTracks['Hits not sorted']++; + track.badtrack.push('Hits not sorted'); + } + if (debugTrack) { + console.log('pos: ', p, '/', numPolyline[i], ':', pos); + console.log( + 'theta:', + theta, + 'theta from hit', + Math.acos(z / radius), + ); + } + maxR = radius; + } + polylineCounter += numPolyline[i]; + track.pos = pos; + } + if ( + // eslint-disable-next-line + false && + numHits.length > 0 && + trackCollectionName?.includes('Muon') + ) { + // Disable for the moment. + + // Now loop over hits, and if possible, see if we can extend the track + const measurementPositions = []; + if (numHits.length > 0) { + let hitIdentifier = 0; + let distance = 0.0; + let found = false; + for (let p = 0; p < numHits[i]; p++) { + hitIdentifier = hits[hitsCounter + p]; + listOfHits.push(hitIdentifier); + // Now try to find matching hit + [found, x, y, z] = this.getPositionOfHit( + hitIdentifier, + eventData, + ); + if (found) { + distance = Math.sqrt(x * x + y * y + z * z); + if (distance > maxR) { + measurementPositions.push([x, y, z, distance]); + } + } + } + hitsCounter += numHits[i]; + track.hits = listOfHits; + } + + // This seems to give pretty poor results, so try to filter. + // Sort radially (sorry cosmics!) + const sortedMeasurements = measurementPositions.sort( + (a, b) => a[3] - b[3], + ); + const minDelta = 250; // tweaked by trial and error + let newHitCount = 0; + let rejectedHitCount = 0; + let lastDistance = maxR + minDelta; + if (sortedMeasurements.length) { + for (const meas of sortedMeasurements) { + if (meas[3] > lastDistance + minDelta) { + track.pos.push([meas[0], meas[1], meas[2]]); + lastDistance = meas[3] + minDelta; + newHitCount++; + } else { + rejectedHitCount++; + } + } + } + console.log( + 'Added ' + + newHitCount + + ' hits to ' + + trackCollectionName + + ' (and rejected ' + + rejectedHitCount + + ')', + ); + } + + if (storeTrack) jsontracks.push(track); + } + // Final sanity check here + if ( + numPolyline && + (polylineCounter != polylineX.length || + polylineCounter != polylineY.length || + polylineCounter != polylineZ.length) + ) { + console.log( + 'ERROR: something has gone wrong with assigning the positions to the tracks!', + ); + } + + eventData.Tracks[trackCollectionName] = jsontracks; + // } + } + for (const error in badTracks) { + if (badTracks[error] > 0) + console.log( + badTracks[error] + + ' tracks had "' + + error + + '" and were marked as bad.', + ); + } + } + + /** + * Extract Pixel Clusters (type of Hits) from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Pixel Clusters. + */ + public getPixelClusters(firstEvent: Element, eventData: { Hits: any }) { + eventData.Hits = {}; + if (firstEvent.getElementsByTagName('PixCluster').length === 0) { + return; + } + const pixClustersHTML = firstEvent.getElementsByTagName('PixCluster')[0]; + const numOfClusters = Number(pixClustersHTML.getAttribute('count')); + const id = this.getNumberArrayFromHTML(pixClustersHTML, 'id'); + const x0 = this.getNumberArrayFromHTML(pixClustersHTML, 'x0'); + const y0 = this.getNumberArrayFromHTML(pixClustersHTML, 'y0'); + const z0 = this.getNumberArrayFromHTML(pixClustersHTML, 'z0'); + const eloss = this.getNumberArrayFromHTML(pixClustersHTML, 'eloss'); + + eventData.Hits.Pixel = []; + + for (let i = 0; i < numOfClusters; i++) { + const pixel = { pos: [] as number[], id: 0, energyLoss: 0 }; + pixel.pos = [x0[i] * 10.0, y0[i] * 10.0, z0[i] * 10.0]; + pixel.id = id[i]; + pixel.energyLoss = eloss[i]; + eventData.Hits.Pixel.push(pixel); + } + } + + /** + * Extract SCT Clusters (type of Hits) from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with SCT Clusters. + */ + public getSCTClusters(firstEvent: Element, eventData: { Hits: any }) { + if (firstEvent.getElementsByTagName('STC').length === 0) { + return; + } + + const sctClustersHTML = firstEvent.getElementsByTagName('STC')[0]; // No idea why this is not SCT! + const numOfSCTClusters = Number(sctClustersHTML.getAttribute('count')); + const id = this.getNumberArrayFromHTML(sctClustersHTML, 'id'); + const phiModule = this.getNumberArrayFromHTML(sctClustersHTML, 'phiModule'); + const side = this.getNumberArrayFromHTML(sctClustersHTML, 'side'); + // Commenting out some variables we don't yet use. + // const width = this.getNumberArrayFromHTML(sctClustersHTML, 'width'); + const x0 = this.getNumberArrayFromHTML(sctClustersHTML, 'x0'); + // const x1 = this.getNumberArrayFromHTML(sctClustersHTML, 'x1'); + const y0 = this.getNumberArrayFromHTML(sctClustersHTML, 'y0'); + // const y1 = this.getNumberArrayFromHTML(sctClustersHTML, 'y1'); + const z0 = this.getNumberArrayFromHTML(sctClustersHTML, 'z0'); + // const z1 = this.getNumberArrayFromHTML(sctClustersHTML, 'z1'); + eventData.Hits.SCT = []; + + for (let i = 0; i < numOfSCTClusters; i++) { + const sct = { pos: [] as number[], id: 0, phiModule: 0, side: 0 }; + sct.pos = [x0[i] * 10.0, y0[i] * 10.0, z0[i] * 10.0]; + sct.id = id[i]; + sct.phiModule = phiModule[i]; + sct.side = side[i]; + eventData.Hits.SCT.push(sct); + } + } + + /** + * Extract TRT Drift Circles (type of Hits) from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with TRT Drift Circles. + */ + public getTRT_DriftCircles(firstEvent: Element, eventData: { Hits: any }) { + if (firstEvent.getElementsByTagName('TRT').length === 0) { + return; + } + + const dcHTML = firstEvent.getElementsByTagName('TRT')[0]; + const numOfDC = Number(dcHTML.getAttribute('count')); + // Ignoring bitpattern + const driftR = this.getNumberArrayFromHTML(dcHTML, 'driftR'); + const id = this.getNumberArrayFromHTML(dcHTML, 'id'); + const noise = this.getNumberArrayFromHTML(dcHTML, 'noise'); + const phi = this.getNumberArrayFromHTML(dcHTML, 'phi'); + const rhoz = this.getNumberArrayFromHTML(dcHTML, 'rhoz'); + const sub = this.getNumberArrayFromHTML(dcHTML, 'sub'); + const threshold = this.getNumberArrayFromHTML(dcHTML, 'threshold'); + const timeOverThreshold = this.getNumberArrayFromHTML( + dcHTML, + 'timeOverThreshold', + ); + + eventData.Hits.TRT = []; + + // Hardcoding TRT size here. Could maybe think of generalising this? + for (let i = 0; i < numOfDC; i++) { + const trt = { + pos: [] as number[], + id: 0, + type: 'Line', + driftR: 0.0, + threshold: 0.0, + timeOverThreshold: 0.0, + noise: false, + }; + + if (sub[i] == 1 || sub[i] == 2) { + // Barrel - rhoz = radial position + const z1 = sub[i] == 1 ? -3.5 : 3.5; + const z2 = sub[i] == 1 ? -742 : 742; + trt.pos = [ + Math.cos(phi[i]) * rhoz[i] * 10.0, + Math.sin(phi[i]) * rhoz[i] * 10.0, + z1, + Math.cos(phi[i]) * rhoz[i] * 10.0, + Math.sin(phi[i]) * rhoz[i] * 10.0, + z2, + ]; + } else { + // endcap - rhoz = z position + const r1 = Math.abs(rhoz[i]) > 280 ? 480 : 640; + const r2 = 1030; + trt.pos = [ + Math.cos(phi[i]) * r1, + Math.sin(phi[i]) * r1, + rhoz[i] * 10.0, + Math.cos(phi[i]) * r2, + Math.sin(phi[i]) * r2, + rhoz[i] * 10.0, + ]; + } + trt.id = id[i]; + trt.driftR = driftR[i]; + trt.noise = noise[i]; + trt.threshold = threshold[i]; + trt.timeOverThreshold = timeOverThreshold[i]; + eventData.Hits.TRT.push(trt); + } + } + + /** + * Extract Muon PRDs (type of Hits) from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param name Event data collection name. + * @param eventData Event data object to be updated with TRT Drift Circles. + */ + public getMuonPRD( + firstEvent: Element, + name: string, + eventData: { Hits: any }, + ) { + if (firstEvent.getElementsByTagName(name).length === 0) { + return; + } + + const dcHTML = firstEvent.getElementsByTagName(name)[0]; + + // Bit of a nasty hack, but JiveXML stores CSCs as CSCD for some reason. + if (name == 'CSCD') name = 'CSC'; + + const numOfDC = Number(dcHTML.getAttribute('count')); + const x = this.getNumberArrayFromHTML(dcHTML, 'x'); + const y = this.getNumberArrayFromHTML(dcHTML, 'y'); + const z = this.getNumberArrayFromHTML(dcHTML, 'z'); + const length = this.getNumberArrayFromHTML(dcHTML, 'length'); + + // if (dcHTML.getElementsByTagName('driftR').length > 0) { + // const driftR = this.getNumberArrayFromHTML(dcHTML, 'driftR'); + // } + + // if (dcHTML.getElementsByTagName('measuresPhi').length > 0) { + // const measuresPhi = this.getNumberArrayFromHTML(dcHTML, 'measuresPhi'); + // } + + const id = this.getNumberArrayFromHTML(dcHTML, 'id'); + const identifier = this.getStringArrayFromHTML(dcHTML, 'identifier'); + + eventData.Hits[name] = []; + + for (let i = 0; i < numOfDC; i++) { + const muonHit = { + pos: this.getMuonLinePositions(i, x, y, z, length), + id: id[i], + type: 'Line', + identifier: identifier[i], + }; + + eventData.Hits[name].push(muonHit); + } + } + + /** + * Extract RPC measurements from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with TRT Drift Circles. + */ + public getRPC(firstEvent: Element, eventData: { Hits: any }) { + const name = 'RPC'; + if (firstEvent.getElementsByTagName(name).length === 0) { + return; + } + + const dcHTML = firstEvent.getElementsByTagName(name)[0]; + + const numOfDC = Number(dcHTML.getAttribute('count')); + const x = this.getNumberArrayFromHTML(dcHTML, 'x'); + const y = this.getNumberArrayFromHTML(dcHTML, 'y'); + const z = this.getNumberArrayFromHTML(dcHTML, 'z'); + const length = this.getNumberArrayFromHTML(dcHTML, 'length'); + const width = this.getNumberArrayFromHTML(dcHTML, 'width'); + const id = this.getNumberArrayFromHTML(dcHTML, 'id'); + const identifier = this.getStringArrayFromHTML(dcHTML, 'identifier'); + + eventData.Hits[name] = []; + + for (let i = 0; i < numOfDC; i++) { + const rpcHit = { + pos: this.getMuonLinePositions(i, x, y, z, length), + id: id[i], + type: 'Line', + identifier: identifier[i], + width: width[i], + }; + + //TODO - handle phi measurements + eventData.Hits[name].push(rpcHit); + } + } + /** + * Get the end coordinates of a line, given its centre and its length. + * @param i index of the current coordinate + * @param x Array of x coordinates + * @param y Array of y coordinates + * @param z Array of z coordinates + * @param length Length of the line (i.e. strip or tube) that we need to draw + */ + private getMuonLinePositions( + i: number, + x: number[], + y: number[], + z: number[], + length: number[], + ) { + const radius = Math.sqrt(x[i] * x[i] + y[i] * y[i]); + const scaling = length[i] / radius; + // didn't bother multiplying by 10 for radius and length, since they cancel in scaling + // 2 coords, beginning and end of line + const pos = [ + x[i] * 10.0 - y[i] * scaling * 5.0, + y[i] * 10.0 + x[i] * scaling * 5.0, + z[i] * 10.0, + x[i] * 10.0 + y[i] * scaling * 5.0, + y[i] * 10.0 - x[i] * scaling * 5.0, + z[i] * 10.0, + ]; + return pos; + } + + /** + * Extract Jets from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Jets. + */ + public getJets(firstEvent: Element, eventData: { Jets: any }) { + const jetsHTML = firstEvent.getElementsByTagName('Jet'); + const jetCollections = Array.from(jetsHTML); + for (const jetColl of jetCollections) { + const numOfJets = Number(jetColl.getAttribute('count')); + + // 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 phi = this.getNumberArrayFromHTML(jetColl, 'phi'); + const eta = this.getNumberArrayFromHTML(jetColl, 'eta'); + const energy = this.getNumberArrayFromHTML(jetColl, 'energy'); + const coneR = this.getNumberArrayFromHTML(jetColl, 'coneR'); + const temp = []; // Ugh + for (let i = 0; i < numOfJets; i++) { + temp.push({ + coneR: coneR[i] ?? 0.4, // Set default of 0.4, since some JiveXML files might not have this. + phi: phi[i], + eta: eta[i], + energy: energy[i] * 1000.0, + }); + } + const key = jetColl.getAttribute('storeGateKey'); + if (key) eventData.Jets[key] = temp; + } + } + + /** + * Extract Calo Clusters from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Calo Clusters. + */ + public getCaloClusters( + firstEvent: Element, + eventData: { CaloClusters: any }, + ) { + const clustersHTML = firstEvent.getElementsByTagName('Cluster'); + const clusterCollections = Array.from(clustersHTML); + for (const clusterColl of clusterCollections) { + const numOfClusters = Number(clusterColl.getAttribute('count')); + + const phi = this.getNumberArrayFromHTML(clusterColl, 'phi'); + const eta = this.getNumberArrayFromHTML(clusterColl, 'eta'); + const energy = this.getNumberArrayFromHTML(clusterColl, 'et'); + + const temp = []; // Ugh + for (let i = 0; i < numOfClusters; i++) { + temp.push({ phi: phi[i], eta: eta[i], energy: energy[i] * 1000.0 }); + } + const key = clusterColl.getAttribute('storeGateKey'); + if (key) eventData.CaloClusters[key] = temp; + // } + } + } + + /** + * Extract Calo Cells from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Calo Clusters. + */ + public getFCALCaloCells( + firstEvent: Element, + name: string, + eventData: { PlanarCaloCells: any }, + ) { + if (firstEvent.getElementsByTagName(name).length === 0) { + return; + } + const dcHTML = firstEvent.getElementsByTagName(name)[0]; + + const numOfDC = Number(dcHTML.getAttribute('count')); + const x = this.getNumberArrayFromHTML(dcHTML, 'x'); + const y = this.getNumberArrayFromHTML(dcHTML, 'y'); + const z = this.getNumberArrayFromHTML(dcHTML, 'z'); + const dx = this.getNumberArrayFromHTML(dcHTML, 'dx'); + const dy = this.getNumberArrayFromHTML(dcHTML, 'dy'); + const dz = this.getNumberArrayFromHTML(dcHTML, 'dz'); + const channel = this.getNumberArrayFromHTML(dcHTML, 'channel'); + const energy = this.getNumberArrayFromHTML(dcHTML, 'energy'); + const id = this.getNumberArrayFromHTML(dcHTML, 'id'); + const slot = this.getStringArrayFromHTML(dcHTML, 'slot'); + + eventData.PlanarCaloCells[name] = { cells: [] }; + + let oldZ = 0; + for (let i = 0; i < numOfDC; i++) { + // Planar Calo cells need: + // pos + // length, size + // and on the collection, a plane + + // Need to handle that some JiveXML is missing z,dz + const cellz = z.length ? z[i] * 10 : 10; + const celldz = dz.length ? dz[i] * 10 : dx[i]; + + const cell = { + pos: [x[i] * 10, y[i] * 10, cellz], + length: celldz, + cellSize: dx[i] * 10, + id: id[i], + slot: slot[i], + channel: channel[i], + }; + + eventData.PlanarCaloCells[name].cells.push(cell); + if (oldZ && oldZ != cellz) { + console.log( + "WARNING - we're assuming that all cells have the same z. This is apparently not correct!", + ); + } + oldZ = cellz; + } + eventData.PlanarCaloCells[name].plane = [10, 10, oldZ]; // Just assuming + } + + /** + * Extract Calo Cells from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Calo Clusters. + */ + public getCaloCells( + firstEvent: Element, + name: string = 'FCAL', + eventData: { CaloCells: any }, + ) { + if (firstEvent.getElementsByTagName(name).length === 0) { + return; + } + const dcHTML = firstEvent.getElementsByTagName(name)[0]; + + const numOfDC = Number(dcHTML.getAttribute('count')); + const eta = this.getNumberArrayFromHTML(dcHTML, 'eta'); + const phi = this.getNumberArrayFromHTML(dcHTML, 'phi'); + const channel = this.getNumberArrayFromHTML(dcHTML, 'channel'); + const energy = this.getNumberArrayFromHTML(dcHTML, 'energy'); + const id = this.getNumberArrayFromHTML(dcHTML, 'id'); + const slot = this.getStringArrayFromHTML(dcHTML, 'slot'); + + eventData.CaloCells[name] = []; + + for (let i = 0; i < numOfDC; i++) { + const cell = { + eta: eta[i], + phi: phi[i], + id: id[i], + energy: energy[i], + channel: channel[i], + }; + eventData.CaloCells[name].push(cell); + } + } + + /** + * Extract Vertices from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Vertices. + */ + public getVertices(firstEvent: Element, eventData: { Vertices: any }) { + const verticesHTML = firstEvent.getElementsByTagName('RVx'); + const vertexCollections = Array.from(verticesHTML); + 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 = []; + let trackIndex = 0; + for (let i = 0; i < numOfObjects; i++) { + const maxIndex = trackIndex + (numTracks[i] || 0); + const thisTrackIndices = []; + for (; trackIndex < maxIndex; trackIndex++) { + if (trackIndex >= trackIndices.length) { + console.warn('TrackIndex exceeds maximum number of track indices.'); + break; + } + thisTrackIndices.push(trackIndices[trackIndex]); + } + temp.push({ + x: x[i], + y: y[i], + z: z[i], + chi2: chi2[i], + primVxCand: primVxCand[i], + vertexType: vertexType[i], + linkedTracks: thisTrackIndices, + linkedTrackCollection: sgkeyOfTracks[i], + }); + } + const key = vertexColl.getAttribute('storeGateKey'); + if (key) eventData.Vertices[key] = temp; + } + } + + /** + * Extract Muons from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Muons. + */ + public getMuons(firstEvent: Element, eventData: { Muons: any }) { + const objHTML = firstEvent.getElementsByTagName('Muon'); + const objCollections = Array.from(objHTML); + for (const collection of objCollections) { + const numOfObjects = Number(collection.getAttribute('count')); + const temp = []; // Ugh + for (let i = 0; i < numOfObjects; i++) { + const chi2 = this.getNumberArrayFromHTML(collection, 'chi2'); + const energy = this.getNumberArrayFromHTML(collection, 'energy'); + const eta = this.getNumberArrayFromHTML(collection, 'eta'); + const phi = this.getNumberArrayFromHTML(collection, 'phi'); + const pt = this.getNumberArrayFromHTML(collection, 'pt'); + const pdgId = this.getNumberArrayFromHTML(collection, 'pdgId'); + + temp.push({ + chi2: chi2[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, // JiveXML uses GeV + pdgId: pdgId[i], + }); + } + const key = collection.getAttribute('storeGateKey'); + if (key) eventData.Muons[key] = temp; + } + } + + /** + * Extract Electrons from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Electrons. + */ + public getElectrons(firstEvent: Element, eventData: { Electrons: any }) { + const objHTML = firstEvent.getElementsByTagName('Electron'); + const objCollections = Array.from(objHTML); + for (const collection of objCollections) { + const numOfObjects = Number(collection.getAttribute('count')); + const temp = []; // Ugh + for (let i = 0; i < numOfObjects; i++) { + const author = this.getStringArrayFromHTML(collection, 'author'); + const energy = this.getNumberArrayFromHTML(collection, 'energy'); + const eta = this.getNumberArrayFromHTML(collection, 'eta'); + const phi = this.getNumberArrayFromHTML(collection, 'phi'); + const pt = this.getNumberArrayFromHTML(collection, 'pt'); + const pdgId = this.getNumberArrayFromHTML(collection, 'pdgId'); + + temp.push({ + author: author[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, // JiveXML uses GeV + pdgId: pdgId[i], + }); + } + const key = collection.getAttribute('storeGateKey'); + if (key) eventData.Electrons[key] = temp; + } + } + + /** + * Extract Photons from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Photons. + */ + public getPhotons(firstEvent: Element, eventData: { Photons: any }) { + const objHTML = firstEvent.getElementsByTagName('Photon'); + const objCollections = Array.from(objHTML); + for (const collection of objCollections) { + const numOfObjects = Number(collection.getAttribute('count')); + const temp = []; // Ugh + for (let i = 0; i < numOfObjects; i++) { + const author = this.getStringArrayFromHTML(collection, 'author'); + const energy = this.getNumberArrayFromHTML(collection, 'energy'); + const eta = this.getNumberArrayFromHTML(collection, 'eta'); + const phi = this.getNumberArrayFromHTML(collection, 'phi'); + const pt = this.getNumberArrayFromHTML(collection, 'pt'); + temp.push({ + author: author[i], + energy: energy[i], + eta: eta[i], + phi: phi[i], + pt: pt[i] * 1000, // JiveXML uses GeV + }); + } + const key = collection.getAttribute('storeGateKey'); + if (key) eventData.Photons[key] = temp; + } + } + + /** + * Extract MET from the JiveXML data format and process them. + * @param firstEvent First "Event" element in the XML DOM of the JiveXML data format. + * @param eventData Event data object to be updated with Photons. + */ + public getMissingEnergy( + firstEvent: Element, + eventData: { MissingEnergy: any }, + ) { + const objHTML = firstEvent.getElementsByTagName('ETMis'); + const objCollections = Array.from(objHTML); + for (const collection of objCollections) { + const numOfObjects = Number(collection.getAttribute('count')); + const temp = []; // Ugh + for (let i = 0; i < numOfObjects; i++) { + const et = this.getStringArrayFromHTML(collection, 'et'); + const etx = this.getNumberArrayFromHTML(collection, 'etx'); + const ety = this.getNumberArrayFromHTML(collection, 'ety'); + + temp.push({ + et: et[i], + etx: etx[i], + ety: ety[i], + }); + } + const key = collection.getAttribute('storeGateKey'); + if (key) eventData.MissingEnergy[key] = temp; + } + } +} diff --git a/phoenix/packages/phoenix-event-display/src/managers/url-options-manager.ts b/phoenix/packages/phoenix-event-display/src/managers/url-options-manager.ts new file mode 100644 index 000000000..cab39f888 --- /dev/null +++ b/phoenix/packages/phoenix-event-display/src/managers/url-options-manager.ts @@ -0,0 +1,313 @@ +import { JiveXMLLoader } from '../loaders/jivexml-loader'; +import { PhoenixLoader } from '../loaders/phoenix-loader'; +import type { Configuration } from '../lib/types/configuration'; +import { EventDisplay } from '../event-display'; +import { StateManager } from './state-manager'; +import { readZipFile } from '../helpers/zip'; + +/** + * Model for Phoenix URL options. + */ +export const phoenixURLOptions = { + file: '', + type: '', + config: '', + hideWidgets: false, + embed: false, +}; + +/** + * A manager for managing options given through URL. + */ +export class URLOptionsManager { + /** Variable containing all URL search parameters. */ + private urlOptions: URLSearchParams; + + /** + * Constructor for the URL options manager. + * @param eventDisplay The Phoenix event display. + * @param configuration Configuration of the event display. + */ + constructor( + private eventDisplay: EventDisplay, + private configuration: Configuration, + ) { + this.urlOptions = new URLSearchParams( + window.location.href.substr(window.location.href.lastIndexOf('?')), + ); + } + + /** + * Initialize and apply all URL options on page load. + */ + public applyOptions() { + // Initialize event with data from URL if there is any + this.applyEventOptions( + this.configuration.defaultEventFile?.eventFile, + this.configuration.defaultEventFile?.eventType, + ); + this.applyHideWidgetsOptions(); + this.applyEmbedOption(); + this.applySSAnimationOption(); + } + + /** + * Initialize the event display with event data and configuration from URL. + * (Only JiveXML and JSON) + * @param defaultEventPath Default event path to fallback to if none in URL. + * @param defaultEventType Default event type to fallback to if none in URL. + */ + public applyEventOptions( + defaultEventPath: string = '', + defaultEventType: string = '', + ) { + if (!('fetch' in window)) { + return; + } + + let file: string, type: string; + + if ( + (!this.urlOptions.get('file') && this.urlOptions.get('type')) || + (this.urlOptions.get('file') && !this.urlOptions.get('type')) + ) { + console.log( + 'WARNING - if you set one of type or file, then you need to set both!', + ); + console.log('WARNING - reverting to defaults!'); + } + + if (!this.urlOptions.get('file') || !this.urlOptions.get('type')) { + file = defaultEventPath; + type = defaultEventType; + } else { + file = this.urlOptions.get('file') ?? ''; + type = this.urlOptions.get('type')?.toLowerCase() ?? ''; + console.log( + 'Default file(', + defaultEventPath, + ') was overridden by URL options to: ', + file, + ); + } + + console.log('Try to load event file: ', file, 'of type', type); + // Try to load config from URL + const loadConfig = () => { + if (this.urlOptions.get('config')) { + this.eventDisplay.getLoadingManager().addLoadableItem('url_config'); + fetch(this.urlOptions.get('config') ?? '') + .then((res) => res.json()) + .then((jsonState) => { + console.log( + 'Applying configuration ', + this.urlOptions.get('config'), + ' from urlOptions', + ); + const stateManager = new StateManager(); + stateManager.loadStateFromJSON(jsonState); + }) + .finally(() => { + this.eventDisplay.getLoadingManager().itemLoaded('url_config'); + }); + } + }; + + const processEventFile = (fileURL: string) => { + if (type === 'jivexml') { + console.log('Opening JiveXML'); + return this.handleJiveXMLEvent(fileURL); + } else if (type === 'zip') { + console.log('Opening zip file'); + return this.handleZipFileEvents(fileURL); + } else { + return this.handleJSONEvent(fileURL); + } + }; + + // Load event file from URL + if (file && type) { + this.eventDisplay.getLoadingManager().addLoadableItem('url_event'); + processEventFile(file) + .catch((error) => { + this.eventDisplay + .getInfoLogger() + .add('Could not find the file specified in URL.', 'Error'); + console.error('Could not find the file specified in URL.', error); + }) + .finally(() => { + // Load config from URL after loading the event + loadConfig(); + this.eventDisplay.getLoadingManager().itemLoaded('url_event'); + }); + } else { + loadConfig(); + } + } + + /** + * Handle JiveXML event from file URL. + * @param fileURL URL to the XML file. + * @returns An empty promise. ;( + */ + private async handleJiveXMLEvent(fileURL: string) { + const fileData = await ( + await fetch(fileURL).then((response) => { + if (response.status >= 400 && response.status < 600) { + throw new Error('Bad response from server'); + } + return response; + }) + ).text(); + if (!this.configuration.eventDataLoader) { + this.configuration.eventDataLoader = new JiveXMLLoader(); + } + // Parse the XML to extract events and their data + const loader = this.configuration.eventDataLoader as JiveXMLLoader; + loader.process(fileData); + const eventData = loader.getEventData(); + this.eventDisplay.buildEventDataFromJSON(eventData); + } + + /** + * Handle JSON event from file URL. + * @param fileURL URL to the JSON file. + * @returns An empty promise. ;( + */ + private async handleJSONEvent(fileURL: string) { + const fileData = await (await fetch(fileURL)).json(); + this.configuration.eventDataLoader = new PhoenixLoader(); + this.eventDisplay.parsePhoenixEvents(fileData); + } + + /** + * Handle zip containing event data files. + * @param fileURL URL to the zip file. + * @returns An empty promise. ;( + */ + private async handleZipFileEvents(fileURL: string) { + const fileBuffer = await (await fetch(fileURL)).arrayBuffer(); + const allEventsObject = {}; + let filesWithData: { [fileName: string]: string }; + + // Using a try catch block to catch any errors in Promises + try { + filesWithData = await readZipFile(fileBuffer); + } catch (error) { + console.error('Error while reading zip', error); + this.eventDisplay.getInfoLogger().add('Could not read zip file', 'Error'); + return; + } + + // JSON event data + Object.keys(filesWithData) + .filter((fileName) => fileName.endsWith('.json')) + .forEach((fileName) => { + Object.assign(allEventsObject, JSON.parse(filesWithData[fileName])); + }); + + // JiveXML event data + const jiveloader = + this.configuration.eventDataLoader instanceof JiveXMLLoader + ? (this.configuration.eventDataLoader as JiveXMLLoader) + : new JiveXMLLoader(); + Object.keys(filesWithData) + .filter((fileName) => { + return fileName.endsWith('.xml') || fileName.startsWith('JiveXML'); + }) + .forEach((fileName) => { + jiveloader.process(filesWithData[fileName]); + const eventData = jiveloader.getEventData(); + Object.assign(allEventsObject, { [fileName]: eventData }); + }); + // For some reason the above doesn't pick up JiveXML_XXX_YYY.zip + + this.eventDisplay.parsePhoenixEvents(allEventsObject); + } + + /** + * Hide all overlay widgets if "hideWidgets" option from the URL is true. + */ + public applyHideWidgetsOptions() { + const hideWidgetsOptions = { + hideWidgets: [ + 'mainLogo', // Main logo + 'uiMenu', // UI menu + 'experimentInfo', // Experiment info + 'phoenixMenu', // Phoenix menu + 'statsElement', // Stats at the bottom left + 'gui', // dat.GUI menu + ], + }; + + this.hideIdsWithURLOption(hideWidgetsOptions); + } + + /** + * Hide all overlay widgets and enable embed menu if "hideWidgets" option from the URL is true. + */ + public applyEmbedOption() { + if (this.urlOptions.get('embed') === 'true') { + const hideWidgetsOptions = { + embed: [ + 'mainLogo', // Main logo + 'uiMenu', // UI menu + 'experimentInfo', // Experiment info + 'phoenixMenu', // Phoenix menu + 'statsElement', // Stats at the bottom left + 'gui', // dat.GUI menu + ], + }; + + this.hideIdsWithURLOption(hideWidgetsOptions); + + document + .getElementById('embedMenu') + ?.style.setProperty('display', 'block'); + } + } + + /** + * If the URL contains `?ss=preset-name`, enter screenshot (fullscreen) mode + * and run the named preset animation once all assets have loaded. + * This allows recording with OBS or similar screen capture software by + * simply sharing a URL. + * + * Example: https://my-phoenix/?ss=Cavern+to+ID + */ + public applySSAnimationOption() { + const presetName = this.urlOptions.get('ss'); + if (!presetName) return; + + this.eventDisplay.getLoadingManager().addLoadListenerWithCheck(() => { + // Small delay to let the renderer settle after loading + setTimeout(() => { + this.eventDisplay.animatePresetInSSMode(presetName); + }, 500); + }); + } + + /** + * Hide element with IDs based on a URL option. + * @param urlOptionWithIds IDs to hide with keys as the URL option and its array value as IDs. + */ + private hideIdsWithURLOption(urlOptionWithIds: { [key: string]: string[] }) { + Object.entries(urlOptionWithIds).forEach(([urlOption, idsToHide]) => { + if (this.urlOptions.get(urlOption) === 'true') { + idsToHide.forEach((singleId) => { + document + .getElementById(singleId) + ?.style.setProperty('display', 'none'); + }); + } + }); + } + + /** + * Get options from URL set through query parameters. + * @returns URL options. + */ + public getURLOptions() { + return this.urlOptions; + } +} diff --git a/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.html b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.html new file mode 100644 index 000000000..4e7014070 --- /dev/null +++ b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.html @@ -0,0 +1,25 @@ + + + + + + + + + + + + + diff --git a/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.ts b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.ts new file mode 100644 index 000000000..54115b79c --- /dev/null +++ b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/animate-camera/animate-camera.component.ts @@ -0,0 +1,108 @@ +import { Component, Input, ViewChild } from '@angular/core'; +import { type AnimationPreset, SceneManager } from 'phoenix-event-display'; +import { EventDisplayService } from '../../../services/event-display.service'; +import { SSModeComponent } from '../ss-mode/ss-mode.component'; +import { Mesh } from 'three'; + +export const defaultAnimationPresets: { + [key: string]: AnimationPreset; +} = { + 'Cavern to ID': { + name: 'Cavern to ID', + positions: [ + { + position: [66388.95051168812, 5264.228603228927, -46910.7848593543], + duration: 1000, + }, + { + position: [12834.18729094943, 677.7571205763458, 135.68755273443463], + duration: 2000, + }, + { + position: [312.02688693297375, 25.884223757326, 270.10019006776236], + duration: 3500, + }, + { + position: [263.3640855132258, 19.874838262525053, -318.16541790248885], + duration: 3000, + }, + { + position: [5534.140362338047, 234.03507981484574, -2933.619479808285], + duration: 2000, + }, + { + position: [2681.277288705242, 646.5795158318147, 5628.5248735111745], + duration: 1000, + }, + { + position: [-6062.586283740076, 790.5876682946184, 1381.1675900848818], + duration: 1000, + }, + { + position: [-1766.7693725879053, 1007.1048030984678, -5928.901341784575], + duration: 1000, + }, + { + position: [12814.982506255355, 2516.987185037266, -22891.902734328327], + duration: 1000, + }, + ], + animateEventAfterInterval: 5000, + collisionDuration: 6000, + }, +}; + +@Component({ + standalone: false, + selector: 'app-animate-camera', + templateUrl: './animate-camera.component.html', + styleUrls: ['./animate-camera.component.scss'], +}) +export class AnimateCameraComponent { + @Input() animationPresets = defaultAnimationPresets; + @ViewChild(SSModeComponent) ssModeComponent: SSModeComponent; + animationPresetsKeys = Object.keys(this.animationPresets); + isAnimating = false; + + constructor(private eventDisplay: EventDisplayService) {} + + animatePreset(preset: string) { + this.setDetectorOpacity(0.2); + this.eventDisplay.animatePreset(this.animationPresets[preset], () => { + this.setDetectorOpacity(1); + }); + } + + /** + * Enter screenshot (fullscreen) mode, then run the preset animation. + * Exits screenshot mode automatically when the animation completes. + */ + animatePresetInSSMode(preset: string) { + this.ssModeComponent.enterSSMode(() => { + this.setDetectorOpacity(0.2); + this.eventDisplay.animatePreset(this.animationPresets[preset], () => { + this.setDetectorOpacity(1); + this.ssModeComponent.exitSSMode(); + document.exitFullscreen?.(); + }); + }); + } + + animateCamera() { + if (!this.isAnimating) { + this.isAnimating = true; + this.eventDisplay.animateThroughEvent([11976, 7262, 11927], 3000, () => { + this.isAnimating = false; + }); + } + } + + private setDetectorOpacity(opacity: number) { + const sceneManager = this.eventDisplay.getThreeManager().getSceneManager(); + const geometriesGroup = sceneManager.getObjectByName( + SceneManager.GEOMETRIES_ID, + ); + + sceneManager.setGeometryOpacity(geometriesGroup as Mesh, opacity); + } +} diff --git a/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ss-mode/ss-mode.component.ts b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ss-mode/ss-mode.component.ts new file mode 100644 index 000000000..2e9f9e15b --- /dev/null +++ b/phoenix/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ss-mode/ss-mode.component.ts @@ -0,0 +1,61 @@ +import { type OnInit, Output, EventEmitter } from '@angular/core'; +import { Component, ViewEncapsulation } from '@angular/core'; + +@Component({ + standalone: false, + selector: 'app-ss-mode', + templateUrl: './ss-mode.component.html', + styleUrls: ['./ss-mode.component.scss'], + encapsulation: ViewEncapsulation.None, +}) +export class SSModeComponent implements OnInit { + ssMode: boolean = false; + + /** Emits when SS mode is exited, so parent components can clean up. */ + @Output() ssModeExited = new EventEmitter(); + + ngOnInit() { + document.onfullscreenchange = () => { + if (!document.fullscreenElement) { + this.exitSSMode(); + } + }; + } + + toggleSSMode() { + if (this.ssMode) { + document.exitFullscreen?.(); + } else { + this.enterSSMode(); + } + } + + enterSSMode(onReady?: () => void) { + if (this.ssMode) { + onReady?.(); + return; + } + this.ssMode = true; + document.body.classList.add('ss-mode'); + // WORKAROUND - Adding the event listener directly somehow calls it on the first click + setTimeout(() => { + document.addEventListener('click', this.onDocumentClick); + document.addEventListener('touchstart', this.onDocumentClick); + onReady?.(); + }, 1); + document.documentElement.requestFullscreen?.(); + } + + exitSSMode() { + if (!this.ssMode) return; + this.ssMode = false; + document.body.classList.remove('ss-mode'); + document.removeEventListener('click', this.onDocumentClick); + document.removeEventListener('touchstart', this.onDocumentClick); + this.ssModeExited.emit(); + } + + private onDocumentClick = () => { + document.exitFullscreen?.(); + }; +}