From 846edcab55e0d88cd356c2c3568486d51de2b282 Mon Sep 17 00:00:00 2001 From: rx18-eng Date: Tue, 24 Mar 2026 20:05:49 +0530 Subject: [PATCH] feat: add track kinematics panel with config-driven columns Signed-off-by: rx18-eng --- .../src/helpers/kinematics-config.ts | 93 +++++ packages/phoenix-event-display/src/index.ts | 1 + .../src/assets/icons/kinematics.svg | 16 + .../lib/assets/icons/kinematics.svg | 16 + .../lib/components/phoenix-ui.module.ts | 4 + .../lib/components/ui-menu/index.ts | 2 + .../kinematics-panel-overlay.component.html | 149 ++++++++ .../kinematics-panel-overlay.component.scss | 238 +++++++++++++ .../kinematics-panel-overlay.component.ts | 325 ++++++++++++++++++ .../kinematics-panel.component.html | 7 + .../kinematics-panel.component.scss | 1 + .../kinematics-panel.component.ts | 54 +++ .../components/ui-menu/ui-menu.component.html | 3 + .../components/ui-menu/ui-menu.component.ts | 4 + 14 files changed, 913 insertions(+) create mode 100644 packages/phoenix-event-display/src/helpers/kinematics-config.ts create mode 100644 packages/phoenix-ng/projects/phoenix-app/src/assets/icons/kinematics.svg create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/kinematics.svg create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.html create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.scss create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.ts create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.html create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.scss create mode 100644 packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.ts diff --git a/packages/phoenix-event-display/src/helpers/kinematics-config.ts b/packages/phoenix-event-display/src/helpers/kinematics-config.ts new file mode 100644 index 000000000..ac4a10df6 --- /dev/null +++ b/packages/phoenix-event-display/src/helpers/kinematics-config.ts @@ -0,0 +1,93 @@ +/** + * Configuration for a single column in the kinematics panel. + * Experiment-agnostic — each experiment provides its own column definitions. + */ +export interface KinematicsColumn { + /** Column identifier, used for sorting. */ + id: string; + /** Display label for the column header. */ + label: string; + /** Unit string shown in header as "label (unit)". */ + unit?: string; + /** Tooltip text shown on hover — helps students learn physics quantities. */ + tooltip?: string; + /** Extract the display value from a physics object's userData. */ + getter: (objectData: any) => number | string; + /** Decimal places for numeric values. Defaults to 1. */ + precision?: number; +} + +/** + * Configuration for the kinematics panel. + * Experiment-agnostic — each experiment provides its own config. + */ +export interface KinematicsConfig { + /** Display title for the panel header. */ + title: string; + /** Collection group type to filter (e.g., 'Tracks', 'Jets'). */ + collectionType?: string; + /** Column definitions. */ + columns: KinematicsColumn[]; + /** Default sort column ID. */ + defaultSort?: string; + /** Default sort direction. */ + defaultSortDirection?: 'asc' | 'desc'; + /** Column ID for the threshold filter. If set, shows a min-value filter input. */ + filterColumn?: string; +} + +/** Default ATLAS track kinematics config for JiveXML/PHYSLITE data. */ +export const ATLAS_KINEMATICS: KinematicsConfig = { + title: 'Track Momenta', + collectionType: 'Tracks', + defaultSort: 'pT', + defaultSortDirection: 'desc', + filterColumn: 'pT', + columns: [ + { + id: 'pT', + label: 'pT', + unit: 'GeV', + tooltip: 'Transverse momentum', + getter: (t) => (t.pT ?? 0) / 1000, + precision: 1, + }, + { + id: 'eta', + label: '\u03B7', + tooltip: 'Pseudorapidity', + getter: (t) => t.eta ?? 0, + precision: 2, + }, + { + id: 'phi', + label: '\u03C6', + tooltip: 'Azimuthal angle', + getter: (t) => t.phi ?? 0, + precision: 2, + }, + { + id: 'charge', + label: 'q', + tooltip: 'Electric charge', + getter: (t) => { + if (t.charge != null) return t.charge; + const qOverP = t.dparams?.[4]; + return qOverP ? Math.sign(qOverP) : 0; + }, + precision: 0, + }, + { + id: 'p', + label: '|p|', + unit: 'GeV', + tooltip: 'Total momentum', + getter: (t) => { + const qOverP = t.dparams?.[4]; + if (!qOverP || !isFinite(1 / qOverP)) return 0; + return Math.abs(1 / qOverP) / 1000; + }, + precision: 1, + }, + ], +}; diff --git a/packages/phoenix-event-display/src/index.ts b/packages/phoenix-event-display/src/index.ts index bbf07c499..f45a1f60c 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/kinematics-config'; // Loaders export * from './loaders/event-data-loader'; diff --git a/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/kinematics.svg b/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/kinematics.svg new file mode 100644 index 000000000..d372360d0 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-app/src/assets/icons/kinematics.svg @@ -0,0 +1,16 @@ + + + + + + p + + + + + + + + + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/kinematics.svg b/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/kinematics.svg new file mode 100644 index 000000000..d372360d0 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/assets/icons/kinematics.svg @@ -0,0 +1,16 @@ + + + + + + p + + + + + + + + + + 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..8ff885699 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, + KinematicsPanelComponent, + KinematicsPanelOverlayComponent, } from './ui-menu'; import { AttributePipe } from '../services/extras/attribute.pipe'; @@ -127,6 +129,8 @@ const PHOENIX_COMPONENTS: Type[] = [ FileExplorerComponent, RingLoaderComponent, CycleEventsComponent, + KinematicsPanelComponent, + KinematicsPanelOverlayComponent, ]; @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..c09d53bc5 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 './kinematics-panel/kinematics-panel.component'; +export * from './kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component'; diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.html new file mode 100644 index 000000000..cebc1814f --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.html @@ -0,0 +1,149 @@ + +
+ +
+
+
+ +
+ + {{ selectedCollection }} + + + {{ rows.length + }} / {{ allRows.length }} tracks + + +
+ + +
+ +
+
+ + +
+ + + + + + + + + + + + + + + +
# + {{ col.label }} + ({{ col.unit }}) + + {{ sortDirection === 'asc' ? '\u25B2' : '\u25BC' }} + +
+ {{ i + 1 }} + + + + {{ formatValue(row.values[col.id], col) }} +
+
+ + +

