Warning, /firebird/packages/firebird-ng/display/src/lib/components/data-selector/root-event-picker.component.ts is written in an unsupported language. File is not indexed.
0001 import { ChangeDetectionStrategy, Component, DestroyRef, computed, effect, inject, input, output, signal, untracked } from '@angular/core';
0002 import { MatIcon } from '@angular/material/icon';
0003 import { MatProgressSpinner } from '@angular/material/progress-spinner';
0004 import type { DataSource } from '@dexvis/firebird-core';
0005 import { injectEventLoaders } from '@dexvis/firebird-ng/api';
0006 import { RootFileService } from '../../services/root-file.service';
0007
0008 /** Checkbox captions of the conversion collection groups. */
0009 const GROUP_LABELS: Record<string, string> = {
0010 tracker_hits: 'Tracker hits',
0011 tracks: 'Tracks',
0012 mc_trajectories: 'MC hit trajectories',
0013 mc_particles: 'MC particles',
0014 };
0015
0016 /**
0017 * Event numbers and collection groups to convert from a ROOT event source.
0018 * Opens the file in the converter worker to show its data model and event
0019 * count (only the header is read; nothing is uploaded), when a registered
0020 * loader with an event picker claims the source. The picker opens it under
0021 * its own worker handle, so it never disturbs a conversion the display runs.
0022 * When the source changes while a file is opening, only the latest source's
0023 * result is shown. Sources only pyrobird can
0024 * convert (`root://`) get the range and group inputs without the count.
0025 *
0026 * The choice is emitted as the `events.rootEventRange` and
0027 * `events.rootCollections` values ('' = all groups), the same knobs deep
0028 * links and `pyrobird convert --collections` use.
0029 */
0030 @Component({
0031 selector: 'firebird-root-event-picker',
0032 changeDetection: ChangeDetectionStrategy.OnPush,
0033 imports: [MatIcon, MatProgressSpinner],
0034 template: `
0035 @if (openedFile(); as file) {
0036 <div class="file-meta">
0037 <span class="badge">{{ file.model }}</span>
0038 <span>{{ file.entryCount }} events</span>
0039 </div>
0040 } @else if (busy()) {
0041 <div class="status busy"><mat-spinner diameter="18"></mat-spinner><span>Reading ROOT file header…</span></div>
0042 } @else if (error(); as message) {
0043 <div class="status error"><mat-icon>error_outline</mat-icon><span>{{ message }}</span></div>
0044 } @else if (!pickerAvailable()) {
0045 <div class="status note">Converted by the server when shown; the event count is not known in advance.</div>
0046 }
0047
0048 <div class="event-row">
0049 <label for="root-event-range">Events</label>
0050 <input id="root-event-range"
0051 class="event-range-input"
0052 type="text"
0053 [value]="eventRange()"
0054 (input)="onRangeInput($any($event.target).value)"/>
0055 <span class="range-hint">single, list or range{{ maxEventHint() }} (e.g. <code>0,2,4-5</code>)</span>
0056 </div>
0057
0058 <div class="collections-row">
0059 <span class="collections-label">Convert</span>
0060 @for (group of groups(); track group) {
0061 <label class="collection-checkbox">
0062 <input type="checkbox" [checked]="isGroupSelected(group)" (change)="toggleGroup(group)"/>
0063 <span>{{ groupLabel(group) }}</span>
0064 </label>
0065 }
0066 </div>
0067 `,
0068 styles: `
0069 @use './shared';
0070
0071 :host {
0072 display: flex;
0073 flex-direction: column;
0074 gap: 8px;
0075 padding: 8px 10px;
0076 border-left: 2px solid var(--mat-sys-outline-variant, rgba(128, 128, 128, 0.3));
0077 font-size: 0.82rem;
0078 }
0079
0080 .file-meta {
0081 display: flex;
0082 align-items: center;
0083 gap: 10px;
0084 font-size: 0.78rem;
0085 opacity: 0.85;
0086 }
0087
0088 .badge {
0089 padding: 1px 8px;
0090 border-radius: 10px;
0091 background: color-mix(in srgb, #bec2ff 25%, transparent);
0092 font-family: monospace;
0093 }
0094
0095 .event-row {
0096 display: flex;
0097 align-items: center;
0098 flex-wrap: wrap;
0099 gap: 8px;
0100 }
0101
0102 .event-range-input {
0103 width: 7rem;
0104 @include shared.text-input;
0105 }
0106
0107 .range-hint {
0108 opacity: 0.7;
0109 font-size: 0.72rem;
0110 }
0111
0112 .collections-row {
0113 display: flex;
0114 align-items: center;
0115 flex-wrap: wrap;
0116 gap: 6px 12px;
0117 }
0118
0119 .collections-label {
0120 opacity: 0.8;
0121 }
0122
0123 .collection-checkbox {
0124 display: inline-flex;
0125 align-items: center;
0126 gap: 4px;
0127 cursor: pointer;
0128 white-space: nowrap;
0129
0130 input {
0131 accent-color: var(--mat-sys-primary, #4bac84);
0132 cursor: pointer;
0133 }
0134 }
0135
0136 .status {
0137 @include shared.status;
0138 align-items: center;
0139 }
0140 `,
0141 })
0142 export class RootEventPickerComponent {
0143 /** The ROOT event source (URL or picked file). */
0144 readonly source = input.required<DataSource>();
0145 /** Event numbers as typed: '0', '0,2,4-5'. */
0146 readonly eventRange = input('0');
0147 /** Comma list of collection groups to convert, '' = all. */
0148 readonly collections = input('');
0149 readonly eventRangeChange = output<string>();
0150 readonly collectionsChange = output<string>();
0151
0152 private readonly rootFile = inject(RootFileService).createHandle();
0153 private readonly loaders = injectEventLoaders();
0154 /** Bumped per source; a result for an older source is not shown. */
0155 private openToken = 0;
0156
0157 readonly openedFile = this.rootFile.openedFile;
0158 readonly busy = this.rootFile.busy;
0159 readonly error = signal<string | null>(null);
0160
0161 /** True when a loader that can report the event count claims the source. */
0162 readonly pickerAvailable = computed(() => {
0163 const source = this.source();
0164 // untracked: a loader's canLoad() may declare a config key, which
0165 // applies pending layer values (signal writes) — forbidden inside computed()
0166 return untracked(() => this.loaders.find(loader => loader.canLoad(source))?.meta.offersEventPicker === true);
0167 });
0168
0169 /** Groups of the open file; the known groups when the file could not be opened. */
0170 readonly groups = computed(() => this.openedFile()?.collectionGroups ?? Object.keys(GROUP_LABELS));
0171
0172 readonly maxEventHint = computed(() => {
0173 const file = this.openedFile();
0174 return file ? ` within 0–${Math.max(0, file.entryCount - 1)}` : '';
0175 });
0176
0177 constructor() {
0178 effect(() => {
0179 const source = this.source();
0180 const available = this.pickerAvailable();
0181 untracked(() => void this.openForCount(source, available));
0182 });
0183 inject(DestroyRef).onDestroy(() => this.rootFile.close());
0184 }
0185
0186 private async openForCount(source: DataSource, available: boolean): Promise<void> {
0187 const token = ++this.openToken;
0188 this.error.set(null);
0189 if (!available) {
0190 this.rootFile.close();
0191 return;
0192 }
0193 try {
0194 await this.rootFile.open(source);
0195 } catch (error) {
0196 if (token === this.openToken) {
0197 this.error.set(error instanceof Error ? error.message : String(error));
0198 }
0199 }
0200 }
0201
0202 onRangeInput(value: string): void {
0203 this.eventRangeChange.emit(value);
0204 }
0205
0206 groupLabel(group: string): string {
0207 return GROUP_LABELS[group] ?? group;
0208 }
0209
0210 private selectedGroups(): string[] {
0211 const configured = this.collections().split(',').map(group => group.trim()).filter(Boolean);
0212 return configured.length ? configured : this.groups();
0213 }
0214
0215 isGroupSelected(group: string): boolean {
0216 return this.selectedGroups().includes(group);
0217 }
0218
0219 toggleGroup(group: string): void {
0220 const selected = new Set(this.selectedGroups());
0221 if (selected.has(group)) {
0222 selected.delete(group);
0223 } else {
0224 selected.add(group);
0225 }
0226 // All groups selected collapses back to '' (= all), so groups added in
0227 // later versions stay included by default
0228 const all = this.groups();
0229 const value = all.every(candidate => selected.has(candidate)) ? '' : all.filter(candidate => selected.has(candidate)).join(',');
0230 this.collectionsChange.emit(value);
0231 }
0232 }