Warning, /firebird/packages/firebird-ng/display/src/lib/components/data-selector/manual-tab.component.ts is written in an unsupported language. File is not indexed.
0001 import { ChangeDetectionStrategy, Component, computed, inject, signal, untracked } from '@angular/core';
0002 import { MatIcon } from '@angular/material/icon';
0003 import type { DataSource } from '@dexvis/firebird-core';
0004 import { ConfigService } from '@dexvis/app-features';
0005 import {
0006 DataCatalogService,
0007 DataSelectionService,
0008 GEOMETRY_ROOT_FILTER_CONFIG,
0009 isRootSource,
0010 OPTIMIZE_EDIT_RULE_SET,
0011 resolveRootGeometryRules,
0012 ROOT_GEOMETRY_RULES,
0013 type RootGeometryEditRuleSet,
0014 } from '@dexvis/firebird-ng/api';
0015 import { FileOpenRouterService } from '../../services/file-open-router.service';
0016 import { RootEventPickerComponent } from './root-event-picker.component';
0017 import { SourcePickerComponent } from './source-picker.component';
0018
0019 /**
0020 * Manual tab: geometry and events each by URL (a known one from the catalog,
0021 * or typed) or from a local file. A ROOT event source gets the event-range
0022 * and collection-group picker under it.
0023 *
0024 * A `.root` file holds geometry OR events, and a user does not always know
0025 * which: every `.root` source is probed through FileOpenRouterService, and one
0026 * dropped into the wrong field moves to the right one with a note.
0027 */
0028 @Component({
0029 selector: 'firebird-manual-tab',
0030 changeDetection: ChangeDetectionStrategy.OnPush,
0031 imports: [MatIcon, SourcePickerComponent, RootEventPickerComponent],
0032 templateUrl: './manual-tab.component.html',
0033 styleUrl: './manual-tab.component.scss',
0034 })
0035 export class ManualTabComponent {
0036 private readonly selection = inject(DataSelectionService);
0037 private readonly catalog = inject(DataCatalogService);
0038 private readonly router = inject(FileOpenRouterService);
0039 private readonly config = inject(ConfigService);
0040
0041 readonly geometryOptions = this.catalog.geometrySources;
0042 readonly eventOptions = this.catalog.eventSources;
0043
0044 // Fields start from what is configured; only what the user changes goes to the draft
0045 readonly geometry = signal<DataSource | ''>(this.selection.configuredGeometry());
0046 readonly events = signal<DataSource | ''>(this.selection.configuredEvents());
0047 readonly eventRange = signal(this.selection.configuredEventRange());
0048 readonly collections = signal(this.selection.configuredCollections());
0049 readonly note = signal<string | null>(null);
0050 // Bumped when a typed source leaves a field, so that field shows its value again
0051 readonly geometryResync = signal(0);
0052 readonly eventsResync = signal(0);
0053 readonly eventsIsRoot = computed(() => this.events() !== '' && isRootSource(this.events() as DataSource));
0054
0055 /**
0056 * "Optimize geometry" switches config `geometry.rootFilterName` between
0057 * the edit rule set OPTIMIZE_EDIT_RULE_SET and 'off'; the finer pipeline
0058 * options stay on the config page. Creation is untracked: declaring a key
0059 * applies pending layer values (signal writes), which a computed() forbids.
0060 */
0061 private readonly rootFilter = untracked(() => this.config.declare(GEOMETRY_ROOT_FILTER_CONFIG));
0062 readonly optimize = computed(() => this.rootFilter.valueSignal() !== 'off');
0063
0064 /**
0065 * The rule set the toggle selects, or null while none is known. Without a
0066 * pack that registers it the toggle stays hidden: it would select nothing.
0067 * Rule data may arrive through a dynamic import, so it resolves once here.
0068 */
0069 readonly optimizeRuleSet = signal<RootGeometryEditRuleSet | null>(null);
0070
0071 constructor() {
0072 const ruleSources = inject(ROOT_GEOMETRY_RULES, { optional: true }) ?? [];
0073 if (ruleSources.length === 0) return;
0074 resolveRootGeometryRules(ruleSources).then(
0075 rules => this.optimizeRuleSet.set(rules.editRules?.[OPTIMIZE_EDIT_RULE_SET] ?? null),
0076 error => console.error('[DataSelector] Geometry rules failed to load:', error),
0077 );
0078 }
0079
0080 toggleOptimize(): void {
0081 this.rootFilter.value = this.optimize() ? 'off' : OPTIMIZE_EDIT_RULE_SET;
0082 }
0083
0084 onGeometryChange(value: DataSource | ''): void {
0085 this.note.set(null);
0086 void this.placeSource(value, 'geometry');
0087 }
0088
0089 onEventsChange(value: DataSource | ''): void {
0090 this.note.set(null);
0091 void this.placeSource(value, 'events');
0092 }
0093
0094 onEventRangeChange(value: string): void {
0095 this.eventRange.set(value);
0096 this.selection.updateDraft({ eventRange: value });
0097 }
0098
0099 onCollectionsChange(value: string): void {
0100 this.collections.set(value);
0101 this.selection.updateDraft({ collections: value });
0102 }
0103
0104 /** Bumped per change of each field; a probe for an older value is not applied. */
0105 private readonly placeTokens = { geometry: 0, events: 0 };
0106
0107 /**
0108 * Puts the source into its field — after asking the loaders which field a
0109 * `.root` source belongs to. Probes run in parallel; when a field changes
0110 * again before its probe answers, only the latest value is placed.
0111 */
0112 private async placeSource(value: DataSource | '', field: 'geometry' | 'events'): Promise<void> {
0113 const token = ++this.placeTokens[field];
0114 const isLatest = () => token === this.placeTokens[field];
0115 let target = field;
0116 if (value !== '' && isRootSource(value)) {
0117 try {
0118 const route = await this.router.route(value);
0119 if (!isLatest()) return;
0120 if (route.kind === 'unknown') {
0121 this.note.set(route.message);
0122 } else if (route.kind !== field) {
0123 target = route.kind;
0124 const name = typeof value === 'string' ? value : value.name;
0125 this.note.set(`'${name}' holds ${route.kind}, so it went to the ${route.kind} field.`);
0126 (field === 'geometry' ? this.geometryResync : this.eventsResync).update(token => token + 1);
0127 }
0128 } catch (error) {
0129 if (!isLatest()) return;
0130 // The probe could not read the file (offline, CORS, server path
0131 // without pyrobird): keep the value where it was typed, Show still
0132 // tries the load, and the reason is on screen
0133 this.note.set(`Could not look inside the file: ${error instanceof Error ? error.message : error}`);
0134 }
0135 }
0136 if (target === 'geometry') {
0137 this.geometry.set(value);
0138 this.selection.updateDraft({ geometry: value });
0139 } else {
0140 this.events.set(value);
0141 this.selection.updateDraft({ events: value });
0142 }
0143 }
0144 }