+ No tracks above {{ filterCol?.label }} ≥ {{ filterValue }} + {{ filterCol?.unit }}. +

+

+ No objects in this collection. +

+

+ Load event data to view track kinematics. +

+
+
diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.scss new file mode 100644 index 000000000..19ae185c3 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.scss @@ -0,0 +1,238 @@ +.kinematics-panel { + height: 95%; + display: flex; + flex-direction: column; +} + +.panel-header { + flex-shrink: 0; + padding: 0.5rem 0.75rem 0.25rem; + + .header-top { + display: flex; + align-items: center; + gap: 0.8rem; + min-height: 2rem; + } + + .collection-selector select { + padding: 4px 8px; + font-size: 12px; + border: 1px solid rgba(88, 88, 88, 0.08); + box-shadow: var(--phoenix-icon-shadow); + background-color: var(--phoenix-background-color-tertiary); + color: var(--phoenix-text-color-secondary); + } + + .collection-name { + color: var(--phoenix-text-color-secondary); + font-size: 0.85rem; + font-weight: 500; + } + + .track-count { + color: var(--phoenix-text-color-secondary); + font-size: 0.75rem; + opacity: 0.6; + margin-left: auto; + } + + .export-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.5rem; + height: 1.5rem; + padding: 0.3rem; + border: none; + background: var(--phoenix-options-icon-bg); + border-radius: 8px; + cursor: pointer; + flex-shrink: 0; + + &:hover { + outline: 1px solid var(--phoenix-options-icon-path); + } + + svg { + width: 100%; + height: 100%; + } + } +} + +.filter-row { + padding: 0.3rem 0; + + label { + display: flex; + align-items: center; + gap: 0.4rem; + color: var(--phoenix-text-color-secondary); + font-size: 0.75rem; + opacity: 0.8; + } + + .filter-input { + width: 4rem; + padding: 2px 6px; + font-size: 0.75rem; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + background: var(--phoenix-background-color-tertiary); + color: var(--phoenix-text-color-secondary); + + &:focus { + outline: 1px solid var(--phoenix-text-color-secondary); + } + } + + .filter-unit { + opacity: 0.5; + font-size: 0.7rem; + } +} + +.table-container { + flex: 1; + overflow: auto; + outline: none; +} + +.kinematics-table { + width: 100%; + border-collapse: collapse; + color: var(--phoenix-text-color-secondary); + + thead tr th { + position: sticky; + top: 0; + z-index: 10; + background: var(--phoenix-background-color-secondary); + padding: 0.4rem 0.7rem; + font-size: 0.78rem; + font-weight: 600; + white-space: nowrap; + text-align: left; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + + &.sortable { + cursor: pointer; + user-select: none; + + &:hover { + color: var(--phoenix-text-color); + } + } + + &.sorted { + color: var(--phoenix-text-color); + } + + .col-unit { + opacity: 0.5; + font-weight: normal; + font-size: 0.68rem; + margin-left: 0.15rem; + } + + .sort-arrow { + font-size: 0.55rem; + margin-left: 0.2rem; + opacity: 0.7; + } + } + + tbody tr { + cursor: pointer; + transition: background 0.12s ease; + + &:hover { + background: rgba(255, 255, 255, 0.04); + } + + &.active-object { + color: var(--phoenix-background-color); + background: var(--phoenix-text-color); + box-shadow: 0 0 12px var(--phoenix-text-color); + + .icon-btn { + --phoenix-options-icon-path: var(--phoenix-background-color); + --phoenix-options-icon-bg: var(--phoenix-text-color-hover); + } + } + + &.is-cut { + opacity: 0.35; + } + } + + td { + padding: 0.3rem 0.7rem; + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + + &.col-index { + color: rgba(255, 255, 255, 0.4); + width: 2.5rem; + text-align: right; + padding-right: 0.4rem; + white-space: nowrap; + } + + &.col-action { + width: 2rem; + padding: 0.2rem; + } + + &.col-data { + text-align: right; + } + } +} + +.charge-pos { + color: #ff7b7b; + font-weight: 600; + + &::before { + content: '+'; + } +} + +.charge-neg { + color: #7bb5ff; + font-weight: 600; +} + +.icon-btn { + display: flex; + align-items: center; + justify-content: center; + width: 1.4rem; + height: 1.4rem; + padding: 0.3rem; + border: none; + background: var(--phoenix-options-icon-bg); + border-radius: 8px; + cursor: pointer; + + &:hover { + outline: 1px solid var(--phoenix-options-icon-path); + } + + svg { + width: 100%; + height: 100%; + } +} + +.empty-state { + color: var(--phoenix-text-color-secondary); + opacity: 0.5; + text-align: center; + padding: 2rem; + max-width: 18em; + margin: 2rem auto; + font-size: 0.85rem; +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.ts new file mode 100644 index 000000000..265ee5838 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel-overlay/kinematics-panel-overlay.component.ts @@ -0,0 +1,325 @@ +import { + ChangeDetectorRef, + Component, + Input, + type OnInit, + type OnDestroy, +} from '@angular/core'; +import { + ActiveVariable, + SceneManager, + type KinematicsConfig, + type KinematicsColumn, + ATLAS_KINEMATICS, +} from 'phoenix-event-display'; +import { EventDisplayService } from '../../../../services/event-display.service'; + +/** Pre-computed row for display. */ +interface KinematicsRow { + uuid: string; + values: { [columnId: string]: number | string }; + isCut: boolean; +} + +@Component({ + standalone: false, + selector: 'app-kinematics-panel-overlay', + templateUrl: './kinematics-panel-overlay.component.html', + styleUrls: ['./kinematics-panel-overlay.component.scss'], +}) +export class KinematicsPanelOverlayComponent implements OnInit, OnDestroy { + /** Visibility toggle — refreshes data when panel becomes visible. */ + private _showKinematics = false; + @Input() set showKinematics(val: boolean) { + const wasHidden = !this._showKinematics; + this._showKinematics = val; + if (val && wasHidden) { + this.refreshData(); + } + } + get showKinematics(): boolean { + return this._showKinematics; + } + + @Input() config: KinematicsConfig = ATLAS_KINEMATICS; + + /** Available collections matching the configured type. */ + collections: string[]; + /** Currently selected collection name. */ + selectedCollection: string; + /** All rows before filtering. */ + allRows: KinematicsRow[] = []; + /** Filtered display rows. */ + rows: KinematicsRow[] = []; + /** Current sort state. */ + sortColumn: string; + sortDirection: 'asc' | 'desc' = 'desc'; + /** Tracks the 3D-selected object for bidirectional highlighting. */ + activeObject: ActiveVariable; + /** Resolved group uuid for the currently active 3D object. */ + activeRowUuid = ''; + /** Filter threshold value (for pT cut etc.). */ + filterValue: number = 0; + /** Resolved filter column definition. */ + filterCol: KinematicsColumn | undefined; + + private unsubscribes: (() => void)[] = []; + + constructor( + private eventDisplay: EventDisplayService, + private cdr: ChangeDetectorRef, + ) {} + + ngOnInit() { + this.sortColumn = this.config.defaultSort ?? ''; + this.sortDirection = this.config.defaultSortDirection ?? 'desc'; + this.filterCol = this.config.filterColumn + ? this.config.columns.find((c) => c.id === this.config.filterColumn) + : undefined; + + // Refresh when event data changes (new event loaded). + this.unsubscribes.push( + this.eventDisplay.listenToDisplayedEventChange(() => { + this.refreshData(); + }), + ); + + // Bidirectional selection — scroll to row when object hovered/selected in 3D. + // Walks up the scene hierarchy to resolve child mesh uuids to row group uuids. + // Note: currently only fires for Mesh objects (jets, calo cells). Tracks are + // rendered as Lines which the selection manager doesn't hover-detect yet. + this.activeObject = this.eventDisplay.getActiveObjectId(); + this.unsubscribes.push( + this.activeObject.onUpdate((uuid: string) => { + if (!uuid) { + this.activeRowUuid = ''; + this.cdr.detectChanges(); + return; + } + let resolvedUuid = uuid; + let el = document.getElementById('kin-' + uuid); + if (!el) { + const scene = this.eventDisplay + .getThreeManager() + .getSceneManager() + .getScene(); + const obj = scene.getObjectByProperty('uuid', uuid); + let ancestor = obj?.parent; + while (ancestor && !el) { + el = document.getElementById('kin-' + ancestor.uuid); + if (el) { + resolvedUuid = ancestor.uuid; + } + ancestor = ancestor.parent; + } + } + this.activeRowUuid = resolvedUuid; + this.cdr.detectChanges(); + if (el) { + el.scrollIntoView({ block: 'nearest' }); + } + }), + ); + } + + ngOnDestroy() { + this.unsubscribes.forEach((unsub) => unsub?.()); + } + + /** Reload collections and recompute rows. */ + refreshData() { + const allCollections = this.eventDisplay.getCollections(); + const type = this.config.collectionType ?? 'Tracks'; + this.collections = allCollections[type] ?? []; + + if (this.collections.length > 0) { + if ( + !this.selectedCollection || + !this.collections.includes(this.selectedCollection) + ) { + this.selectCollection(this.collections[0]); + } else { + this.selectCollection(this.selectedCollection); + } + } else { + this.selectedCollection = ''; + this.allRows = []; + this.rows = []; + } + } + + /** Load and compute rows for a collection. */ + selectCollection(name: string) { + this.selectedCollection = name; + const rawObjects = this.eventDisplay.getCollection(name); + if (!rawObjects || !Array.isArray(rawObjects)) { + this.allRows = []; + this.rows = []; + return; + } + + const eventDataGroup = this.eventDisplay + .getThreeManager() + .getSceneManager() + .getScene() + .getObjectByName(SceneManager.EVENT_DATA_ID); + + this.allRows = rawObjects.map((obj: any) => { + const values: { [id: string]: number | string } = {}; + for (const col of this.config.columns) { + try { + values[col.id] = col.getter(obj); + } catch { + values[col.id] = 0; + } + } + + return { + uuid: obj.uuid, + values, + isCut: obj.uuid + ? !eventDataGroup?.getObjectByProperty('uuid', obj.uuid)?.visible + : false, + }; + }); + + this.applyFilterAndSort(); + } + + /** Toggle sort on a column. */ + sort(columnId: string) { + if (this.sortColumn === columnId) { + this.sortDirection = this.sortDirection === 'asc' ? 'desc' : 'asc'; + } else { + this.sortColumn = columnId; + this.sortDirection = 'desc'; + } + this.applyFilterAndSort(); + } + + /** Update the filter threshold and refilter. */ + onFilterChange(value: string) { + this.filterValue = parseFloat(value) || 0; + this.applyFilterAndSort(); + } + + /** Apply filter then sort. */ + private applyFilterAndSort() { + // Filter + if (this.filterCol && this.filterValue > 0) { + const colId = this.filterCol.id; + this.rows = this.allRows.filter((row) => { + const v = row.values[colId]; + return typeof v === 'number' && v >= this.filterValue; + }); + } else { + this.rows = [...this.allRows]; + } + + // Sort + if (this.sortColumn) { + const col = this.sortColumn; + const dir = this.sortDirection === 'asc' ? 1 : -1; + this.rows.sort((a, b) => { + const va = a.values[col]; + const vb = b.values[col]; + if (typeof va === 'number' && typeof vb === 'number') { + return (va - vb) * dir; + } + return String(va).localeCompare(String(vb)) * dir; + }); + } + } + + /** Highlight a track in 3D and update active object. */ + selectTrack(row: KinematicsRow) { + if (!row.uuid) return; + this.activeRowUuid = row.uuid; + this.activeObject.update(row.uuid); + this.eventDisplay.highlightObject(row.uuid); + + // TODO: Emit 'track:inspected' via event bus when #826 event bus lands on main. + } + + /** Pan camera to a track and highlight it. */ + lookAtTrack(uuid: string, event: Event) { + event.stopPropagation(); + if (!uuid) return; + this.activeObject.update(uuid); + this.eventDisplay.lookAtObject(uuid); + } + + /** Keyboard navigation: arrow keys move between rows, Enter looks at track. */ + onKeyDown(event: KeyboardEvent) { + if (!this.rows.length) return; + const currentIndex = this.rows.findIndex( + (r) => r.uuid === this.activeObject?.value, + ); + + if (event.key === 'ArrowDown') { + event.preventDefault(); + const next = Math.min(currentIndex + 1, this.rows.length - 1); + this.selectTrack(this.rows[next]); + } else if (event.key === 'ArrowUp') { + event.preventDefault(); + const prev = Math.max(currentIndex < 0 ? 0 : currentIndex - 1, 0); + this.selectTrack(this.rows[prev]); + } else if (event.key === 'Enter' && currentIndex >= 0) { + event.preventDefault(); + this.eventDisplay.lookAtObject(this.rows[currentIndex].uuid); + } + } + + /** Export displayed rows as TSV. */ + exportTSV() { + if (!this.rows.length) return; + const cols = this.config.columns; + + // Header row + let tsv = + cols + .map((c) => (c.unit ? `${c.label} (${c.unit})` : c.label)) + .join('\t') + '\n'; + + // Data rows + for (const row of this.rows) { + tsv += + cols.map((c) => this.formatValue(row.values[c.id], c)).join('\t') + + '\n'; + } + + const filename = + this.selectedCollection.toLowerCase().replace(/\s+/g, '_') + + '_kinematics.tsv'; + const blob = new Blob([tsv], { type: 'text/tab-separated-values' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename; + a.click(); + URL.revokeObjectURL(url); + } + + /** Format a value for display. */ + formatValue(value: number | string, col: KinematicsColumn): string { + if (typeof value === 'string') return value; + if (!isFinite(value)) return '\u2014'; + const precision = col.precision ?? 1; + return value.toFixed(precision); + } + + /** Get the charge sign class for colored +/- display. */ + getChargeClass(col: KinematicsColumn, value: number | string): string { + if (col.id !== 'charge' || typeof value !== 'number') return ''; + if (value > 0) return 'charge-pos'; + if (value < 0) return 'charge-neg'; + return ''; + } + + /** Charge-based color for the row index number. */ + getChargeColor(row: KinematicsRow): string { + const v = row.values['charge']; + if (typeof v !== 'number' || v === 0) return ''; + return v > 0 ? '#ff6b6b' : '#6baaff'; + } +} diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.html b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.html new file mode 100644 index 000000000..55f156ef4 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.html @@ -0,0 +1,7 @@ + + diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.scss b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.scss new file mode 100644 index 000000000..2a0f55fe5 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.scss @@ -0,0 +1 @@ +/* Parent component — styling handled by menu-toggle. */ diff --git a/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.ts b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.ts new file mode 100644 index 000000000..a4bf5e545 --- /dev/null +++ b/packages/phoenix-ng/projects/phoenix-ui-components/lib/components/ui-menu/kinematics-panel/kinematics-panel.component.ts @@ -0,0 +1,54 @@ +import { + Component, + type OnInit, + ComponentRef, + type OnDestroy, + Input, + type OnChanges, + type SimpleChanges, +} from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; +import { ComponentPortal } from '@angular/cdk/portal'; +import { KinematicsPanelOverlayComponent } from './kinematics-panel-overlay/kinematics-panel-overlay.component'; +import type { KinematicsConfig } from 'phoenix-event-display'; + +@Component({ + standalone: false, + selector: 'app-kinematics-panel', + templateUrl: './kinematics-panel.component.html', + styleUrls: ['./kinematics-panel.component.scss'], +}) +export class KinematicsPanelComponent implements OnInit, OnDestroy, OnChanges { + showKinematics = false; + overlayWindow: ComponentRef; + @Input() config?: KinematicsConfig; + + constructor(private overlay: Overlay) {} + + ngOnInit() { + const overlayRef = this.overlay.create(); + const overlayPortal = new ComponentPortal(KinematicsPanelOverlayComponent); + this.overlayWindow = overlayRef.attach(overlayPortal); + this.overlayWindow.instance.showKinematics = this.showKinematics; + 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.showKinematics = !this.showKinematics; + this.overlayWindow.instance.showKinematics = this.showKinematics; + } +} 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 a3126156c..86ed5755e 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 @@ -72,6 +72,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 e11304181..0115cbca1 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 { KinematicsConfig } from 'phoenix-event-display'; export interface UIMenuConfig { showVRToggle?: boolean; @@ -53,4 +54,7 @@ export class UiMenuComponent { @Input() uiConfig: UIMenuConfig = { ...defaultUIMenuConfig }; + + @Input() + kinematicsConfig?: KinematicsConfig; }