Warning, /firebird/packages/firebird-ng/api/src/lib/services/data-selection.service.ts is written in an unsupported language. File is not indexed.
0001 import { Injectable, computed, inject, signal } from '@angular/core';
0002 import type { DataCatalogEntry, DataSource } from '@dexvis/firebird-core';
0003 import { ConfigService } from '@dexvis/app-features';
0004 import { DataCatalogService } from './data-catalog.service';
0005 import {
0006 DEX_EVENTS_SOURCE_CONFIG,
0007 GEOMETRY_URL_CONFIG,
0008 ROOT_COLLECTIONS_CONFIG,
0009 ROOT_EVENT_RANGE_CONFIG,
0010 ROOT_EVENTS_SOURCE_CONFIG,
0011 } from '../config-keys';
0012
0013 /**
0014 * What the user chose in the data selector. Each field: `undefined` = leave
0015 * the current setting alone, `''` = clear it, a string = a URL, a File = a
0016 * picked/dropped local file (read in place, never uploaded, never persisted).
0017 */
0018 export interface DataSelection {
0019 geometry?: DataSource | '';
0020 events?: DataSource | '';
0021 /** Event numbers to convert when `events` is a ROOT file: '0', '0-4', '1,3'. */
0022 eventRange?: string;
0023 /** Collection groups to convert when `events` is a ROOT file ('' = all). */
0024 collections?: string;
0025 /** Catalog entry the selection came from, when it did. */
0026 entry?: DataCatalogEntry;
0027 }
0028
0029 /** Which halves of a selection changed — what a display reload has to touch. */
0030 export interface SelectionParts {
0031 geometry: boolean;
0032 events: boolean;
0033 }
0034
0035 /**
0036 * Config keys the selection writes. Deep links, pyrobird config and yaml use
0037 * the same keys; their defaults and validators live in `config-keys.ts`.
0038 */
0039 export const SELECTION_CONFIG_KEYS = {
0040 geometry: GEOMETRY_URL_CONFIG.key,
0041 dexEvents: DEX_EVENTS_SOURCE_CONFIG.key,
0042 rootEvents: ROOT_EVENTS_SOURCE_CONFIG.key,
0043 rootEventRange: ROOT_EVENT_RANGE_CONFIG.key,
0044 rootCollections: ROOT_COLLECTIONS_CONFIG.key,
0045 } as const;
0046
0047 /** A `.root` source goes to the ROOT event keys, anything else to the DEX key. */
0048 export function isRootSource(source: DataSource): boolean {
0049 const name = typeof source === 'string' ? source.split('?')[0].split('#')[0] : source.name;
0050 return /\.root$/i.test(name);
0051 }
0052
0053 /**
0054 * The one "apply" path of the data selector, shared by the display toolbar
0055 * panel and the config page: the selection is written to the config keys
0056 * (`geometry.selectedGeometry`, `events.dexEventsSource`, ...), and a live
0057 * display then reloads from those keys — the same load the display runs at
0058 * startup. The config page has no display; it writes the keys and navigates,
0059 * and the display page picks them up on init.
0060 *
0061 * Picked local Files cannot live in config (a blob URL dies with the page and
0062 * loses the file name that decides the loader), so they wait in a stash that
0063 * the display's config load consumes first. The config keys keep their last
0064 * URL meanwhile: after a reload the URL loads again, the file is gone.
0065 */
0066 @Injectable({ providedIn: 'root' })
0067 export class DataSelectionService {
0068 private readonly config = inject(ConfigService);
0069 private readonly catalog = inject(DataCatalogService);
0070
0071 /** The selection tabs build up; the host's Show button applies it. */
0072 readonly draft = signal<DataSelection>({});
0073
0074 private pickedGeometryFile: File | null = null;
0075 private pickedEventsFile: File | null = null;
0076 private displayReload: ((parts: SelectionParts) => void) | null = null;
0077 /** Parts applied while no display was attached; the next display mount loads them. */
0078 private pendingReload: SelectionParts = { geometry: false, events: false };
0079
0080 private readonly geometryKey = this.config.declare(GEOMETRY_URL_CONFIG);
0081 private readonly dexEventsKey = this.config.declare(DEX_EVENTS_SOURCE_CONFIG);
0082 private readonly rootEventsKey = this.config.declare(ROOT_EVENTS_SOURCE_CONFIG);
0083 private readonly rootEventRangeKey = this.config.declare(ROOT_EVENT_RANGE_CONFIG);
0084 private readonly rootCollectionsKey = this.config.declare(ROOT_COLLECTIONS_CONFIG);
0085
0086 /** Configured geometry URL (reactive). */
0087 readonly configuredGeometry = computed(() => this.geometryKey.valueSignal() || '');
0088 /** Configured events URL — the DEX key, or the ROOT key when the DEX key is empty (reactive). */
0089 readonly configuredEvents = computed(() => this.dexEventsKey.valueSignal() || this.rootEventsKey.valueSignal() || '');
0090 readonly configuredEventRange = computed(() => this.rootEventRangeKey.valueSignal() || ROOT_EVENT_RANGE_CONFIG.default);
0091 readonly configuredCollections = computed(() => this.rootCollectionsKey.valueSignal() || '');
0092
0093 /** The catalog entry whose geometry and events match the configured values, if any. */
0094 readonly activeEntry = computed<DataCatalogEntry | null>(() => {
0095 const geometry = this.configuredGeometry();
0096 const events = this.configuredEvents();
0097 return this.catalog.entries().find(entry =>
0098 (entry.geometry ?? '') === geometry && (entry.events ?? '') === events) ?? null;
0099 });
0100
0101 /** True when the draft would change something. */
0102 readonly draftHasChanges = computed(() => {
0103 const draft = this.draft();
0104 return draft.geometry !== undefined || draft.events !== undefined
0105 || draft.eventRange !== undefined || draft.collections !== undefined;
0106 });
0107
0108 /**
0109 * The selection a catalog entry stands for. An entry without geometry keeps
0110 * the loaded geometry (event-only datasets); an entry without events shows
0111 * none (a "geometry only" preset clears the events).
0112 */
0113 selectionForEntry(entry: DataCatalogEntry): DataSelection {
0114 const events = entry.events ?? '';
0115 const isRoot = events !== '' && isRootSource(events);
0116 return {
0117 geometry: entry.geometry,
0118 events,
0119 eventRange: isRoot ? (entry.eventRange ?? ROOT_EVENT_RANGE_CONFIG.default) : undefined,
0120 collections: isRoot ? (entry.collections ?? '') : undefined,
0121 entry,
0122 };
0123 }
0124
0125 updateDraft(patch: DataSelection): void {
0126 this.draft.update(draft => ({ ...draft, ...patch }));
0127 }
0128
0129 clearDraft(): void {
0130 this.draft.set({});
0131 }
0132
0133 /**
0134 * The display registers how it reloads from config; `apply()` calls it
0135 * after writing the keys, with the parts the selection touched — an
0136 * events-only change must not reload the geometry (a picked geometry file
0137 * has no URL to dedupe on and would be replaced by the configured URL).
0138 * Registered by EventDisplayService while a display page is mounted, so
0139 * the selection service never imports the display stack.
0140 */
0141 attachDisplay(reload: (parts: SelectionParts) => void): void {
0142 this.displayReload = reload;
0143 }
0144
0145 detachDisplay(): void {
0146 this.displayReload = null;
0147 }
0148
0149 /**
0150 * Writes the selection to config, stashes picked files, and reloads a live
0151 * display. Without one (the config page), the applied parts are remembered
0152 * for the next display mount (`takePendingReload()`): the user asked for
0153 * them, so that mount loads them even when the config value did not change.
0154 */
0155 apply(selection: DataSelection = this.draft()): void {
0156 if (selection.geometry !== undefined) {
0157 if (selection.geometry instanceof File) {
0158 this.pickedGeometryFile = selection.geometry;
0159 } else {
0160 this.pickedGeometryFile = null;
0161 this.geometryKey.value = selection.geometry;
0162 }
0163 }
0164 if (selection.events !== undefined) {
0165 if (selection.events instanceof File) {
0166 this.pickedEventsFile = selection.events;
0167 // A stashed file loads INSTEAD of the URL keys, so both keys are
0168 // cleared to keep the startup load from also fetching the old URL
0169 this.dexEventsKey.value = '';
0170 this.rootEventsKey.value = '';
0171 } else {
0172 this.pickedEventsFile = null;
0173 // One events field, two keys: the other key is cleared so the startup
0174 // load (which runs both) does not load two datasets
0175 const isRoot = selection.events !== '' && isRootSource(selection.events);
0176 this.rootEventsKey.value = isRoot ? selection.events : '';
0177 this.dexEventsKey.value = isRoot ? '' : selection.events;
0178 }
0179 }
0180 if (selection.eventRange !== undefined) {
0181 this.rootEventRangeKey.value = selection.eventRange.trim() || ROOT_EVENT_RANGE_CONFIG.default;
0182 }
0183 if (selection.collections !== undefined) {
0184 this.rootCollectionsKey.value = selection.collections;
0185 }
0186 this.draft.set({});
0187 const parts: SelectionParts = {
0188 geometry: selection.geometry !== undefined,
0189 events: selection.events !== undefined || selection.eventRange !== undefined || selection.collections !== undefined,
0190 };
0191 if (!parts.geometry && !parts.events) return;
0192 if (this.displayReload) {
0193 this.displayReload(parts);
0194 } else {
0195 this.pendingReload = {
0196 geometry: this.pendingReload.geometry || parts.geometry,
0197 events: this.pendingReload.events || parts.events,
0198 };
0199 }
0200 }
0201
0202 /** Hands out (once) the parts applied while no display was attached. */
0203 takePendingReload(): SelectionParts {
0204 const parts = this.pendingReload;
0205 this.pendingReload = { geometry: false, events: false };
0206 return parts;
0207 }
0208
0209 /** Hands the stashed geometry file to the loader (once). */
0210 takePickedGeometryFile(): File | null {
0211 const file = this.pickedGeometryFile;
0212 this.pickedGeometryFile = null;
0213 return file;
0214 }
0215
0216 /** Hands the stashed events file to the loader (once). */
0217 takePickedEventsFile(): File | null {
0218 const file = this.pickedEventsFile;
0219 this.pickedEventsFile = null;
0220 return file;
0221 }
0222 }