diff --git a/packages/phoenix-event-display/src/event-display.ts b/packages/phoenix-event-display/src/event-display.ts index ee04afc4d..fc48be8b2 100644 --- a/packages/phoenix-event-display/src/event-display.ts +++ b/packages/phoenix-event-display/src/event-display.ts @@ -39,6 +39,8 @@ export class EventDisplay { private onEventsChange: ((events: any) => void)[] = []; /** Array containing callbacks to be called when the displayed event changes. */ private onDisplayedEventChange: ((nowDisplayingEvent: any) => void)[] = []; + /** Generic event bus for integration with external frameworks. */ + private eventBus: Map void>> = new Map(); /** Three manager for three.js operations. */ private graphicsLibrary: ThreeManager; /** Info logger for storing event display logs. */ @@ -127,6 +129,7 @@ export class EventDisplay { // Clear accumulated callbacks this.onEventsChange = []; this.onDisplayedEventChange = []; + this.eventBus.clear(); // Reset singletons for clean view transition this.loadingManager?.reset(); this.stateManager?.resetForViewTransition(); @@ -620,6 +623,48 @@ export class EventDisplay { }; } + /** + * Subscribe to a named event on the integration event bus. + * Allows external frameworks to react to actions like particle tagging + * or result recording. + * + * Standard event names: + * - `'particle-tagged'`: Fired when a particle is tagged in the masterclass panel. + * - `'particle-untagged'`: Fired when a tagged particle is removed. + * - `'result-recorded'`: Fired when an invariant mass result is recorded. + * + * @param eventName The event name to listen for. + * @param callback Callback invoked with event-specific data. + * @returns Unsubscribe function to remove the listener. + */ + public on(eventName: string, callback: (data: any) => void): () => void { + if (!this.eventBus.has(eventName)) { + this.eventBus.set(eventName, new Set()); + } + this.eventBus.get(eventName).add(callback); + return () => { + const listeners = this.eventBus.get(eventName); + if (listeners) { + listeners.delete(callback); + if (listeners.size === 0) { + this.eventBus.delete(eventName); + } + } + }; + } + + /** + * Emit a named event on the integration event bus. + * @param eventName The event name to emit. + * @param data Data to pass to listeners. + */ + public emit(eventName: string, data?: any): void { + const listeners = this.eventBus.get(eventName); + if (listeners) { + listeners.forEach((cb) => cb(data)); + } + } + /** * Get metadata associated to the displayed event (experiment info, time, run, event...). * @returns Metadata of the displayed event. diff --git a/packages/phoenix-event-display/src/helpers/invariant-mass.ts b/packages/phoenix-event-display/src/helpers/invariant-mass.ts new file mode 100644 index 000000000..24eb45ad7 --- /dev/null +++ b/packages/phoenix-event-display/src/helpers/invariant-mass.ts @@ -0,0 +1,178 @@ +/** A Lorentz 4-momentum vector (E, px, py, pz) in MeV. */ +export interface FourMomentum { + E: number; + px: number; + py: number; + pz: number; +} + +/** A tagged particle with its physics properties. */ +export interface TaggedParticle { + uuid: string; + tag: string; + fourMomentum: FourMomentum; + /** Display-friendly properties. */ + pT: number; + eta: number; + phi: number; +} + +/** Definition of a particle tag for use in masterclass exercises. */ +export interface ParticleTagDef { + /** Unique identifier, e.g. 'electron', 'kaon'. */ + id: string; + /** Human-readable label, e.g. 'Electron'. */ + label: string; + /** Symbol for display, e.g. 'e\u00B1', 'K\u00B1'. */ + symbol: string; + /** CSS color for the tag button and badge. */ + color: string; + /** Rest mass in MeV/c\u00B2. */ + mass: number; +} + +/** + * Configuration for experiment-specific masterclass exercises. + * Each experiment (ATLAS, LHCb, CMS, ...) provides its own config. + */ +export interface MasterclassConfig { + /** Panel title, e.g. 'ATLAS Z-Path Masterclass'. */ + title: string; + /** Available particle tags for this exercise. */ + particleTags: ParticleTagDef[]; + /** Educational hints shown when invariant mass is computed. */ + hints: string[]; + /** + * Classify an event from the tag counts. + * Receives a map of tag id to count, e.g. { electron: 2, muon: 0 }. + * Returns a short label like "e", "4e", "2e2m". + */ + classifyEvent: (tagCounts: Record) => string; +} + +/** + * Extract a 4-momentum vector from track userData. + * Tracks have pT, eta/phi (or dparams), and we assign mass from the tag definition. + * @param userData Track user data containing kinematic properties. + * @param mass Particle rest mass in MeV/c². + */ +export function fourMomentumFromTrack( + userData: any, + mass: number, +): FourMomentum | null { + const pT = userData.pT; + if (pT == null) return null; + + const phi = userData.phi ?? userData.dparams?.[2]; + // theta from dparams, or compute from eta + let theta = userData.dparams?.[3]; + if (theta == null && userData.eta != null) { + theta = 2 * Math.atan(Math.exp(-userData.eta)); + } + if (phi == null || theta == null) return null; + + const px = pT * Math.cos(phi); + const py = pT * Math.sin(phi); + const pz = pT / Math.tan(theta); + const p2 = px * px + py * py + pz * pz; + const E = Math.sqrt(p2 + mass * mass); + + return { E, px, py, pz }; +} + +/** + * Extract a 4-momentum vector from a calorimeter cluster. + * Clusters have energy, eta, phi — treated as massless. + */ +export function fourMomentumFromCluster(userData: any): FourMomentum | null { + const energy = userData.energy; + const eta = userData.eta; + const phi = userData.phi; + if (energy == null || eta == null || phi == null) return null; + + const theta = 2 * Math.atan(Math.exp(-eta)); + const px = energy * Math.sin(theta) * Math.cos(phi); + const py = energy * Math.sin(theta) * Math.sin(phi); + const pz = energy * Math.cos(theta); + + return { E: energy, px, py, pz }; +} + +/** + * Compute the invariant mass of a set of particles in MeV. + * M² = (ΣE)² - (Σpx)² - (Σpy)² - (Σpz)² + */ +export function invariantMass(momenta: FourMomentum[]): number { + if (momenta.length < 2) return 0; + + let sumE = 0, + sumPx = 0, + sumPy = 0, + sumPz = 0; + for (const p of momenta) { + sumE += p.E; + sumPx += p.px; + sumPy += p.py; + sumPz += p.pz; + } + + const m2 = sumE * sumE - sumPx * sumPx - sumPy * sumPy - sumPz * sumPz; + return m2 > 0 ? Math.sqrt(m2) : 0; +} + +/** + * Default event classifier for ATLAS Z-path masterclass. + * Classifies events by electron/muon/photon counts. + */ +export function atlasClassifyEvent(tagCounts: Record): string { + const e = tagCounts['electron'] ?? 0; + const m = tagCounts['muon'] ?? 0; + const g = tagCounts['photon'] ?? 0; + + if (e === 2 && m === 0 && g === 0) return 'e'; + if (e === 0 && m === 2 && g === 0) return 'm'; + if (e === 0 && m === 0 && g === 2) return 'g'; + if (e === 4 && m === 0 && g === 0) return '4e'; + if (e === 2 && m === 2 && g === 0) return '2e2m'; + if (e === 0 && m === 4 && g === 0) return '4m'; + + const parts: string[] = []; + if (e > 0) parts.push(`${e}e`); + if (m > 0) parts.push(`${m}m`); + if (g > 0) parts.push(`${g}g`); + return parts.join('') || '?'; +} + +/** Default masterclass configuration for ATLAS Z-path exercises. */ +export const ATLAS_MASTERCLASS_CONFIG: MasterclassConfig = { + title: 'Masterclass \u2014 Invariant Mass', + particleTags: [ + { + id: 'electron', + label: 'Electron', + symbol: 'e\u00B1', + color: '#f0c040', + mass: 0.511, + }, + { + id: 'muon', + label: 'Muon', + symbol: '\u03BC\u00B1', + color: '#40c060', + mass: 105.658, + }, + { + id: 'photon', + label: 'Photon', + symbol: '\u03B3', + color: '#e04040', + mass: 0, + }, + ], + hints: [ + 'Z boson \u2248 91 GeV', + 'Higgs \u2248 125 GeV', + 'J/\u03C8 \u2248 3.1 GeV', + ], + classifyEvent: atlasClassifyEvent, +}; diff --git a/packages/phoenix-event-display/src/index.ts b/packages/phoenix-event-display/src/index.ts index bbf07c499..a12e776bf 100644 --- a/packages/phoenix-event-display/src/index.ts +++ b/packages/phoenix-event-display/src/index.ts @@ -32,6 +32,7 @@ export * from './helpers/runge-kutta'; export * from './helpers/pretty-symbols'; export * from './helpers/active-variable'; export * from './helpers/zip'; +export * from './helpers/invariant-mass'; // Loaders export * from './loaders/event-data-loader'; diff --git a/packages/phoenix-event-display/src/managers/three-manager/index.ts b/packages/phoenix-event-display/src/managers/three-manager/index.ts index fa9cc0007..8927eb040 100644 --- a/packages/phoenix-event-display/src/managers/three-manager/index.ts +++ b/packages/phoenix-event-display/src/managers/three-manager/index.ts @@ -1197,7 +1197,7 @@ export class ThreeManager { * Get the selection manager. * @returns Selection manager responsible for managing selection of 3D objects. */ - private getSelectionManager(): SelectionManager { + public getSelectionManager(): SelectionManager { if (!this.selectionManager) { this.selectionManager = new SelectionManager(); } diff --git a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.html b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.html index 621648fa2..064f887c3 100644 --- a/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.html +++ b/packages/phoenix-ng/projects/phoenix-app/src/app/sections/atlas/atlas.component.html @@ -1,6 +1,9 @@ - + + + + + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts index 7b96687c6..50ed9c299 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/phoenix-ui.module.ts @@ -65,6 +65,8 @@ import { EventDataExplorerComponent, EventDataExplorerDialogComponent, CycleEventsComponent, + MasterclassPanelComponent, + MasterclassPanelOverlayComponent, } from './ui-menu'; import { AttributePipe } from '../services/extras/attribute.pipe'; @@ -127,6 +129,8 @@ const PHOENIX_COMPONENTS: Type[] = [ FileExplorerComponent, RingLoaderComponent, CycleEventsComponent, + MasterclassPanelComponent, + MasterclassPanelOverlayComponent, ]; @NgModule({ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts index 50b4566a0..3c4d6f55e 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/index.ts @@ -37,3 +37,5 @@ export * from './event-data-explorer/event-data-explorer.component'; export * from './event-data-explorer/event-data-explorer-dialog/event-data-explorer-dialog.component'; export * from './cycle-events/cycle-events.component'; export * from './ui-menu-wrapper/ui-menu-wrapper.component'; +export * from './masterclass-panel/masterclass-panel.component'; +export * from './masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.html new file mode 100644 index 000000000..04886570f --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.html @@ -0,0 +1,201 @@ + +
+ +
+
+ 1 + Select tracks +
+ + + +
+
+ + #{{ item.index }} + + pT={{ ptGeV(item.pT) }} + + + η={{ item.eta | number: '1.1-1' }} + +
+
+

+ Load an event to see collections. +

+
+ + +
+
+ 2 + Tag as ({{ selectedCount }} selected) +
+ +
+ +
+
+ + +

+ {{ statusMessage }} +

+ + +
+
+ 3 + Tagged particles +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#TypepT GeVηφE GeV
{{ i + 1 }} + + {{ getTagDef(p.tag)?.symbol ?? p.tag }} + + {{ ptGeV(p.pT) }}{{ p.eta | number: '1.2-2' }}{{ p.phi | number: '1.2-2' }}{{ massGeV(p.fourMomentum.E) }} + +
+ M({{ liveEventType }}) + + {{ massGeV(liveMass) }} GeV +
+ +
+ + +
+ + +
+ Hint: + + {{ hint }} · + +
+
+ + +
+
+ 4 + Results ({{ massResults.length }}) +
+ + + + + + + + + + + + + + + + + + + + +
EventTypeParticlesM GeV
{{ i + 1 }} + {{ r.eventType }} + {{ r.particleCount }} + {{ massGeV(r.mass) }} + + +
+ +
+ + +
+
+
+
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.scss new file mode 100644 index 000000000..9a6645812 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.scss @@ -0,0 +1,343 @@ +// ── Masterclass Panel ──────────────────────────────────── +// Experiment-agnostic UI for masterclass exercises. +// Designed for high-school students: clear steps, big targets. + +$cyan: #00bcd4; +$gold: #f0c040; +$green: #40c060; +$red: #e04040; +$bg-card: rgba(255, 255, 255, 0.06); +$border: rgba(255, 255, 255, 0.12); +$text: #e0e0e0; +$muted: rgba(255, 255, 255, 0.5); + +.mc { + width: 400px; + font-size: 0.82rem; + color: $text; + display: flex; + flex-direction: column; + gap: 10px; + padding: 6px; +} + +// ── Sections ────────────────────────────────────────────── +.mc-section { + background: $bg-card; + border: 1px solid $border; + border-radius: 8px; + padding: 10px 12px; +} + +.mc-step-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-weight: 600; + font-size: 0.85rem; +} + +.mc-step-num { + display: inline-flex; + align-items: center; + justify-content: center; + width: 22px; + height: 22px; + border-radius: 50%; + background: $cyan; + color: #000; + font-weight: 700; + font-size: 0.75rem; + flex-shrink: 0; +} + +// ── Collection select ───────────────────────────────────── +.mc-select { + width: 100%; + padding: 6px 8px; + border: 1px solid $border; + border-radius: 6px; + background: rgba(0, 0, 0, 0.3); + color: $text; + font-size: 0.82rem; + margin-bottom: 6px; + cursor: pointer; + + option { + background: #1a1a1a; + color: $text; + } +} + +// ── Track list ──────────────────────────────────────────── +.mc-track-list { + max-height: 200px; + overflow-y: auto; + border: 1px solid $border; + border-radius: 6px; + + &::-webkit-scrollbar { + width: 4px; + } + &::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 2px; + } +} + +.mc-track-row { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + cursor: pointer; + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.12s; + + &:hover { + background: rgba(255, 255, 255, 0.08); + } + + &.mc-track-selected { + background: rgba($cyan, 0.18); + border-left: 3px solid $cyan; + } + + input[type='checkbox'] { + accent-color: $cyan; + cursor: pointer; + } +} + +.mc-track-id { + font-weight: 600; + min-width: 28px; + color: $text; +} + +.mc-track-prop { + font-size: 0.75rem; + color: $muted; +} + +// ── Tag buttons ─────────────────────────────────────────── +.mc-tag-row { + display: flex; + gap: 6px; +} + +.mc-tag-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 8px 4px; + border: 2px solid; + border-radius: 8px; + background: transparent; + color: $text; + font-weight: 700; + font-size: 1.1rem; + cursor: pointer; + transition: all 0.15s; + + small { + font-size: 0.6rem; + font-weight: 400; + opacity: 0.7; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + background: rgba(255, 255, 255, 0.08); + } + + &:disabled { + opacity: 0.3; + cursor: not-allowed; + } +} + +// ── Status bar ──────────────────────────────────────────── +.mc-status { + font-size: 0.78rem; + padding: 6px 10px; + border-radius: 6px; + margin: 0; +} +.mc-status-info { + background: rgba($cyan, 0.12); + color: $cyan; +} +.mc-status-success { + background: rgba($green, 0.12); + color: $green; +} +.mc-status-warning { + background: rgba($gold, 0.12); + color: $gold; +} + +// ── Tables (HYPATIA-style) ──────────────────────────────── +.mc-table { + width: 100%; + border-collapse: collapse; + font-size: 0.78rem; + + th, + td { + padding: 4px 6px; + text-align: left; + color: $text; + } + + th { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.03em; + color: $muted; + border-bottom: 1px solid $border; + font-weight: 600; + + small { + opacity: 0.6; + font-size: 0.6rem; + } + } + + td { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + } + + tbody tr:hover { + background: rgba(255, 255, 255, 0.05); + } +} + +.mc-badge { + display: inline-block; + padding: 1px 8px; + border-radius: 4px; + font-weight: 700; + font-size: 0.82rem; +} + +.mc-mass-row { + td { + border-top: 2px solid $cyan; + padding-top: 6px; + border-bottom: none; + } +} + +.mc-mass-value { + color: $cyan; + font-size: 0.95rem; +} + +.mc-event-type { + font-family: monospace; + font-weight: 700; + font-size: 0.9rem; + color: $cyan; +} + +.mc-remove { + background: none; + border: none; + color: $muted; + font-size: 1.1rem; + cursor: pointer; + padding: 0 4px; + line-height: 1; + + &:hover { + color: $red; + } +} + +// ── Buttons ─────────────────────────────────────────────── +.mc-actions { + display: flex; + gap: 6px; + margin-top: 8px; +} + +.mc-btn { + padding: 6px 14px; + border-radius: 6px; + font-size: 0.78rem; + font-weight: 600; + cursor: pointer; + border: 1px solid $border; + background: transparent; + color: $text; + transition: all 0.15s; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.08); + } + + &:disabled { + opacity: 0.35; + cursor: not-allowed; + } +} + +.mc-btn-primary { + background: rgba($cyan, 0.15); + border-color: $cyan; + color: $cyan; + &:hover:not(:disabled) { + background: rgba($cyan, 0.25); + } +} + +.mc-btn-export { + background: rgba($green, 0.12); + border-color: $green; + color: $green; + &:hover { + background: rgba($green, 0.2); + } +} + +.mc-btn-ghost { + border-color: transparent; + color: $muted; + &:hover { + color: $text; + } +} + +// ── Hint box ────────────────────────────────────────────── +.mc-hint-box { + margin-top: 8px; + padding: 6px 10px; + border-radius: 6px; + background: rgba($cyan, 0.08); + border: 1px dashed rgba($cyan, 0.3); + font-size: 0.72rem; + color: $muted; + + strong { + color: $cyan; + } +} + +.mc-hint { + font-size: 0.75rem; + color: $muted; + font-style: italic; +} + +// ── Results table ───────────────────────────────────────── +.mc-table-results { + td strong { + color: $cyan; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.ts new file mode 100644 index 000000000..666ca611d --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel-overlay/masterclass-panel-overlay.component.ts @@ -0,0 +1,269 @@ +import { Component, Input, type OnDestroy, type OnInit } from '@angular/core'; +import { + type TaggedParticle, + type ParticleTagDef, + type MasterclassConfig, + type FourMomentum, + ATLAS_MASTERCLASS_CONFIG, + fourMomentumFromTrack, + fourMomentumFromCluster, + invariantMass, +} from 'phoenix-event-display'; +import { EventDisplayService } from '../../../../services/event-display.service'; + +interface MassResult { + eventType: string; + mass: number; + particleCount: number; +} + +interface CollectionItem { + index: number; + uuid: string; + userData: any; + selected: boolean; + pT: number; + eta: number; + phi: number; +} + +@Component({ + standalone: false, + selector: 'app-masterclass-panel-overlay', + templateUrl: './masterclass-panel-overlay.component.html', + styleUrls: ['./masterclass-panel-overlay.component.scss'], +}) +export class MasterclassPanelOverlayComponent implements OnInit, OnDestroy { + @Input() showPanel: boolean; + @Input() config: MasterclassConfig = ATLAS_MASTERCLASS_CONFIG; + + // Step 1: Collection selection + collectionNames: string[] = []; + selectedCollection = ''; + collectionItems: CollectionItem[] = []; + + // Step 2: Tagged particles + taggedParticles: TaggedParticle[] = []; + + // Step 3: Invariant mass results (persisted across events) + massResults: MassResult[] = []; + + // UI state + statusMessage = ''; + statusType: 'info' | 'success' | 'warning' = 'info'; + + /** Live invariant mass of currently tagged particles. */ + get liveMass(): number { + if (this.taggedParticles.length < 2) return 0; + return invariantMass(this.taggedParticles.map((p) => p.fourMomentum)); + } + + /** Live event type classification using experiment-specific classifier. */ + get liveEventType(): string { + const tagCounts: Record = {}; + for (const p of this.taggedParticles) { + tagCounts[p.tag] = (tagCounts[p.tag] ?? 0) + 1; + } + return this.config.classifyEvent(tagCounts); + } + + get selectedCount(): number { + return this.collectionItems.filter((i) => i.selected).length; + } + + private unsubscribes: (() => void)[] = []; + + constructor(private eventDisplay: EventDisplayService) {} + + ngOnInit() { + this.unsubscribes.push( + this.eventDisplay.listenToDisplayedEventChange(() => { + this.taggedParticles = []; + this.statusMessage = ''; + this.selectedCollection = ''; + this.loadCollections(); + }), + ); + setTimeout(() => this.loadCollections(), 500); + } + + ngOnDestroy() { + this.unsubscribes.forEach((fn) => fn?.()); + } + + // ── Step 1: Collection ────────────────────────────────── + + private loadCollections() { + const grouped = this.eventDisplay.getCollections(); + this.collectionNames = []; + for (const [, names] of Object.entries(grouped) as [string, string[]][]) { + this.collectionNames.push(...names); + } + if (this.collectionNames.length > 0 && !this.selectedCollection) { + this.selectCollection(this.collectionNames[0]); + } + } + + selectCollection(name: string) { + this.selectedCollection = name; + const objects = this.eventDisplay.getCollection(name) || []; + this.collectionItems = objects.map((obj: any, i: number) => ({ + index: i + 1, + uuid: obj.uuid, + userData: obj, + selected: false, + pT: obj.pT ?? 0, + eta: obj.eta ?? 0, + phi: obj.phi ?? obj.dparams?.[2] ?? 0, + })); + } + + toggleItem(item: CollectionItem) { + item.selected = !item.selected; + } + + highlightItem(uuid: string) { + this.eventDisplay.highlightObject(uuid); + } + + // ── Step 2: Tagging ───────────────────────────────────── + + tagSelectedAs(tagDef: ParticleTagDef) { + this.statusMessage = ''; + const selected = this.collectionItems.filter((i) => i.selected); + + if (selected.length === 0) { + this.setStatus('Select tracks from the list first.', 'warning'); + return; + } + + let added = 0; + for (const item of selected) { + if (this.taggedParticles.some((p) => p.uuid === item.uuid)) continue; + + const ud = item.userData; + let fourMom: FourMomentum | null = null; + + // For massless particles, prefer cluster data; otherwise prefer track data. + if (tagDef.mass === 0) { + fourMom = + fourMomentumFromCluster(ud) || fourMomentumFromTrack(ud, tagDef.mass); + } else { + fourMom = + fourMomentumFromTrack(ud, tagDef.mass) || fourMomentumFromCluster(ud); + } + + if (!fourMom) continue; + + const tagged: TaggedParticle = { + uuid: item.uuid, + tag: tagDef.id, + fourMomentum: fourMom, + pT: ud.pT ?? 0, + eta: ud.eta ?? 0, + phi: ud.phi ?? ud.dparams?.[2] ?? 0, + }; + + this.taggedParticles.push(tagged); + this.eventDisplay.emit('particle-tagged', tagged); + added++; + } + + // Uncheck all + this.collectionItems.forEach((i) => (i.selected = false)); + + if (added > 0) { + this.setStatus( + `Tagged ${added} as ${tagDef.symbol}. ${this.taggedParticles.length} total.`, + 'success', + ); + } else { + this.setStatus( + 'Could not extract momentum from selected items.', + 'warning', + ); + } + } + + removeTagged(index: number) { + const removed = this.taggedParticles.splice(index, 1); + if (removed.length > 0) { + this.eventDisplay.emit('particle-untagged', removed[0]); + } + } + + clearTagged() { + this.taggedParticles = []; + this.statusMessage = ''; + } + + // ── Step 3: Record result ─────────────────────────────── + + recordResult() { + if (this.taggedParticles.length < 2) return; + + const result: MassResult = { + eventType: this.liveEventType, + mass: this.liveMass, + particleCount: this.taggedParticles.length, + }; + this.massResults.push(result); + + this.eventDisplay.emit('result-recorded', result); + + this.setStatus( + `Recorded: ${result.eventType} \u2192 ${this.massGeV(result.mass)} GeV`, + 'success', + ); + + // Clear tagged for next event + this.taggedParticles = []; + } + + removeResult(index: number) { + this.massResults.splice(index, 1); + } + + clearResults() { + this.massResults = []; + } + + // ── Export ────────────────────────────────────────────── + + exportResults() { + if (this.massResults.length === 0) return; + + const lines = ['type\tmass_GeV']; + for (const r of this.massResults) { + lines.push(`${r.eventType}\t${this.massGeV(r.mass)}`); + } + + const blob = new Blob([lines.join('\n')], { type: 'text/plain' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'Invariant_Masses.txt'; + a.click(); + URL.revokeObjectURL(url); + } + + // ── Helpers ───────────────────────────────────────────── + + massGeV(mev: number): string { + return (mev / 1000).toFixed(2); + } + + ptGeV(mev: number): string { + return (mev / 1000).toFixed(1); + } + + /** Look up the tag definition for a tagged particle. */ + getTagDef(tagId: string): ParticleTagDef | undefined { + return this.config.particleTags.find((t) => t.id === tagId); + } + + private setStatus(msg: string, type: 'info' | 'success' | 'warning') { + this.statusMessage = msg; + this.statusType = type; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.html new file mode 100644 index 000000000..acde4bfcc --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.html @@ -0,0 +1,7 @@ + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.scss new file mode 100644 index 000000000..9d41c4173 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.scss @@ -0,0 +1 @@ +/* Masterclass panel toolbar button - inherits from menu-toggle */ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.ts new file mode 100644 index 000000000..8f7f133a2 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/masterclass-panel/masterclass-panel.component.ts @@ -0,0 +1,55 @@ +import { + Component, + Input, + type OnInit, + ComponentRef, + type OnDestroy, + type OnChanges, + type SimpleChanges, +} from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import type { MasterclassConfig } from 'phoenix-event-display'; +import { MasterclassPanelOverlayComponent } from './masterclass-panel-overlay/masterclass-panel-overlay.component'; + +@Component({ + standalone: false, + selector: 'app-masterclass-panel', + templateUrl: './masterclass-panel.component.html', + styleUrls: ['./masterclass-panel.component.scss'], +}) +export class MasterclassPanelComponent implements OnInit, OnDestroy, OnChanges { + @Input() config?: MasterclassConfig; + + showPanel = false; + overlayWindow: ComponentRef; + + constructor(private overlay: Overlay) {} + + ngOnInit() { + const overlayRef = this.overlay.create(); + const overlayPortal = new ComponentPortal(MasterclassPanelOverlayComponent); + this.overlayWindow = overlayRef.attach(overlayPortal); + this.overlayWindow.instance.showPanel = this.showPanel; + if (this.config) { + this.overlayWindow.instance.config = this.config; + } + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['config'] && this.overlayWindow) { + if (this.config) { + this.overlayWindow.instance.config = this.config; + } + } + } + + ngOnDestroy(): void { + this.overlayWindow.destroy(); + } + + toggleOverlay() { + this.showPanel = !this.showPanel; + this.overlayWindow.instance.showPanel = this.showPanel; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html index b694b5eae..fa7ccad8b 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.html @@ -38,6 +38,9 @@ + + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts index 8b7c1e42f..1acb65030 100644 --- a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/ui-menu.component.ts @@ -4,6 +4,7 @@ import { type EventDataImportOption, } from '../../services/extras/event-data-import'; import { defaultAnimationPresets } from './animate-camera/animate-camera.component'; +import type { MasterclassConfig } from 'phoenix-event-display'; @Component({ standalone: false, // this is now required when using NgModule @@ -17,4 +18,6 @@ export class UiMenuComponent { Object.values(EventDataFormat); @Input() animationPresets = defaultAnimationPresets; + @Input() + masterclassConfig?: MasterclassConfig; }