@meshmakers/octo-meshboard 3.4.1280 → 3.4.1340

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import { FieldFilterOperatorsDto, CkTypeSelectorService, RuntimeEntitySelectDataSource as RuntimeEntitySelectDataSource$2, RuntimeEntityDialogDataSource as RuntimeEntityDialogDataSource$2, DeleteStrategiesDto, AssociationModOptionsDto, CkModelService, QueryModeDto, GraphDirectionDto, AttributeSelectorService, GraphQL, GetCkTypeAvailableQueryColumnsDtoGQL, SortOrdersDto, SeriesResolutionSignalDto, HealthService, TENANT_ID_PROVIDER, AssetRepoService, JobManagementService } from '@meshmakers/octo-services';
2
2
  export { RuntimeEntityDialogDataSource, RuntimeEntitySelectDataSource, TENANT_ID_PROVIDER } from '@meshmakers/octo-services';
3
3
  import * as i0 from '@angular/core';
4
- import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, Directive, ElementRef, NgZone, makeEnvironmentProviders, provideAppInitializer, InjectionToken, effect } from '@angular/core';
4
+ import { Injectable, inject, EventEmitter, forwardRef, Output, Input, ViewChild, ChangeDetectionStrategy, Component, Injector, EnvironmentInjector, ApplicationRef, signal, computed, effect, untracked, Directive, ElementRef, NgZone, makeEnvironmentProviders, provideAppInitializer, InjectionToken } from '@angular/core';
5
5
  import * as i1$1 from '@angular/forms';
