Warning, /firebird/packages/firebird-ng/display/src/lib/components/painter-config-panel/painter-config-panel.component.ts is written in an unsupported language. File is not indexed.
0001 import {
0002 ChangeDetectionStrategy,
0003 Component,
0004 computed,
0005 effect,
0006 inject,
0007 linkedSignal,
0008 untracked,
0009 } from '@angular/core';
0010 import { form, FormField } from '@angular/forms/signals';
0011 import { Listbox, Option } from '@angular/aria/listbox';
0012
0013 import {
0014 EventPiece,
0015 PainterConfigDescriptor,
0016 PiecePainterConstructor,
0017 painterIdOf,
0018 painterMetaOf,
0019 } from '@dexvis/firebird-core';
0020 import { DataModelService } from '../../services/data-model.service';
0021 import { EventDisplayService } from '../../services/event-display.service';
0022 import { PainterConfigService } from '../../services/painter-config.service';
0023 import { SelectionService } from '../../services/selection.service';
0024
0025 /**
0026 * The painter panel of the right pane: for the piece selected in the model
0027 * tree (or via a 3D pick) it shows WHICH painter draws the piece and the
0028 * selected painter's knobs — both auto-rendered from the painter's static
0029 * meta, no per-painter UI code.
0030 *
0031 * Everything routes through config keys (`painters.byPiece.<piece>` and
0032 * `painters.byPiece.<piece>.<knob>`), so the same state is reachable from
0033 * yaml/server config, URL deep links and batch scripts; edits here apply
0034 * live through the painter's `onConfigChanged`.
0035 *
0036 * Form plumbing is Signal Forms (`form` + `[formField]`) over a model built
0037 * from the knob declarations; pickers are Angular Aria listboxes.
0038 */
0039 @Component({
0040 selector: 'app-painter-config-panel',
0041 imports: [FormField, Listbox, Option],
0042 templateUrl: './painter-config-panel.component.html',
0043 changeDetection: ChangeDetectionStrategy.OnPush,
0044 styleUrls: ['./painter-config-panel.component.scss'],
0045 })
0046 export class PainterConfigPanelComponent {
0047 private dataService = inject(DataModelService);
0048 private eventDisplay = inject(EventDisplayService);
0049 private painterConfig = inject(PainterConfigService);
0050 public selection = inject(SelectionService);
0051
0052 /** The piece the panel configures (from the shared selection). */
0053 piece = computed<EventPiece | null>(() => {
0054 const pieceName = this.selection.selectedPiece();
0055 if (!pieceName) return null;
0056 return this.dataService.currentEntry()?.pieces.find(piece => piece.name === pieceName) ?? null;
0057 });
0058
0059 /** Painter classes registered for the piece's type. */
0060 candidates = computed<readonly PiecePainterConstructor[]>(() => {
0061 const piece = this.piece();
0062 return piece ? this.eventDisplay.paintersForType(piece.type) : [];
0063 });
0064
0065 painterOptions = computed(() => this.candidates().map(candidate => ({
0066 id: painterIdOf(candidate),
0067 label: painterMetaOf(candidate)?.label ?? painterIdOf(candidate),
0068 })));
0069
0070 /** The selected painter id (reactive through the selection config key). */
0071 selectedPainterId = computed<string | null>(() => {
0072 const piece = this.piece();
0073 const candidates = this.candidates();
0074 if (!piece || candidates.length === 0) return null;
0075 return this.painterConfig.selectionProperty(piece.name, candidates).valueSignal();
0076 });
0077
0078 /** Aria listbox holds selections as arrays; resets when the piece changes. */
0079 painterListboxValue = linkedSignal<string[]>(() => {
0080 const id = this.selectedPainterId();
0081 return id ? [id] : [];
0082 });
0083
0084 private selectedPainterClass = computed<PiecePainterConstructor | null>(() => {
0085 const id = this.selectedPainterId();
0086 return this.candidates().find(candidate => painterIdOf(candidate) === id)
0087 ?? this.candidates()[0]
0088 ?? null;
0089 });
0090
0091 /** Knob declarations of the selected painter. */
0092 descriptors = computed<readonly PainterConfigDescriptor[]>(() =>
0093 painterMetaOf(this.selectedPainterClass() ?? undefined as never)?.configs ?? []);
0094
0095 /** The declared config properties behind the knobs. */
0096 private knobProperties = computed(() => {
0097 const piece = this.piece();
0098 const painterClass = this.selectedPainterClass();
0099 if (!piece || !painterClass) return new Map<string, import('@dexvis/app-features').ConfigProperty<unknown>>();
0100 return this.painterConfig.knobProperties(piece, painterClass);
0101 });
0102
0103 /**
0104 * Signal Forms model over the knob values. linkedSignal: recomputes from
0105 * the config properties (so URL/server/other-writer changes reach the
0106 * form), stays writable (so form edits land here first).
0107 */
0108 formModel = linkedSignal<Record<string, unknown>>(() => {
0109 const model: Record<string, unknown> = {};
0110 for (const [key, property] of this.knobProperties()) {
0111 model[key] = property.valueSignal();
0112 }
0113 return model;
0114 });
0115
0116 /** The Signal Forms field tree the inputs bind to via [formField]. */
0117 panelForm = form(this.formModel);
0118
0119 constructor() {
0120 // Form edits -> config writes. The write triggers the painter's live
0121 // restyle (EventDisplayService watches the knob keys). Values coming
0122 // back through the linkedSignal recompute equal the written ones, so
0123 // this settles without loops.
0124 effect(() => {
0125 const values = this.formModel();
0126 const properties = untracked(() => this.knobProperties());
0127 for (const [key, property] of properties) {
0128 if (!(key in values)) continue;
0129 const value = this.coerceLike(values[key], property.value);
0130 if (value !== property.value) {
0131 property.setValue(value);
0132 }
0133 }
0134 });
0135 }
0136
0137 /** Field of one knob for [formField] binding. */
0138 field(key: string) {
0139 return (this.panelForm as unknown as Record<string, unknown>)[key] as never;
0140 }
0141
0142 onPainterPicked(ids: string[]): void {
0143 const piece = this.piece();
0144 const candidates = this.candidates();
0145 if (!piece || ids.length === 0) return;
0146 this.painterConfig.selectionProperty(piece.name, candidates).setValue(ids[0]);
0147 }
0148
0149 /** Knob listboxes: single-select over the descriptor options. */
0150 knobListboxValue(key: string): unknown[] {
0151 const value = this.formModel()[key];
0152 return value === undefined ? [] : [value];
0153 }
0154
0155 onKnobOptionPicked(key: string, values: unknown[]): void {
0156 if (values.length === 0) return;
0157 this.formModel.update(model => ({ ...model, [key]: values[0] }));
0158 }
0159
0160 onRangeInput(key: string, event: Event): void {
0161 const value = Number((event.target as HTMLInputElement).value);
0162 this.formModel.update(model => ({ ...model, [key]: value }));
0163 }
0164
0165 /** Which control a descriptor renders as. */
0166 controlKind(descriptor: PainterConfigDescriptor): 'options' | 'range' | 'color' | 'checkbox' | 'text' {
0167 if (descriptor.options?.length) return 'options';
0168 if (typeof descriptor.default === 'number') return 'range';
0169 if (typeof descriptor.default === 'boolean') return 'checkbox';
0170 if (typeof descriptor.default === 'string' && descriptor.default.startsWith('#')) return 'color';
0171 return 'text';
0172 }
0173
0174 knobValue(key: string): unknown {
0175 return this.formModel()[key];
0176 }
0177
0178 optionLabel(option: unknown): string {
0179 return String(option);
0180 }
0181
0182 /** Coerces form strings back to the property's type (number inputs). */
0183 private coerceLike(value: unknown, sample: unknown): unknown {
0184 if (typeof sample === 'number' && typeof value === 'string') {
0185 const parsed = Number(value);
0186 return isNaN(parsed) ? value : parsed;
0187 }
0188 return value;
0189 }
0190 }