6
6
  import { NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';
7
7
  import { firstValueFrom, from, map, Observable, of, catchError, interval, filter } from 'rxjs';
@@ -26,6 +26,7 @@ import { LoaderModule } from '@progress/kendo-angular-indicators';
26
26
  import * as i1$4 from '@progress/kendo-angular-icons';
27
27
  import { SVGIconModule } from '@progress/kendo-angular-icons';
28
28
  import { arrowUpIcon, arrowDownIcon, minusIcon, arrowRightIcon, arrowLeftIcon, linkIcon, chevronDownIcon, chevronRightIcon, circleIcon, questionCircleIcon, minusCircleIcon, warningTriangleIcon, exclamationCircleIcon, xCircleIcon, checkCircleIcon, columnsIcon, sortAscIcon, filterIcon, searchIcon, chartPieIcon, infoCircleIcon, plusIcon, trashIcon, pencilIcon, chartLineIcon, gearsIcon, clipboardMarkdownIcon, copyIcon, gridIcon, heartIcon, gridLayoutIcon, chartLineMarkersIcon, chartColumnStackedIcon, chartDoughnutIcon, tableIcon, shareIcon, fileTxtIcon, checkIcon, xIcon, downloadIcon, uploadIcon, gearIcon, saveIcon, arrowRotateCwIcon, undoIcon } from '@progress/kendo-svg-icons';
29
+ import { ExpressionEvaluatorService, GetProcessDiagramDtoGQL, GetProcessDiagramsDtoGQL, CreateProcessDiagramDtoGQL, UpdateProcessDiagramDtoGQL, SymbolLibraryService, renderAnimations, getFlowParticlesAnimation, renderFlowParticles } from '@meshmakers/octo-process-diagrams';
29
30
  import { NotificationService } from '@progress/kendo-angular-notification';
30
31
  import * as i1$6 from '@progress/kendo-angular-gauges';
31
32
  import { CollectionChangesService, KENDO_GAUGES } from '@progress/kendo-angular-gauges';
@@ -38,7 +39,6 @@ import { MarkdownModule } from 'ngx-markdown';
38
39
  import * as i4$1 from '@progress/kendo-angular-label';
39
40
  import { LabelModule, KENDO_LABELS } from '@progress/kendo-angular-label';
40
41
  import { DomSanitizer } from '@angular/platform-browser';
41
- import { GetProcessDiagramDtoGQL, GetProcessDiagramsDtoGQL, CreateProcessDiagramDtoGQL, UpdateProcessDiagramDtoGQL, SymbolLibraryService, ExpressionEvaluatorService, renderAnimations, getFlowParticlesAnimation, renderFlowParticles } from '@meshmakers/octo-process-diagrams';
42
42
  import * as i5 from '@progress/kendo-angular-layout';
43
43
  import { LayoutModule, TabStripModule, TileLayoutModule, TileLayoutComponent } from '@progress/kendo-angular-layout';
44
44
  import * as i4$2 from '@progress/kendo-angular-dateinputs';
@@ -2019,6 +2019,13 @@ class MeshBoardStateService {
2019
2019
  */
2020
2020
  _entitySelectorRtIds = signal({}, /* @ts-ignore */
2021
2021
  ...(ngDevMode ? [{ debugName: "_entitySelectorRtIds" }] : /* istanbul ignore next */ []));
2022
+ /**
2023
+ * Transient variables published by widgets (KPI output variables, AB#5364).
2024
+ * Kept apart from `_meshBoardConfig` so publishing triggers no persistence and
2025
+ * no dirty state. Cleared on every board switch.
2026
+ */
2027
+ _widgetVariables = signal([], /* @ts-ignore */
2028
+ ...(ngDevMode ? [{ debugName: "_widgetVariables" }] : /* istanbul ignore next */ []));
2022
2029
  // Public computed signals
2023
2030
  meshBoardConfig = computed(() => this._meshBoardConfig(), /* @ts-ignore */
2024
2031
  ...(ngDevMode ? [{ debugName: "meshBoardConfig" }] : /* istanbul ignore next */ []));
@@ -2120,6 +2127,7 @@ class MeshBoardStateService {
2120
2127
  if (movedWidgets.length > 0) {
2121
2128
  console.warn(`Resolved ${movedWidgets.length} overlapping widget(s)`);
2122
2129
  }
2130
+ this._widgetVariables.set([]);
2123
2131
  this._meshBoardConfig.set(config);
2124
2132
  this._persistedMeshBoardId.set(result.meshBoard.rtId);
2125
2133
  this._existingWidgetRtIds.set(result.widgets.map(w => w.rtId));
@@ -2425,11 +2433,55 @@ class MeshBoardStateService {
2425
2433
  // Variable Management
2426
2434
  // ============================================================================
2427
2435
  /**
2428
- * Gets all variables from the current MeshBoard configuration.
2436
+ * Gets all variables: the MeshBoard configuration's variables plus the runtime
2437
+ * variables published by widgets. A configuration variable wins on a name clash.
2438
+ * Reads signals, so `computed()`s calling it react to both.
2429
2439
  */
2430
2440
  getVariables() {
2441
+ const configVariables = this.configVariables();
2442
+ const widgetVariables = this._widgetVariables();
2443
+ if (widgetVariables.length === 0) {
2444
+ return configVariables;
2445
+ }
2446
+ const configNames = new Set(configVariables.map(v => v.name));
2447
+ return [...configVariables, ...widgetVariables.filter(v => !configNames.has(v.name))];
2448
+ }
2449
+ /**
2450
+ * Variables stored in the MeshBoard configuration (without widget variables).
2451
+ * Every write path builds on this, so widget variables never reach the config.
2452
+ */
2453
+ configVariables() {
2431
2454
  return this._meshBoardConfig().variables ?? [];
2432
2455
  }
2456
+ /**
2457
+ * Publishes a widget's value as runtime variable (source `'widget'`).
2458
+ * Replaces a variable the same widget published under another name, and does
2459
+ * nothing when name and value are unchanged, so publishing effects cannot loop.
2460
+ */
2461
+ setWidgetVariable(widgetId, name, value) {
2462
+ const current = this._widgetVariables();
2463
+ const existing = current.find(v => v.widgetId === widgetId);
2464
+ if (existing && existing.name === name && existing.value === value) {
2465
+ return;
2466
+ }
2467
+ const variable = {
2468
+ name,
2469
+ type: value !== '' && !isNaN(Number(value)) ? 'number' : 'string',
2470
+ source: 'widget',
2471
+ value,
2472
+ widgetId
2473
+ };
2474
+ this._widgetVariables.set([...current.filter(v => v.widgetId !== widgetId), variable]);
2475
+ }
2476
+ /**
2477
+ * Removes the runtime variable published by a widget (e.g. when it is destroyed).
2478
+ */
2479
+ clearWidgetVariable(widgetId) {
2480
+ const current = this._widgetVariables();
2481
+ if (current.some(v => v.widgetId === widgetId)) {
2482
+ this._widgetVariables.set(current.filter(v => v.widgetId !== widgetId));
2483
+ }
2484
+ }
2433
2485
  /**
2434
2486
  * Gets a specific variable by name.
2435
2487
  */
@@ -2449,7 +2501,7 @@ class MeshBoardStateService {
2449
2501
  * Sets the value of a single variable.
2450
2502
  */
2451
2503
  setVariableValue(name, value) {
2452
- const variables = [...this.getVariables()];
2504
+ const variables = [...this.configVariables()];
2453
2505
  const index = variables.findIndex(v => v.name === name);
2454
2506
  if (index >= 0) {
2455
2507
  variables[index] = { ...variables[index], value };
@@ -2460,14 +2512,14 @@ class MeshBoardStateService {
2460
2512
  * Adds a new variable to the MeshBoard.
2461
2513
  */
2462
2514
  addVariable(variable) {
2463
- const variables = [...this.getVariables(), variable];
2515
+ const variables = [...this.configVariables(), variable];
2464
2516
  this.updateVariables(variables);
2465
2517
  }
2466
2518
  /**
2467
2519
  * Removes a variable by name.
2468
2520
  */
2469
2521
  removeVariable(name) {
2470
- const variables = this.getVariables().filter(v => v.name !== name);
2522
+ const variables = this.configVariables().filter(v => v.name !== name);
2471
2523
  this.updateVariables(variables);
2472
2524
  }
2473
2525
  // ============================================================================
@@ -2620,7 +2672,7 @@ class MeshBoardStateService {
2620
2672
  */
2621
2673
  setTimeFilterVariables(fromISO, toISO) {
2622
2674
  // Filter out existing timeFilter variables
2623
- const currentVars = this.getVariables().filter(v => v.source !== 'timeFilter');
2675
+ const currentVars = this.configVariables().filter(v => v.source !== 'timeFilter');
2624
2676
  // Create new time filter variables
2625
2677
  const timeFilterVars = [
2626
2678
  {
@@ -2643,7 +2695,7 @@ class MeshBoardStateService {
2643
2695
  * Clears time filter variables (called when time filter is disabled).
2644
2696
  */
2645
2697
  clearTimeFilterVariables() {
2646
- const currentVars = this.getVariables().filter(v => v.source !== 'timeFilter');
2698
+ const currentVars = this.configVariables().filter(v => v.source !== 'timeFilter');
2647
2699
  this.updateVariables(currentVars);
2648
2700
  }
2649
2701
  // ============================================================================
@@ -2687,7 +2739,7 @@ class MeshBoardStateService {
2687
2739
  */
2688
2740
  setEntitySelectorVariables(selectorId, values) {
2689
2741
  // Remove existing variables for this selector
2690
- const currentVars = this.getVariables().filter(v => !(v.source === 'entitySelector' && v.entitySelectorId === selectorId));
2742
+ const currentVars = this.configVariables().filter(v => !(v.source === 'entitySelector' && v.entitySelectorId === selectorId));
2691
2743
  // Create new variables
2692
2744
  const selectorVars = values.map(v => ({
2693
2745
  name: v.name,
@@ -2702,7 +2754,7 @@ class MeshBoardStateService {
2702
2754
  * Clears entity selector variables for a given selector.
2703
2755
  */
2704
2756
  clearEntitySelectorVariables(selectorId) {
2705
- const currentVars = this.getVariables().filter(v => !(v.source === 'entitySelector' && v.entitySelectorId === selectorId));
2757
+ const currentVars = this.configVariables().filter(v => !(v.source === 'entitySelector' && v.entitySelectorId === selectorId));
2706
2758
  this.updateVariables(currentVars);
2707
2759
  }
2708
2760
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: MeshBoardStateService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
@@ -5360,11 +5412,162 @@ function applyValueMultiplier(value, multiplier) {
5360
5412
  return value * multiplier;
5361
5413
  }
5362
5414
 
5415
+ const VARIABLE_REFERENCE = /\$\{([^}]+)\}|\$([a-zA-Z_]\w*)/g;
5416
+ const IDENTIFIER_PREFIX = '__v_';
5417
+ const NUMERIC_VALUE = /^[-+]?(\d+\.?\d*|\.\d+)([eE][-+]?\d+)?$/;
5418
+ /**
5419
+ * Safe expr-eval identifier for a variable name. Every character outside `[A-Za-z0-9]`,
5420
+ * including `_`, is hex-escaped as `_<hex>_`, so underscores only ever delimit escapes and
5421
+ * the mapping is injective (`my-var` and `my_2d_var` stay distinct).
5422
+ */
5423
+ function toIdentifier(name) {
5424
+ return IDENTIFIER_PREFIX + name.replace(/[^A-Za-z0-9]/g, c => `_${c.charCodeAt(0).toString(16)}_`);
5425
+ }
5426
+ /**
5427
+ * Replaces `${name}` / `$name` references with safe identifiers.
5428
+ */
5429
+ function rewriteFormula(formula) {
5430
+ const referencedNames = [];
5431
+ const expression = (formula ?? '').replace(VARIABLE_REFERENCE, (_match, bracketName, simpleName) => {
5432
+ const name = (bracketName ?? simpleName).trim();
5433
+ if (!referencedNames.includes(name)) {
5434
+ referencedNames.push(name);
5435
+ }
5436
+ return toIdentifier(name);
5437
+ });
5438
+ return { expression, referencedNames };
5439
+ }
5440
+ /**
5441
+ * Validates a formula: syntax, and that every referenced variable exists.
5442
+ * A bare identifier (`a` instead of `${a}`) is reported as unknown variable.
5443
+ */
5444
+ function validateFormula(formula, availableNames, evaluator) {
5445
+ if (!formula?.trim()) {
5446
+ return { valid: false, error: 'Formula is empty', unknownVariables: [] };
5447
+ }
5448
+ const { expression, referencedNames } = rewriteFormula(formula);
5449
+ const syntax = evaluator.validate(expression);
5450
+ if (!syntax.valid) {
5451
+ return { valid: false, error: syntax.error ?? 'Invalid formula', unknownVariables: [] };
5452
+ }
5453
+ const unknownVariables = referencedNames.filter(name => !availableNames.includes(name));
5454
+ const bareIdentifiers = evaluator.getVariables(expression)
5455
+ .filter(identifier => !identifier.startsWith(IDENTIFIER_PREFIX));
5456
+ for (const identifier of bareIdentifiers) {
5457
+ if (!unknownVariables.includes(identifier)) {
5458
+ unknownVariables.push(identifier);
5459
+ }
5460
+ }
5461
+ if (unknownVariables.length > 0) {
5462
+ return {
5463
+ valid: false,
5464
+ error: `Unknown variable${unknownVariables.length > 1 ? 's' : ''}: ${unknownVariables.join(', ')}`,
5465
+ unknownVariables
5466
+ };
5467
+ }
5468
+ return { valid: true, unknownVariables: [] };
5469
+ }
5470
+ /** Converts a variable's string value to the type expr-eval should see. */
5471
+ function toContextValue(value) {
5472
+ const trimmed = value.trim();
5473
+ if (NUMERIC_VALUE.test(trimmed)) {
5474
+ return Number(trimmed);
5475
+ }
5476
+ if (trimmed === 'true' || trimmed === 'false') {
5477
+ return trimmed === 'true';
5478
+ }
5479
+ return value;
5480
+ }
5481
+ /**
5482
+ * Evaluates a formula against the given variables.
5483
+ *
5484
+ * Returns `pending` while the formula is empty or a referenced variable is missing or empty
5485
+ * (e.g. an upstream widget has not published yet), so the widget shows `-` instead of an error.
5486
+ */
5487
+ function evaluateFormula(formula, variables, evaluator) {
5488
+ if (!formula?.trim()) {
5489
+ return { status: 'pending' };
5490
+ }
5491
+ const { expression, referencedNames } = rewriteFormula(formula);
5492
+ const context = {};
5493
+ for (const name of referencedNames) {
5494
+ const variable = variables.find(v => v.name === name);
5495
+ const value = variable?.value || variable?.defaultValue;
5496
+ if (value === undefined || value === '') {
5497
+ return { status: 'pending' };
5498
+ }
5499
+ context[toIdentifier(name)] = toContextValue(value);
5500
+ }
5501
+ const result = evaluator.evaluate(expression, context);
5502
+ if (!result.success) {
5503
+ return { status: 'error', error: result.error ?? 'Evaluation failed' };
5504
+ }
5505
+ const value = result.value;
5506
+ if (typeof value === 'number') {
5507
+ return Number.isFinite(value)
5508
+ ? { status: 'ok', value }
5509
+ : { status: 'error', error: 'Result is not a finite number' };
5510
+ }
5511
+ if (typeof value === 'string' || typeof value === 'boolean') {
5512
+ return { status: 'ok', value };
5513
+ }
5514
+ return { status: 'error', error: 'Result is not a number' };
5515
+ }
5516
+ /** True when the widget is a KPI in formula mode. */
5517
+ function isFormulaKpi(widget) {
5518
+ return widget.type === 'kpi' && widget.valueMode === 'formula';
5519
+ }
5520
+ /**
5521
+ * Finds widgets that depend on their own output, directly or through other widgets.
5522
+ *
5523
+ * Graph: a formula KPI depends on every widget whose `outputVariableName` its formula
5524
+ * references. A name that is also a configured MeshBoard variable resolves to that variable
5525
+ * at runtime (config variables win), so it creates no dependency on the widget.
5526
+ * Returns the IDs of all widgets that are part of a cycle (including self-references).
5527
+ */
5528
+ function findVariableCycles(widgets, configuredVariableNames = []) {
5529
+ const configuredNames = new Set(configuredVariableNames);
5530
+ const producerByName = new Map();
5531
+ for (const widget of widgets) {
5532
+ const name = widget.type === 'kpi' ? widget.outputVariableName?.trim() : undefined;
5533
+ if (name) {
5534
+ producerByName.set(name, widget.id);
5535
+ }
5536
+ }
5537
+ const dependencies = new Map();
5538
+ for (const widget of widgets) {
5539
+ if (!isFormulaKpi(widget))
5540
+ continue;
5541
+ const producers = rewriteFormula(widget.formula ?? '').referencedNames
5542
+ .map(name => configuredNames.has(name) ? undefined : producerByName.get(name))
5543
+ .filter((id) => id !== undefined);
5544
+ dependencies.set(widget.id, producers);
5545
+ }
5546
+ const inCycle = new Set();
5547
+ for (const start of dependencies.keys()) {
5548
+ const visited = new Set();
5549
+ const stack = [...(dependencies.get(start) ?? [])];
5550
+ while (stack.length > 0) {
5551
+ const current = stack.pop();
5552
+ if (current === start) {
5553
+ inCycle.add(start);
5554
+ break;
5555
+ }
5556
+ if (visited.has(current))
5557
+ continue;
5558
+ visited.add(current);
5559
+ stack.push(...(dependencies.get(current) ?? []));
5560
+ }
5561
+ }
5562
+ return inCycle;
5563
+ }
5564
+
5363
5565
  class KpiWidgetComponent {
5364
5566
  dataService = inject(DashboardDataService);
5365
5567
  queryExecutor = inject(QueryExecutorService);
5366
5568
  stateService = inject(MeshBoardStateService);
5367
5569
  variableService = inject(MeshBoardVariableService);
5570
+ expressionEvaluator = inject(ExpressionEvaluatorService);
5368
5571
  /**
5369
5572
  * Row __typenames KPI extraction recognises.
5370
5573
  * Runtime queries discriminate; stream-data queries collapse all kinds
@@ -5389,6 +5592,34 @@ class KpiWidgetComponent {
5389
5592
  isLoading = this._isLoading.asReadonly();
5390
5593
  data = this._data.asReadonly();
5391
5594
  error = this._error.asReadonly();
5595
+ /**
5596
+ * Signal mirror of the `config` input, so computeds re-run when the config
5597
+ * changes (the input itself is a plain property). Falls back to the input
5598
+ * before the first lifecycle hook.
5599
+ */
5600
+ _config = signal(undefined, /* @ts-ignore */
5601
+ ...(ngDevMode ? [{ debugName: "_config" }] : /* istanbul ignore next */ []));
5602
+ currentConfig() {
5603
+ return this._config() ?? this.config;
5604
+ }
5605
+ constructor() {
5606
+ // Publishes the raw value as runtime MeshBoard variable (AB#5364).
5607
+ effect(() => {
5608
+ const config = this.currentConfig();
5609
+ if (!config)
5610
+ return;
5611
+ const name = config.outputVariableName?.trim();
5612
+ const value = name ? this.publishedValue() : null;
5613
+ untracked(() => {
5614
+ if (name && value !== null) {
5615
+ this.stateService.setWidgetVariable(config.id, name, value);
5616
+ }
5617
+ else {
5618
+ this.stateService.clearWidgetVariable(config.id);
5619
+ }
5620
+ });
5621
+ });
5622
+ }
5392
5623
  /**
5393
5624
  * Check if widget is not configured (needs data source setup).
5394
5625
  * This is a method (not computed) to ensure it re-evaluates when config changes via @Input.
@@ -5408,39 +5639,92 @@ class KpiWidgetComponent {
5408
5639
  return !dataSource.queryRtId;
5409
5640
  }
5410
5641
  if (dataSource.type === 'static') {
5642
+ if (this.isFormulaMode()) {
5643
+ return !this.config.formula?.trim();
5644
+ }
5411
5645
  return false;
5412
5646
  }
5413
5647
  return false;
5414
5648
  }
5415
- value = computed(() => {
5649
+ /** True when the value is computed from a formula (AB#5364). */
5650
+ isFormulaMode() {
5651
+ return this.currentConfig()?.valueMode === 'formula';
5652
+ }
5653
+ /**
5654
+ * Formula evaluation result, live over the MeshBoard variables.
5655
+ * `null` outside formula mode. A widget in a circular reference is not evaluated,
5656
+ * otherwise publishing would ping-pong between the widgets of the cycle.
5657
+ */
5658
+ formulaResult = computed(() => {
5659
+ const config = this.currentConfig();
5660
+ if (config?.valueMode !== 'formula')
5661
+ return null;
5662
+ const configuredNames = (this.stateService.meshBoardConfig().variables ?? []).map(v => v.name);
5663
+ if (findVariableCycles(this.stateService.widgets(), configuredNames).has(config.id)) {
5664
+ return { status: 'error', error: 'Circular variable reference' };
5665
+ }
5666
+ return evaluateFormula(config.formula, this.stateService.getVariables(), this.expressionEvaluator);
5667
+ }, /* @ts-ignore */
5668
+ ...(ngDevMode ? [{ debugName: "formulaResult" }] : /* istanbul ignore next */ []));
5669
+ /** Error message of the formula (syntax, evaluation or circular reference). */
5670
+ formulaError = computed(() => {
5671
+ const result = this.formulaResult();
5672
+ return result?.status === 'error' ? result.error : null;
5673
+ }, /* @ts-ignore */
5674
+ ...(ngDevMode ? [{ debugName: "formulaError" }] : /* istanbul ignore next */ []));
5675
+ /**
5676
+ * Unformatted value: the formula result, or the value taken from the loaded data
5677
+ * (query values already scaled by `valueMultiplier`).
5678
+ */
5679
+ rawValue = computed(() => {
5680
+ const formulaResult = this.formulaResult();
5681
+ if (formulaResult) {
5682
+ return formulaResult.status === 'ok' ? formulaResult.value : null;
5683
+ }
5416
5684
  const data = this._data();
5417
5685
  if (!data)
5418
- return '-';
5686
+ return null;
5687
+ const config = this.currentConfig();
5419
5688
  // Determine attribute name based on data source type
5420
5689
  let attributeName;
5421
- if (this.config?.dataSource?.type === 'persistentQuery') {
5690
+ if (config?.dataSource?.type === 'persistentQuery') {
5422
5691
  attributeName = '_queryValue';
5423
5692
  }
5424
- else if (this.config?.dataSource?.type === 'static') {
5693
+ else if (config?.dataSource?.type === 'static') {
5425
5694
  attributeName = '_staticValue';
5426
5695
  }
5427
5696
  else {
5428
- attributeName = this.config?.valueAttribute;
5697
+ attributeName = config?.valueAttribute;
5429
5698
  }
5430
5699
  // First check for system properties (direct properties on RuntimeEntityData)
5431
5700
  const systemValue = this.getSystemPropertyValue(data, attributeName);
5432
5701
  if (systemValue !== undefined) {
5433
- return this.formatDisplayValue(systemValue);
5702
+ return systemValue;
5434
5703
  }
5435
5704
  // Then check in the attributes array
5436
- if (!data.attributes)
5437
- return '-';
5438
- const attr = data.attributes.find(a => a.attributeName === attributeName);
5439
- if (!attr)
5440
- return '-';
5441
- return this.formatDisplayValue(attr.value);
5705
+ return data.attributes?.find(a => a.attributeName === attributeName)?.value ?? null;
5442
5706
  }, /* @ts-ignore */
5707
+ ...(ngDevMode ? [{ debugName: "rawValue" }] : /* istanbul ignore next */ []));
5708
+ value = computed(() => this.formatDisplayValue(this.rawValue()), /* @ts-ignore */
5443
5709
  ...(ngDevMode ? [{ debugName: "value" }] : /* istanbul ignore next */ []));
5710
+ /**
5711
+ * Value published as output variable: the raw value as string, or `null` while
5712
+ * there is none (nothing loaded, pending formula, unresolved placeholder, failed load).
5713
+ * During a refresh the last value stays published, so dependents do not flicker to `-`.
5714
+ */
5715
+ publishedValue = computed(() => {
5716
+ if (this._error())
5717
+ return null;
5718
+ const raw = this.rawValue();
5719
+ if (raw === null || raw === undefined)
5720
+ return null;
5721
+ const str = String(raw);
5722
+ if (str === '' || str === 'null' || this.variableService.hasUnresolvedVariables(str)) {
5723
+ return null;
5724
+ }
5725
+ return str;
5726
+ }, /* @ts-ignore */
5727
+ ...(ngDevMode ? [{ debugName: "publishedValue" }] : /* istanbul ignore next */ []));
5444
5728
  /**
5445
5729
  * Gets a system property value from RuntimeEntityData.
5446
5730
  * System properties are top-level properties like rtId, ckTypeId, rtWellKnownName.
@@ -5530,18 +5814,32 @@ class KpiWidgetComponent {
5530
5814
  }, /* @ts-ignore */
5531
5815
  ...(ngDevMode ? [{ debugName: "trendClass" }] : /* istanbul ignore next */ []));
5532
5816
  comparisonText = computed(() => {
5533
- if (!this.config?.comparisonText)
5817
+ const comparisonText = this.currentConfig()?.comparisonText;
5818
+ if (!comparisonText)
5534
5819
  return null;
5535
5820
  const variables = this.stateService.getVariables();
5536
- return this.variableService.resolveVariables(this.config.comparisonText, variables);
5821
+ return this.variableService.resolveVariables(comparisonText, variables);
5537
5822
  }, /* @ts-ignore */
5538
5823
  ...(ngDevMode ? [{ debugName: "comparisonText" }] : /* istanbul ignore next */ []));
5539
5824
  ngOnInit() {
5825
+ this._config.set(this.config);
5540
5826
  this.loadData();
5541
5827
  }
5542
5828
  ngOnChanges(changes) {
5543
- if (changes['config'] && !changes['config'].firstChange) {
5544
- this.loadData();
5829
+ if (changes['config']) {
5830
+ const previous = changes['config'].previousValue;
5831
+ if (previous && previous.id !== this.config?.id) {
5832
+ this.stateService.clearWidgetVariable(previous.id);
5833
+ }
5834
+ this._config.set(this.config);
5835
+ if (!changes['config'].firstChange) {
5836
+ this.loadData();
5837
+ }
5838
+ }
5839
+ }
5840
+ ngOnDestroy() {
5841
+ if (this.config?.id) {
5842
+ this.stateService.clearWidgetVariable(this.config.id);
5545
5843
  }
5546
5844
  }
5547
5845
  refresh() {
@@ -5553,6 +5851,12 @@ class KpiWidgetComponent {
5553
5851
  return;
5554
5852
  }
5555
5853
  const dataSource = this.config?.dataSource;
5854
+ if (this.isFormulaMode()) {
5855
+ // Formula values are computed live from the variables (see formulaResult)
5856
+ this._data.set(null);
5857
+ this._error.set(null);
5858
+ return;
5859
+ }
5556
5860
  if (dataSource.type === 'static') {
5557
5861
  // Resolve static value with variable substitution
5558
5862
  const staticValue = this.config.staticValue ?? '';
@@ -5753,12 +6057,12 @@ class KpiWidgetComponent {
5753
6057
  return this.variableService.convertToFieldFilterDto(filters, variables);
5754
6058
  }
5755
6059
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: KpiWidgetComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
5756
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: KpiWidgetComponent, isStandalone: true, selector: "mm-kpi-widget", inputs: { config: "config" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"kpi-widget\" [class.loading]=\"isLoading()\" [class.error]=\"error()\">\n @if (isNotConfigured()) {\n <mm-widget-not-configured></mm-widget-not-configured>\n } @else if (isLoading()) {\n <div class=\"loading-indicator\">\n <span>...</span>\n </div>\n } @else if (error()) {\n <div class=\"error-message\">\n <span>{{ error() }}</span>\n </div>\n } @else {\n <div class=\"kpi-content\">\n <div class=\"kpi-value-container\">\n @if (config.prefix) {\n <span class=\"kpi-prefix\">{{ config.prefix }}</span>\n }\n <span class=\"kpi-value\">{{ value() }}</span>\n @if (config.suffix) {\n <span class=\"kpi-suffix\">{{ config.suffix }}</span>\n }\n </div>\n\n @if (comparisonText()) {\n <div class=\"kpi-comparison\" [ngClass]=\"trendClass()\">\n @if (config.trend) {\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"xsmall\"></kendo-svg-icon>\n }\n {{ comparisonText() }}\n </div>\n }\n\n @if (label()) {\n <div class=\"kpi-label\">{{ label() }}</div>\n }\n </div>\n\n @if (config.trend) {\n <div class=\"kpi-trend\" [ngClass]=\"trendClass()\">\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"medium\"></kendo-svg-icon>\n </div>\n }\n }\n</div>\n", styles: [".kpi-widget{display:flex;align-items:center;justify-content:center;height:100%;padding:16px;gap:16px}.kpi-content{display:flex;flex-direction:column;align-items:center;text-align:center}.kpi-value-container{display:flex;align-items:baseline;gap:4px}.kpi-prefix,.kpi-suffix{font-size:1.25rem;font-weight:500;color:var(--kendo-color-subtle, #6c757d)}.kpi-value{font-size:2.5rem;font-weight:700;color:var(--kendo-color-on-surface, #212529);line-height:1}.kpi-comparison{margin-top:4px;font-size:.75rem;font-weight:500;display:flex;align-items:center;gap:4px;justify-content:center}.kpi-comparison.trend-up{color:var(--kendo-color-success, #28a745)}.kpi-comparison.trend-down{color:var(--kendo-color-error, #dc3545)}.kpi-comparison.trend-neutral{color:var(--kendo-color-subtle, #6c757d)}.kpi-label{margin-top:8px;font-size:.875rem;color:var(--kendo-color-subtle, #6c757d)}.kpi-trend{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%}.kpi-trend.trend-up{background:#28a74526;color:var(--kendo-color-success, #28a745)}.kpi-trend.trend-down{background:#dc354526;color:var(--kendo-color-error, #dc3545)}.kpi-trend.trend-neutral{background:#6c757d26;color:var(--kendo-color-subtle, #6c757d)}@media(max-width:768px){.kpi-value{font-size:1.75rem}.kpi-prefix,.kpi-suffix{font-size:1rem}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$3.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: i1$4.SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon", "variant"], exportAs: ["kendoSVGIcon"] }, { kind: "component", type: WidgetNotConfiguredComponent, selector: "mm-widget-not-configured" }], changeDetection: i0.ChangeDetectionStrategy.Eager });
6060
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: KpiWidgetComponent, isStandalone: true, selector: "mm-kpi-widget", inputs: { config: "config" }, usesOnChanges: true, ngImport: i0, template: "<div class=\"kpi-widget\" [class.loading]=\"isLoading()\" [class.error]=\"error() || formulaError()\">\n @if (isNotConfigured()) {\n <mm-widget-not-configured></mm-widget-not-configured>\n } @else if (isLoading()) {\n <div class=\"loading-indicator\">\n <span>...</span>\n </div>\n } @else if (error() || formulaError()) {\n <div class=\"error-message\">\n <span>{{ error() ?? formulaError() }}</span>\n </div>\n } @else {\n <div class=\"kpi-content\">\n <div class=\"kpi-value-container\">\n @if (config.prefix) {\n <span class=\"kpi-prefix\">{{ config.prefix }}</span>\n }\n <span class=\"kpi-value\">{{ value() }}</span>\n @if (config.suffix) {\n <span class=\"kpi-suffix\">{{ config.suffix }}</span>\n }\n </div>\n\n @if (comparisonText()) {\n <div class=\"kpi-comparison\" [ngClass]=\"trendClass()\">\n @if (config.trend) {\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"xsmall\"></kendo-svg-icon>\n }\n {{ comparisonText() }}\n </div>\n }\n\n @if (label()) {\n <div class=\"kpi-label\">{{ label() }}</div>\n }\n </div>\n\n @if (config.trend) {\n <div class=\"kpi-trend\" [ngClass]=\"trendClass()\">\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"medium\"></kendo-svg-icon>\n </div>\n }\n }\n</div>\n", styles: [".kpi-widget{display:flex;align-items:center;justify-content:center;height:100%;padding:16px;gap:16px}.kpi-content{display:flex;flex-direction:column;align-items:center;text-align:center}.kpi-value-container{display:flex;align-items:baseline;gap:4px}.kpi-prefix,.kpi-suffix{font-size:1.25rem;font-weight:500;color:var(--kendo-color-subtle, #6c757d)}.kpi-value{font-size:2.5rem;font-weight:700;color:var(--kendo-color-on-surface, #212529);line-height:1}.kpi-comparison{margin-top:4px;font-size:.75rem;font-weight:500;display:flex;align-items:center;gap:4px;justify-content:center}.kpi-comparison.trend-up{color:var(--kendo-color-success, #28a745)}.kpi-comparison.trend-down{color:var(--kendo-color-error, #dc3545)}.kpi-comparison.trend-neutral{color:var(--kendo-color-subtle, #6c757d)}.kpi-label{margin-top:8px;font-size:.875rem;color:var(--kendo-color-subtle, #6c757d)}.kpi-trend{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%}.kpi-trend.trend-up{background:#28a74526;color:var(--kendo-color-success, #28a745)}.kpi-trend.trend-down{background:#dc354526;color:var(--kendo-color-error, #dc3545)}.kpi-trend.trend-neutral{background:#6c757d26;color:var(--kendo-color-subtle, #6c757d)}@media(max-width:768px){.kpi-value{font-size:1.75rem}.kpi-prefix,.kpi-suffix{font-size:1rem}}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "directive", type: i1$3.NgClass, selector: "[ngClass]", inputs: ["class", "ngClass"] }, { kind: "ngmodule", type: SVGIconModule }, { kind: "component", type: i1$4.SVGIconComponent, selector: "kendo-svg-icon, kendo-svgicon", inputs: ["icon", "variant"], exportAs: ["kendoSVGIcon"] }, { kind: "component", type: WidgetNotConfiguredComponent, selector: "mm-widget-not-configured" }], changeDetection: i0.ChangeDetectionStrategy.Eager });
5757
6061
  }
5758
6062
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: KpiWidgetComponent, decorators: [{
5759
6063
  type: Component,
5760
- args: [{ selector: 'mm-kpi-widget', standalone: true, imports: [CommonModule, SVGIconModule, WidgetNotConfiguredComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"kpi-widget\" [class.loading]=\"isLoading()\" [class.error]=\"error()\">\n @if (isNotConfigured()) {\n <mm-widget-not-configured></mm-widget-not-configured>\n } @else if (isLoading()) {\n <div class=\"loading-indicator\">\n <span>...</span>\n </div>\n } @else if (error()) {\n <div class=\"error-message\">\n <span>{{ error() }}</span>\n </div>\n } @else {\n <div class=\"kpi-content\">\n <div class=\"kpi-value-container\">\n @if (config.prefix) {\n <span class=\"kpi-prefix\">{{ config.prefix }}</span>\n }\n <span class=\"kpi-value\">{{ value() }}</span>\n @if (config.suffix) {\n <span class=\"kpi-suffix\">{{ config.suffix }}</span>\n }\n </div>\n\n @if (comparisonText()) {\n <div class=\"kpi-comparison\" [ngClass]=\"trendClass()\">\n @if (config.trend) {\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"xsmall\"></kendo-svg-icon>\n }\n {{ comparisonText() }}\n </div>\n }\n\n @if (label()) {\n <div class=\"kpi-label\">{{ label() }}</div>\n }\n </div>\n\n @if (config.trend) {\n <div class=\"kpi-trend\" [ngClass]=\"trendClass()\">\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"medium\"></kendo-svg-icon>\n </div>\n }\n }\n</div>\n", styles: [".kpi-widget{display:flex;align-items:center;justify-content:center;height:100%;padding:16px;gap:16px}.kpi-content{display:flex;flex-direction:column;align-items:center;text-align:center}.kpi-value-container{display:flex;align-items:baseline;gap:4px}.kpi-prefix,.kpi-suffix{font-size:1.25rem;font-weight:500;color:var(--kendo-color-subtle, #6c757d)}.kpi-value{font-size:2.5rem;font-weight:700;color:var(--kendo-color-on-surface, #212529);line-height:1}.kpi-comparison{margin-top:4px;font-size:.75rem;font-weight:500;display:flex;align-items:center;gap:4px;justify-content:center}.kpi-comparison.trend-up{color:var(--kendo-color-success, #28a745)}.kpi-comparison.trend-down{color:var(--kendo-color-error, #dc3545)}.kpi-comparison.trend-neutral{color:var(--kendo-color-subtle, #6c757d)}.kpi-label{margin-top:8px;font-size:.875rem;color:var(--kendo-color-subtle, #6c757d)}.kpi-trend{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%}.kpi-trend.trend-up{background:#28a74526;color:var(--kendo-color-success, #28a745)}.kpi-trend.trend-down{background:#dc354526;color:var(--kendo-color-error, #dc3545)}.kpi-trend.trend-neutral{background:#6c757d26;color:var(--kendo-color-subtle, #6c757d)}@media(max-width:768px){.kpi-value{font-size:1.75rem}.kpi-prefix,.kpi-suffix{font-size:1rem}}\n"] }]
5761
- }], propDecorators: { config: [{
6064
+ args: [{ selector: 'mm-kpi-widget', standalone: true, imports: [CommonModule, SVGIconModule, WidgetNotConfiguredComponent], changeDetection: ChangeDetectionStrategy.Eager, template: "<div class=\"kpi-widget\" [class.loading]=\"isLoading()\" [class.error]=\"error() || formulaError()\">\n @if (isNotConfigured()) {\n <mm-widget-not-configured></mm-widget-not-configured>\n } @else if (isLoading()) {\n <div class=\"loading-indicator\">\n <span>...</span>\n </div>\n } @else if (error() || formulaError()) {\n <div class=\"error-message\">\n <span>{{ error() ?? formulaError() }}</span>\n </div>\n } @else {\n <div class=\"kpi-content\">\n <div class=\"kpi-value-container\">\n @if (config.prefix) {\n <span class=\"kpi-prefix\">{{ config.prefix }}</span>\n }\n <span class=\"kpi-value\">{{ value() }}</span>\n @if (config.suffix) {\n <span class=\"kpi-suffix\">{{ config.suffix }}</span>\n }\n </div>\n\n @if (comparisonText()) {\n <div class=\"kpi-comparison\" [ngClass]=\"trendClass()\">\n @if (config.trend) {\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"xsmall\"></kendo-svg-icon>\n }\n {{ comparisonText() }}\n </div>\n }\n\n @if (label()) {\n <div class=\"kpi-label\">{{ label() }}</div>\n }\n </div>\n\n @if (config.trend) {\n <div class=\"kpi-trend\" [ngClass]=\"trendClass()\">\n <kendo-svg-icon [icon]=\"trendIcon()\" size=\"medium\"></kendo-svg-icon>\n </div>\n }\n }\n</div>\n", styles: [".kpi-widget{display:flex;align-items:center;justify-content:center;height:100%;padding:16px;gap:16px}.kpi-content{display:flex;flex-direction:column;align-items:center;text-align:center}.kpi-value-container{display:flex;align-items:baseline;gap:4px}.kpi-prefix,.kpi-suffix{font-size:1.25rem;font-weight:500;color:var(--kendo-color-subtle, #6c757d)}.kpi-value{font-size:2.5rem;font-weight:700;color:var(--kendo-color-on-surface, #212529);line-height:1}.kpi-comparison{margin-top:4px;font-size:.75rem;font-weight:500;display:flex;align-items:center;gap:4px;justify-content:center}.kpi-comparison.trend-up{color:var(--kendo-color-success, #28a745)}.kpi-comparison.trend-down{color:var(--kendo-color-error, #dc3545)}.kpi-comparison.trend-neutral{color:var(--kendo-color-subtle, #6c757d)}.kpi-label{margin-top:8px;font-size:.875rem;color:var(--kendo-color-subtle, #6c757d)}.kpi-trend{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:50%}.kpi-trend.trend-up{background:#28a74526;color:var(--kendo-color-success, #28a745)}.kpi-trend.trend-down{background:#dc354526;color:var(--kendo-color-error, #dc3545)}.kpi-trend.trend-neutral{background:#6c757d26;color:var(--kendo-color-subtle, #6c757d)}@media(max-width:768px){.kpi-value{font-size:1.75rem}.kpi-prefix,.kpi-suffix{font-size:1rem}}\n"] }]
6065
+ }], ctorParameters: () => [], propDecorators: { config: [{
5762
6066
  type: Input
5763
6067
  }] } });
5764
6068
 
@@ -6002,6 +6306,8 @@ class KpiConfigDialogComponent {
6002
6306
  'StreamDataQueryRow'
6003
6307
  ]);
6004
6308
  meshBoardStateService = inject(MeshBoardStateService);
6309
+ variableService = inject(MeshBoardVariableService);
6310
+ expressionEvaluator = inject(ExpressionEvaluatorService);
6005
6311
  windowRef = inject(WindowRef);
6006
6312
  ckTypeSelectorInput;
6007
6313
  entitySelectorInput;
@@ -6028,6 +6334,11 @@ class KpiConfigDialogComponent {
6028
6334
  initialQueryCategoryValue;
6029
6335
  // Initial values for static
6030
6336
  initialStaticValue;
6337
+ // Initial values for formula / output variable (AB#5364)
6338
+ /** Id of the edited widget: excludes its own output from the checks and drives the cycle check */
6339
+ initialWidgetId;
6340
+ initialFormula;
6341
+ initialOutputVariableName;
6031
6342
  // Initial values for filters
6032
6343
  initialFilters;
6033
6344
  // Data source type selection
@@ -6081,8 +6392,103 @@ class KpiConfigDialogComponent {
6081
6392
  queryCategoryField: '',
6082
6393
  queryCategoryValue: '',
6083
6394
  // Static form fields
6084
- staticValue: ''
6395
+ staticValue: '',
6396
+ // Formula form fields
6397
+ formula: '',
6398
+ outputVariableName: ''
6085
6399
  };
6400
+ /**
6401
+ * Variables a formula can reference: MeshBoard variables plus other widgets' outputs.
6402
+ * Read live, so outputs published while the dialog is open (e.g. a query KPI that
6403
+ * finished loading) become available immediately.
6404
+ */
6405
+ get formulaVariables() {
6406
+ return this.meshBoardStateService.getVariables()
6407
+ .filter(v => !(v.source === 'widget' && v.widgetId === this.initialWidgetId));
6408
+ }
6409
+ formulaValidationCache;
6410
+ /**
6411
+ * Variable names that exist by configuration even while they carry no value yet: the
6412
+ * outputs declared by other KPIs (e.g. still loading or in error) and the variables an
6413
+ * entity selector exposes once an entity is picked.
6414
+ */
6415
+ get declaredVariableNames() {
6416
+ const outputs = this.meshBoardStateService.widgets()
6417
+ .filter(w => w.id !== this.initialWidgetId && w.type === 'kpi')
6418
+ .map(w => w.outputVariableName?.trim())
6419
+ .filter((name) => !!name);
6420
+ const selectorVariables = this.meshBoardStateService.getEntitySelectors().flatMap(selector => [
6421
+ ...(selector.attributeMappings ?? []).map(m => m.variableName),
6422
+ `${selector.id}_rtId`,
6423
+ `${selector.id}_rtCkTypeId`
6424
+ ]);
6425
+ return [...outputs, ...selectorVariables];
6426
+ }
6427
+ /**
6428
+ * Validation of the current formula (memoized per formula text and available variable names).
6429
+ * Declared names count as known, so a reference to an output without a value yet can be saved;
6430
+ * the preview keeps evaluating the live values and shows it as pending.
6431
+ */
6432
+ get formulaValidation() {
6433
+ const formula = this.form.formula;
6434
+ const availableNames = [...new Set([...this.formulaVariables.map(v => v.name), ...this.declaredVariableNames])];
6435
+ const names = JSON.stringify(availableNames);
6436
+ if (this.formulaValidationCache?.formula !== formula || this.formulaValidationCache.names !== names) {
6437
+ this.formulaValidationCache = {
6438
+ formula,
6439
+ names,
6440
+ result: validateFormula(formula, availableNames, this.expressionEvaluator)
6441
+ };
6442
+ }
6443
+ return this.formulaValidationCache.result;
6444
+ }
6445
+ /** Result of the current formula over the current variable values */
6446
+ get formulaPreview() {
6447
+ const result = evaluateFormula(this.form.formula, this.formulaVariables, this.expressionEvaluator);
6448
+ switch (result.status) {
6449
+ case 'ok': return String(result.value);
6450
+ case 'pending': return '- (a referenced variable has no value yet)';
6451
+ default: return result.error;
6452
+ }
6453
+ }
6454
+ /** Validation message for the output variable name, or `null` when valid or empty */
6455
+ get outputVariableError() {
6456
+ const name = this.form.outputVariableName.trim();
6457
+ if (!name)
6458
+ return null;
6459
+ if (!this.variableService.isValidVariableName(name)) {
6460
+ return 'Use letters, digits and underscores only; the name must not start with a digit.';
6461
+ }
6462
+ if ((this.meshBoardStateService.meshBoardConfig().variables ?? []).some(v => v.name === name)) {
6463
+ return `A MeshBoard variable named "${name}" already exists.`;
6464
+ }
6465
+ const otherProducer = this.meshBoardStateService.widgets().find(w => w.id !== this.initialWidgetId && w.type === 'kpi' && w.outputVariableName?.trim() === name);
6466
+ if (otherProducer) {
6467
+ return `Widget "${otherProducer.title}" already publishes "${name}".`;
6468
+ }
6469
+ return null;
6470
+ }
6471
+ /** Circular reference introduced by the pending formula / output name, or `null` */
6472
+ get cycleError() {
6473
+ if (this.dataSourceType !== 'formula')
6474
+ return null;
6475
+ const widgetId = this.initialWidgetId ?? '__pending-kpi__';
6476
+ const widgets = this.meshBoardStateService.widgets();
6477
+ const current = widgets.find(w => w.id === widgetId);
6478
+ const pending = {
6479
+ ...(current ?? { id: widgetId, type: 'kpi', title: '', col: 1, row: 1, colSpan: 1, rowSpan: 1, valueAttribute: '', dataSource: { type: 'static' } }),
6480
+ valueMode: 'formula',
6481
+ formula: this.form.formula,
6482
+ outputVariableName: this.form.outputVariableName.trim() || undefined
6483
+ };
6484
+ const pendingWidgets = current
6485
+ ? widgets.map(w => w.id === widgetId ? pending : w)
6486
+ : [...widgets, pending];
6487
+ const configuredNames = (this.meshBoardStateService.meshBoardConfig().variables ?? []).map(v => v.name);
6488
+ return findVariableCycles(pendingWidgets, configuredNames).has(widgetId)
6489
+ ? 'Circular reference: the formula depends on this widget\'s own output.'
6490
+ : null;
6491
+ }
6086
6492
  variableSyntaxHint = '${variableName}';
6087
6493
  trendOptions = [
6088
6494
  { value: undefined, label: 'None' },
@@ -6096,6 +6502,11 @@ class KpiConfigDialogComponent {
6096
6502
  { value: 'groupedAggregation', label: 'Grouped Aggregation' }
6097
6503
  ];
6098
6504
  get isValid() {
6505
+ if (this.outputVariableError)
6506
+ return false;
6507
+ if (this.dataSourceType === 'formula') {
6508
+ return this.formulaValidation.valid && !this.cycleError;
6509
+ }
6099
6510
  if (this.dataSourceType === 'static') {
6100
6511
  return (this.form.staticValue?.trim() ?? '') !== '';
6101
6512
  }
@@ -6138,6 +6549,8 @@ class KpiConfigDialogComponent {
6138
6549
  this.form.queryCategoryField = this.initialQueryCategoryField || '';
6139
6550
  this.form.queryCategoryValue = this.initialQueryCategoryValue || '';
6140
6551
  this.form.staticValue = this.initialStaticValue || '';
6552
+ this.form.formula = this.initialFormula || '';
6553
+ this.form.outputVariableName = this.initialOutputVariableName || '';
6141
6554
  // Initialize filters
6142
6555
  if (this.initialFilters && this.initialFilters.length > 0) {
6143
6556
  this.filters = this.initialFilters.map((f, index) => ({
@@ -6300,6 +6713,20 @@ class KpiConfigDialogComponent {
6300
6713
  comparisonValue: f.comparisonValue
6301
6714
  }))
6302
6715
  : undefined;
6716
+ if (this.dataSourceType === 'formula') {
6717
+ this.windowRef.close({
6718
+ dataSourceType: 'formula',
6719
+ ckTypeId: '',
6720
+ valueAttribute: '',
6721
+ formula: this.form.formula.trim(),
6722
+ prefix: this.form.prefix || undefined,
6723
+ suffix: this.form.suffix || undefined,
6724
+ trend: this.form.trend,
6725
+ comparisonText: this.form.comparisonText || undefined,
6726
+ outputVariableName: this.form.outputVariableName.trim() || undefined
6727
+ });
6728
+ return;
6729
+ }
6303
6730
  if (this.dataSourceType === 'static') {
6304
6731
  this.windowRef.close({
6305
6732
  dataSourceType: 'static',
@@ -6309,7 +6736,8 @@ class KpiConfigDialogComponent {
6309
6736
  prefix: this.form.prefix || undefined,
6310
6737
  suffix: this.form.suffix || undefined,
6311
6738
  trend: this.form.trend,
6312
- comparisonText: this.form.comparisonText || undefined
6739
+ comparisonText: this.form.comparisonText || undefined,
6740
+ outputVariableName: this.form.outputVariableName.trim() || undefined
6313
6741
  });
6314
6742
  return;
6315
6743
  }
@@ -6333,6 +6761,7 @@ class KpiConfigDialogComponent {
6333
6761
  suffix: this.form.suffix || undefined,
6334
6762
  trend: this.form.trend,
6335
6763
  comparisonText: this.form.comparisonText || undefined,
6764
+ outputVariableName: this.form.outputVariableName.trim() || undefined,
6336
6765
  filters: filtersDto
6337
6766
  });
6338
6767
  }
@@ -6347,6 +6776,7 @@ class KpiConfigDialogComponent {
6347
6776
  suffix: this.form.suffix || undefined,
6348
6777
  trend: this.form.trend,
6349
6778
  comparisonText: this.form.comparisonText || undefined,
6779
+ outputVariableName: this.form.outputVariableName.trim() || undefined,
6350
6780
  filters: filtersDto
6351
6781
  });
6352
6782
  }
@@ -6360,6 +6790,12 @@ class KpiConfigDialogComponent {
6360
6790
  onDataSourceTypeChange(type) {
6361
6791
  this.dataSourceType = type;
6362
6792
  }
6793
+ /** Appends a `${name}` reference to the formula */
6794
+ insertVariable(name) {
6795
+ const formula = this.form.formula;
6796
+ const separator = formula && !formula.endsWith(' ') ? ' ' : '';
6797
+ this.form.formula = `${formula}${separator}\${${name}}`;
6798
+ }
6363
6799
  // ============================================================================
6364
6800
  // Persistent Query Methods
6365
6801
  // ============================================================================
@@ -6489,7 +6925,7 @@ class KpiConfigDialogComponent {
6489
6925
  this.filters = updatedFilters;
6490
6926
  }
6491
6927
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: KpiConfigDialogComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6492
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: KpiConfigDialogComponent, isStandalone: true, selector: "mm-kpi-config-dialog", inputs: { initialCkTypeId: "initialCkTypeId", initialRtId: "initialRtId", initialValueAttribute: "initialValueAttribute", initialLabelAttribute: "initialLabelAttribute", initialPrefix: "initialPrefix", initialSuffix: "initialSuffix", initialTrend: "initialTrend", initialComparisonText: "initialComparisonText", initialDataSourceType: "initialDataSourceType", initialQueryRtId: "initialQueryRtId", initialQueryName: "initialQueryName", initialQueryFamily: "initialQueryFamily", initialIgnoreTimeFilter: "initialIgnoreTimeFilter", initialEntitySelectorId: "initialEntitySelectorId", initialQueryMode: "initialQueryMode", initialQueryValueField: "initialQueryValueField", initialQueryCategoryField: "initialQueryCategoryField", initialQueryCategoryValue: "initialQueryCategoryValue", initialStaticValue: "initialStaticValue", initialFilters: "initialFilters" }, viewQueries: [{ propertyName: "ckTypeSelectorInput", first: true, predicate: ["ckTypeSelector"], descendants: true }, { propertyName: "entitySelectorInput", first: true, predicate: ["entitySelector"], descendants: true }, { propertyName: "querySelector", first: true, predicate: ["querySelector"], descendants: true }], ngImport: i0, template: `
6928
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "22.1.5", type: KpiConfigDialogComponent, isStandalone: true, selector: "mm-kpi-config-dialog", inputs: { initialCkTypeId: "initialCkTypeId", initialRtId: "initialRtId", initialValueAttribute: "initialValueAttribute", initialLabelAttribute: "initialLabelAttribute", initialPrefix: "initialPrefix", initialSuffix: "initialSuffix", initialTrend: "initialTrend", initialComparisonText: "initialComparisonText", initialDataSourceType: "initialDataSourceType", initialQueryRtId: "initialQueryRtId", initialQueryName: "initialQueryName", initialQueryFamily: "initialQueryFamily", initialIgnoreTimeFilter: "initialIgnoreTimeFilter", initialEntitySelectorId: "initialEntitySelectorId", initialQueryMode: "initialQueryMode", initialQueryValueField: "initialQueryValueField", initialQueryCategoryField: "initialQueryCategoryField", initialQueryCategoryValue: "initialQueryCategoryValue", initialStaticValue: "initialStaticValue", initialWidgetId: "initialWidgetId", initialFormula: "initialFormula", initialOutputVariableName: "initialOutputVariableName", initialFilters: "initialFilters" }, viewQueries: [{ propertyName: "ckTypeSelectorInput", first: true, predicate: ["ckTypeSelector"], descendants: true }, { propertyName: "entitySelectorInput", first: true, predicate: ["entitySelector"], descendants: true }, { propertyName: "querySelector", first: true, predicate: ["querySelector"], descendants: true }], ngImport: i0, template: `
6493
6929
  <div class="config-container">
6494
6930
 
6495
6931
  <div class="config-form" [class.loading]="isLoadingInitial">
@@ -6520,6 +6956,13 @@ class KpiConfigDialogComponent {
6520
6956
  (click)="onDataSourceTypeChange('static')">
6521
6957
  Static
6522
6958
  </button>
6959
+ <button
6960
+ kendoButton
6961
+ [fillMode]="dataSourceType === 'formula' ? 'solid' : 'outline'"
6962
+ [themeColor]="dataSourceType === 'formula' ? 'primary' : 'base'"
6963
+ (click)="onDataSourceTypeChange('formula')">
6964
+ Formula
6965
+ </button>
6523
6966
  </div>
6524
6967
  </div>
6525
6968
 
@@ -6806,6 +7249,59 @@ class KpiConfigDialogComponent {
6806
7249
  </div>
6807
7250
  }
6808
7251
 
7252
+ <!-- ============================================================ -->
7253
+ <!-- FORMULA -->
7254
+ <!-- ============================================================ -->
7255
+ @if (dataSourceType === 'formula') {
7256
+ <div class="form-field">
7257
+ <label>Formula <span class="required">*</span></label>
7258
+ <kendo-textbox
7259
+ [(ngModel)]="form.formula"
7260
+ placeholder="e.g., (\${a} - \${b}) / 1000">
7261
+ </kendo-textbox>
7262
+ <p class="field-hint">
7263
+ Reference variables with <code>{{variableSyntaxHint}}</code>. Supported: <code>+ - * / % ^</code>, parentheses,
7264
+ <code>min</code>, <code>max</code>, <code>abs</code>, <code>round</code>, <code>clamp</code>, <code>? :</code>.
7265
+ Numbers use a dot as decimal separator.
7266
+ </p>
7267
+ @if (form.formula.trim() && formulaValidation.error) {
7268
+ <p class="field-error">{{ formulaValidation.error }}</p>
7269
+ } @else if (form.formula.trim()) {
7270
+ <p class="field-hint">Preview: <strong>{{ formulaPreview }}</strong></p>
7271
+ }
7272
+ </div>
7273
+ <div class="form-field">
7274
+ <label>Available Variables</label>
7275
+ @if (formulaVariables.length > 0) {
7276
+ <div class="variable-list">
7277
+ @for (variable of formulaVariables; track variable.name) {
7278
+ <button kendoButton fillMode="flat" size="small" type="button"
7279
+ [title]="'Insert ' + variable.name + ' (current value: ' + (variable.value || '-') + ')'"
7280
+ (click)="insertVariable(variable.name)">
7281
+ {{ variable.name }}@if (variable.source === 'widget') { <span class="variable-source">(widget)</span> }
7282
+ </button>
7283
+ }
7284
+ </div>
7285
+ } @else {
7286
+ <p class="field-hint">No variables defined. Add variables in the MeshBoard settings or publish a widget value as output variable.</p>
7287
+ }
7288
+ </div>
7289
+ }
7290
+
7291
+ <!-- Output Variable -->
7292
+ <div class="form-field">
7293
+ <label>Output Variable</label>
7294
+ <kendo-textbox [(ngModel)]="form.outputVariableName" placeholder="e.g., consumption"></kendo-textbox>
7295
+ @if (outputVariableError) {
7296
+ <p class="field-error">{{ outputVariableError }}</p>
7297
+ } @else {
7298
+ <p class="field-hint">Optional. Publishes the displayed raw value as MeshBoard variable for formulas, texts and filters of other widgets.</p>
7299
+ }
7300
+ @if (cycleError) {
7301
+ <p class="field-error">{{ cycleError }}</p>
7302
+ }
7303
+ </div>
7304
+
6809
7305
  <!-- Display Options -->
6810
7306
  <div class="form-section">
6811
7307
  <h4>Display Options</h4>
@@ -6871,7 +7367,7 @@ class KpiConfigDialogComponent {
6871
7367
  </button>
6872
7368
  </div>
6873
7369
  </div>
6874
- `, isInline: true, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;gap:20px;flex:1;overflow-y:auto;padding:16px;position:relative}.config-form.loading{pointer-events:none}.form-field{display:flex;flex-direction:column;gap:6px}.form-field.disabled{opacity:.6}.form-field.flex-1{flex:1}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.form-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.form-section h4{margin:0 0 16px;font-size:.95rem;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-row{display:flex;gap:16px}.mode-toggle{display:flex;gap:8px}.mode-toggle button{flex:1}.attribute-item{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%}.attribute-path{flex:1}.attribute-type{font-size:.75rem;color:var(--kendo-color-subtle, #6c757d);background:var(--kendo-color-surface-alt, #f8f9fa);padding:2px 6px;border-radius:3px}.required{color:var(--kendo-color-error, #dc3545)}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonsModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "ngmodule", type: DropDownsModule }, { kind: "directive", type: i4.ItemTemplateDirective, selector: "[kendoDropDownListItemTemplate],[kendoComboBoxItemTemplate],[kendoAutoCompleteItemTemplate],[kendoMultiSelectItemTemplate]" }, { kind: "component", type: i4.ComboBoxComponent, selector: "kendo-combobox", inputs: ["icon", "svgIcon", "inputAttributes", "showStickyHeader", "focusableId", "allowCustom", "data", "value", "textField", "valueField", "valuePrimitive", "valueNormalizer", "placeholder", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "loading", "suggest", "clearButton", "disabled", "itemDisabled", "readonly", "tabindex", "tabIndex", "filterable", "virtual", "size", "rounded", "fillMode"], outputs: ["valueChange", "selectionChange", "filterChange", "open", "opened", "close", "closed", "focus", "blur", "inputFocus", "inputBlur", "escape"], exportAs: ["kendoComboBox"] }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "component", type: CkTypeSelectorInputComponent, selector: "mm-ck-type-selector-input", inputs: ["placeholder", "minSearchLength", "maxResults", "debounceMs", "ckModelIds", "allowAbstract", "dialogTitle", "advancedSearchLabel", "derivedFromRtCkTypeId", "messages", "dialogMessages", "disabled", "required"], outputs: ["ckTypeSelected", "ckTypeCleared"] }, { kind: "component", type: EntitySelectInputComponent, selector: "mm-entity-select-input", inputs: ["dataSource", "placeholder", "minSearchLength", "maxResults", "debounceMs", "prefix", "initialDisplayValue", "dialogDataSource", "dialogTitle", "multiSelect", "advancedSearchLabel", "dialogMessages", "messages", "disabled", "required"], outputs: ["entitySelected", "entityCleared", "entitiesSelected"] }, { kind: "component", type: FieldFilterEditorComponent, selector: "mm-field-filter-editor", inputs: ["availableAttributes", "ckTypeId", "hideNavigationProperties", "attributePaths", "enableVariables", "availableVariables", "filters"], outputs: ["filtersChange"] }, { kind: "component", type: QuerySelectorComponent, selector: "mm-query-selector", inputs: ["placeholder", "hint", "disabled", "acceptFamilies"], outputs: ["querySelected", "queriesLoaded"] }, { kind: "component", type: SdTimeFilterToggleComponent, selector: "mm-sd-time-filter-toggle", inputs: ["family", "ignoreTimeFilter"], outputs: ["ignoreTimeFilterChange"] }, { kind: "component", type: EntitySelectorScopePickerComponent, selector: "mm-entity-selector-scope-picker", inputs: ["family", "entitySelectorId", "selectors"], outputs: ["entitySelectorIdChange"] }, { kind: "component", type: LoadingOverlayComponent, selector: "mm-loading-overlay", inputs: ["loading"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
7370
+ `, isInline: true, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;gap:20px;flex:1;overflow-y:auto;padding:16px;position:relative}.config-form.loading{pointer-events:none}.form-field{display:flex;flex-direction:column;gap:6px}.form-field.disabled{opacity:.6}.form-field.flex-1{flex:1}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.form-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.form-section h4{margin:0 0 16px;font-size:.95rem;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-row{display:flex;gap:16px}.mode-toggle{display:flex;gap:8px}.mode-toggle button{flex:1}.attribute-item{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%}.attribute-path{flex:1}.attribute-type{font-size:.75rem;color:var(--kendo-color-subtle, #6c757d);background:var(--kendo-color-surface-alt, #f8f9fa);padding:2px 6px;border-radius:3px}.required{color:var(--kendo-color-error, #dc3545)}.field-error{margin:0;font-size:.8rem;color:var(--kendo-color-error, #dc3545)}.variable-list{display:flex;flex-wrap:wrap;gap:4px}.variable-source{color:var(--kendo-color-subtle, #6c757d)}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"], dependencies: [{ kind: "ngmodule", type: CommonModule }, { kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$1.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$1.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: ButtonsModule }, { kind: "component", type: i2.ButtonComponent, selector: "button[kendoButton]", inputs: ["arrowIcon", "toggleable", "togglable", "selected", "tabIndex", "imageUrl", "iconPosition", "iconClass", "icon", "disabled", "size", "rounded", "fillMode", "themeColor", "svgIcon"], outputs: ["selectedChange", "click"], exportAs: ["kendoButton"] }, { kind: "ngmodule", type: InputsModule }, { kind: "component", type: i3.TextBoxComponent, selector: "kendo-textbox", inputs: ["focusableId", "title", "type", "disabled", "readonly", "tabindex", "value", "selectOnFocus", "showSuccessIcon", "showErrorIcon", "clearButton", "successIcon", "successSvgIcon", "errorIcon", "errorSvgIcon", "clearButtonIcon", "clearButtonSvgIcon", "size", "rounded", "fillMode", "tabIndex", "placeholder", "maxlength", "inputAttributes"], outputs: ["valueChange", "inputFocus", "inputBlur", "focus", "blur"], exportAs: ["kendoTextBox"] }, { kind: "ngmodule", type: DropDownsModule }, { kind: "directive", type: i4.ItemTemplateDirective, selector: "[kendoDropDownListItemTemplate],[kendoComboBoxItemTemplate],[kendoAutoCompleteItemTemplate],[kendoMultiSelectItemTemplate]" }, { kind: "component", type: i4.ComboBoxComponent, selector: "kendo-combobox", inputs: ["icon", "svgIcon", "inputAttributes", "showStickyHeader", "focusableId", "allowCustom", "data", "value", "textField", "valueField", "valuePrimitive", "valueNormalizer", "placeholder", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "loading", "suggest", "clearButton", "disabled", "itemDisabled", "readonly", "tabindex", "tabIndex", "filterable", "virtual", "size", "rounded", "fillMode"], outputs: ["valueChange", "selectionChange", "filterChange", "open", "opened", "close", "closed", "focus", "blur", "inputFocus", "inputBlur", "escape"], exportAs: ["kendoComboBox"] }, { kind: "component", type: i4.DropDownListComponent, selector: "kendo-dropdownlist", inputs: ["customIconClass", "showStickyHeader", "icon", "svgIcon", "loading", "data", "value", "textField", "valueField", "adaptiveMode", "adaptiveTitle", "adaptiveSubtitle", "popupSettings", "listHeight", "defaultItem", "disabled", "itemDisabled", "readonly", "filterable", "virtual", "ignoreCase", "delay", "valuePrimitive", "tabindex", "tabIndex", "size", "rounded", "fillMode", "leftRightArrowsNavigation", "id"], outputs: ["valueChange", "filterChange", "selectionChange", "open", "opened", "close", "closed", "focus", "blur"], exportAs: ["kendoDropDownList"] }, { kind: "component", type: CkTypeSelectorInputComponent, selector: "mm-ck-type-selector-input", inputs: ["placeholder", "minSearchLength", "maxResults", "debounceMs", "ckModelIds", "allowAbstract", "dialogTitle", "advancedSearchLabel", "derivedFromRtCkTypeId", "messages", "dialogMessages", "disabled", "required"], outputs: ["ckTypeSelected", "ckTypeCleared"] }, { kind: "component", type: EntitySelectInputComponent, selector: "mm-entity-select-input", inputs: ["dataSource", "placeholder", "minSearchLength", "maxResults", "debounceMs", "prefix", "initialDisplayValue", "dialogDataSource", "dialogTitle", "multiSelect", "advancedSearchLabel", "dialogMessages", "messages", "disabled", "required"], outputs: ["entitySelected", "entityCleared", "entitiesSelected"] }, { kind: "component", type: FieldFilterEditorComponent, selector: "mm-field-filter-editor", inputs: ["availableAttributes", "ckTypeId", "hideNavigationProperties", "attributePaths", "enableVariables", "availableVariables", "filters"], outputs: ["filtersChange"] }, { kind: "component", type: QuerySelectorComponent, selector: "mm-query-selector", inputs: ["placeholder", "hint", "disabled", "acceptFamilies"], outputs: ["querySelected", "queriesLoaded"] }, { kind: "component", type: SdTimeFilterToggleComponent, selector: "mm-sd-time-filter-toggle", inputs: ["family", "ignoreTimeFilter"], outputs: ["ignoreTimeFilterChange"] }, { kind: "component", type: EntitySelectorScopePickerComponent, selector: "mm-entity-selector-scope-picker", inputs: ["family", "entitySelectorId", "selectors"], outputs: ["entitySelectorIdChange"] }, { kind: "component", type: LoadingOverlayComponent, selector: "mm-loading-overlay", inputs: ["loading"] }], changeDetection: i0.ChangeDetectionStrategy.Eager });
6875
7371
  }
6876
7372
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImport: i0, type: KpiConfigDialogComponent, decorators: [{
6877
7373
  type: Component,
@@ -6919,6 +7415,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
6919
7415
  (click)="onDataSourceTypeChange('static')">
6920
7416
  Static
6921
7417
  </button>
7418
+ <button
7419
+ kendoButton
7420
+ [fillMode]="dataSourceType === 'formula' ? 'solid' : 'outline'"
7421
+ [themeColor]="dataSourceType === 'formula' ? 'primary' : 'base'"
7422
+ (click)="onDataSourceTypeChange('formula')">
7423
+ Formula
7424
+ </button>
6922
7425
  </div>
6923
7426
  </div>
6924
7427
 
@@ -7205,6 +7708,59 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
7205
7708
  </div>
7206
7709
  }
7207
7710
 
7711
+ <!-- ============================================================ -->
7712
+ <!-- FORMULA -->
7713
+ <!-- ============================================================ -->
7714
+ @if (dataSourceType === 'formula') {
7715
+ <div class="form-field">
7716
+ <label>Formula <span class="required">*</span></label>
7717
+ <kendo-textbox
7718
+ [(ngModel)]="form.formula"
7719
+ placeholder="e.g., (\${a} - \${b}) / 1000">
7720
+ </kendo-textbox>
7721
+ <p class="field-hint">
7722
+ Reference variables with <code>{{variableSyntaxHint}}</code>. Supported: <code>+ - * / % ^</code>, parentheses,
7723
+ <code>min</code>, <code>max</code>, <code>abs</code>, <code>round</code>, <code>clamp</code>, <code>? :</code>.
7724
+ Numbers use a dot as decimal separator.
7725
+ </p>
7726
+ @if (form.formula.trim() && formulaValidation.error) {
7727
+ <p class="field-error">{{ formulaValidation.error }}</p>
7728
+ } @else if (form.formula.trim()) {
7729
+ <p class="field-hint">Preview: <strong>{{ formulaPreview }}</strong></p>
7730
+ }
7731
+ </div>
7732
+ <div class="form-field">
7733
+ <label>Available Variables</label>
7734
+ @if (formulaVariables.length > 0) {
7735
+ <div class="variable-list">
7736
+ @for (variable of formulaVariables; track variable.name) {
7737
+ <button kendoButton fillMode="flat" size="small" type="button"
7738
+ [title]="'Insert ' + variable.name + ' (current value: ' + (variable.value || '-') + ')'"
7739
+ (click)="insertVariable(variable.name)">
7740
+ {{ variable.name }}@if (variable.source === 'widget') { <span class="variable-source">(widget)</span> }
7741
+ </button>
7742
+ }
7743
+ </div>
7744
+ } @else {
7745
+ <p class="field-hint">No variables defined. Add variables in the MeshBoard settings or publish a widget value as output variable.</p>
7746
+ }
7747
+ </div>
7748
+ }
7749
+
7750
+ <!-- Output Variable -->
7751
+ <div class="form-field">
7752
+ <label>Output Variable</label>
7753
+ <kendo-textbox [(ngModel)]="form.outputVariableName" placeholder="e.g., consumption"></kendo-textbox>
7754
+ @if (outputVariableError) {
7755
+ <p class="field-error">{{ outputVariableError }}</p>
7756
+ } @else {
7757
+ <p class="field-hint">Optional. Publishes the displayed raw value as MeshBoard variable for formulas, texts and filters of other widgets.</p>
7758
+ }
7759
+ @if (cycleError) {
7760
+ <p class="field-error">{{ cycleError }}</p>
7761
+ }
7762
+ </div>
7763
+
7208
7764
  <!-- Display Options -->
7209
7765
  <div class="form-section">
7210
7766
  <h4>Display Options</h4>
@@ -7270,7 +7826,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
7270
7826
  </button>
7271
7827
  </div>
7272
7828
  </div>
7273
- `, changeDetection: ChangeDetectionStrategy.Eager, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;gap:20px;flex:1;overflow-y:auto;padding:16px;position:relative}.config-form.loading{pointer-events:none}.form-field{display:flex;flex-direction:column;gap:6px}.form-field.disabled{opacity:.6}.form-field.flex-1{flex:1}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.form-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.form-section h4{margin:0 0 16px;font-size:.95rem;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-row{display:flex;gap:16px}.mode-toggle{display:flex;gap:8px}.mode-toggle button{flex:1}.attribute-item{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%}.attribute-path{flex:1}.attribute-type{font-size:.75rem;color:var(--kendo-color-subtle, #6c757d);background:var(--kendo-color-surface-alt, #f8f9fa);padding:2px 6px;border-radius:3px}.required{color:var(--kendo-color-error, #dc3545)}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"] }]
7829
+ `, changeDetection: ChangeDetectionStrategy.Eager, styles: [":host{display:block;height:100%}.config-container{display:flex;flex-direction:column;height:100%}.action-bar{display:flex;justify-content:flex-end;gap:8px;padding:8px 16px;border-top:1px solid var(--kendo-color-border, #dee2e6)}.config-form{display:flex;flex-direction:column;gap:20px;flex:1;overflow-y:auto;padding:16px;position:relative}.config-form.loading{pointer-events:none}.form-field{display:flex;flex-direction:column;gap:6px}.form-field.disabled{opacity:.6}.form-field.flex-1{flex:1}.form-field label{font-weight:600;font-size:.9rem;color:var(--kendo-color-on-app-surface, #212529)}.field-hint{margin:0;font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.form-section{padding:16px;background:var(--kendo-color-surface-alt, #f8f9fa);border:1px solid var(--kendo-color-border, #dee2e6);border-radius:4px}.form-section h4{margin:0 0 16px;font-size:.95rem;color:var(--kendo-color-primary, #0d6efd)}.section-hint{margin:0 0 12px;font-size:.85rem;color:var(--kendo-color-subtle, #6c757d)}.form-row{display:flex;gap:16px}.mode-toggle{display:flex;gap:8px}.mode-toggle button{flex:1}.attribute-item{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%}.attribute-path{flex:1}.attribute-type{font-size:.75rem;color:var(--kendo-color-subtle, #6c757d);background:var(--kendo-color-surface-alt, #f8f9fa);padding:2px 6px;border-radius:3px}.required{color:var(--kendo-color-error, #dc3545)}.field-error{margin:0;font-size:.8rem;color:var(--kendo-color-error, #dc3545)}.variable-list{display:flex;flex-wrap:wrap;gap:4px}.variable-source{color:var(--kendo-color-subtle, #6c757d)}.query-item{display:flex;flex-direction:column;gap:2px}.query-name{font-weight:500}.query-description{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}.column-item{display:flex;justify-content:space-between;gap:16px}.column-path{font-weight:500}.column-type{font-size:.8rem;color:var(--kendo-color-subtle, #6c757d)}\n"] }]
7274
7830
  }], propDecorators: { ckTypeSelectorInput: [{
7275
7831
  type: ViewChild,
7276
7832
  args: ['ckTypeSelector']
@@ -7318,6 +7874,12 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "22.1.5", ngImpor
7318
7874
  type: Input
7319
7875
  }], initialStaticValue: [{
7320
7876
  type: Input
7877
+ }], initialWidgetId: [{
7878
+ type: Input
7879
+ }], initialFormula: [{
7880
+ type: Input
7881
+ }], initialOutputVariableName: [{
7882
+ type: Input
7321
7883
  }], initialFilters: [{
7322
7884
  type: Input
7323
7885
  }] } });
@@ -28425,8 +28987,10 @@ function registerDefaultWidgets(registry) {
28425
28987
  const kpiWidget = widget;
28426
28988
  const isPersistentQuery = kpiWidget.dataSource.type === 'persistentQuery';
28427
28989
  const isStatic = kpiWidget.dataSource.type === 'static';
28428
- const dataSourceType = isPersistentQuery ? 'persistentQuery' : isStatic ? 'static' : 'runtimeEntity';
28990
+ const isFormula = isStatic && kpiWidget.valueMode === 'formula';
28991
+ const dataSourceType = isPersistentQuery ? 'persistentQuery' : isFormula ? 'formula' : isStatic ? 'static' : 'runtimeEntity';
28429
28992
  return {
28993
+ initialWidgetId: kpiWidget.id,
28430
28994
  // Runtime entity fields
28431
28995
  initialCkTypeId: getDataSourceInfo(widget).ckTypeId,
28432
28996
  initialRtId: getDataSourceInfo(widget).rtId,
@@ -28445,6 +29009,9 @@ function registerDefaultWidgets(registry) {
28445
29009
  initialQueryCategoryValue: kpiWidget.queryCategoryValue,
28446
29010
  // Static fields
28447
29011
  initialStaticValue: kpiWidget.staticValue,
29012
+ // Formula / output variable (AB#5364)
29013
+ initialFormula: kpiWidget.formula,
29014
+ initialOutputVariableName: kpiWidget.outputVariableName,
28448
29015
  // Display options
28449
29016
  initialPrefix: kpiWidget.prefix,
28450
29017
  initialSuffix: kpiWidget.suffix,
@@ -28461,9 +29028,33 @@ function registerDefaultWidgets(registry) {
28461
29028
  operator: String(f.operator),
28462
29029
  comparisonValue: f.comparisonValue
28463
29030
  }));
28464
- if (result.dataSourceType === 'static') {
29031
+ if (result.dataSourceType === 'formula') {
29032
+ return {
29033
+ ...widget,
29034
+ dataSource: { type: 'static' },
29035
+ valueMode: 'formula',
29036
+ formula: result.formula,
29037
+ outputVariableName: result.outputVariableName,
29038
+ valueAttribute: '',
29039
+ staticValue: undefined,
29040
+ labelAttribute: undefined,
29041
+ queryMode: undefined,
29042
+ queryValueField: undefined,
29043
+ queryCategoryField: undefined,
29044
+ queryCategoryValue: undefined,
29045
+ prefix: result.prefix,
29046
+ suffix: result.suffix,
29047
+ trend: result.trend,
29048
+ comparisonText: result.comparisonText,
29049
+ filters: undefined
29050
+ };
29051
+ }
29052
+ else if (result.dataSourceType === 'static') {
28465
29053
  return {
28466
29054
  ...widget,
29055
+ valueMode: undefined,
29056
+ formula: undefined,
29057
+ outputVariableName: result.outputVariableName,
28467
29058
  dataSource: { type: 'static' },
28468
29059
  valueAttribute: '',
28469
29060
  staticValue: result.staticValue,
@@ -28475,6 +29066,7 @@ function registerDefaultWidgets(registry) {
28475
29066
  prefix: result.prefix,
28476
29067
  suffix: result.suffix,
28477
29068
  trend: result.trend,
29069
+ comparisonText: result.comparisonText,
28478
29070
  filters: undefined
28479
29071
  };
28480
29072
  }
@@ -28490,6 +29082,9 @@ function registerDefaultWidgets(registry) {
28490
29082
  };
28491
29083
  return {
28492
29084
  ...widget,
29085
+ valueMode: undefined,
29086
+ formula: undefined,
29087
+ outputVariableName: result.outputVariableName,
28493
29088
  dataSource,
28494
29089
  valueAttribute: '',
28495
29090
  staticValue: undefined,
@@ -28500,6 +29095,7 @@ function registerDefaultWidgets(registry) {
28500
29095
  prefix: result.prefix,
28501
29096
  suffix: result.suffix,
28502
29097
  trend: result.trend,
29098
+ comparisonText: result.comparisonText,
28503
29099
  filters
28504
29100
  };
28505
29101
  }
@@ -28507,6 +29103,9 @@ function registerDefaultWidgets(registry) {
28507
29103
  // Create RuntimeEntityDataSource (default behavior)
28508
29104
  return {
28509
29105
  ...widget,
29106
+ valueMode: undefined,
29107
+ formula: undefined,
29108
+ outputVariableName: result.outputVariableName,
28510
29109
  dataSource: createDataSource(result, false),
28511
29110
  valueAttribute: result.valueAttribute,
28512
29111
  labelAttribute: result.labelAttribute,
@@ -28518,6 +29117,7 @@ function registerDefaultWidgets(registry) {
28518
29117
  prefix: result.prefix,
28519
29118
  suffix: result.suffix,
28520
29119
  trend: result.trend,
29120
+ comparisonText: result.comparisonText,
28521
29121
  filters
28522
29122
  };
28523
29123
  }
@@ -28549,7 +29149,11 @@ function registerDefaultWidgets(registry) {
28549
29149
  suffix: widget.suffix,
28550
29150
  icon: widget.icon,
28551
29151
  trend: widget.trend,
29152
+ comparisonText: widget.comparisonText,
28552
29153
  staticValue: widget.staticValue,
29154
+ valueMode: widget.valueMode,
29155
+ formula: widget.formula,
29156
+ outputVariableName: widget.outputVariableName,
28553
29157
  queryMode: widget.queryMode,
28554
29158
  queryValueField: widget.queryValueField,
28555
29159
  queryCategoryField: widget.queryCategoryField,
@@ -28584,11 +29188,14 @@ function registerDefaultWidgets(registry) {
28584
29188
  dataSource,
28585
29189
  valueAttribute: '',
28586
29190
  staticValue: config['staticValue'],
29191
+ valueMode: config['valueMode'] === 'formula' ? 'formula' : undefined,
29192
+ formula: config['formula'],
28587
29193
  prefix: config['prefix'],
28588
29194
  suffix: config['suffix'],
28589
29195
  icon: config['icon'],
28590
29196
  trend: config['trend'],
28591
- comparisonText: config['comparisonText']
29197
+ comparisonText: config['comparisonText'],
29198
+ outputVariableName: config['outputVariableName']
28592
29199
  };
28593
29200
  }
28594
29201
  if (dataSource.type === 'persistentQuery') {
@@ -28608,6 +29215,7 @@ function registerDefaultWidgets(registry) {
28608
29215
  icon: config['icon'],
28609
29216
  trend: config['trend'],
28610
29217
  comparisonText: config['comparisonText'],
29218
+ outputVariableName: config['outputVariableName'],
28611
29219
  filters: config['filters']
28612
29220
  };
28613
29221
  }
@@ -28623,6 +29231,7 @@ function registerDefaultWidgets(registry) {
28623
29231
  icon: config['icon'],
28624
29232
  trend: config['trend'],
28625
29233
  comparisonText: config['comparisonText'],
29234
+ outputVariableName: config['outputVariableName'],
28626
29235
  filters: config['filters']
28627
29236
  };
28628
29237
  }