@praxisui/charts 10.0.0-rc.38 → 10.0.0-rc.39

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 * as i0 from '@angular/core';
2
2
  import { Injectable, Optional, Inject, InjectionToken, input, booleanAttribute, output, viewChild, inject, ElementRef, NgZone, DestroyRef, signal, linkedSignal, computed, afterNextRender, effect, untracked, ChangeDetectionStrategy, Component, LOCALE_ID, Injector, ENVIRONMENT_INITIALIZER, ViewChild, Input } from '@angular/core';
3
3
  import * as i1 from '@praxisui/core';
4
- import { buildApiUrl, API_URL, PraxisI18nService, ComponentMetadataRegistry, SETTINGS_PANEL_BRIDGE, normalizePraxisDataQueryContext, resolvePraxisFilterCriteria, BUILTIN_PAGE_THEME_PRESETS, PraxisIconButtonComponent, normalizePraxisPresentationVisualization, resolvePraxisMicroVisualizationTone, AnalyticsStatsRequestBuilderService, AnalyticsPresentationResolver, ResourceDiscoveryService, humanizeCatalogFieldName, createDefaultTableConfig, providePraxisI18n, PRAXIS_TABLE_DETAIL_INLINE_RENDERERS, DynamicWidgetPageComponent, SETTINGS_PANEL_DATA, inferDomainSemanticTone, inferDomainSemanticHexColor, PraxisIconDirective } from '@praxisui/core';
4
+ import { validateResourceQuickViewBinding, normalizeResourceQuickViewBinding, buildApiUrl, API_URL, PraxisI18nService, ComponentMetadataRegistry, SETTINGS_PANEL_BRIDGE, ResourceQuickViewService, normalizePraxisDataQueryContext, resolvePraxisFilterCriteria, BUILTIN_PAGE_THEME_PRESETS, PraxisIconButtonComponent, PraxisQuickViewTriggerDirective, normalizePraxisPresentationVisualization, resolvePraxisMicroVisualizationTone, AnalyticsStatsRequestBuilderService, AnalyticsPresentationResolver, ResourceDiscoveryService, humanizeCatalogFieldName, createDefaultTableConfig, providePraxisI18n, PRAXIS_TABLE_DETAIL_INLINE_RENDERERS, DynamicWidgetPageComponent, SETTINGS_PANEL_DATA, inferDomainSemanticTone, inferDomainSemanticHexColor, PraxisIconDirective } from '@praxisui/core';
5
5
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
  import { throwError, map, finalize, shareReplay, of, isObservable, from, firstValueFrom, timeout, BehaviorSubject, Subscription } from 'rxjs';
7
7
  import * as i1$2 from '@angular/material/tooltip';
@@ -11,8 +11,8 @@ import { HttpErrorResponse } from '@angular/common/http';
11
11
  import { catchError } from 'rxjs/operators';
12
12
  import { AnalyticsTableConfigAdapterService, AnalyticsTableStatsApiService, ANALYTICS_TABLE_ROW_KEY_FIELD, PraxisTable } from '@praxisui/table';
13
13
  import { color, use, init } from 'echarts/core';
14
- import { BarChart, FunnelChart, GaugeChart, LineChart, PieChart, ScatterChart, TreemapChart } from 'echarts/charts';
15
- import { AriaComponent, DatasetComponent, GridComponent, LegendComponent, TimelineComponent, TitleComponent, TooltipComponent, TransformComponent } from 'echarts/components';
14
+ import { BarChart, FunnelChart, GaugeChart, HeatmapChart, LineChart, PieChart, ScatterChart, TreemapChart } from 'echarts/charts';
15
+ import { AriaComponent, DatasetComponent, GridComponent, LegendComponent, TimelineComponent, TitleComponent, TooltipComponent, TransformComponent, VisualMapComponent } from 'echarts/components';
16
16
  import { UniversalTransition } from 'echarts/features';
17
17
  import { CanvasRenderer } from 'echarts/renderers';
18
18
  import * as i1$3 from '@angular/forms';
@@ -79,6 +79,108 @@ const PRAXIS_CHART_THEME_VARIANTS = {
79
79
  },
80
80
  };
81
81
 
82
+ function hasChartQuickViewBinding(config) {
83
+ if (config.interactions?.quickView) {
84
+ if (validateResourceQuickViewBinding(config.interactions.quickView).valid) {
85
+ return true;
86
+ }
87
+ }
88
+ return (config.series ?? []).some((series) => {
89
+ if (!series.quickView) {
90
+ return false;
91
+ }
92
+ return validateResourceQuickViewBinding(series.quickView).valid;
93
+ });
94
+ }
95
+ function resolveChartSeriesForPoint(config, seriesId, seriesName) {
96
+ const seriesList = config.series ?? [];
97
+ if (!seriesList.length) {
98
+ return undefined;
99
+ }
100
+ if (seriesId) {
101
+ const exact = seriesList.find((candidate) => candidate.id === seriesId);
102
+ if (exact) {
103
+ return exact;
104
+ }
105
+ const prefixed = seriesList.find((candidate) => seriesId.startsWith(`${candidate.id}:`));
106
+ if (prefixed) {
107
+ return prefixed;
108
+ }
109
+ }
110
+ if (seriesName) {
111
+ const byName = seriesList.find((candidate) => candidate.name === seriesName);
112
+ if (byName) {
113
+ return byName;
114
+ }
115
+ }
116
+ return seriesList[0];
117
+ }
118
+ function resolveChartPointQuickView(config, point) {
119
+ const series = resolveChartSeriesForPoint(config, point.seriesId, point.seriesName);
120
+ const rawBinding = series?.quickView ?? config.interactions?.quickView;
121
+ if (!rawBinding) {
122
+ return null;
123
+ }
124
+ const validation = validateResourceQuickViewBinding(rawBinding);
125
+ if (!validation.valid || !validation.binding) {
126
+ return null;
127
+ }
128
+ const binding = validation.binding;
129
+ if (!point.data || typeof point.data !== 'object' || Array.isArray(point.data)) {
130
+ return null;
131
+ }
132
+ const record = {
133
+ ...point.data,
134
+ };
135
+ const sourceIdentityField = binding.recordOpen.sourceIdentityField;
136
+ const isCanonicalStats = config.dataSource?.kind === 'remote'
137
+ && config.dataSource.query?.sourceKind === 'praxis.stats';
138
+ if (isCanonicalStats) {
139
+ const statsOperation = config.dataSource?.kind === 'remote'
140
+ ? config.dataSource.query?.statsOperation
141
+ : undefined;
142
+ const distributionMode = config.dataSource?.kind === 'remote'
143
+ ? config.dataSource.query?.distributionMode
144
+ : undefined;
145
+ if (statsOperation === 'timeseries'
146
+ || (statsOperation === 'distribution'
147
+ && (distributionMode === 'histogram' || record['from'] != null || record['to'] != null))) {
148
+ return null;
149
+ }
150
+ const categoryField = config.axes?.x?.field
151
+ ?? config.series.find((candidate) => candidate.categoryField)?.categoryField;
152
+ const secondaryCategoryField = config.series.find((candidate) => candidate.secondaryCategoryField)?.secondaryCategoryField
153
+ ?? (config.axes?.y?.type === 'category' ? config.axes.y.field : undefined);
154
+ const coordinates = record['coordinates'] && typeof record['coordinates'] === 'object' && !Array.isArray(record['coordinates'])
155
+ ? record['coordinates']
156
+ : null;
157
+ if (sourceIdentityField === categoryField && record['key'] !== undefined && record['key'] !== null) {
158
+ record[sourceIdentityField] = record['key'];
159
+ }
160
+ else if (sourceIdentityField === secondaryCategoryField
161
+ && record['secondaryKey'] !== undefined
162
+ && record['secondaryKey'] !== null) {
163
+ record[sourceIdentityField] = record['secondaryKey'];
164
+ }
165
+ else if (coordinates && sourceIdentityField in coordinates) {
166
+ record[sourceIdentityField] = coordinates[sourceIdentityField];
167
+ }
168
+ else if (sourceIdentityField !== 'key' && sourceIdentityField !== 'secondaryKey') {
169
+ delete record[sourceIdentityField];
170
+ }
171
+ }
172
+ const identityCandidate = record[sourceIdentityField];
173
+ const hasValidIdentity = (typeof identityCandidate === 'number' && Number.isFinite(identityCandidate))
174
+ || (typeof identityCandidate === 'string' && identityCandidate.trim().length > 0);
175
+ if (!hasValidIdentity) {
176
+ return null;
177
+ }
178
+ return {
179
+ binding,
180
+ record,
181
+ };
182
+ }
183
+
82
184
  class PraxisChartDataTransformerService {
83
185
  transform(config, rows) {
84
186
  if (config.type === 'pie' || config.type === 'donut') {
@@ -93,6 +195,9 @@ class PraxisChartDataTransformerService {
93
195
  if (config.type === 'gauge') {
94
196
  return this.transformGauge(config, rows);
95
197
  }
198
+ if (config.type === 'heatmap') {
199
+ return this.transformHeatmap(config, rows);
200
+ }
96
201
  if (config.type === 'scatter') {
97
202
  return this.transformScatter(config, rows);
98
203
  }
@@ -151,6 +256,99 @@ class PraxisChartDataTransformerService {
151
256
  diagnostic: { code, message },
152
257
  };
153
258
  }
259
+ transformHeatmap(config, rows) {
260
+ const seriesConfig = config.series[0];
261
+ const xCategoryField = seriesConfig?.categoryField ?? config.axes?.x?.field;
262
+ const yCategoryField = xCategoryField
263
+ ? (this.resolveSecondaryCategoryField(config, xCategoryField)
264
+ ?? (config.axes?.y?.type === 'category' && config.axes?.y?.field !== xCategoryField
265
+ ? config.axes.y.field
266
+ : undefined))
267
+ : undefined;
268
+ if (!seriesConfig || !xCategoryField || !yCategoryField || !rows.length) {
269
+ return {
270
+ mode: 'heatmap',
271
+ categories: [],
272
+ series: [],
273
+ slices: [],
274
+ hasData: false,
275
+ };
276
+ }
277
+ const usesCanonicalStatsIdentity = config.dataSource?.kind === 'remote'
278
+ && config.dataSource.query?.sourceKind === 'praxis.stats';
279
+ const xBuckets = this.buildCategoryBuckets(config, rows, xCategoryField);
280
+ const yBuckets = this.buildSecondaryCategoryBuckets(rows, yCategoryField, usesCanonicalStatsIdentity);
281
+ const xCategories = xBuckets.map((bucket) => bucket.label);
282
+ const yCategories = yBuckets.map((bucket) => bucket.label);
283
+ const cells = [];
284
+ let min = Number.POSITIVE_INFINITY;
285
+ let max = Number.NEGATIVE_INFINITY;
286
+ let hasFiniteValue = false;
287
+ xBuckets.forEach((xBucket, xIndex) => {
288
+ yBuckets.forEach((yBucket, yIndex) => {
289
+ const cellRows = xBucket.rows.filter((row) => this.resolveSecondaryCategoryIdentity(row, this.normalizeCategory(row[yCategoryField]), usesCanonicalStatsIdentity) === yBucket.identity);
290
+ if (!cellRows.length) {
291
+ return;
292
+ }
293
+ const metricField = seriesConfig.metric?.field;
294
+ const aggregation = seriesConfig.metric?.aggregation ?? 'sum';
295
+ const allCellMetricValuesMissing = Boolean(metricField
296
+ && aggregation !== 'count'
297
+ && aggregation !== 'distinct-count'
298
+ && cellRows.every((row) => this.extractStrictMetricValue(row, seriesConfig) === null));
299
+ const rawValue = allCellMetricValuesMissing
300
+ ? null
301
+ : this.aggregate(cellRows, seriesConfig);
302
+ if (rawValue !== null && Number.isFinite(rawValue)) {
303
+ hasFiniteValue = true;
304
+ if (rawValue < min) {
305
+ min = rawValue;
306
+ }
307
+ if (rawValue > max) {
308
+ max = rawValue;
309
+ }
310
+ }
311
+ const source = cellRows[0];
312
+ const pointTuple = [xIndex, yIndex, rawValue];
313
+ const cellData = this.buildBucketPointData(cellRows, pointTuple, config, seriesConfig, {
314
+ category: xBucket.label,
315
+ secondaryCategory: yBucket.label,
316
+ });
317
+ cells.push({
318
+ id: `${seriesConfig.id}:${xBucket.identity}:${yBucket.identity}`,
319
+ xIndex,
320
+ yIndex,
321
+ xCategory: xBucket.label,
322
+ yCategory: yBucket.label,
323
+ value: pointTuple,
324
+ rawValue,
325
+ data: cellData,
326
+ });
327
+ });
328
+ });
329
+ const normalizedMin = hasFiniteValue ? min : 0;
330
+ const normalizedMax = hasFiniteValue
331
+ ? (max > min ? max : min + (min === 0 ? 1 : Math.abs(min) * 0.25))
332
+ : 1;
333
+ return {
334
+ mode: 'heatmap',
335
+ categories: xCategories,
336
+ series: [],
337
+ slices: [],
338
+ heatmap: {
339
+ id: seriesConfig.id,
340
+ name: seriesConfig.name ?? seriesConfig.metric?.label ?? seriesConfig.id,
341
+ xCategories,
342
+ yCategories,
343
+ cells,
344
+ min: normalizedMin,
345
+ max: normalizedMax,
346
+ labelsVisible: seriesConfig.labels?.visible ?? false,
347
+ color: seriesConfig.color,
348
+ },
349
+ hasData: xCategories.length > 0 && yCategories.length > 0 && hasFiniteValue,
350
+ };
351
+ }
154
352
  transformCartesian(config, rows) {
155
353
  const categoryField = config.axes?.x?.field;
156
354
  if (!categoryField || !rows.length || !config.series.length) {
@@ -165,7 +363,12 @@ class PraxisChartDataTransformerService {
165
363
  const categoryBuckets = this.buildCategoryBuckets(config, rows, categoryField);
166
364
  const categories = categoryBuckets.map((bucket) => bucket.label);
167
365
  const timeAxis = this.usesCanonicalTimeCoordinates(config);
168
- const series = config.series.map((seriesConfig) => this.buildCartesianSeries(config, seriesConfig, categoryBuckets, timeAxis));
366
+ const secondaryCategoryField = this.resolveSecondaryCategoryField(config, categoryField);
367
+ const hasSecondaryValues = Boolean(secondaryCategoryField
368
+ && rows.some((row) => row[secondaryCategoryField] !== undefined && row[secondaryCategoryField] !== null));
369
+ const series = hasSecondaryValues && secondaryCategoryField
370
+ ? this.buildPivotedCartesianSeries(config, rows, categoryBuckets, secondaryCategoryField, timeAxis)
371
+ : config.series.map((seriesConfig) => this.buildCartesianSeries(config, seriesConfig, categoryBuckets, timeAxis));
169
372
  return {
170
373
  mode: 'cartesian',
171
374
  categories,
@@ -194,7 +397,7 @@ class PraxisChartDataTransformerService {
194
397
  name: bucket.label,
195
398
  value,
196
399
  color: seriesConfig.color,
197
- data: this.buildPointData(bucket.rows[0], value),
400
+ data: this.buildBucketPointData(bucket.rows, value, config, seriesConfig),
198
401
  }];
199
402
  });
200
403
  return {
@@ -220,11 +423,85 @@ class PraxisChartDataTransformerService {
220
423
  const value = this.aggregate(bucket.rows, seriesConfig);
221
424
  const source = bucket.rows[0];
222
425
  return source
223
- ? this.buildPointData(source, timeAxis ? [bucket.coordinate, value] : value)
426
+ ? this.buildBucketPointData(bucket.rows, timeAxis ? [bucket.coordinate, value] : value, config, seriesConfig)
224
427
  : (timeAxis ? [bucket.coordinate, value] : value);
225
428
  }),
226
429
  };
227
430
  }
431
+ resolveSecondaryCategoryField(config, primaryCategoryField) {
432
+ const seriesSecondary = config.series.find((s) => Boolean(s.secondaryCategoryField?.trim()))?.secondaryCategoryField?.trim();
433
+ if (seriesSecondary && seriesSecondary !== primaryCategoryField) {
434
+ return seriesSecondary;
435
+ }
436
+ if (config.dataSource?.kind === 'remote') {
437
+ const secondDimension = config.dataSource.query?.dimensions?.[1]?.trim();
438
+ if (secondDimension && secondDimension !== primaryCategoryField) {
439
+ return secondDimension;
440
+ }
441
+ }
442
+ return undefined;
443
+ }
444
+ buildPivotedCartesianSeries(config, rows, categoryBuckets, secondaryCategoryField, timeAxis) {
445
+ const usesCanonicalStatsIdentity = config.dataSource?.kind === 'remote'
446
+ && config.dataSource.query?.sourceKind === 'praxis.stats';
447
+ const secondaryBuckets = this.buildSecondaryCategoryBuckets(rows, secondaryCategoryField, usesCanonicalStatsIdentity);
448
+ const multiMetric = config.series.length > 1;
449
+ return config.series.flatMap((seriesConfig) => secondaryBuckets.map((secondaryBucket) => {
450
+ const baseName = seriesConfig.name ?? seriesConfig.metric?.label ?? seriesConfig.id;
451
+ return {
452
+ id: `${seriesConfig.id}:${secondaryBucket.identity}`,
453
+ name: multiMetric ? `${baseName} - ${secondaryBucket.label}` : secondaryBucket.label,
454
+ type: this.resolveCartesianSeriesType(config, seriesConfig),
455
+ axis: seriesConfig.axis ?? 'primary',
456
+ stack: config.type === 'stacked-bar' || config.type === 'stacked-area'
457
+ ? seriesConfig.stackId ?? 'stack-1'
458
+ : undefined,
459
+ smooth: seriesConfig.smooth ?? this.isAreaLikeSeries(config, seriesConfig),
460
+ area: this.isAreaLikeSeries(config, seriesConfig),
461
+ color: multiMetric ? undefined : undefined,
462
+ labelsVisible: seriesConfig.labels?.visible ?? false,
463
+ points: categoryBuckets.map((primaryBucket) => {
464
+ const cellRows = primaryBucket.rows.filter((row) => this.resolveSecondaryCategoryIdentity(row, this.normalizeCategory(row[secondaryCategoryField]), usesCanonicalStatsIdentity) === secondaryBucket.identity);
465
+ if (!cellRows.length) {
466
+ return timeAxis ? [primaryBucket.coordinate, null] : null;
467
+ }
468
+ const value = this.aggregate(cellRows, seriesConfig);
469
+ return this.buildBucketPointData(cellRows, timeAxis ? [primaryBucket.coordinate, value] : value, config, seriesConfig);
470
+ }),
471
+ };
472
+ }));
473
+ }
474
+ buildSecondaryCategoryBuckets(rows, secondaryCategoryField, usesCanonicalStatsIdentity) {
475
+ const buckets = new Map();
476
+ for (const row of rows) {
477
+ if (row[secondaryCategoryField] === undefined) {
478
+ continue;
479
+ }
480
+ const label = this.normalizeCategory(row['secondaryLabel'] ?? row[secondaryCategoryField]);
481
+ const identity = this.resolveSecondaryCategoryIdentity(row, label, usesCanonicalStatsIdentity);
482
+ const existing = buckets.get(identity);
483
+ if (existing) {
484
+ existing.rows.push(row);
485
+ continue;
486
+ }
487
+ buckets.set(identity, {
488
+ identity,
489
+ label,
490
+ coordinate: label,
491
+ rows: [row],
492
+ });
493
+ }
494
+ return Array.from(buckets.values());
495
+ }
496
+ resolveSecondaryCategoryIdentity(row, label, usesCanonicalStatsIdentity) {
497
+ if (!usesCanonicalStatsIdentity) {
498
+ return label;
499
+ }
500
+ if (row['secondaryKey'] !== null && row['secondaryKey'] !== undefined) {
501
+ return `key:${this.typedIdentity(row['secondaryKey'])}`;
502
+ }
503
+ return `label:${label}`;
504
+ }
228
505
  buildCategoryBuckets(config, rows, categoryField) {
229
506
  const buckets = new Map();
230
507
  const usesCanonicalStatsIdentity = config.dataSource?.kind === 'remote'
@@ -310,6 +587,59 @@ class PraxisChartDataTransformerService {
310
587
  value,
311
588
  };
312
589
  }
590
+ buildBucketPointData(rows, value, config, seriesConfig, extra) {
591
+ const source = rows[0] ?? {};
592
+ const pointData = {
593
+ ...source,
594
+ ...(extra ?? {}),
595
+ value,
596
+ };
597
+ if (rows.length > 1) {
598
+ const identityFields = this.collectQuickViewIdentityFields(config, seriesConfig);
599
+ for (const field of identityFields) {
600
+ if (!this.hasUniformEntityIdentity(rows, field)) {
601
+ delete pointData[field];
602
+ }
603
+ }
604
+ }
605
+ return pointData;
606
+ }
607
+ collectQuickViewIdentityFields(config, seriesConfig) {
608
+ const fields = new Set();
609
+ const seriesField = seriesConfig?.quickView?.recordOpen?.sourceIdentityField?.trim();
610
+ if (seriesField) {
611
+ fields.add(seriesField);
612
+ }
613
+ const chartField = config.interactions?.quickView?.recordOpen?.sourceIdentityField?.trim();
614
+ if (chartField) {
615
+ fields.add(chartField);
616
+ }
617
+ for (const candidate of config.series ?? []) {
618
+ const candidateField = candidate.quickView?.recordOpen?.sourceIdentityField?.trim();
619
+ if (candidateField) {
620
+ fields.add(candidateField);
621
+ }
622
+ }
623
+ return Array.from(fields);
624
+ }
625
+ hasUniformEntityIdentity(rows, field) {
626
+ if (!rows.length) {
627
+ return false;
628
+ }
629
+ const first = rows[0]?.[field];
630
+ if (typeof first !== 'string' && typeof first !== 'number') {
631
+ return false;
632
+ }
633
+ const normalizedFirst = String(first).trim();
634
+ if (!normalizedFirst) {
635
+ return false;
636
+ }
637
+ return rows.every((row) => {
638
+ const candidate = row?.[field];
639
+ return (typeof candidate === 'string' || typeof candidate === 'number')
640
+ && String(candidate).trim() === normalizedFirst;
641
+ });
642
+ }
313
643
  hasPointValue(point) {
314
644
  if (point == null) {
315
645
  return false;
@@ -383,9 +713,12 @@ class PraxisChartDataTransformerService {
383
713
  };
384
714
  }
385
715
  aggregate(rows, seriesConfig) {
716
+ const field = seriesConfig.metric?.field;
717
+ if (field && rows.length > 0 && rows.every((row) => row[field] === null)) {
718
+ return null;
719
+ }
386
720
  const aggregation = seriesConfig.metric?.aggregation ?? 'sum';
387
721
  if (aggregation === 'count') {
388
- const field = seriesConfig.metric?.field;
389
722
  if (field) {
390
723
  const values = rows
391
724
  .map((row) => this.extractMetricValue(row, seriesConfig))
@@ -397,19 +730,20 @@ class PraxisChartDataTransformerService {
397
730
  return rows.length;
398
731
  }
399
732
  if (aggregation === 'distinct-count') {
400
- const field = seriesConfig.metric?.field;
401
733
  if (!field) {
402
734
  return 0;
403
735
  }
404
736
  return new Set(rows.map((row) => this.normalizeCategory(row[field]))).size;
405
737
  }
406
738
  const values = rows
739
+ .filter((row) => !field || row[field] !== null)
407
740
  .map((row) => this.extractMetricValue(row, seriesConfig))
408
741
  .filter((value) => Number.isFinite(value));
409
742
  if (!values.length)
410
743
  return 0;
411
744
  switch (aggregation) {
412
745
  case 'avg':
746
+ case 'avg-per-cycle-occurrence':
413
747
  return values.reduce((sum, value) => sum + value, 0) / values.length;
414
748
  case 'min':
415
749
  return Math.min(...values);
@@ -497,8 +831,37 @@ class ChartContractNormalizerService {
497
831
  document = this.normalizeKindSpecificFields(document);
498
832
  document = this.normalizeOperationSpecificFields(document);
499
833
  document = this.normalizeSourceSpecificFields(document);
834
+ document = this.normalizeQuickViewBindings(document);
500
835
  return document;
501
836
  }
837
+ normalizeQuickViewBindings(document) {
838
+ let nextDocument = document;
839
+ if (nextDocument.metrics?.length) {
840
+ nextDocument = {
841
+ ...nextDocument,
842
+ metrics: nextDocument.metrics.map((metric) => {
843
+ if (!metric.quickView) {
844
+ return metric;
845
+ }
846
+ const normalized = normalizeResourceQuickViewBinding(metric.quickView);
847
+ return normalized ? { ...metric, quickView: normalized } : metric;
848
+ }),
849
+ };
850
+ }
851
+ if (nextDocument.events?.quickView) {
852
+ const normalized = normalizeResourceQuickViewBinding(nextDocument.events.quickView);
853
+ if (normalized) {
854
+ nextDocument = {
855
+ ...nextDocument,
856
+ events: {
857
+ ...nextDocument.events,
858
+ quickView: normalized,
859
+ },
860
+ };
861
+ }
862
+ }
863
+ return nextDocument;
864
+ }
502
865
  normalizeMotion(document) {
503
866
  if (!document.motion) {
504
867
  return document;
@@ -595,8 +958,18 @@ class ChartContractNormalizerService {
595
958
  const options = { ...document.source.options };
596
959
  if (document.source.operation !== 'timeseries') {
597
960
  options.granularity = undefined;
961
+ }
962
+ if (document.source.operation !== 'timeseries' && document.source.operation !== 'group-by') {
598
963
  options.fillGaps = undefined;
599
964
  }
965
+ if (document.source.operation !== 'group-by') {
966
+ if (options.timezone !== undefined)
967
+ options.timezone = undefined;
968
+ if (options.periodField !== undefined)
969
+ options.periodField = undefined;
970
+ if (options.periodPreset !== undefined)
971
+ options.periodPreset = undefined;
972
+ }
600
973
  if (document.source.operation !== 'distribution') {
601
974
  options.mode = undefined;
602
975
  options.bucketSize = undefined;
@@ -632,7 +1005,8 @@ class ChartContractNormalizerService {
632
1005
  || document.kind === 'donut'
633
1006
  || document.kind === 'funnel'
634
1007
  || document.kind === 'pyramid'
635
- || document.kind === 'treemap';
1008
+ || document.kind === 'treemap'
1009
+ || document.kind === 'heatmap';
636
1010
  }
637
1011
  normalizeCssSizeValue(value) {
638
1012
  if (typeof value !== 'string') {
@@ -665,6 +1039,30 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
665
1039
  args: [{ providedIn: 'root' }]
666
1040
  }] });
667
1041
 
1042
+ const SUPPORTED_DIMENSION_EXTRACTIONS = new Set([
1043
+ 'NONE',
1044
+ 'HOUR_OF_DAY',
1045
+ 'DAY_OF_WEEK',
1046
+ 'DAY_OF_MONTH',
1047
+ 'MONTH_OF_YEAR',
1048
+ 'QUARTER_OF_YEAR',
1049
+ 'HOUR',
1050
+ 'DAY',
1051
+ 'WEEK',
1052
+ 'MONTH',
1053
+ 'QUARTER',
1054
+ 'YEAR',
1055
+ ]);
1056
+ const SUPPORTED_PERIOD_PRESETS = new Set([
1057
+ 'TODAY',
1058
+ 'YESTERDAY',
1059
+ 'LAST_7_DAYS',
1060
+ 'LAST_30_DAYS',
1061
+ 'THIS_MONTH',
1062
+ 'LAST_MONTH',
1063
+ 'THIS_QUARTER',
1064
+ 'THIS_YEAR',
1065
+ ]);
668
1066
  class ChartContractValidationService {
669
1067
  validate(document) {
670
1068
  const issues = [];
@@ -748,12 +1146,34 @@ class ChartContractValidationService {
748
1146
  if (!metric.field?.trim()) {
749
1147
  issues.push(this.error('metric-field-required', `metrics[${index}].field`, 'x-ui.chart metric.field is required.'));
750
1148
  }
1149
+ if (metric.quickView !== undefined) {
1150
+ this.validateQuickViewBinding(metric.quickView, `metrics[${index}].quickView`, issues);
1151
+ }
751
1152
  });
752
1153
  document.metrics
753
1154
  ?.filter((metric) => metric.aggregation === 'distinct-count' && !metric.field?.trim())
754
1155
  .forEach((_metric, index) => {
755
1156
  issues.push(this.error('distinct-count-missing-field', `metrics[${index}].field`, 'x-ui.chart aggregation "distinct-count" requires a metric field.'));
756
1157
  });
1158
+ document.metrics?.forEach((metric, index) => {
1159
+ if (metric.aggregation !== 'avg-per-cycle-occurrence') {
1160
+ return;
1161
+ }
1162
+ if (document.source.kind !== 'praxis.stats' || document.source.operation !== 'group-by') {
1163
+ issues.push(this.error('avg-per-cycle-occurrence-operation-unsupported', `metrics[${index}].aggregation`, 'x-ui.chart aggregation "avg-per-cycle-occurrence" requires source.kind="praxis.stats" and source.operation="group-by".'));
1164
+ }
1165
+ const hasTemporalExtraction = (document.dimensions ?? []).some((dimension) => Boolean(dimension.extraction && dimension.extraction !== 'NONE'));
1166
+ if (!hasTemporalExtraction) {
1167
+ issues.push(this.error('avg-per-cycle-occurrence-missing-temporal-extraction', 'dimensions', 'x-ui.chart aggregation "avg-per-cycle-occurrence" requires at least one dimension with a temporal extraction.'));
1168
+ }
1169
+ if (!document.source.options?.timezone?.trim()) {
1170
+ issues.push(this.error('avg-per-cycle-occurrence-missing-timezone', 'source.options.timezone', 'x-ui.chart aggregation "avg-per-cycle-occurrence" requires source.options.timezone.'));
1171
+ }
1172
+ const periodPreset = document.source.options?.periodPreset;
1173
+ if (!periodPreset || !SUPPORTED_PERIOD_PRESETS.has(periodPreset)) {
1174
+ issues.push(this.error('avg-per-cycle-occurrence-missing-period-preset', 'source.options.periodPreset', 'x-ui.chart aggregation "avg-per-cycle-occurrence" requires a supported source.options.periodPreset.'));
1175
+ }
1176
+ });
757
1177
  }
758
1178
  validateComparison(document, issues) {
759
1179
  if (document.source.kind !== 'praxis.stats' || document.source.operation !== 'comparison') {
@@ -842,6 +1262,7 @@ class ChartContractValidationService {
842
1262
  'pyramid',
843
1263
  'treemap',
844
1264
  'gauge',
1265
+ 'heatmap',
845
1266
  ];
846
1267
  if (!supportedKinds.includes(document.kind)) {
847
1268
  issues.push(this.error('unsupported-kind', 'kind', `x-ui.chart kind="${document.kind}" is not supported in @praxisui/charts.`));
@@ -852,7 +1273,15 @@ class ChartContractValidationService {
852
1273
  if (!dimension.field?.trim()) {
853
1274
  issues.push(this.error('dimension-field-required', `dimensions[${index}].field`, 'x-ui.chart dimension.field is required.'));
854
1275
  }
1276
+ if (dimension.extraction !== undefined && !SUPPORTED_DIMENSION_EXTRACTIONS.has(dimension.extraction)) {
1277
+ issues.push(this.error('dimension-extraction-unsupported', `dimensions[${index}].extraction`, `x-ui.chart dimensions[${index}].extraction="${String(dimension.extraction)}" is not supported.`));
1278
+ }
855
1279
  });
1280
+ if (document.source.kind === 'praxis.stats'
1281
+ && document.source.operation === 'group-by'
1282
+ && (document.dimensions?.length ?? 0) > 2) {
1283
+ issues.push(this.error('group-by-max-dimensions', 'dimensions', 'x-ui.chart praxis.stats group-by supports at most 2 dimensions.'));
1284
+ }
856
1285
  if (!categorySliceChart && document.kind !== 'gauge' && !document.dimensions?.length) {
857
1286
  issues.push(this.error('missing-dimension', 'dimensions', 'x-ui.chart cartesian charts require at least one dimension.'));
858
1287
  }
@@ -866,6 +1295,7 @@ class ChartContractValidationService {
866
1295
  issues.push(this.error('combo-min-metrics', 'metrics', 'x-ui.chart combo charts require at least two metrics.'));
867
1296
  }
868
1297
  this.validateGauge(document, issues);
1298
+ this.validateHeatmap(document, issues);
869
1299
  if (document.kind !== 'combo' && document.metrics?.some((metric) => metric.axis === 'secondary')) {
870
1300
  issues.push(this.error('secondary-axis-non-combo', 'metrics', 'x-ui.chart axis="secondary" is supported only for combo charts in @praxisui/charts.'));
871
1301
  }
@@ -943,6 +1373,21 @@ class ChartContractValidationService {
943
1373
  issues.push(this.error('gauge-limit-required', 'source.options.limit', 'x-ui.chart gauge charts over praxis.stats require source.options.limit=1.'));
944
1374
  }
945
1375
  }
1376
+ validateHeatmap(document, issues) {
1377
+ if (document.kind !== 'heatmap') {
1378
+ return;
1379
+ }
1380
+ if ((document.dimensions?.length ?? 0) !== 2) {
1381
+ issues.push(this.error('heatmap-two-dimensions', 'dimensions', 'x-ui.chart heatmap charts require exactly two dimensions (x and y matrix axes).'));
1382
+ }
1383
+ if ((document.metrics?.length ?? 0) !== 1) {
1384
+ issues.push(this.error('heatmap-single-metric', 'metrics', 'x-ui.chart heatmap charts require exactly one metric.'));
1385
+ }
1386
+ if (document.source.kind === 'praxis.stats'
1387
+ && document.source.operation !== 'group-by') {
1388
+ issues.push(this.error('heatmap-operation-unsupported', 'source.operation', 'x-ui.chart heatmap charts over praxis.stats require operation="group-by".'));
1389
+ }
1390
+ }
946
1391
  isCategorySliceChart(kind) {
947
1392
  return kind === 'pie'
948
1393
  || kind === 'donut'
@@ -997,6 +1442,15 @@ class ChartContractValidationService {
997
1442
  this.validateEventAction('drillDown', document.events?.drillDown, issues);
998
1443
  this.validateEventAction('selectionChange', document.events?.selectionChange, issues);
999
1444
  this.validateEventAction('crossFilter', document.events?.crossFilter, issues);
1445
+ if (document.events?.quickView !== undefined) {
1446
+ this.validateQuickViewBinding(document.events.quickView, 'events.quickView', issues);
1447
+ }
1448
+ }
1449
+ validateQuickViewBinding(binding, field, issues) {
1450
+ const result = validateResourceQuickViewBinding(binding);
1451
+ if (!result.valid || !result.binding) {
1452
+ issues.push(this.error('quick-view-binding-invalid', field, `x-ui.chart ${field} must be a valid ResourceQuickViewBinding with recordOpen.sourceIdentityField and recordOpen.target.{resourceKey, surfaceId}.`));
1453
+ }
1000
1454
  }
1001
1455
  validateEventAction(eventKey, eventAction, issues) {
1002
1456
  if (!eventAction?.action) {
@@ -1199,7 +1653,15 @@ class PraxisChartCanonicalContractMapperService {
1199
1653
  return this.normalizer.normalize(contract);
1200
1654
  }
1201
1655
  buildAxes(contract) {
1202
- const firstDimension = contract.dimensions?.[0];
1656
+ const dimensions = contract.dimensions ?? [];
1657
+ const firstDimension = dimensions[0];
1658
+ const secondDimension = dimensions[1];
1659
+ const firstDimensionField = firstDimension
1660
+ ? this.resolveEffectiveDimensionField(firstDimension, dimensions)
1661
+ : undefined;
1662
+ const secondDimensionField = secondDimension
1663
+ ? this.resolveEffectiveDimensionField(secondDimension, dimensions)
1664
+ : undefined;
1203
1665
  const firstMetric = contract.metrics?.[0];
1204
1666
  const secondMetric = contract.metrics?.[1];
1205
1667
  const secondaryMetric = contract.metrics?.find((metric) => metric.axis === 'secondary');
@@ -1208,16 +1670,30 @@ class PraxisChartCanonicalContractMapperService {
1208
1670
  if (this.isCategorySliceChart(contract.kind) || contract.kind === 'gauge') {
1209
1671
  return {
1210
1672
  x: {
1211
- field: firstDimension?.field,
1673
+ field: firstDimensionField,
1212
1674
  label: this.toLabel(firstDimension?.label),
1213
1675
  },
1214
1676
  };
1215
1677
  }
1678
+ if (contract.kind === 'heatmap') {
1679
+ return {
1680
+ x: {
1681
+ field: firstDimensionField,
1682
+ label: this.toLabel(firstDimension?.label),
1683
+ type: 'category',
1684
+ },
1685
+ y: {
1686
+ field: secondDimensionField,
1687
+ label: this.toLabel(secondDimension?.label),
1688
+ type: 'category',
1689
+ },
1690
+ };
1691
+ }
1216
1692
  if (contract.kind === 'scatter') {
1217
1693
  const groupedMeasures = !!secondMetric;
1218
1694
  return {
1219
1695
  x: {
1220
- field: groupedMeasures ? firstMetric?.field : firstDimension?.field,
1696
+ field: groupedMeasures ? firstMetric?.field : firstDimensionField,
1221
1697
  label: this.toLabel(groupedMeasures ? firstMetric?.label : firstDimension?.label),
1222
1698
  type: groupedMeasures ? 'value' : firstDimension?.role === 'time' ? 'time' : 'value',
1223
1699
  },
@@ -1230,7 +1706,7 @@ class PraxisChartCanonicalContractMapperService {
1230
1706
  }
1231
1707
  return {
1232
1708
  x: {
1233
- field: firstDimension?.field,
1709
+ field: firstDimensionField,
1234
1710
  label: this.toLabel(firstDimension?.label),
1235
1711
  type: firstDimension?.role === 'time' ? 'time' : 'category',
1236
1712
  },
@@ -1251,7 +1727,15 @@ class PraxisChartCanonicalContractMapperService {
1251
1727
  };
1252
1728
  }
1253
1729
  buildSeries(contract) {
1254
- const firstDimension = contract.dimensions?.[0];
1730
+ const dimensions = contract.dimensions ?? [];
1731
+ const firstDimension = dimensions[0];
1732
+ const secondDimension = dimensions[1];
1733
+ const firstDimensionField = firstDimension
1734
+ ? this.resolveEffectiveDimensionField(firstDimension, dimensions)
1735
+ : undefined;
1736
+ const secondDimensionField = secondDimension
1737
+ ? this.resolveEffectiveDimensionField(secondDimension, dimensions)
1738
+ : undefined;
1255
1739
  const labelsVisible = this.resolveOptionalToggle(contract.labels);
1256
1740
  const comparison = contract.source.kind === 'praxis.stats' && contract.source.operation === 'comparison';
1257
1741
  const metrics = contract.kind === 'scatter' && (contract.metrics?.length ?? 0) === 2
@@ -1266,8 +1750,9 @@ class PraxisChartCanonicalContractMapperService {
1266
1750
  type: this.resolveSeriesType(contract.kind, metric.seriesKind, index),
1267
1751
  axis: metric.axis ?? 'primary',
1268
1752
  categoryField: comparison || this.isCategorySliceChart(contract.kind) || contract.kind === 'gauge'
1269
- ? firstDimension?.field
1753
+ ? firstDimensionField
1270
1754
  : undefined,
1755
+ ...(secondDimensionField ? { secondaryCategoryField: secondDimensionField } : {}),
1271
1756
  metric: {
1272
1757
  field: period ? this.comparisonMetricField(metric.field, period) : metric.field,
1273
1758
  aggregation: this.mapAggregation(metric.aggregation),
@@ -1282,6 +1767,7 @@ class PraxisChartCanonicalContractMapperService {
1282
1767
  }
1283
1768
  : undefined,
1284
1769
  smooth: this.shouldSmoothSeries(contract.kind, metric.seriesKind),
1770
+ ...(metric.quickView ? { quickView: structuredClone(metric.quickView) } : {}),
1285
1771
  }));
1286
1772
  });
1287
1773
  }
@@ -1304,18 +1790,26 @@ class PraxisChartCanonicalContractMapperService {
1304
1790
  const combinedFilters = {
1305
1791
  ...filtersFromContract,
1306
1792
  };
1793
+ const dimensions = contract.dimensions ?? [];
1794
+ const dimensionConfigs = this.buildDimensionConfigs(dimensions);
1307
1795
  return {
1308
1796
  sourceKind: 'praxis.stats',
1309
1797
  statsOperation: contract.source.operation,
1310
1798
  granularity: contract.source.options?.granularity,
1311
1799
  fillGaps: contract.source.options?.fillGaps,
1800
+ ...(contract.source.options?.timezone ? { timezone: contract.source.options.timezone } : {}),
1801
+ ...(contract.source.options?.periodField ? { periodField: contract.source.options.periodField } : {}),
1802
+ ...(contract.source.options?.periodPreset ? { periodPreset: contract.source.options.periodPreset } : {}),
1312
1803
  distributionMode: contract.source.options?.mode,
1313
1804
  bucketSize: contract.source.options?.bucketSize,
1314
1805
  bucketCount: contract.source.options?.bucketCount,
1315
1806
  statsOrderBy: this.mapStatsOrderBy(contract.source.options?.orderBy),
1316
1807
  statsPath: this.buildStatsPath(contract),
1317
1808
  statsRequest: this.buildStatsRequest(contract, combinedFilters),
1318
- dimensions: contract.dimensions?.map((dimension) => dimension.field),
1809
+ dimensions: dimensions.length
1810
+ ? dimensions.map((dimension) => this.resolveEffectiveDimensionField(dimension, dimensions))
1811
+ : undefined,
1812
+ ...(dimensionConfigs?.length ? { dimensionConfigs } : {}),
1319
1813
  metrics: contract.source.operation === 'comparison'
1320
1814
  ? contract.metrics?.flatMap((metric) => ['current', 'previous'].map((period) => ({
1321
1815
  field: this.comparisonMetricField(metric.field, period),
@@ -1338,6 +1832,7 @@ class PraxisChartCanonicalContractMapperService {
1338
1832
  selection: Boolean(contract.events?.selectionChange),
1339
1833
  drillDown: Boolean(contract.events?.drillDown),
1340
1834
  crossFilter: Boolean(contract.events?.crossFilter),
1835
+ ...(contract.events?.quickView ? { quickView: structuredClone(contract.events.quickView) } : {}),
1341
1836
  eventActions: {
1342
1837
  pointClick: contract.events?.pointClick,
1343
1838
  selectionChange: contract.events?.selectionChange,
@@ -1362,7 +1857,7 @@ class PraxisChartCanonicalContractMapperService {
1362
1857
  tooltip: {
1363
1858
  ...(variantTheme?.tooltip ?? {}),
1364
1859
  enabled: this.resolveToggle(contract.tooltip, true),
1365
- trigger: this.isCategorySliceChart(contract.kind) || contract.kind === 'scatter' || contract.kind === 'gauge' ? 'item' : 'axis',
1860
+ trigger: this.isCategorySliceChart(contract.kind) || contract.kind === 'scatter' || contract.kind === 'gauge' || contract.kind === 'heatmap' ? 'item' : 'axis',
1366
1861
  },
1367
1862
  surface,
1368
1863
  backgroundColor: surface?.background ?? variantTheme?.backgroundColor,
@@ -1491,6 +1986,7 @@ class PraxisChartCanonicalContractMapperService {
1491
1986
  case 'count':
1492
1987
  case 'distinct-count':
1493
1988
  case 'sum':
1989
+ case 'avg-per-cycle-occurrence':
1494
1990
  return aggregation;
1495
1991
  case undefined:
1496
1992
  return undefined;
@@ -1523,15 +2019,24 @@ class PraxisChartCanonicalContractMapperService {
1523
2019
  const orderBy = this.mapStatsOrderByToBackend(contract.source.options?.orderBy);
1524
2020
  const requestMetrics = metrics.length > 1 ? metrics : undefined;
1525
2021
  switch (contract.source.operation) {
1526
- case 'group-by':
2022
+ case 'group-by': {
2023
+ const dimensionConfigs = this.buildDimensionConfigs(contract.dimensions ?? []);
2024
+ const includeDimensions = (dimensionConfigs?.length ?? 0) > 1
2025
+ || Boolean(dimensionConfigs?.some((item) => (item.extraction && item.extraction !== 'NONE') || item.alias));
1527
2026
  return {
1528
2027
  filter: filters,
1529
- field,
2028
+ field: (dimensionConfigs?.length ?? 0) > 1 ? undefined : field,
2029
+ ...(includeDimensions && dimensionConfigs ? { dimensions: dimensionConfigs } : {}),
1530
2030
  metric,
1531
2031
  metrics: requestMetrics,
2032
+ ...(contract.source.options?.timezone ? { timezone: contract.source.options.timezone } : {}),
2033
+ ...(contract.source.options?.periodField ? { periodField: contract.source.options.periodField } : {}),
2034
+ ...(contract.source.options?.periodPreset ? { periodPreset: contract.source.options.periodPreset } : {}),
2035
+ ...(contract.source.options?.fillGaps !== undefined ? { fillGaps: contract.source.options.fillGaps } : {}),
1532
2036
  limit,
1533
2037
  orderBy,
1534
2038
  };
2039
+ }
1535
2040
  case 'timeseries':
1536
2041
  return {
1537
2042
  filter: filters,
@@ -1584,24 +2089,66 @@ class PraxisChartCanonicalContractMapperService {
1584
2089
  if (!metric) {
1585
2090
  throw new Error('x-ui.chart requires metrics[0] to derive the canonical praxis.stats request.');
1586
2091
  }
1587
- const operation = this.mapStatsMetricOperation(metric.aggregation);
1588
- return {
1589
- operation,
1590
- field: operation === 'COUNT' ? undefined : metric.field,
1591
- alias: metric.field,
1592
- };
2092
+ return this.toStatsMetricRequest(metric);
1593
2093
  }
1594
2094
  buildStatsMetricRequests(contract) {
1595
2095
  if (!contract.metrics?.length) {
1596
2096
  throw new Error('x-ui.chart requires at least one metric to derive the canonical praxis.stats request.');
1597
2097
  }
1598
- return contract.metrics.map((metric) => {
1599
- const operation = this.mapStatsMetricOperation(metric.aggregation);
2098
+ return contract.metrics.map((metric) => this.toStatsMetricRequest(metric));
2099
+ }
2100
+ toStatsMetricRequest(metric) {
2101
+ const operation = this.mapStatsMetricOperation(metric.aggregation);
2102
+ const sourceField = metric.sourceField?.trim();
2103
+ if (operation === 'COUNT') {
2104
+ return {
2105
+ operation,
2106
+ field: undefined,
2107
+ alias: metric.field,
2108
+ };
2109
+ }
2110
+ if (operation === 'AVG_PER_CYCLE_OCCURRENCE') {
1600
2111
  return {
1601
2112
  operation,
1602
- field: operation === 'COUNT' ? undefined : metric.field,
2113
+ field: sourceField || undefined,
1603
2114
  alias: metric.field,
1604
2115
  };
2116
+ }
2117
+ return {
2118
+ operation,
2119
+ field: sourceField || metric.field,
2120
+ alias: metric.field,
2121
+ };
2122
+ }
2123
+ resolveEffectiveDimensionField(dimension, allDimensions = [dimension]) {
2124
+ const explicitAlias = dimension.alias?.trim();
2125
+ if (explicitAlias) {
2126
+ return explicitAlias;
2127
+ }
2128
+ const field = dimension.field.trim();
2129
+ const extraction = dimension.extraction && dimension.extraction !== 'NONE' ? dimension.extraction : undefined;
2130
+ if (!extraction) {
2131
+ return field;
2132
+ }
2133
+ const sameFieldCount = allDimensions.filter((item) => item.field?.trim() === field).length;
2134
+ return sameFieldCount > 1 ? `${field}_${extraction.toLowerCase()}` : field;
2135
+ }
2136
+ buildDimensionConfigs(dimensions) {
2137
+ if (!dimensions.length) {
2138
+ return undefined;
2139
+ }
2140
+ return dimensions.map((dimension) => {
2141
+ const effectiveAlias = this.resolveEffectiveDimensionField(dimension, dimensions);
2142
+ const extraction = dimension.extraction && dimension.extraction !== 'NONE' ? dimension.extraction : undefined;
2143
+ return {
2144
+ field: dimension.field.trim(),
2145
+ ...(extraction ? { extraction } : {}),
2146
+ ...(dimension.alias?.trim()
2147
+ ? { alias: dimension.alias.trim() }
2148
+ : effectiveAlias !== dimension.field.trim()
2149
+ ? { alias: effectiveAlias }
2150
+ : {}),
2151
+ };
1605
2152
  });
1606
2153
  }
1607
2154
  mapStatsOrderBy(value) {
@@ -1672,6 +2219,8 @@ class PraxisChartCanonicalContractMapperService {
1672
2219
  return 'MIN';
1673
2220
  case 'max':
1674
2221
  return 'MAX';
2222
+ case 'avg-per-cycle-occurrence':
2223
+ return 'AVG_PER_CYCLE_OCCURRENCE';
1675
2224
  default:
1676
2225
  throw new Error(`x-ui.chart aggregation "${aggregation}" is not supported in @praxisui/charts.`);
1677
2226
  }
@@ -1712,9 +2261,12 @@ class PraxisChartCanonicalContractMapperService {
1712
2261
  case 'min':
1713
2262
  case 'max':
1714
2263
  case 'distinct-count':
2264
+ case 'avg-per-cycle-occurrence':
1715
2265
  return lower;
1716
2266
  case 'distinct_count':
1717
2267
  return 'distinct-count';
2268
+ case 'avg_per_cycle_occurrence':
2269
+ return 'avg-per-cycle-occurrence';
1718
2270
  default:
1719
2271
  return undefined;
1720
2272
  }
@@ -1816,6 +2368,20 @@ class PraxisChartCanonicalContractMapperService {
1816
2368
  mode: comparisonPeriod.mode ?? 'PREVIOUS_ALIGNED',
1817
2369
  };
1818
2370
  }
2371
+ if (operation === 'group-by') {
2372
+ const timezone = query.timezone ?? query.statsRequest?.timezone;
2373
+ if (typeof timezone === 'string' && timezone.trim()) {
2374
+ options.timezone = timezone.trim();
2375
+ }
2376
+ const groupByPeriodField = query.periodField ?? query.statsRequest?.periodField;
2377
+ if (typeof groupByPeriodField === 'string' && groupByPeriodField.trim()) {
2378
+ options.periodField = groupByPeriodField.trim();
2379
+ }
2380
+ const periodPreset = query.periodPreset ?? query.statsRequest?.periodPreset;
2381
+ if (typeof periodPreset === 'string' && periodPreset.trim()) {
2382
+ options.periodPreset = periodPreset.trim();
2383
+ }
2384
+ }
1819
2385
  return {
1820
2386
  kind: 'praxis.stats',
1821
2387
  resource,
@@ -1847,6 +2413,21 @@ class PraxisChartCanonicalContractMapperService {
1847
2413
  : (config.axes?.x?.type === 'value' ? 'value' : 'category');
1848
2414
  const label = this.toCanonicalTextValue(config.axes?.x?.label);
1849
2415
  const format = config.axes?.x?.labels?.format;
2416
+ const dimensionConfigs = (remoteQuery?.dimensionConfigs
2417
+ ?? remoteQuery?.statsRequest?.dimensions);
2418
+ if (dimensionConfigs?.length) {
2419
+ return dimensionConfigs.map((dimConfig, index) => ({
2420
+ field: dimConfig.field,
2421
+ ...(index === 0 && label !== undefined ? { label } : {}),
2422
+ ...(index === 1 && config.axes?.y?.type === 'category' && config.axes?.y?.label !== undefined
2423
+ ? { label: this.toCanonicalTextValue(config.axes.y.label) }
2424
+ : {}),
2425
+ role: index === 0 ? role : 'category',
2426
+ ...(index === 0 && format ? { format } : {}),
2427
+ ...(dimConfig.extraction ? { extraction: dimConfig.extraction } : {}),
2428
+ ...(dimConfig.alias ? { alias: dimConfig.alias } : {}),
2429
+ }));
2430
+ }
1850
2431
  const primaryDimension = {
1851
2432
  field: primaryField,
1852
2433
  ...(label !== undefined ? { label } : {}),
@@ -1857,10 +2438,22 @@ class PraxisChartCanonicalContractMapperService {
1857
2438
  .slice(1)
1858
2439
  .map((f) => f.trim())
1859
2440
  .filter((f) => Boolean(f) && f !== primaryField);
2441
+ const seriesSecondaryField = config.series?.[0]?.secondaryCategoryField?.trim()
2442
+ || (config.axes?.y?.type === 'category' ? config.axes.y.field?.trim() : undefined);
2443
+ if (!extraFields.length && seriesSecondaryField && seriesSecondaryField !== primaryField) {
2444
+ extraFields.push(seriesSecondaryField);
2445
+ }
1860
2446
  if (extraFields.length) {
2447
+ const secondaryLabel = config.axes?.y?.type === 'category'
2448
+ ? this.toCanonicalTextValue(config.axes.y.label)
2449
+ : undefined;
1861
2450
  return [
1862
2451
  primaryDimension,
1863
- ...extraFields.map((f) => ({ field: f, role: 'category' })),
2452
+ ...extraFields.map((f, idx) => ({
2453
+ field: f,
2454
+ role: 'category',
2455
+ ...(idx === 0 && secondaryLabel !== undefined ? { label: secondaryLabel } : {}),
2456
+ })),
1864
2457
  ];
1865
2458
  }
1866
2459
  return [primaryDimension];
@@ -1886,6 +2479,7 @@ class PraxisChartCanonicalContractMapperService {
1886
2479
  ...(axis ? { axis } : {}),
1887
2480
  ...(color ? { color } : {}),
1888
2481
  ...(format ? { format } : {}),
2482
+ ...(s.quickView ? { quickView: structuredClone(s.quickView) } : {}),
1889
2483
  };
1890
2484
  });
1891
2485
  }
@@ -1925,12 +2519,13 @@ class PraxisChartCanonicalContractMapperService {
1925
2519
  return undefined;
1926
2520
  }
1927
2521
  extractCanonicalEvents(config) {
1928
- if (config.interactions?.eventActions) {
2522
+ if (config.interactions?.eventActions || config.interactions?.quickView) {
1929
2523
  return {
1930
- pointClick: config.interactions.eventActions.pointClick,
1931
- selectionChange: config.interactions.eventActions.selectionChange,
1932
- drillDown: config.interactions.eventActions.drillDown,
1933
- crossFilter: config.interactions.eventActions.crossFilter,
2524
+ pointClick: config.interactions?.eventActions?.pointClick,
2525
+ selectionChange: config.interactions?.eventActions?.selectionChange,
2526
+ drillDown: config.interactions?.eventActions?.drillDown,
2527
+ crossFilter: config.interactions?.eventActions?.crossFilter,
2528
+ ...(config.interactions?.quickView ? { quickView: structuredClone(config.interactions.quickView) } : {}),
1934
2529
  };
1935
2530
  }
1936
2531
  if (config.interactions?.crossFilter) {
@@ -2043,6 +2638,12 @@ class PraxisChartStatsApiService {
2043
2638
  };
2044
2639
  statsRequest = this.synthesizeStatsRequest(effectiveQuery, statsType);
2045
2640
  }
2641
+ if (statsRequest && statsRequest.filter == null) {
2642
+ statsRequest = {
2643
+ ...statsRequest,
2644
+ filter: query?.filters ?? {},
2645
+ };
2646
+ }
2046
2647
  if (!statsPath || !statsRequest) {
2047
2648
  return throwError(() => new Error('PraxisChartStatsApiService requires query.statsPath (or resourceKey) and query.statsRequest (or field) for praxis.stats execution.'));
2048
2649
  }
@@ -2094,11 +2695,17 @@ class PraxisChartStatsApiService {
2094
2695
  }
2095
2696
  case 'group-by':
2096
2697
  default: {
2698
+ const dimensionConfigs = query.dimensionConfigs;
2097
2699
  return {
2098
2700
  filter,
2099
- field,
2701
+ field: (dimensionConfigs?.length ?? 0) > 1 ? undefined : field,
2702
+ ...(dimensionConfigs?.length ? { dimensions: dimensionConfigs } : {}),
2100
2703
  metric,
2101
2704
  ...(query.metrics?.length ? { metrics: this.normalizeMetricRequests(query.metrics) } : {}),
2705
+ ...(query.timezone ? { timezone: query.timezone } : {}),
2706
+ ...(query.periodField ? { periodField: query.periodField } : {}),
2707
+ ...(query.periodPreset ? { periodPreset: query.periodPreset } : {}),
2708
+ ...(query.fillGaps !== undefined ? { fillGaps: query.fillGaps } : {}),
2102
2709
  ...(query.limit !== undefined ? { limit: query.limit } : {}),
2103
2710
  ...(query.statsOrderBy ? { orderBy: this.normalizeOrderBy(query.statsOrderBy) } : {}),
2104
2711
  };
@@ -2110,7 +2717,14 @@ class PraxisChartStatsApiService {
2110
2717
  return { operation: 'COUNT' };
2111
2718
  }
2112
2719
  if (typeof metric === 'string') {
2113
- return { operation: metric.toUpperCase() };
2720
+ const normalized = metric.toLowerCase();
2721
+ return {
2722
+ operation: (normalized === 'distinct-count'
2723
+ ? 'DISTINCT_COUNT'
2724
+ : normalized === 'avg-per-cycle-occurrence'
2725
+ ? 'AVG_PER_CYCLE_OCCURRENCE'
2726
+ : metric.toUpperCase()),
2727
+ };
2114
2728
  }
2115
2729
  if ('operation' in metric && metric.operation) {
2116
2730
  const op = metric.operation.toUpperCase();
@@ -2124,14 +2738,22 @@ class PraxisChartStatsApiService {
2124
2738
  return result;
2125
2739
  }
2126
2740
  if ('aggregation' in metric && metric.aggregation) {
2127
- const op = (metric.aggregation === 'distinct-count' ? 'DISTINCT_COUNT' : metric.aggregation.toUpperCase());
2741
+ const op = (metric.aggregation === 'distinct-count'
2742
+ ? 'DISTINCT_COUNT'
2743
+ : metric.aggregation === 'avg-per-cycle-occurrence'
2744
+ ? 'AVG_PER_CYCLE_OCCURRENCE'
2745
+ : metric.aggregation.toUpperCase());
2128
2746
  const result = {
2129
2747
  operation: op,
2130
2748
  };
2131
- if (metric.field && op !== 'COUNT')
2749
+ if (metric.field && op !== 'COUNT' && op !== 'AVG_PER_CYCLE_OCCURRENCE')
2132
2750
  result.field = metric.field;
2133
- if (metric.alias)
2751
+ if (metric.alias) {
2134
2752
  result.alias = metric.alias;
2753
+ }
2754
+ else if (metric.field && (op === 'COUNT' || op === 'AVG_PER_CYCLE_OCCURRENCE')) {
2755
+ result.alias = metric.field;
2756
+ }
2135
2757
  return result;
2136
2758
  }
2137
2759
  const result = { operation: 'COUNT' };
@@ -2222,13 +2844,62 @@ class PraxisChartStatsApiService {
2222
2844
  });
2223
2845
  }
2224
2846
  if ('buckets' in response) {
2847
+ const primaryCoordAlias = this.resolveDimensionCoordinateAlias(0, request, response, categoryField);
2848
+ const secondaryCategoryField = this.resolveSecondaryCategoryField(config, request, response);
2849
+ const secondaryCoordAlias = this.resolveDimensionCoordinateAlias(1, request, response, secondaryCategoryField);
2225
2850
  return response.buckets.map((bucket) => {
2226
- const category = this.resolveBucketCategory(bucket, categoryField, config);
2851
+ const isMultiDimBucket = Boolean(secondaryCategoryField
2852
+ && ((bucket.keys && bucket.keys.length > 1)
2853
+ || (bucket.labels && bucket.labels.length > 1)
2854
+ || (bucket.coordinates && Object.keys(bucket.coordinates).length > 1)));
2855
+ const primaryKey = isMultiDimBucket
2856
+ ? (bucket.keys && bucket.keys.length > 0
2857
+ ? bucket.keys[0]
2858
+ : (primaryCoordAlias && bucket.coordinates && primaryCoordAlias in bucket.coordinates
2859
+ ? bucket.coordinates[primaryCoordAlias]
2860
+ : (bucket.key ?? null)))
2861
+ : (bucket.key !== undefined
2862
+ ? bucket.key
2863
+ : (bucket.keys && bucket.keys.length > 0 ? bucket.keys[0] : null));
2864
+ const primaryLabelRaw = isMultiDimBucket
2865
+ ? (bucket.labels && bucket.labels.length > 0
2866
+ ? bucket.labels[0]
2867
+ : (primaryKey !== null && primaryKey !== undefined ? String(primaryKey) : (bucket.label ?? null)))
2868
+ : (bucket.label !== undefined
2869
+ ? bucket.label
2870
+ : (bucket.labels && bucket.labels.length > 0 ? bucket.labels[0] : null));
2871
+ const category = this.resolveBucketCategory({ key: primaryKey, label: primaryLabelRaw, from: bucket.from, to: bucket.to }, categoryField, config);
2872
+ if (isMultiDimBucket && secondaryCategoryField) {
2873
+ const secondaryKey = bucket.keys && bucket.keys.length > 1
2874
+ ? bucket.keys[1]
2875
+ : (secondaryCoordAlias && bucket.coordinates ? bucket.coordinates[secondaryCoordAlias] : null);
2876
+ const secondaryLabelRaw = bucket.labels && bucket.labels.length > 1
2877
+ ? bucket.labels[1]
2878
+ : (secondaryKey !== null && secondaryKey !== undefined ? String(secondaryKey) : null);
2879
+ const secondaryCategory = this.resolveBucketCategory({ key: secondaryKey, label: secondaryLabelRaw }, secondaryCategoryField, config);
2880
+ return {
2881
+ [categoryField]: category,
2882
+ [secondaryCategoryField]: secondaryCategory,
2883
+ ...this.projectMetricValues(metricBindings, bucket.values, bucket.value, bucket.count),
2884
+ key: primaryKey ?? null,
2885
+ label: this.isNullishBucketToken(primaryLabelRaw) ? category : String(primaryLabelRaw),
2886
+ secondaryKey: secondaryKey ?? null,
2887
+ secondaryLabel: this.isNullishBucketToken(secondaryLabelRaw) ? secondaryCategory : String(secondaryLabelRaw),
2888
+ compositeKey: bucket.key ?? null,
2889
+ coordinates: bucket.coordinates ?? null,
2890
+ value: bucket.value ?? null,
2891
+ count: bucket.count ?? null,
2892
+ from: bucket.from ?? null,
2893
+ to: bucket.to ?? null,
2894
+ mode: 'mode' in response ? response.mode ?? null : null,
2895
+ requestMode: 'mode' in request ? request.mode : null,
2896
+ };
2897
+ }
2227
2898
  return {
2228
2899
  [categoryField]: category,
2229
2900
  ...this.projectMetricValues(metricBindings, bucket.values, bucket.value, bucket.count),
2230
- key: bucket.key ?? null,
2231
- label: this.isNullishBucketToken(bucket.label) ? category : String(bucket.label),
2901
+ key: primaryKey ?? null,
2902
+ label: this.isNullishBucketToken(primaryLabelRaw) ? category : String(primaryLabelRaw),
2232
2903
  value: bucket.value ?? null,
2233
2904
  count: bucket.count ?? null,
2234
2905
  from: bucket.from ?? null,
@@ -2326,11 +2997,60 @@ class PraxisChartStatsApiService {
2326
2997
  }
2327
2998
  resolveCategoryField(config, request) {
2328
2999
  const firstSeries = config.series[0];
3000
+ const firstRequestDim = 'dimensions' in request && request.dimensions?.[0]
3001
+ ? this.resolveEffectiveStatsDimensionField(request.dimensions[0], request.dimensions)
3002
+ : undefined;
2329
3003
  return (firstSeries?.categoryField
2330
3004
  || config.axes?.x?.field
3005
+ || firstRequestDim
2331
3006
  || request.field
2332
3007
  || 'category');
2333
3008
  }
3009
+ resolveSecondaryCategoryField(config, request, response) {
3010
+ const seriesSecondary = config.series[0]?.secondaryCategoryField?.trim();
3011
+ if (seriesSecondary) {
3012
+ return seriesSecondary;
3013
+ }
3014
+ const querySecondDim = config.dataSource?.kind === 'remote'
3015
+ ? config.dataSource.query?.dimensions?.[1]?.trim()
3016
+ : undefined;
3017
+ if (querySecondDim) {
3018
+ return querySecondDim;
3019
+ }
3020
+ if ('dimensions' in request && request.dimensions?.[1]) {
3021
+ return this.resolveEffectiveStatsDimensionField(request.dimensions[1], request.dimensions);
3022
+ }
3023
+ if ('dimensions' in response && response.dimensions?.[1]) {
3024
+ return this.resolveEffectiveStatsDimensionField(response.dimensions[1], response.dimensions);
3025
+ }
3026
+ return undefined;
3027
+ }
3028
+ resolveEffectiveStatsDimensionField(dim, allDims) {
3029
+ if (dim.alias?.trim()) {
3030
+ return dim.alias.trim();
3031
+ }
3032
+ const field = dim.field.trim();
3033
+ const extraction = dim.extraction?.trim();
3034
+ const duplicateCount = allDims.filter((candidate) => candidate.field.trim() === field).length;
3035
+ if (duplicateCount > 1 && extraction && extraction !== 'NONE') {
3036
+ return `${field}_${extraction.toLowerCase()}`;
3037
+ }
3038
+ return field;
3039
+ }
3040
+ resolveDimensionCoordinateAlias(index, request, response, fallback) {
3041
+ const dim = ('dimensions' in response && response.dimensions?.[index])
3042
+ || ('dimensions' in request && request.dimensions?.[index]);
3043
+ if (!dim) {
3044
+ return fallback;
3045
+ }
3046
+ if (dim.alias?.trim()) {
3047
+ return dim.alias.trim();
3048
+ }
3049
+ if (dim.extraction && dim.extraction !== 'NONE') {
3050
+ return `${dim.field.trim()}_${dim.extraction.toLowerCase()}`;
3051
+ }
3052
+ return dim.field.trim();
3053
+ }
2334
3054
  resolveMetricBindings(config, request, response) {
2335
3055
  const queryMetrics = config.dataSource?.kind === 'remote'
2336
3056
  ? (config.dataSource.query?.metrics ?? [])
@@ -2365,7 +3085,19 @@ class PraxisChartStatsApiService {
2365
3085
  }
2366
3086
  projectMetricValues(bindings, values, primaryValue, count) {
2367
3087
  return bindings.reduce((acc, binding, index) => {
2368
- const rawValue = values?.[binding.alias];
3088
+ const hasExplicitAliasValue = Boolean(values && Object.prototype.hasOwnProperty.call(values, binding.alias));
3089
+ const rawValue = hasExplicitAliasValue ? values[binding.alias] : undefined;
3090
+ if (hasExplicitAliasValue && rawValue === null) {
3091
+ acc[binding.field] = null;
3092
+ return acc;
3093
+ }
3094
+ if (!hasExplicitAliasValue
3095
+ && index === 0
3096
+ && primaryValue === null
3097
+ && (count === null || count === undefined || count === 0)) {
3098
+ acc[binding.field] = null;
3099
+ return acc;
3100
+ }
2369
3101
  acc[binding.field] = index === 0
2370
3102
  ? this.resolveMetricValue(rawValue ?? primaryValue, count)
2371
3103
  : this.resolveMetricValue(rawValue, null);
@@ -2467,7 +3199,9 @@ class PraxisChartComponent {
2467
3199
  ngZone = inject(NgZone);
2468
3200
  componentMetadataRegistry = inject(ComponentMetadataRegistry);
2469
3201
  settingsPanel = inject(SETTINGS_PANEL_BRIDGE, { optional: true });
3202
+ quickView = inject(ResourceQuickViewService, { optional: true });
2470
3203
  destroyRef = inject(DestroyRef);
3204
+ activeQuickViewAnchor = null;
2471
3205
  resizeObserver = signal(null, ...(ngDevMode ? [{ debugName: "resizeObserver" }] : /* istanbul ignore next */ []));
2472
3206
  resizeObserverHost = null;
2473
3207
  shellObserver = signal(null, ...(ngDevMode ? [{ debugName: "shellObserver" }] : /* istanbul ignore next */ []));
@@ -2787,8 +3521,14 @@ class PraxisChartComponent {
2787
3521
  || this.t('praxis.charts.runtime.accessibility.dataTitle', 'Chart data'), ...(ngDevMode ? [{ debugName: "accessibleDataTitle" }] : /* istanbul ignore next */ []));
2788
3522
  accessibleDataDescription = computed(() => this.i18n.resolve(this.renderConfig().subtitle)
2789
3523
  || this.t('praxis.charts.runtime.accessibility.dataDescription', 'Structured alternative for the values represented in the chart.'), ...(ngDevMode ? [{ debugName: "accessibleDataDescription" }] : /* istanbul ignore next */ []));
2790
- accessibleCategoryHeading = computed(() => this.t('praxis.charts.runtime.accessibility.category', 'Category'), ...(ngDevMode ? [{ debugName: "accessibleCategoryHeading" }] : /* istanbul ignore next */ []));
2791
- accessibleSeriesHeading = computed(() => this.t('praxis.charts.runtime.accessibility.series', 'Series'), ...(ngDevMode ? [{ debugName: "accessibleSeriesHeading" }] : /* istanbul ignore next */ []));
3524
+ accessibleCategoryHeading = computed(() => (this.renderConfig().type === 'heatmap'
3525
+ ? this.i18n.resolve(this.renderConfig().axes?.x?.label).trim()
3526
+ : '')
3527
+ || this.t('praxis.charts.runtime.accessibility.category', 'Category'), ...(ngDevMode ? [{ debugName: "accessibleCategoryHeading" }] : /* istanbul ignore next */ []));
3528
+ accessibleSeriesHeading = computed(() => (this.renderConfig().type === 'heatmap'
3529
+ ? this.i18n.resolve(this.renderConfig().axes?.y?.label).trim()
3530
+ : '')
3531
+ || this.t('praxis.charts.runtime.accessibility.series', 'Series'), ...(ngDevMode ? [{ debugName: "accessibleSeriesHeading" }] : /* istanbul ignore next */ []));
2792
3532
  accessibleValueHeading = computed(() => this.t('praxis.charts.runtime.accessibility.value', 'Value'), ...(ngDevMode ? [{ debugName: "accessibleValueHeading" }] : /* istanbul ignore next */ []));
2793
3533
  widgetShellActions = computed(() => {
2794
3534
  if (this.loadState() !== 'ready' || !this.accessiblePoints().length) {
@@ -2878,12 +3618,16 @@ class PraxisChartComponent {
2878
3618
  }
2879
3619
  this.previousSelectionScopeSignature = signature;
2880
3620
  this.activeSelectionSignature.set(null);
3621
+ this.dismissActiveQuickView('anchor-invalid');
2881
3622
  });
2882
3623
  effect(() => {
2883
3624
  const nextState = this.loadState();
2884
3625
  if (nextState !== 'ready' || !this.accessiblePoints().length) {
2885
3626
  this.showAccessibleData.set(false);
2886
3627
  }
3628
+ if (nextState !== 'ready') {
3629
+ this.dismissActiveQuickView('anchor-invalid');
3630
+ }
2887
3631
  if (this.currentLoadState() !== nextState) {
2888
3632
  this.currentLoadState.set(nextState);
2889
3633
  this.loadStateChange.emit(nextState);
@@ -2973,9 +3717,12 @@ class PraxisChartComponent {
2973
3717
  config: this.renderConfig(),
2974
3718
  data: this.resolvedData(),
2975
3719
  onPointClick: (event) => this.ngZone.run(() => this.handlePointClick(event)),
3720
+ onPointHoverEnter: (event) => this.ngZone.run(() => this.handlePointHoverEnter(host, event)),
3721
+ onPointHoverLeave: () => this.ngZone.run(() => this.handlePointHoverLeave()),
2976
3722
  });
2977
3723
  if (this.pendingResizeOnReady) {
2978
3724
  this.pendingResizeOnReady = false;
3725
+ this.dismissActiveQuickView('anchor-invalid');
2979
3726
  this.engine.resize();
2980
3727
  }
2981
3728
  if (!untracked(() => this.hasRenderedOnce())) {
@@ -2983,6 +3730,7 @@ class PraxisChartComponent {
2983
3730
  }
2984
3731
  });
2985
3732
  this.destroyRef.onDestroy(() => {
3733
+ this.dismissActiveQuickView('anchor-invalid');
2986
3734
  this.clearEditorSessionSubscriptions();
2987
3735
  this.cancelRemoteDataRequest();
2988
3736
  this.destroyResizeObserver();
@@ -3296,8 +4044,67 @@ class PraxisChartComponent {
3296
4044
  this.renderAttempt.update((value) => value + 1);
3297
4045
  }
3298
4046
  toggleAccessibleData() {
4047
+ this.dismissActiveQuickView('anchor-invalid');
3299
4048
  this.showAccessibleData.update((visible) => !visible);
3300
4049
  }
4050
+ accessiblePointQuickViewBinding(point) {
4051
+ return resolveChartPointQuickView(this.renderConfig(), point)?.binding ?? null;
4052
+ }
4053
+ accessiblePointQuickViewRecord(point) {
4054
+ return resolveChartPointQuickView(this.renderConfig(), point)?.record ?? null;
4055
+ }
4056
+ handleShellKeydown(event) {
4057
+ if (event.key !== 'Escape' || !this.activeQuickViewAnchor || !this.quickView) {
4058
+ return;
4059
+ }
4060
+ if (this.quickView.state !== 'idle'
4061
+ && this.quickView.state !== 'dismissed') {
4062
+ event.preventDefault();
4063
+ event.stopPropagation();
4064
+ this.dismissActiveQuickView('escape');
4065
+ }
4066
+ }
4067
+ handlePointHoverEnter(host, event) {
4068
+ if (!this.quickView || this.showAccessibleData() || this.loadState() !== 'ready') {
4069
+ return;
4070
+ }
4071
+ const resolved = resolveChartPointQuickView(this.renderConfig(), event);
4072
+ if (!resolved) {
4073
+ this.handlePointHoverLeave();
4074
+ return;
4075
+ }
4076
+ const anchor = {
4077
+ kind: 'virtual-rect',
4078
+ anchorKey: event.anchorKey,
4079
+ hostElement: host,
4080
+ focusOrigin: host,
4081
+ getViewportRect: () => event.getViewportRect(),
4082
+ isValid: () => !this.destroyRef.destroyed
4083
+ && this.loadState() === 'ready'
4084
+ && !this.showAccessibleData()
4085
+ && host.isConnected,
4086
+ };
4087
+ this.activeQuickViewAnchor = anchor;
4088
+ this.quickView.openFromHover({
4089
+ binding: resolved.binding,
4090
+ sourceRecord: resolved.record,
4091
+ anchor,
4092
+ trigger: 'hover',
4093
+ });
4094
+ }
4095
+ handlePointHoverLeave() {
4096
+ if (this.activeQuickViewAnchor && this.quickView) {
4097
+ this.quickView.notifyTriggerLeave(this.activeQuickViewAnchor);
4098
+ }
4099
+ }
4100
+ dismissActiveQuickView(reason) {
4101
+ if (!this.activeQuickViewAnchor || !this.quickView) {
4102
+ this.activeQuickViewAnchor = null;
4103
+ return;
4104
+ }
4105
+ this.quickView.dismissIfAnchoredTo(this.activeQuickViewAnchor, reason);
4106
+ this.activeQuickViewAnchor = null;
4107
+ }
3301
4108
  setWidgetShellActionHostActive(active) {
3302
4109
  this.widgetShellActionHostActive.set(active);
3303
4110
  }
@@ -3411,6 +4218,29 @@ class PraxisChartComponent {
3411
4218
  buildEventFilters(config, event, mapping) {
3412
4219
  const sourceValues = this.extractPointSourceValues(config, event);
3413
4220
  if (!mapping || !Object.keys(mapping).length) {
4221
+ const categoryField = config.axes?.x?.field
4222
+ ?? config.series.find((series) => series.categoryField)?.categoryField;
4223
+ const secondaryCategoryField = config.series.find((series) => series.secondaryCategoryField)?.secondaryCategoryField
4224
+ ?? (config.axes?.y?.type === 'category' ? config.axes.y.field : undefined);
4225
+ const dimensionFields = new Set([categoryField, secondaryCategoryField].filter((field) => Boolean(field)));
4226
+ const isCanonicalStats = config.dataSource?.kind === 'remote'
4227
+ && config.dataSource.query?.sourceKind === 'praxis.stats';
4228
+ const syntheticStatsKeys = new Set([
4229
+ 'key',
4230
+ 'label',
4231
+ 'secondaryKey',
4232
+ 'secondaryLabel',
4233
+ 'compositeKey',
4234
+ 'coordinates',
4235
+ 'count',
4236
+ 'from',
4237
+ 'to',
4238
+ 'mode',
4239
+ 'requestMode',
4240
+ 'start',
4241
+ 'end',
4242
+ 'granularity',
4243
+ ]);
3414
4244
  const metricFields = new Set();
3415
4245
  for (const series of config.series) {
3416
4246
  if (series.metric?.field) {
@@ -3425,8 +4255,31 @@ class PraxisChartComponent {
3425
4255
  }
3426
4256
  const defaultFilters = {};
3427
4257
  for (const [key, value] of Object.entries(sourceValues)) {
3428
- if (!metricFields.has(key)) {
3429
- defaultFilters[key] = value;
4258
+ if (metricFields.has(key)) {
4259
+ continue;
4260
+ }
4261
+ if (key === 'value' && !dimensionFields.has('value')) {
4262
+ continue;
4263
+ }
4264
+ if (key === 'secondaryCategory' && !dimensionFields.has('secondaryCategory')) {
4265
+ continue;
4266
+ }
4267
+ if (key === 'category' && categoryField && !dimensionFields.has('category')) {
4268
+ continue;
4269
+ }
4270
+ if (isCanonicalStats && syntheticStatsKeys.has(key) && !dimensionFields.has(key)) {
4271
+ continue;
4272
+ }
4273
+ defaultFilters[key] = value;
4274
+ }
4275
+ if (isCanonicalStats) {
4276
+ if (categoryField && sourceValues['key'] !== undefined && sourceValues['key'] !== null) {
4277
+ defaultFilters[categoryField] = sourceValues['key'];
4278
+ }
4279
+ if (secondaryCategoryField
4280
+ && sourceValues['secondaryKey'] !== undefined
4281
+ && sourceValues['secondaryKey'] !== null) {
4282
+ defaultFilters[secondaryCategoryField] = sourceValues['secondaryKey'];
3430
4283
  }
3431
4284
  }
3432
4285
  return defaultFilters;
@@ -3452,6 +4305,13 @@ class PraxisChartComponent {
3452
4305
  if (categoryField && values[categoryField] === undefined && event.category !== undefined) {
3453
4306
  values[categoryField] = event.category;
3454
4307
  }
4308
+ const secondaryCategoryField = config.series.find((series) => series.secondaryCategoryField)?.secondaryCategoryField
4309
+ ?? (config.axes?.y?.type === 'category' ? config.axes.y.field : undefined);
4310
+ if (secondaryCategoryField
4311
+ && values[secondaryCategoryField] === undefined
4312
+ && values['secondaryCategory'] !== undefined) {
4313
+ values[secondaryCategoryField] = values['secondaryCategory'];
4314
+ }
3455
4315
  const series = config.series.find((candidate) => candidate.id === event.seriesId)
3456
4316
  ?? config.series.find((candidate) => candidate.name === event.seriesName)
3457
4317
  ?? config.series[0];
@@ -3698,6 +4558,7 @@ class PraxisChartComponent {
3698
4558
  }
3699
4559
  if (typeof requestAnimationFrame !== 'function') {
3700
4560
  this.pendingResizeOnReady = false;
4561
+ this.dismissActiveQuickView('anchor-invalid');
3701
4562
  this.engine.resize();
3702
4563
  return;
3703
4564
  }
@@ -3713,6 +4574,7 @@ class PraxisChartComponent {
3713
4574
  return;
3714
4575
  }
3715
4576
  this.pendingResizeOnReady = false;
4577
+ this.dismissActiveQuickView('anchor-invalid');
3716
4578
  this.engine.resize();
3717
4579
  });
3718
4580
  }
@@ -3759,6 +4621,7 @@ class PraxisChartComponent {
3759
4621
  static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisChartComponent, isStandalone: true, selector: "praxis-chart", inputs: { config: { classPropertyName: "config", publicName: "config", isSignal: true, isRequired: false, transformFunction: null }, resourceKey: { classPropertyName: "resourceKey", publicName: "resourceKey", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, chartDocument: { classPropertyName: "chartDocument", publicName: "chartDocument", isSignal: true, isRequired: false, transformFunction: null }, filterCriteria: { classPropertyName: "filterCriteria", publicName: "filterCriteria", isSignal: true, isRequired: false, transformFunction: null }, queryContext: { classPropertyName: "queryContext", publicName: "queryContext", isSignal: true, isRequired: false, transformFunction: null }, remoteDataResolver: { classPropertyName: "remoteDataResolver", publicName: "remoteDataResolver", isSignal: true, isRequired: false, transformFunction: null }, enableCustomization: { classPropertyName: "enableCustomization", publicName: "enableCustomization", isSignal: true, isRequired: false, transformFunction: null }, availableResources: { classPropertyName: "availableResources", publicName: "availableResources", isSignal: true, isRequired: false, transformFunction: null }, availableFields: { classPropertyName: "availableFields", publicName: "availableFields", isSignal: true, isRequired: false, transformFunction: null }, availableTargets: { classPropertyName: "availableTargets", publicName: "availableTargets", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { pointClick: "pointClick", pointAction: "pointAction", selectionChange: "selectionChange", drillDown: "drillDown", crossFilter: "crossFilter", queryRequest: "queryRequest", loadStateChange: "loadStateChange", chartDocumentApplied: "chartDocumentApplied", chartDocumentSaved: "chartDocumentSaved", chartDocumentPatchChange: "chartDocumentPatchChange" }, host: { properties: { "class.praxis-chart-host-fill-container": "isFillContainerMode()", "style.height": "resolvedHeight()", "style.min-height": "resolvedHostMinHeight()", "style.max-height": "resolvedMaxHeight()", "style.aspect-ratio": "resolvedAspectRatio()" } }, viewQueries: [{ propertyName: "chartHost", first: true, predicate: ["chartHost"], descendants: true, isSignal: true }], ngImport: i0, template: `
3760
4622
  <section
3761
4623
  class="praxis-chart-shell"
4624
+ (keydown)="handleShellKeydown($event)"
3762
4625
  [class.praxis-chart-shell-fill-container]="isFillContainerMode()"
3763
4626
  [class.praxis-chart-shell-contained]="surfaceMode() === 'contained'"
3764
4627
  [class.praxis-chart-shell-embedded]="surfaceMode() === 'embedded'"
@@ -3929,6 +4792,8 @@ class PraxisChartComponent {
3929
4792
  class="praxis-chart-point-action"
3930
4793
  [attr.aria-label]="accessiblePointActionLabel(point)"
3931
4794
  [attr.aria-pressed]="accessiblePointPressed(point)"
4795
+ [praxisQuickView]="accessiblePointQuickViewBinding(point)"
4796
+ [praxisQuickViewRecord]="accessiblePointQuickViewRecord(point)"
3932
4797
  (click)="activateAccessiblePoint(point)"
3933
4798
  >
3934
4799
  {{ point.categoryLabel }}
@@ -3954,7 +4819,7 @@ class PraxisChartComponent {
3954
4819
  ></div>
3955
4820
  }
3956
4821
  </section>
3957
- `, isInline: true, styles: [":host{display:block;min-height:var(--praxis-chart-runtime-height, 320px);min-width:0;color:var(--md-sys-color-on-surface, #1a1b20)}:host(.praxis-chart-host-fill-container){height:100%;min-height:0}:host-context(.pdx-shell){color:inherit}:host-context(.pdx-shell.no-shell) .praxis-chart-shell,:host-context(.pdx-shell.body-fill) .praxis-chart-shell,:host-context(.pdx-shell.expanded) .praxis-chart-shell,:host-context(.pdx-shell.fullscreen) .praxis-chart-shell{height:100%!important}.praxis-chart-shell{box-sizing:border-box;position:relative;width:100%;height:100%;min-height:240px;border-radius:var(--praxis-chart-config-surface-radius, var(--praxis-chart-surface-radius, 8px));overflow:hidden;background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) );border-color:var( --praxis-chart-config-surface-border, var( --praxis-chart-surface-border, color-mix(in srgb, var(--md-sys-color-outline, #c5c7ce) 44%, transparent) ) );border-style:solid;border-width:var(--praxis-chart-config-surface-border-width, 1px)}.praxis-chart-shell-fill-container{min-height:0}:host-context(.pdx-shell) .praxis-chart-shell:not(.praxis-chart-shell-contained),.praxis-chart-shell-embedded{border-width:var( --praxis-chart-config-surface-border-width, var(--praxis-chart-embedded-surface-border-width, 0) );border-radius:var( --praxis-chart-config-surface-radius, var(--praxis-chart-embedded-surface-radius, 0) );background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-embedded-surface-bg, transparent) )}.praxis-chart-settings-trigger,.praxis-chart-data-trigger{position:absolute;top:10px;right:10px;z-index:5;--praxis-icon-button-filled-background: color-mix(in srgb, var(--md-sys-color-surface, #fff) 88%, rgba(18, 99, 180, .12));--praxis-icon-button-filled-foreground: var(--md-sys-color-on-surface-variant, #44474f);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.praxis-chart-data-trigger-with-settings{right:50px}.praxis-chart-accessible-data{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;overflow:hidden;padding:52px 16px 14px;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20));background:var(--md-sys-color-surface-container-lowest, #fff)}.praxis-chart-accessible-data--shell-controlled{padding-top:16px}.praxis-chart-accessible-header{flex-shrink:0;margin-bottom:8px}.praxis-chart-accessible-header h3{margin:0;font-size:.95rem;font-weight:600;line-height:1.4}.praxis-chart-table-container{flex:1;min-height:0;overflow-y:auto;overflow-x:auto;overscroll-behavior:contain}.praxis-chart-accessible-data table{width:100%;border-collapse:separate;border-spacing:0;font-size:.875rem}.praxis-chart-accessible-data caption{padding:0 0 8px;text-align:left;color:inherit;opacity:.85;font-size:.8125rem}.praxis-chart-accessible-data thead{position:sticky;top:0;z-index:2}.praxis-chart-accessible-data thead th{font-weight:600;background:var(--praxis-chart-accessible-thead-bg, inherit);box-shadow:0 1px color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent)}.praxis-chart-accessible-data th,.praxis-chart-accessible-data td{padding:8px;border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent);text-align:left}.praxis-chart-col-value{text-align:right!important;font-variant-numeric:tabular-nums}.praxis-chart-sort-header{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer;transition:background-color .15s ease}.praxis-chart-sort-header:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-sort-header:focus-visible{outline:2px solid currentColor;outline-offset:1px}.praxis-chart-sort-header--numeric{margin-left:auto}.praxis-chart-sort-indicator{font-size:.75rem;line-height:1;opacity:.85}.praxis-chart-point-action{appearance:none;padding:4px 6px;border:0;border-radius:4px;color:inherit;background:transparent;font:inherit;font-weight:600;text-align:left;text-decoration:none;cursor:pointer;transition:background-color .15s ease}.praxis-chart-point-action:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-point-action:focus-visible{outline:3px solid currentColor;outline-offset:2px}.praxis-chart-point-action[aria-pressed=true]{color:var(--md-sys-color-on-primary-container, #001b3f);background:var(--md-sys-color-primary-container, #d7e3ff)}.praxis-chart-host{width:100%;height:100%;min-height:inherit}.praxis-chart-host--data-hidden{visibility:hidden;pointer-events:none}.praxis-chart-host--refreshing{opacity:.36;pointer-events:none;transition:opacity .18s ease}.praxis-chart-state{height:100%;min-height:min(240px,100%);display:grid;align-content:center;justify-items:center;gap:14px;padding:24px;text-align:center}.praxis-chart-state-loading--overlay{position:absolute;inset:0;z-index:3;background:color-mix(in srgb,var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) ) 72%,transparent)}.praxis-chart-state-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20))}.praxis-chart-state-description{font-size:.925rem;color:color-mix(in srgb,var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%,transparent);max-width:36rem}.praxis-chart-loading-hero{width:min(100%,320px);display:grid;gap:14px}.praxis-chart-loading-summary{display:flex;gap:8px;justify-content:center}.praxis-chart-loading-chip,.praxis-chart-loading-bar{border-radius:999px;background:linear-gradient(90deg,#1263b414,#1263b438,#1263b414);background-size:200% 100%;animation:praxis-chart-loading-wave 1.2s ease-in-out infinite}.praxis-chart-loading-chip{display:block;width:104px;height:12px}.praxis-chart-loading-chip--short{width:64px}.praxis-chart-loading-plot{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));align-items:end;gap:10px;height:108px;padding:12px 8px 4px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container, #eef3f8) 72%,transparent);border:1px solid rgba(18,99,180,.08)}.praxis-chart-loading-bar{display:block;width:100%}.praxis-chart-loading-bar--1{height:32%}.praxis-chart-loading-bar--2{height:68%}.praxis-chart-loading-bar--3{height:48%}.praxis-chart-loading-bar--4{height:78%}.praxis-chart-loading-bar--5{height:56%}@keyframes praxis-chart-loading-wave{0%{background-position:100% 0}to{background-position:-100% 0}}@media(prefers-reduced-motion:reduce){.praxis-chart-host--refreshing{transition:none}.praxis-chart-loading-chip,.praxis-chart-loading-bar{animation:none}}\n"], dependencies: [{ kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i1$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4822
+ `, isInline: true, styles: [":host{display:block;min-height:var(--praxis-chart-runtime-height, 320px);min-width:0;color:var(--md-sys-color-on-surface, #1a1b20)}:host(.praxis-chart-host-fill-container){height:100%;min-height:0}:host-context(.pdx-shell){color:inherit}:host-context(.pdx-shell.no-shell) .praxis-chart-shell,:host-context(.pdx-shell.body-fill) .praxis-chart-shell,:host-context(.pdx-shell.expanded) .praxis-chart-shell,:host-context(.pdx-shell.fullscreen) .praxis-chart-shell{height:100%!important}.praxis-chart-shell{box-sizing:border-box;position:relative;width:100%;height:100%;min-height:240px;border-radius:var(--praxis-chart-config-surface-radius, var(--praxis-chart-surface-radius, 8px));overflow:hidden;background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) );border-color:var( --praxis-chart-config-surface-border, var( --praxis-chart-surface-border, color-mix(in srgb, var(--md-sys-color-outline, #c5c7ce) 44%, transparent) ) );border-style:solid;border-width:var(--praxis-chart-config-surface-border-width, 1px)}.praxis-chart-shell-fill-container{min-height:0}:host-context(.pdx-shell) .praxis-chart-shell:not(.praxis-chart-shell-contained),.praxis-chart-shell-embedded{border-width:var( --praxis-chart-config-surface-border-width, var(--praxis-chart-embedded-surface-border-width, 0) );border-radius:var( --praxis-chart-config-surface-radius, var(--praxis-chart-embedded-surface-radius, 0) );background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-embedded-surface-bg, transparent) )}.praxis-chart-settings-trigger,.praxis-chart-data-trigger{position:absolute;top:10px;right:10px;z-index:5;--praxis-icon-button-filled-background: color-mix(in srgb, var(--md-sys-color-surface, #fff) 88%, rgba(18, 99, 180, .12));--praxis-icon-button-filled-foreground: var(--md-sys-color-on-surface-variant, #44474f);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.praxis-chart-data-trigger-with-settings{right:50px}.praxis-chart-accessible-data{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;overflow:hidden;padding:52px 16px 14px;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20));background:var(--md-sys-color-surface-container-lowest, #fff)}.praxis-chart-accessible-data--shell-controlled{padding-top:16px}.praxis-chart-accessible-header{flex-shrink:0;margin-bottom:8px}.praxis-chart-accessible-header h3{margin:0;font-size:.95rem;font-weight:600;line-height:1.4}.praxis-chart-table-container{flex:1;min-height:0;overflow-y:auto;overflow-x:auto;overscroll-behavior:contain}.praxis-chart-accessible-data table{width:100%;border-collapse:separate;border-spacing:0;font-size:.875rem}.praxis-chart-accessible-data caption{padding:0 0 8px;text-align:left;color:inherit;opacity:.85;font-size:.8125rem}.praxis-chart-accessible-data thead{position:sticky;top:0;z-index:2}.praxis-chart-accessible-data thead th{font-weight:600;background:var(--praxis-chart-accessible-thead-bg, inherit);box-shadow:0 1px color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent)}.praxis-chart-accessible-data th,.praxis-chart-accessible-data td{padding:8px;border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent);text-align:left}.praxis-chart-col-value{text-align:right!important;font-variant-numeric:tabular-nums}.praxis-chart-sort-header{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer;transition:background-color .15s ease}.praxis-chart-sort-header:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-sort-header:focus-visible{outline:2px solid currentColor;outline-offset:1px}.praxis-chart-sort-header--numeric{margin-left:auto}.praxis-chart-sort-indicator{font-size:.75rem;line-height:1;opacity:.85}.praxis-chart-point-action{appearance:none;padding:4px 6px;border:0;border-radius:4px;color:inherit;background:transparent;font:inherit;font-weight:600;text-align:left;text-decoration:none;cursor:pointer;transition:background-color .15s ease}.praxis-chart-point-action:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-point-action:focus-visible{outline:3px solid currentColor;outline-offset:2px}.praxis-chart-point-action[aria-pressed=true]{color:var(--md-sys-color-on-primary-container, #001b3f);background:var(--md-sys-color-primary-container, #d7e3ff)}.praxis-chart-host{width:100%;height:100%;min-height:inherit}.praxis-chart-host--data-hidden{visibility:hidden;pointer-events:none}.praxis-chart-host--refreshing{opacity:.36;pointer-events:none;transition:opacity .18s ease}.praxis-chart-state{height:100%;min-height:min(240px,100%);display:grid;align-content:center;justify-items:center;gap:14px;padding:24px;text-align:center}.praxis-chart-state-loading--overlay{position:absolute;inset:0;z-index:3;background:color-mix(in srgb,var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) ) 72%,transparent)}.praxis-chart-state-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20))}.praxis-chart-state-description{font-size:.925rem;color:color-mix(in srgb,var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%,transparent);max-width:36rem}.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{background-image:var( --pdx-text-shimmer-gradient, linear-gradient( 105deg, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 0%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 28%, var(--md-sys-color-primary, #1263b4) 44%, var(--md-sys-color-tertiary, #7c3aed) 56%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 100% ) );background-size:260% 100%;background-position:200% 50%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;animation:praxis-chart-text-shimmer var(--pdx-text-shimmer-duration, 2.4s) linear infinite}@keyframes praxis-chart-text-shimmer{0%{background-position:200% 50%}to{background-position:-60% 50%}}.praxis-chart-loading-hero{width:min(100%,320px);display:grid;gap:14px}.praxis-chart-loading-summary{display:flex;gap:8px;justify-content:center}.praxis-chart-loading-chip,.praxis-chart-loading-bar{border-radius:999px;background:linear-gradient(90deg,#1263b414,#1263b438,#1263b414);background-size:200% 100%;animation:praxis-chart-loading-wave 1.2s ease-in-out infinite}.praxis-chart-loading-chip{display:block;width:104px;height:12px}.praxis-chart-loading-chip--short{width:64px}.praxis-chart-loading-plot{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));align-items:end;gap:10px;height:108px;padding:12px 8px 4px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container, #eef3f8) 72%,transparent);border:1px solid rgba(18,99,180,.08)}.praxis-chart-loading-bar{display:block;width:100%}.praxis-chart-loading-bar--1{height:32%}.praxis-chart-loading-bar--2{height:68%}.praxis-chart-loading-bar--3{height:48%}.praxis-chart-loading-bar--4{height:78%}.praxis-chart-loading-bar--5{height:56%}@keyframes praxis-chart-loading-wave{0%{background-position:100% 0}to{background-position:-100% 0}}@media(prefers-reduced-motion:reduce),(forced-colors:active),print{.praxis-chart-host--refreshing{transition:none}.praxis-chart-loading-chip,.praxis-chart-loading-bar{animation:none}.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{animation:none;background-image:none;color:var( --pdx-text-shimmer-fallback-color, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20)) );-webkit-text-fill-color:currentColor}}@media(forced-colors:active){.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{color:CanvasText!important;-webkit-text-fill-color:CanvasText!important}}\n"], dependencies: [{ kind: "ngmodule", type: MatTooltipModule }, { kind: "directive", type: i1$2.MatTooltip, selector: "[matTooltip]", inputs: ["matTooltipPosition", "matTooltipPositionAtOrigin", "matTooltipDisabled", "matTooltipShowDelay", "matTooltipHideDelay", "matTooltipTouchGestures", "matTooltip", "matTooltipClass"], exportAs: ["matTooltip"] }, { kind: "component", type: PraxisIconButtonComponent, selector: "button[praxisIconButton]", inputs: ["praxisIconButton", "size", "appearance", "presentation", "pressed", "busy"] }, { kind: "directive", type: PraxisQuickViewTriggerDirective, selector: "[praxisQuickView]", inputs: ["praxisQuickView", "praxisQuickViewRecord", "praxisQuickViewTriggerMode", "praxisQuickViewAnchorId", "praxisQuickViewSourceId", "praxisQuickViewEndpointKey", "praxisQuickViewApiUrlEntry", "praxisQuickViewQueryContext", "praxisQuickViewHoverOpenDelayMs", "praxisQuickViewHoverCloseDelayMs", "praxisQuickViewStopPropagation"], exportAs: ["praxisQuickView"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
3958
4823
  }
3959
4824
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartComponent, decorators: [{
3960
4825
  type: Component,
@@ -3964,9 +4829,10 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
3964
4829
  '[style.min-height]': 'resolvedHostMinHeight()',
3965
4830
  '[style.max-height]': 'resolvedMaxHeight()',
3966
4831
  '[style.aspect-ratio]': 'resolvedAspectRatio()',
3967
- }, imports: [MatTooltipModule, PraxisIconButtonComponent], changeDetection: ChangeDetectionStrategy.OnPush, template: `
4832
+ }, imports: [MatTooltipModule, PraxisIconButtonComponent, PraxisQuickViewTriggerDirective], changeDetection: ChangeDetectionStrategy.OnPush, template: `
3968
4833
  <section
3969
4834
  class="praxis-chart-shell"
4835
+ (keydown)="handleShellKeydown($event)"
3970
4836
  [class.praxis-chart-shell-fill-container]="isFillContainerMode()"
3971
4837
  [class.praxis-chart-shell-contained]="surfaceMode() === 'contained'"
3972
4838
  [class.praxis-chart-shell-embedded]="surfaceMode() === 'embedded'"
@@ -4137,6 +5003,8 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4137
5003
  class="praxis-chart-point-action"
4138
5004
  [attr.aria-label]="accessiblePointActionLabel(point)"
4139
5005
  [attr.aria-pressed]="accessiblePointPressed(point)"
5006
+ [praxisQuickView]="accessiblePointQuickViewBinding(point)"
5007
+ [praxisQuickViewRecord]="accessiblePointQuickViewRecord(point)"
4140
5008
  (click)="activateAccessiblePoint(point)"
4141
5009
  >
4142
5010
  {{ point.categoryLabel }}
@@ -4162,7 +5030,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
4162
5030
  ></div>
4163
5031
  }
4164
5032
  </section>
4165
- `, styles: [":host{display:block;min-height:var(--praxis-chart-runtime-height, 320px);min-width:0;color:var(--md-sys-color-on-surface, #1a1b20)}:host(.praxis-chart-host-fill-container){height:100%;min-height:0}:host-context(.pdx-shell){color:inherit}:host-context(.pdx-shell.no-shell) .praxis-chart-shell,:host-context(.pdx-shell.body-fill) .praxis-chart-shell,:host-context(.pdx-shell.expanded) .praxis-chart-shell,:host-context(.pdx-shell.fullscreen) .praxis-chart-shell{height:100%!important}.praxis-chart-shell{box-sizing:border-box;position:relative;width:100%;height:100%;min-height:240px;border-radius:var(--praxis-chart-config-surface-radius, var(--praxis-chart-surface-radius, 8px));overflow:hidden;background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) );border-color:var( --praxis-chart-config-surface-border, var( --praxis-chart-surface-border, color-mix(in srgb, var(--md-sys-color-outline, #c5c7ce) 44%, transparent) ) );border-style:solid;border-width:var(--praxis-chart-config-surface-border-width, 1px)}.praxis-chart-shell-fill-container{min-height:0}:host-context(.pdx-shell) .praxis-chart-shell:not(.praxis-chart-shell-contained),.praxis-chart-shell-embedded{border-width:var( --praxis-chart-config-surface-border-width, var(--praxis-chart-embedded-surface-border-width, 0) );border-radius:var( --praxis-chart-config-surface-radius, var(--praxis-chart-embedded-surface-radius, 0) );background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-embedded-surface-bg, transparent) )}.praxis-chart-settings-trigger,.praxis-chart-data-trigger{position:absolute;top:10px;right:10px;z-index:5;--praxis-icon-button-filled-background: color-mix(in srgb, var(--md-sys-color-surface, #fff) 88%, rgba(18, 99, 180, .12));--praxis-icon-button-filled-foreground: var(--md-sys-color-on-surface-variant, #44474f);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.praxis-chart-data-trigger-with-settings{right:50px}.praxis-chart-accessible-data{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;overflow:hidden;padding:52px 16px 14px;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20));background:var(--md-sys-color-surface-container-lowest, #fff)}.praxis-chart-accessible-data--shell-controlled{padding-top:16px}.praxis-chart-accessible-header{flex-shrink:0;margin-bottom:8px}.praxis-chart-accessible-header h3{margin:0;font-size:.95rem;font-weight:600;line-height:1.4}.praxis-chart-table-container{flex:1;min-height:0;overflow-y:auto;overflow-x:auto;overscroll-behavior:contain}.praxis-chart-accessible-data table{width:100%;border-collapse:separate;border-spacing:0;font-size:.875rem}.praxis-chart-accessible-data caption{padding:0 0 8px;text-align:left;color:inherit;opacity:.85;font-size:.8125rem}.praxis-chart-accessible-data thead{position:sticky;top:0;z-index:2}.praxis-chart-accessible-data thead th{font-weight:600;background:var(--praxis-chart-accessible-thead-bg, inherit);box-shadow:0 1px color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent)}.praxis-chart-accessible-data th,.praxis-chart-accessible-data td{padding:8px;border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent);text-align:left}.praxis-chart-col-value{text-align:right!important;font-variant-numeric:tabular-nums}.praxis-chart-sort-header{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer;transition:background-color .15s ease}.praxis-chart-sort-header:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-sort-header:focus-visible{outline:2px solid currentColor;outline-offset:1px}.praxis-chart-sort-header--numeric{margin-left:auto}.praxis-chart-sort-indicator{font-size:.75rem;line-height:1;opacity:.85}.praxis-chart-point-action{appearance:none;padding:4px 6px;border:0;border-radius:4px;color:inherit;background:transparent;font:inherit;font-weight:600;text-align:left;text-decoration:none;cursor:pointer;transition:background-color .15s ease}.praxis-chart-point-action:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-point-action:focus-visible{outline:3px solid currentColor;outline-offset:2px}.praxis-chart-point-action[aria-pressed=true]{color:var(--md-sys-color-on-primary-container, #001b3f);background:var(--md-sys-color-primary-container, #d7e3ff)}.praxis-chart-host{width:100%;height:100%;min-height:inherit}.praxis-chart-host--data-hidden{visibility:hidden;pointer-events:none}.praxis-chart-host--refreshing{opacity:.36;pointer-events:none;transition:opacity .18s ease}.praxis-chart-state{height:100%;min-height:min(240px,100%);display:grid;align-content:center;justify-items:center;gap:14px;padding:24px;text-align:center}.praxis-chart-state-loading--overlay{position:absolute;inset:0;z-index:3;background:color-mix(in srgb,var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) ) 72%,transparent)}.praxis-chart-state-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20))}.praxis-chart-state-description{font-size:.925rem;color:color-mix(in srgb,var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%,transparent);max-width:36rem}.praxis-chart-loading-hero{width:min(100%,320px);display:grid;gap:14px}.praxis-chart-loading-summary{display:flex;gap:8px;justify-content:center}.praxis-chart-loading-chip,.praxis-chart-loading-bar{border-radius:999px;background:linear-gradient(90deg,#1263b414,#1263b438,#1263b414);background-size:200% 100%;animation:praxis-chart-loading-wave 1.2s ease-in-out infinite}.praxis-chart-loading-chip{display:block;width:104px;height:12px}.praxis-chart-loading-chip--short{width:64px}.praxis-chart-loading-plot{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));align-items:end;gap:10px;height:108px;padding:12px 8px 4px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container, #eef3f8) 72%,transparent);border:1px solid rgba(18,99,180,.08)}.praxis-chart-loading-bar{display:block;width:100%}.praxis-chart-loading-bar--1{height:32%}.praxis-chart-loading-bar--2{height:68%}.praxis-chart-loading-bar--3{height:48%}.praxis-chart-loading-bar--4{height:78%}.praxis-chart-loading-bar--5{height:56%}@keyframes praxis-chart-loading-wave{0%{background-position:100% 0}to{background-position:-100% 0}}@media(prefers-reduced-motion:reduce){.praxis-chart-host--refreshing{transition:none}.praxis-chart-loading-chip,.praxis-chart-loading-bar{animation:none}}\n"] }]
5033
+ `, styles: [":host{display:block;min-height:var(--praxis-chart-runtime-height, 320px);min-width:0;color:var(--md-sys-color-on-surface, #1a1b20)}:host(.praxis-chart-host-fill-container){height:100%;min-height:0}:host-context(.pdx-shell){color:inherit}:host-context(.pdx-shell.no-shell) .praxis-chart-shell,:host-context(.pdx-shell.body-fill) .praxis-chart-shell,:host-context(.pdx-shell.expanded) .praxis-chart-shell,:host-context(.pdx-shell.fullscreen) .praxis-chart-shell{height:100%!important}.praxis-chart-shell{box-sizing:border-box;position:relative;width:100%;height:100%;min-height:240px;border-radius:var(--praxis-chart-config-surface-radius, var(--praxis-chart-surface-radius, 8px));overflow:hidden;background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) );border-color:var( --praxis-chart-config-surface-border, var( --praxis-chart-surface-border, color-mix(in srgb, var(--md-sys-color-outline, #c5c7ce) 44%, transparent) ) );border-style:solid;border-width:var(--praxis-chart-config-surface-border-width, 1px)}.praxis-chart-shell-fill-container{min-height:0}:host-context(.pdx-shell) .praxis-chart-shell:not(.praxis-chart-shell-contained),.praxis-chart-shell-embedded{border-width:var( --praxis-chart-config-surface-border-width, var(--praxis-chart-embedded-surface-border-width, 0) );border-radius:var( --praxis-chart-config-surface-radius, var(--praxis-chart-embedded-surface-radius, 0) );background:var( --praxis-chart-config-surface-bg, var(--praxis-chart-embedded-surface-bg, transparent) )}.praxis-chart-settings-trigger,.praxis-chart-data-trigger{position:absolute;top:10px;right:10px;z-index:5;--praxis-icon-button-filled-background: color-mix(in srgb, var(--md-sys-color-surface, #fff) 88%, rgba(18, 99, 180, .12));--praxis-icon-button-filled-foreground: var(--md-sys-color-on-surface-variant, #44474f);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}.praxis-chart-data-trigger-with-settings{right:50px}.praxis-chart-accessible-data{position:absolute;inset:0;z-index:4;display:flex;flex-direction:column;overflow:hidden;padding:52px 16px 14px;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20));background:var(--md-sys-color-surface-container-lowest, #fff)}.praxis-chart-accessible-data--shell-controlled{padding-top:16px}.praxis-chart-accessible-header{flex-shrink:0;margin-bottom:8px}.praxis-chart-accessible-header h3{margin:0;font-size:.95rem;font-weight:600;line-height:1.4}.praxis-chart-table-container{flex:1;min-height:0;overflow-y:auto;overflow-x:auto;overscroll-behavior:contain}.praxis-chart-accessible-data table{width:100%;border-collapse:separate;border-spacing:0;font-size:.875rem}.praxis-chart-accessible-data caption{padding:0 0 8px;text-align:left;color:inherit;opacity:.85;font-size:.8125rem}.praxis-chart-accessible-data thead{position:sticky;top:0;z-index:2}.praxis-chart-accessible-data thead th{font-weight:600;background:var(--praxis-chart-accessible-thead-bg, inherit);box-shadow:0 1px color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent)}.praxis-chart-accessible-data th,.praxis-chart-accessible-data td{padding:8px;border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 44%,transparent);text-align:left}.praxis-chart-col-value{text-align:right!important;font-variant-numeric:tabular-nums}.praxis-chart-sort-header{appearance:none;display:inline-flex;align-items:center;gap:4px;padding:2px 4px;border:0;border-radius:4px;background:transparent;color:inherit;font:inherit;font-weight:600;cursor:pointer;transition:background-color .15s ease}.praxis-chart-sort-header:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-sort-header:focus-visible{outline:2px solid currentColor;outline-offset:1px}.praxis-chart-sort-header--numeric{margin-left:auto}.praxis-chart-sort-indicator{font-size:.75rem;line-height:1;opacity:.85}.praxis-chart-point-action{appearance:none;padding:4px 6px;border:0;border-radius:4px;color:inherit;background:transparent;font:inherit;font-weight:600;text-align:left;text-decoration:none;cursor:pointer;transition:background-color .15s ease}.praxis-chart-point-action:hover{background:color-mix(in srgb,currentColor 8%,transparent)}.praxis-chart-point-action:focus-visible{outline:3px solid currentColor;outline-offset:2px}.praxis-chart-point-action[aria-pressed=true]{color:var(--md-sys-color-on-primary-container, #001b3f);background:var(--md-sys-color-primary-container, #d7e3ff)}.praxis-chart-host{width:100%;height:100%;min-height:inherit}.praxis-chart-host--data-hidden{visibility:hidden;pointer-events:none}.praxis-chart-host--refreshing{opacity:.36;pointer-events:none;transition:opacity .18s ease}.praxis-chart-state{height:100%;min-height:min(240px,100%);display:grid;align-content:center;justify-items:center;gap:14px;padding:24px;text-align:center}.praxis-chart-state-loading--overlay{position:absolute;inset:0;z-index:3;background:color-mix(in srgb,var( --praxis-chart-config-surface-bg, var(--praxis-chart-surface-bg, var(--md-sys-color-surface-container-lowest, #fff)) ) 72%,transparent)}.praxis-chart-state-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20))}.praxis-chart-state-description{font-size:.925rem;color:color-mix(in srgb,var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%,transparent);max-width:36rem}.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{background-image:var( --pdx-text-shimmer-gradient, linear-gradient( 105deg, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 0%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 28%, var(--md-sys-color-primary, #1263b4) 44%, var(--md-sys-color-tertiary, #7c3aed) 56%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 72%, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface-variant, #5a5d67)) 100% ) );background-size:260% 100%;background-position:200% 50%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;animation:praxis-chart-text-shimmer var(--pdx-text-shimmer-duration, 2.4s) linear infinite}@keyframes praxis-chart-text-shimmer{0%{background-position:200% 50%}to{background-position:-60% 50%}}.praxis-chart-loading-hero{width:min(100%,320px);display:grid;gap:14px}.praxis-chart-loading-summary{display:flex;gap:8px;justify-content:center}.praxis-chart-loading-chip,.praxis-chart-loading-bar{border-radius:999px;background:linear-gradient(90deg,#1263b414,#1263b438,#1263b414);background-size:200% 100%;animation:praxis-chart-loading-wave 1.2s ease-in-out infinite}.praxis-chart-loading-chip{display:block;width:104px;height:12px}.praxis-chart-loading-chip--short{width:64px}.praxis-chart-loading-plot{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));align-items:end;gap:10px;height:108px;padding:12px 8px 4px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-surface-container, #eef3f8) 72%,transparent);border:1px solid rgba(18,99,180,.08)}.praxis-chart-loading-bar{display:block;width:100%}.praxis-chart-loading-bar--1{height:32%}.praxis-chart-loading-bar--2{height:68%}.praxis-chart-loading-bar--3{height:48%}.praxis-chart-loading-bar--4{height:78%}.praxis-chart-loading-bar--5{height:56%}@keyframes praxis-chart-loading-wave{0%{background-position:100% 0}to{background-position:-100% 0}}@media(prefers-reduced-motion:reduce),(forced-colors:active),print{.praxis-chart-host--refreshing{transition:none}.praxis-chart-loading-chip,.praxis-chart-loading-bar{animation:none}.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{animation:none;background-image:none;color:var( --pdx-text-shimmer-fallback-color, var(--praxis-chart-config-text-color, var(--md-sys-color-on-surface, #1a1b20)) );-webkit-text-fill-color:currentColor}}@media(forced-colors:active){.praxis-chart-state-loading .praxis-chart-state-title,.praxis-chart-state-loading .praxis-chart-state-description{color:CanvasText!important;-webkit-text-fill-color:CanvasText!important}}\n"] }]
4166
5034
  }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", required: false }] }], resourceKey: [{ type: i0.Input, args: [{ isSignal: true, alias: "resourceKey", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], chartDocument: [{ type: i0.Input, args: [{ isSignal: true, alias: "chartDocument", required: false }] }], filterCriteria: [{ type: i0.Input, args: [{ isSignal: true, alias: "filterCriteria", required: false }] }], queryContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "queryContext", required: false }] }], remoteDataResolver: [{ type: i0.Input, args: [{ isSignal: true, alias: "remoteDataResolver", required: false }] }], enableCustomization: [{ type: i0.Input, args: [{ isSignal: true, alias: "enableCustomization", required: false }] }], availableResources: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableResources", required: false }] }], availableFields: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableFields", required: false }] }], availableTargets: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableTargets", required: false }] }], pointClick: [{ type: i0.Output, args: ["pointClick"] }], pointAction: [{ type: i0.Output, args: ["pointAction"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], drillDown: [{ type: i0.Output, args: ["drillDown"] }], crossFilter: [{ type: i0.Output, args: ["crossFilter"] }], queryRequest: [{ type: i0.Output, args: ["queryRequest"] }], loadStateChange: [{ type: i0.Output, args: ["loadStateChange"] }], chartDocumentApplied: [{ type: i0.Output, args: ["chartDocumentApplied"] }], chartDocumentSaved: [{ type: i0.Output, args: ["chartDocumentSaved"] }], chartDocumentPatchChange: [{ type: i0.Output, args: ["chartDocumentPatchChange"] }], chartHost: [{ type: i0.ViewChild, args: ['chartHost', { isSignal: true }] }] } });
4167
5035
  function mergeRemoteQueryContext(config, queryContext, runtimeFilters) {
4168
5036
  const dataSource = config.dataSource;
@@ -4244,6 +5112,24 @@ function buildAccessibleChartPoints(config, transformed, formatValue, pointFallb
4244
5112
  seriesFallback,
4245
5113
  }));
4246
5114
  }
5115
+ if (transformed.mode === 'heatmap' && transformed.heatmap) {
5116
+ const heatmap = transformed.heatmap;
5117
+ return heatmap.cells
5118
+ .filter((cell) => cell.rawValue !== null && cell.rawValue !== undefined)
5119
+ .map((cell, index) => createAccessiblePoint({
5120
+ id: cell.id,
5121
+ config,
5122
+ seriesId: heatmap.id,
5123
+ seriesName: `${cell.yCategory} (${heatmap.name})`,
5124
+ category: cell.xCategory,
5125
+ value: cell.rawValue,
5126
+ data: cell.data,
5127
+ index,
5128
+ formatValue,
5129
+ pointFallback,
5130
+ seriesFallback,
5131
+ }));
5132
+ }
4247
5133
  return transformed.series.flatMap((series) => series.points.flatMap((point, index) => {
4248
5134
  const value = accessiblePointValue(point);
4249
5135
  if (value === null || value === undefined) {
@@ -4291,7 +5177,7 @@ function createAccessiblePoint(options) {
4291
5177
  }
4292
5178
  function accessiblePointValue(point) {
4293
5179
  if (Array.isArray(point)) {
4294
- return point.length > 1 ? point[1] : point[0];
5180
+ return point.length > 1 ? point[point.length - 1] : point[0];
4295
5181
  }
4296
5182
  if (point && typeof point === 'object' && 'value' in point) {
4297
5183
  return accessiblePointValue(point['value']);
@@ -5915,7 +6801,8 @@ class AnalyticsChartConfigAdapterService {
5915
6801
  toPraxisChartConfig(projection, options) {
5916
6802
  const chartType = this.resolveChartType(projection);
5917
6803
  const orientation = chartType === 'horizontal-bar' ? 'horizontal' : undefined;
5918
- const dimension = projection.bindings.primaryDimension;
6804
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
6805
+ const dimension = dimensions[0];
5919
6806
  const metrics = projection.bindings.primaryMetrics ?? [];
5920
6807
  if (!dimension?.field) {
5921
6808
  throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension for projection "${projection.id}".`);
@@ -5924,10 +6811,23 @@ class AnalyticsChartConfigAdapterService {
5924
6811
  throw new Error(`AnalyticsChartConfigAdapterService requires at least one metric for projection "${projection.id}".`);
5925
6812
  }
5926
6813
  const crossFilter = Boolean(projection.interactions?.crossFilter);
6814
+ const pointSelection = Boolean(projection.interactions?.pointSelection);
6815
+ const drillDown = Boolean(projection.interactions?.drillDown);
5927
6816
  const keyFilterField = dimension.keyFilterField?.trim();
6817
+ const secondaryKeyFilterField = dimensions[1]?.keyFilterField?.trim();
5928
6818
  if (crossFilter && !keyFilterField) {
5929
6819
  throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension.keyFilterField when crossFilter is enabled for projection "${projection.id}".`);
5930
6820
  }
6821
+ if (crossFilter && dimensions[1] && dimensions[1].keyFilterField !== undefined && !secondaryKeyFilterField) {
6822
+ throw new Error(`AnalyticsChartConfigAdapterService requires secondaryDimension.keyFilterField to be non-empty when crossFilter is enabled for projection "${projection.id}".`);
6823
+ }
6824
+ const keyFilterMapping = keyFilterField
6825
+ ? {
6826
+ key: keyFilterField,
6827
+ ...(secondaryKeyFilterField ? { secondaryKey: secondaryKeyFilterField } : {}),
6828
+ }
6829
+ : undefined;
6830
+ const hasMappedEventActions = Boolean(keyFilterMapping && (crossFilter || pointSelection || drillDown));
5931
6831
  return {
5932
6832
  id: projection.id,
5933
6833
  type: chartType,
@@ -5940,17 +6840,37 @@ class AnalyticsChartConfigAdapterService {
5940
6840
  series: this.buildSeries(projection, chartType),
5941
6841
  dataSource: this.buildDataSource(projection),
5942
6842
  interactions: {
5943
- pointClick: Boolean(projection.interactions?.pointSelection || projection.interactions?.drillDown),
5944
- drillDown: Boolean(projection.interactions?.drillDown),
5945
- selection: Boolean(projection.interactions?.pointSelection),
6843
+ pointClick: Boolean(pointSelection || drillDown),
6844
+ drillDown,
6845
+ selection: pointSelection,
5946
6846
  crossFilter,
5947
- ...(crossFilter
6847
+ ...(hasMappedEventActions
5948
6848
  ? {
5949
6849
  eventActions: {
5950
- crossFilter: {
5951
- action: 'emit',
5952
- mapping: { key: keyFilterField },
5953
- },
6850
+ ...(crossFilter
6851
+ ? {
6852
+ crossFilter: {
6853
+ action: 'emit',
6854
+ mapping: { ...keyFilterMapping },
6855
+ },
6856
+ }
6857
+ : {}),
6858
+ ...(pointSelection
6859
+ ? {
6860
+ selectionChange: {
6861
+ action: 'emit',
6862
+ mapping: { ...keyFilterMapping },
6863
+ },
6864
+ }
6865
+ : {}),
6866
+ ...(drillDown
6867
+ ? {
6868
+ drillDown: {
6869
+ action: 'emit',
6870
+ mapping: { ...keyFilterMapping },
6871
+ },
6872
+ }
6873
+ : {}),
5954
6874
  },
5955
6875
  }
5956
6876
  : {}),
@@ -5958,20 +6878,40 @@ class AnalyticsChartConfigAdapterService {
5958
6878
  };
5959
6879
  }
5960
6880
  buildAxes(projection, chartType) {
5961
- const dimension = projection.bindings.primaryDimension;
6881
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
6882
+ const dimension = dimensions[0];
6883
+ const secondaryDimension = dimensions[1];
6884
+ const primaryField = this.statsBuilder.resolveEffectiveDimensionField(dimension, dimensions);
6885
+ const secondaryField = secondaryDimension
6886
+ ? this.statsBuilder.resolveEffectiveDimensionField(secondaryDimension, dimensions)
6887
+ : undefined;
5962
6888
  const metrics = this.getDisplayMetrics(projection);
5963
6889
  const firstMetric = metrics[0];
5964
6890
  if (this.isCategorySliceChart(chartType)) {
5965
6891
  return {
5966
6892
  x: {
5967
- field: dimension.field,
6893
+ field: primaryField,
5968
6894
  label: dimension.label ?? undefined,
5969
6895
  },
5970
6896
  };
5971
6897
  }
6898
+ if (chartType === 'heatmap') {
6899
+ return {
6900
+ x: {
6901
+ field: primaryField,
6902
+ label: dimension.label ?? undefined,
6903
+ type: 'category',
6904
+ },
6905
+ y: {
6906
+ field: secondaryField,
6907
+ label: secondaryDimension?.label ?? undefined,
6908
+ type: 'category',
6909
+ },
6910
+ };
6911
+ }
5972
6912
  return {
5973
6913
  x: {
5974
- field: dimension.field,
6914
+ field: primaryField,
5975
6915
  label: dimension.label ?? undefined,
5976
6916
  type: dimension.role === 'time' ? 'time' : 'category',
5977
6917
  },
@@ -5984,31 +6924,42 @@ class AnalyticsChartConfigAdapterService {
5984
6924
  };
5985
6925
  }
5986
6926
  buildSeries(projection, chartType) {
5987
- const dimension = projection.bindings.primaryDimension;
6927
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
6928
+ const dimension = dimensions[0];
6929
+ const secondaryDimension = dimensions[1];
6930
+ const primaryField = this.statsBuilder.resolveEffectiveDimensionField(dimension, dimensions);
6931
+ const secondaryField = secondaryDimension
6932
+ ? this.statsBuilder.resolveEffectiveDimensionField(secondaryDimension, dimensions)
6933
+ : undefined;
5988
6934
  const comparison = projection.source.operation === 'comparison';
5989
6935
  return this.getDisplayMetrics(projection).flatMap((metric, index) => comparison
5990
6936
  ? ['current', 'previous'].map((period) => ({
5991
6937
  id: `${projection.id}.${metric.field}.${period}`,
5992
6938
  name: `${metric.label ?? metric.field} (${this.comparisonPeriodLabel(period)})`,
5993
6939
  type: chartType,
5994
- categoryField: dimension.field,
6940
+ categoryField: primaryField,
5995
6941
  metric: { field: this.comparisonMetricField(metric.field, period), aggregation: this.mapAggregation(metric.aggregation) },
5996
6942
  }))
5997
6943
  : [{
5998
6944
  id: `${projection.id}.${metric.field}.${index + 1}`,
5999
6945
  name: metric.label ?? metric.field,
6000
- type: chartType,
6001
- categoryField: this.isCategorySliceChart(chartType) ? dimension.field : undefined,
6946
+ type: chartType === 'stacked-bar' ? 'bar' : chartType,
6947
+ categoryField: this.isCategorySliceChart(chartType) ? primaryField : undefined,
6948
+ ...(secondaryField ? { secondaryCategoryField: secondaryField } : {}),
6949
+ ...(chartType === 'stacked-bar' || chartType === 'stacked-area' ? { stackId: 'stack-1' } : {}),
6002
6950
  metric: {
6003
6951
  field: metric.field,
6004
6952
  aggregation: this.mapAggregation(metric.aggregation),
6005
6953
  label: metric.label ?? undefined,
6006
6954
  },
6007
- smooth: chartType === 'line' || chartType === 'area',
6955
+ smooth: chartType === 'line' || chartType === 'area' || chartType === 'stacked-area',
6008
6956
  }]);
6009
6957
  }
6010
6958
  buildDataSource(projection) {
6011
6959
  const executionPlan = this.statsBuilder.buildExecutionPlan(projection);
6960
+ const groupByRequest = executionPlan.operation === 'group-by'
6961
+ ? executionPlan.statsRequest
6962
+ : undefined;
6012
6963
  return {
6013
6964
  kind: 'remote',
6014
6965
  resourcePath: executionPlan.resourcePath,
@@ -6016,9 +6967,14 @@ class AnalyticsChartConfigAdapterService {
6016
6967
  query: {
6017
6968
  sourceKind: 'praxis.stats',
6018
6969
  statsOperation: executionPlan.operation,
6970
+ ...(groupByRequest?.fillGaps !== undefined ? { fillGaps: groupByRequest.fillGaps } : {}),
6971
+ ...(groupByRequest?.timezone ? { timezone: groupByRequest.timezone } : {}),
6972
+ ...(groupByRequest?.periodField ? { periodField: groupByRequest.periodField } : {}),
6973
+ ...(groupByRequest?.periodPreset ? { periodPreset: groupByRequest.periodPreset } : {}),
6019
6974
  statsPath: executionPlan.statsPath,
6020
6975
  statsRequest: executionPlan.statsRequest,
6021
6976
  dimensions: executionPlan.dimensions,
6977
+ ...(groupByRequest?.dimensions?.length ? { dimensionConfigs: groupByRequest.dimensions } : {}),
6022
6978
  metrics: projection.source.operation === 'comparison'
6023
6979
  ? this.getDisplayMetrics(projection).flatMap((metric) => ['current', 'previous'].map((period) => ({
6024
6980
  field: this.comparisonMetricField(metric.field, period),
@@ -6032,6 +6988,7 @@ class AnalyticsChartConfigAdapterService {
6032
6988
  };
6033
6989
  }
6034
6990
  resolveChartType(projection) {
6991
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
6035
6992
  if (projection.intent === 'trend' && projection.source.operation === 'timeseries') {
6036
6993
  return 'line';
6037
6994
  }
@@ -6039,10 +6996,10 @@ class AnalyticsChartConfigAdapterService {
6039
6996
  return 'horizontal-bar';
6040
6997
  }
6041
6998
  if (projection.intent === 'composition') {
6042
- return 'pie';
6999
+ return dimensions.length > 1 ? 'stacked-bar' : 'pie';
6043
7000
  }
6044
7001
  if (projection.intent === 'distribution') {
6045
- return 'bar';
7002
+ return dimensions.length > 1 ? 'heatmap' : 'bar';
6046
7003
  }
6047
7004
  return 'bar';
6048
7005
  }
@@ -6062,7 +7019,10 @@ class AnalyticsChartConfigAdapterService {
6062
7019
  case 'count':
6063
7020
  case 'distinct-count':
6064
7021
  case 'sum':
7022
+ case 'avg-per-cycle-occurrence':
6065
7023
  return normalizedAggregation;
7024
+ case 'avg_per_cycle_occurrence':
7025
+ return 'avg-per-cycle-occurrence';
6066
7026
  case '':
6067
7027
  return undefined;
6068
7028
  default:
@@ -6123,6 +7083,7 @@ class PraxisAnalyticsPresentationComponent {
6123
7083
  queryRequest = output();
6124
7084
  loadStateChange = output();
6125
7085
  resolver = inject(AnalyticsPresentationResolver);
7086
+ statsBuilder = inject(AnalyticsStatsRequestBuilderService);
6126
7087
  i18n = inject(PraxisI18nService);
6127
7088
  chartAdapter = inject(AnalyticsChartConfigAdapterService);
6128
7089
  tableAdapter = inject(AnalyticsTableConfigAdapterService);
@@ -6160,15 +7121,17 @@ class PraxisAnalyticsPresentationComponent {
6160
7121
  kpiSubtitle = computed(() => this.resolveText(this.subtitle()), ...(ngDevMode ? [{ debugName: "kpiSubtitle" }] : /* istanbul ignore next */ []));
6161
7122
  kpiItems = computed(() => {
6162
7123
  const projection = this.resolved().projection;
6163
- const dimension = projection?.bindings.primaryDimension;
7124
+ const dimensions = projection ? this.statsBuilder.resolveDimensionBindings(projection) : [];
7125
+ const dimension = dimensions[0];
6164
7126
  const metric = projection?.bindings.primaryMetrics[0];
6165
7127
  if (!projection || !dimension?.field || !metric?.field || !this.isKpiEligible(projection)) {
6166
7128
  return [];
6167
7129
  }
7130
+ const effectiveDimField = this.statsBuilder.resolveEffectiveDimensionField(dimension, dimensions);
6168
7131
  return this.resultRows().map((row, index) => {
6169
7132
  const rawValue = row[metric.field];
6170
7133
  const value = typeof rawValue === 'number' && Number.isFinite(rawValue) ? rawValue : 0;
6171
- const label = stringifyValue(row[dimension.field]) ?? this.t('praxis.charts.analyticsPresentation.kpiUnknownBucket', 'Unidentified category');
7134
+ const label = stringifyValue(row[effectiveDimField]) ?? this.t('praxis.charts.analyticsPresentation.kpiUnknownBucket', 'Unidentified category');
6172
7135
  return {
6173
7136
  trackId: `${stringifyValue(row[ANALYTICS_TABLE_ROW_KEY_FIELD]) ?? label}-${index}`,
6174
7137
  label,
@@ -6240,7 +7203,10 @@ class PraxisAnalyticsPresentationComponent {
6240
7203
  const projection = this.resolved().projection;
6241
7204
  if (!row || !projection)
6242
7205
  return;
6243
- const dimensionField = projection.bindings.primaryDimension?.field;
7206
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
7207
+ const dimensionField = dimensions[0]
7208
+ ? this.statsBuilder.resolveEffectiveDimensionField(dimensions[0], dimensions)
7209
+ : undefined;
6244
7210
  const metricField = projection.bindings.primaryMetrics[0]?.field;
6245
7211
  this.pointClick.emit({
6246
7212
  chartId: projection.id,
@@ -6318,8 +7284,10 @@ class PraxisAnalyticsPresentationComponent {
6318
7284
  }
6319
7285
  }
6320
7286
  isKpiEligible(projection) {
7287
+ const dimensions = this.statsBuilder.resolveDimensionBindings(projection);
6321
7288
  return (projection.source.operation === 'group-by'
6322
- && !!projection.bindings.primaryDimension?.field
7289
+ && dimensions.length === 1
7290
+ && !!dimensions[0]?.field
6323
7291
  && projection.bindings.primaryMetrics.length === 1
6324
7292
  && !!projection.bindings.primaryMetrics[0]?.field
6325
7293
  && (projection.presentationHints?.preferredFamilies ?? []).includes('kpi'));
@@ -6340,11 +7308,11 @@ class PraxisAnalyticsPresentationComponent {
6340
7308
  return Object.entries(values).reduce((message, [name, value]) => message.replaceAll(`{${name}}`, value), this.t(key, fallback));
6341
7309
  }
6342
7310
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisAnalyticsPresentationComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
6343
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisAnalyticsPresentationComponent, isStandalone: true, selector: "praxis-analytics-presentation", inputs: { analytics: { classPropertyName: "analytics", publicName: "analytics", isSignal: true, isRequired: true, transformFunction: null }, projectionId: { classPropertyName: "projectionId", publicName: "projectionId", isSignal: true, isRequired: true, transformFunction: null }, availableFamilies: { classPropertyName: "availableFamilies", publicName: "availableFamilies", isSignal: true, isRequired: false, transformFunction: null }, preferredFamily: { classPropertyName: "preferredFamily", publicName: "preferredFamily", isSignal: true, isRequired: false, transformFunction: null }, queryContext: { classPropertyName: "queryContext", publicName: "queryContext", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, motion: { classPropertyName: "motion", publicName: "motion", isSignal: true, isRequired: false, transformFunction: null }, enableCustomization: { classPropertyName: "enableCustomization", publicName: "enableCustomization", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { presentationChange: "presentationChange", pointClick: "pointClick", pointAction: "pointAction", selectionChange: "selectionChange", drillDown: "drillDown", crossFilter: "crossFilter", queryRequest: "queryRequest", loadStateChange: "loadStateChange" }, ngImport: i0, template: "<section class=\"analytics-presentation\" [attr.aria-label]=\"projectionId()\">\n <div class=\"analytics-presentation__toolbar\" role=\"group\" [attr.aria-label]=\"t('praxis.charts.analyticsPresentation.selectorLabel', 'Analytics presentation')\">\n @if (eligibleFamilies().includes('chart')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'chart'\" (click)=\"selectFamily('chart')\">\n {{ t('praxis.charts.analyticsPresentation.chart', 'Chart') }}\n </button>\n }\n @if (eligibleFamilies().includes('analytic-table')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'analytic-table'\" (click)=\"selectFamily('analytic-table')\">\n {{ t('praxis.charts.analyticsPresentation.table', 'Table') }}\n </button>\n }\n @if (eligibleFamilies().includes('kpi')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'kpi'\" (click)=\"selectFamily('kpi')\">\n {{ t('praxis.charts.analyticsPresentation.kpi', 'KPI') }}\n </button>\n }\n </div>\n\n @if (diagnostic(); as message) {\n <section class=\"analytics-presentation__state analytics-presentation__state--error\" role=\"alert\">\n <strong>{{ t('praxis.charts.analyticsPresentation.unavailable', 'Presentation unavailable') }}</strong>\n <span>{{ message }}</span>\n </section>\n } @else if (activeFamily() === 'chart' && chartConfig(); as config) {\n <praxis-chart\n [config]=\"config\"\n [queryContext]=\"queryContext()\"\n [enableCustomization]=\"enableCustomization()\"\n (pointClick)=\"pointClick.emit($event)\"\n (pointAction)=\"pointAction.emit($event)\"\n (selectionChange)=\"selectionChange.emit($event)\"\n (drillDown)=\"drillDown.emit($event)\"\n (crossFilter)=\"crossFilter.emit($event)\"\n (queryRequest)=\"queryRequest.emit($event)\"\n (loadStateChange)=\"loadStateChange.emit($event)\"\n />\n } @else if (activeFamily() === 'analytic-table' && tableConfig(); as config) {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <praxis-table\n [config]=\"config\"\n [data]=\"resultRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\n }\n } @else if (activeFamily() === 'kpi') {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <section class=\"analytics-presentation__kpi\" [attr.aria-label]=\"kpiTitle() || t('praxis.charts.analyticsPresentation.kpiCollection', 'Key metrics')\">\n @if (kpiTitle(); as heading) {\n <header class=\"analytics-presentation__kpi-header\">\n <h3>{{ heading }}</h3>\n @if (kpiSubtitle(); as description) {\n <p>{{ description }}</p>\n }\n </header>\n }\n <div class=\"analytics-presentation__kpi-grid\" role=\"list\">\n @for (item of kpiItems(); track item.trackId) {\n <div role=\"listitem\">\n <button\n class=\"analytics-presentation__kpi-card\"\n type=\"button\"\n [attr.aria-label]=\"kpiItemAriaLabel(item.label, item.formattedValue, item.metricLabel)\"\n (click)=\"handleKpiClick(item.row, item.label, item.value)\"\n >\n <strong>{{ item.formattedValue }}</strong>\n <span>{{ item.label }}</span>\n <small>{{ item.metricLabel }}</small>\n </button>\n </div>\n }\n </div>\n </section>\n }\n }\n</section>\n", styles: [":host{display:block;min-width:0}.analytics-presentation{display:grid;gap:12px;min-width:0}.analytics-presentation__toolbar{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}.analytics-presentation__toolbar button{min-height:40px;padding:0 14px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:999px;background:var(--md-sys-color-surface, var(--mat-sys-surface));color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;cursor:pointer}.analytics-presentation__toolbar button[aria-pressed=true]{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-primary-container, #d7e3ff);color:var(--md-sys-color-on-primary-container, #001b3e)}.analytics-presentation__toolbar button:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__state{min-height:220px;display:grid;place-content:center;gap:6px;padding:24px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);text-align:center}.analytics-presentation__state--error{border-color:var(--md-sys-color-error, #ba1a1a);color:var(--md-sys-color-error, #ba1a1a)}.analytics-presentation__kpi{display:grid;gap:16px;min-width:0}.analytics-presentation__kpi-header{display:grid;gap:4px}.analytics-presentation__kpi-header h3,.analytics-presentation__kpi-header p{margin:0}.analytics-presentation__kpi-header h3{color:var(--md-sys-color-on-surface, #1a1c1e);font-size:1rem;font-weight:650}.analytics-presentation__kpi-header p{color:var(--md-sys-color-on-surface-variant, #44474e);font-size:.875rem}.analytics-presentation__kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,176px),1fr));gap:12px;min-width:0}.analytics-presentation__kpi-card{display:grid;gap:6px;width:100%;height:100%;min-width:0;min-height:132px;align-content:center;padding:18px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;text-align:left;cursor:pointer}.analytics-presentation__kpi-card:hover{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-surface-container, #f0f1f3)}.analytics-presentation__kpi-card:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__kpi-card strong{overflow-wrap:anywhere;color:var(--md-sys-color-primary, #1263b4);font-size:clamp(1.75rem,5vw,2.5rem);font-variant-numeric:tabular-nums;line-height:1}.analytics-presentation__kpi-card span,.analytics-presentation__kpi-card small{overflow-wrap:anywhere}.analytics-presentation__kpi-card span{font-weight:600}.analytics-presentation__kpi-card small{color:var(--md-sys-color-on-surface-variant, #44474e)}@media(max-width:600px){.analytics-presentation__toolbar{justify-content:stretch}.analytics-presentation__toolbar button{flex:1 1 0}}\n"], dependencies: [{ kind: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "resourceKey", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved", "chartDocumentPatchChange"] }, { kind: "component", type: PraxisTable, selector: "praxis-table", inputs: ["config", "resourcePath", "data", "tableId", "componentInstanceId", "configPersistenceStrategy", "title", "subtitle", "icon", "autoDelete", "notifyIfOutdated", "snoozeMs", "autoOpenSettingsOnOutdated", "crudContext", "filterCriteria", "filterTeleport", "widgetId", "queryContext", "aiContext", "aiAssistantVoiceInputMode", "aiAssistantVoiceLanguage", "horizontalScroll", "enableCustomization", "authoringCapability", "dense"], outputs: ["filterCriteriaChange", "queryContextClear", "queryContextChange", "rowClick", "widgetEvent", "resourceEvent", "rowDoubleClick", "rowExpansionChange", "rowAction", "toolbarAction", "bulkAction", "exportAction", "columnReorder", "columnReorderAttempt", "columnResize", "beforeDelete", "afterDelete", "deleteError", "beforeBulkDelete", "afterBulkDelete", "bulkDeleteError", "schemaStatusChange", "configChange", "metadataChange", "loadingStateChange", "collectionLinksChange", "selectionChange", "drawerOpen", "drawerClose", "drawerTabChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7311
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisAnalyticsPresentationComponent, isStandalone: true, selector: "praxis-analytics-presentation", inputs: { analytics: { classPropertyName: "analytics", publicName: "analytics", isSignal: true, isRequired: true, transformFunction: null }, projectionId: { classPropertyName: "projectionId", publicName: "projectionId", isSignal: true, isRequired: true, transformFunction: null }, availableFamilies: { classPropertyName: "availableFamilies", publicName: "availableFamilies", isSignal: true, isRequired: false, transformFunction: null }, preferredFamily: { classPropertyName: "preferredFamily", publicName: "preferredFamily", isSignal: true, isRequired: false, transformFunction: null }, queryContext: { classPropertyName: "queryContext", publicName: "queryContext", isSignal: true, isRequired: false, transformFunction: null }, title: { classPropertyName: "title", publicName: "title", isSignal: true, isRequired: false, transformFunction: null }, subtitle: { classPropertyName: "subtitle", publicName: "subtitle", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, motion: { classPropertyName: "motion", publicName: "motion", isSignal: true, isRequired: false, transformFunction: null }, enableCustomization: { classPropertyName: "enableCustomization", publicName: "enableCustomization", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { presentationChange: "presentationChange", pointClick: "pointClick", pointAction: "pointAction", selectionChange: "selectionChange", drillDown: "drillDown", crossFilter: "crossFilter", queryRequest: "queryRequest", loadStateChange: "loadStateChange" }, ngImport: i0, template: "<section class=\"analytics-presentation\" [attr.aria-label]=\"projectionId()\">\n <div class=\"analytics-presentation__toolbar\" role=\"group\" [attr.aria-label]=\"t('praxis.charts.analyticsPresentation.selectorLabel', 'Analytics presentation')\">\n @if (eligibleFamilies().includes('chart')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'chart'\" (click)=\"selectFamily('chart')\">\n {{ t('praxis.charts.analyticsPresentation.chart', 'Chart') }}\n </button>\n }\n @if (eligibleFamilies().includes('analytic-table')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'analytic-table'\" (click)=\"selectFamily('analytic-table')\">\n {{ t('praxis.charts.analyticsPresentation.table', 'Table') }}\n </button>\n }\n @if (eligibleFamilies().includes('kpi')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'kpi'\" (click)=\"selectFamily('kpi')\">\n {{ t('praxis.charts.analyticsPresentation.kpi', 'KPI') }}\n </button>\n }\n </div>\n\n @if (diagnostic(); as message) {\n <section class=\"analytics-presentation__state analytics-presentation__state--error\" role=\"alert\">\n <strong>{{ t('praxis.charts.analyticsPresentation.unavailable', 'Presentation unavailable') }}</strong>\n <span>{{ message }}</span>\n </section>\n } @else if (activeFamily() === 'chart' && chartConfig(); as config) {\n <praxis-chart\n [config]=\"config\"\n [queryContext]=\"queryContext()\"\n [enableCustomization]=\"enableCustomization()\"\n (pointClick)=\"pointClick.emit($event)\"\n (pointAction)=\"pointAction.emit($event)\"\n (selectionChange)=\"selectionChange.emit($event)\"\n (drillDown)=\"drillDown.emit($event)\"\n (crossFilter)=\"crossFilter.emit($event)\"\n (queryRequest)=\"queryRequest.emit($event)\"\n (loadStateChange)=\"loadStateChange.emit($event)\"\n />\n } @else if (activeFamily() === 'analytic-table' && tableConfig(); as config) {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state analytics-presentation__state--loading\" role=\"status\"><span class=\"analytics-presentation__loading-label\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</span></section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <praxis-table\n [config]=\"config\"\n [data]=\"resultRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\n }\n } @else if (activeFamily() === 'kpi') {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state analytics-presentation__state--loading\" role=\"status\"><span class=\"analytics-presentation__loading-label\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</span></section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <section class=\"analytics-presentation__kpi\" [attr.aria-label]=\"kpiTitle() || t('praxis.charts.analyticsPresentation.kpiCollection', 'Key metrics')\">\n @if (kpiTitle(); as heading) {\n <header class=\"analytics-presentation__kpi-header\">\n <h3>{{ heading }}</h3>\n @if (kpiSubtitle(); as description) {\n <p>{{ description }}</p>\n }\n </header>\n }\n <div class=\"analytics-presentation__kpi-grid\" role=\"list\">\n @for (item of kpiItems(); track item.trackId) {\n <div role=\"listitem\">\n <button\n class=\"analytics-presentation__kpi-card\"\n type=\"button\"\n [attr.aria-label]=\"kpiItemAriaLabel(item.label, item.formattedValue, item.metricLabel)\"\n (click)=\"handleKpiClick(item.row, item.label, item.value)\"\n >\n <strong>{{ item.formattedValue }}</strong>\n <span>{{ item.label }}</span>\n <small>{{ item.metricLabel }}</small>\n </button>\n </div>\n }\n </div>\n </section>\n }\n }\n</section>\n", styles: [":host{display:block;min-width:0}.analytics-presentation{display:grid;gap:12px;min-width:0}.analytics-presentation__toolbar{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}.analytics-presentation__toolbar button{min-height:40px;padding:0 14px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:999px;background:var(--md-sys-color-surface, var(--mat-sys-surface));color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;cursor:pointer}.analytics-presentation__toolbar button[aria-pressed=true]{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-primary-container, #d7e3ff);color:var(--md-sys-color-on-primary-container, #001b3e)}.analytics-presentation__toolbar button:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__state{min-height:220px;display:grid;place-content:center;gap:6px;padding:24px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);text-align:center}.analytics-presentation__state--error{border-color:var(--md-sys-color-error, #ba1a1a);color:var(--md-sys-color-error, #ba1a1a)}.analytics-presentation__loading-label{font-weight:600;background-image:var(--pdx-text-shimmer-gradient, linear-gradient(105deg, var(--md-sys-color-on-surface-variant, #44474e) 0%, var(--md-sys-color-on-surface-variant, #44474e) 28%, var(--md-sys-color-primary, #1263b4) 44%, var(--md-sys-color-tertiary, #7c3aed) 56%, var(--md-sys-color-on-surface-variant, #44474e) 72%, var(--md-sys-color-on-surface-variant, #44474e) 100%));background-size:260% 100%;background-position:200% 50%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;animation:praxis-analytics-loading-shimmer var(--pdx-text-shimmer-duration, 2.4s) linear infinite}@media(prefers-reduced-motion:reduce),(forced-colors:active),print{.analytics-presentation__loading-label{animation:none;background-image:none;color:var(--pdx-text-shimmer-fallback-color, var(--md-sys-color-on-surface, #1a1c1e));-webkit-text-fill-color:currentColor}}@media(forced-colors:active){.analytics-presentation__loading-label{color:CanvasText!important;-webkit-text-fill-color:CanvasText!important}}@keyframes praxis-analytics-loading-shimmer{0%{background-position:200% 50%}to{background-position:-60% 50%}}.analytics-presentation__kpi{display:grid;gap:16px;min-width:0}.analytics-presentation__kpi-header{display:grid;gap:4px}.analytics-presentation__kpi-header h3,.analytics-presentation__kpi-header p{margin:0}.analytics-presentation__kpi-header h3{color:var(--md-sys-color-on-surface, #1a1c1e);font-size:1rem;font-weight:650}.analytics-presentation__kpi-header p{color:var(--md-sys-color-on-surface-variant, #44474e);font-size:.875rem}.analytics-presentation__kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,176px),1fr));gap:12px;min-width:0}.analytics-presentation__kpi-card{display:grid;gap:6px;width:100%;height:100%;min-width:0;min-height:132px;align-content:center;padding:18px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;text-align:left;cursor:pointer}.analytics-presentation__kpi-card:hover{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-surface-container, #f0f1f3)}.analytics-presentation__kpi-card:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__kpi-card strong{overflow-wrap:anywhere;color:var(--md-sys-color-primary, #1263b4);font-size:clamp(1.75rem,5vw,2.5rem);font-variant-numeric:tabular-nums;line-height:1}.analytics-presentation__kpi-card span,.analytics-presentation__kpi-card small{overflow-wrap:anywhere}.analytics-presentation__kpi-card span{font-weight:600}.analytics-presentation__kpi-card small{color:var(--md-sys-color-on-surface-variant, #44474e)}@media(max-width:600px){.analytics-presentation__toolbar{justify-content:stretch}.analytics-presentation__toolbar button{flex:1 1 0}}\n"], dependencies: [{ kind: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "resourceKey", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved", "chartDocumentPatchChange"] }, { kind: "component", type: PraxisTable, selector: "praxis-table", inputs: ["config", "resourcePath", "data", "tableId", "componentInstanceId", "configPersistenceStrategy", "title", "subtitle", "icon", "autoDelete", "notifyIfOutdated", "snoozeMs", "autoOpenSettingsOnOutdated", "crudContext", "filterCriteria", "filterTeleport", "widgetId", "queryContext", "aiContext", "aiAssistantVoiceInputMode", "aiAssistantVoiceLanguage", "horizontalScroll", "enableCustomization", "authoringCapability", "dense"], outputs: ["filterCriteriaChange", "queryContextClear", "queryContextChange", "rowClick", "widgetEvent", "resourceEvent", "rowDoubleClick", "rowExpansionChange", "rowAction", "toolbarAction", "bulkAction", "exportAction", "columnReorder", "columnReorderAttempt", "columnResize", "beforeDelete", "afterDelete", "deleteError", "beforeBulkDelete", "afterBulkDelete", "bulkDeleteError", "schemaStatusChange", "configChange", "metadataChange", "loadingStateChange", "collectionLinksChange", "selectionChange", "drawerOpen", "drawerClose", "drawerTabChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
6344
7312
  }
6345
7313
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisAnalyticsPresentationComponent, decorators: [{
6346
7314
  type: Component,
6347
- args: [{ selector: 'praxis-analytics-presentation', imports: [PraxisChartComponent, PraxisTable], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"analytics-presentation\" [attr.aria-label]=\"projectionId()\">\n <div class=\"analytics-presentation__toolbar\" role=\"group\" [attr.aria-label]=\"t('praxis.charts.analyticsPresentation.selectorLabel', 'Analytics presentation')\">\n @if (eligibleFamilies().includes('chart')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'chart'\" (click)=\"selectFamily('chart')\">\n {{ t('praxis.charts.analyticsPresentation.chart', 'Chart') }}\n </button>\n }\n @if (eligibleFamilies().includes('analytic-table')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'analytic-table'\" (click)=\"selectFamily('analytic-table')\">\n {{ t('praxis.charts.analyticsPresentation.table', 'Table') }}\n </button>\n }\n @if (eligibleFamilies().includes('kpi')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'kpi'\" (click)=\"selectFamily('kpi')\">\n {{ t('praxis.charts.analyticsPresentation.kpi', 'KPI') }}\n </button>\n }\n </div>\n\n @if (diagnostic(); as message) {\n <section class=\"analytics-presentation__state analytics-presentation__state--error\" role=\"alert\">\n <strong>{{ t('praxis.charts.analyticsPresentation.unavailable', 'Presentation unavailable') }}</strong>\n <span>{{ message }}</span>\n </section>\n } @else if (activeFamily() === 'chart' && chartConfig(); as config) {\n <praxis-chart\n [config]=\"config\"\n [queryContext]=\"queryContext()\"\n [enableCustomization]=\"enableCustomization()\"\n (pointClick)=\"pointClick.emit($event)\"\n (pointAction)=\"pointAction.emit($event)\"\n (selectionChange)=\"selectionChange.emit($event)\"\n (drillDown)=\"drillDown.emit($event)\"\n (crossFilter)=\"crossFilter.emit($event)\"\n (queryRequest)=\"queryRequest.emit($event)\"\n (loadStateChange)=\"loadStateChange.emit($event)\"\n />\n } @else if (activeFamily() === 'analytic-table' && tableConfig(); as config) {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <praxis-table\n [config]=\"config\"\n [data]=\"resultRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\n }\n } @else if (activeFamily() === 'kpi') {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <section class=\"analytics-presentation__kpi\" [attr.aria-label]=\"kpiTitle() || t('praxis.charts.analyticsPresentation.kpiCollection', 'Key metrics')\">\n @if (kpiTitle(); as heading) {\n <header class=\"analytics-presentation__kpi-header\">\n <h3>{{ heading }}</h3>\n @if (kpiSubtitle(); as description) {\n <p>{{ description }}</p>\n }\n </header>\n }\n <div class=\"analytics-presentation__kpi-grid\" role=\"list\">\n @for (item of kpiItems(); track item.trackId) {\n <div role=\"listitem\">\n <button\n class=\"analytics-presentation__kpi-card\"\n type=\"button\"\n [attr.aria-label]=\"kpiItemAriaLabel(item.label, item.formattedValue, item.metricLabel)\"\n (click)=\"handleKpiClick(item.row, item.label, item.value)\"\n >\n <strong>{{ item.formattedValue }}</strong>\n <span>{{ item.label }}</span>\n <small>{{ item.metricLabel }}</small>\n </button>\n </div>\n }\n </div>\n </section>\n }\n }\n</section>\n", styles: [":host{display:block;min-width:0}.analytics-presentation{display:grid;gap:12px;min-width:0}.analytics-presentation__toolbar{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}.analytics-presentation__toolbar button{min-height:40px;padding:0 14px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:999px;background:var(--md-sys-color-surface, var(--mat-sys-surface));color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;cursor:pointer}.analytics-presentation__toolbar button[aria-pressed=true]{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-primary-container, #d7e3ff);color:var(--md-sys-color-on-primary-container, #001b3e)}.analytics-presentation__toolbar button:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__state{min-height:220px;display:grid;place-content:center;gap:6px;padding:24px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);text-align:center}.analytics-presentation__state--error{border-color:var(--md-sys-color-error, #ba1a1a);color:var(--md-sys-color-error, #ba1a1a)}.analytics-presentation__kpi{display:grid;gap:16px;min-width:0}.analytics-presentation__kpi-header{display:grid;gap:4px}.analytics-presentation__kpi-header h3,.analytics-presentation__kpi-header p{margin:0}.analytics-presentation__kpi-header h3{color:var(--md-sys-color-on-surface, #1a1c1e);font-size:1rem;font-weight:650}.analytics-presentation__kpi-header p{color:var(--md-sys-color-on-surface-variant, #44474e);font-size:.875rem}.analytics-presentation__kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,176px),1fr));gap:12px;min-width:0}.analytics-presentation__kpi-card{display:grid;gap:6px;width:100%;height:100%;min-width:0;min-height:132px;align-content:center;padding:18px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;text-align:left;cursor:pointer}.analytics-presentation__kpi-card:hover{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-surface-container, #f0f1f3)}.analytics-presentation__kpi-card:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__kpi-card strong{overflow-wrap:anywhere;color:var(--md-sys-color-primary, #1263b4);font-size:clamp(1.75rem,5vw,2.5rem);font-variant-numeric:tabular-nums;line-height:1}.analytics-presentation__kpi-card span,.analytics-presentation__kpi-card small{overflow-wrap:anywhere}.analytics-presentation__kpi-card span{font-weight:600}.analytics-presentation__kpi-card small{color:var(--md-sys-color-on-surface-variant, #44474e)}@media(max-width:600px){.analytics-presentation__toolbar{justify-content:stretch}.analytics-presentation__toolbar button{flex:1 1 0}}\n"] }]
7315
+ args: [{ selector: 'praxis-analytics-presentation', imports: [PraxisChartComponent, PraxisTable], changeDetection: ChangeDetectionStrategy.OnPush, template: "<section class=\"analytics-presentation\" [attr.aria-label]=\"projectionId()\">\n <div class=\"analytics-presentation__toolbar\" role=\"group\" [attr.aria-label]=\"t('praxis.charts.analyticsPresentation.selectorLabel', 'Analytics presentation')\">\n @if (eligibleFamilies().includes('chart')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'chart'\" (click)=\"selectFamily('chart')\">\n {{ t('praxis.charts.analyticsPresentation.chart', 'Chart') }}\n </button>\n }\n @if (eligibleFamilies().includes('analytic-table')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'analytic-table'\" (click)=\"selectFamily('analytic-table')\">\n {{ t('praxis.charts.analyticsPresentation.table', 'Table') }}\n </button>\n }\n @if (eligibleFamilies().includes('kpi')) {\n <button type=\"button\" [attr.aria-pressed]=\"activeFamily() === 'kpi'\" (click)=\"selectFamily('kpi')\">\n {{ t('praxis.charts.analyticsPresentation.kpi', 'KPI') }}\n </button>\n }\n </div>\n\n @if (diagnostic(); as message) {\n <section class=\"analytics-presentation__state analytics-presentation__state--error\" role=\"alert\">\n <strong>{{ t('praxis.charts.analyticsPresentation.unavailable', 'Presentation unavailable') }}</strong>\n <span>{{ message }}</span>\n </section>\n } @else if (activeFamily() === 'chart' && chartConfig(); as config) {\n <praxis-chart\n [config]=\"config\"\n [queryContext]=\"queryContext()\"\n [enableCustomization]=\"enableCustomization()\"\n (pointClick)=\"pointClick.emit($event)\"\n (pointAction)=\"pointAction.emit($event)\"\n (selectionChange)=\"selectionChange.emit($event)\"\n (drillDown)=\"drillDown.emit($event)\"\n (crossFilter)=\"crossFilter.emit($event)\"\n (queryRequest)=\"queryRequest.emit($event)\"\n (loadStateChange)=\"loadStateChange.emit($event)\"\n />\n } @else if (activeFamily() === 'analytic-table' && tableConfig(); as config) {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state analytics-presentation__state--loading\" role=\"status\"><span class=\"analytics-presentation__loading-label\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</span></section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <praxis-table\n [config]=\"config\"\n [data]=\"resultRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\n }\n } @else if (activeFamily() === 'kpi') {\n @if (isResultLoading()) {\n <section class=\"analytics-presentation__state analytics-presentation__state--loading\" role=\"status\"><span class=\"analytics-presentation__loading-label\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</span></section>\n } @else if (isResultEmpty()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.empty', 'No data available.') }}</section>\n } @else {\n <section class=\"analytics-presentation__kpi\" [attr.aria-label]=\"kpiTitle() || t('praxis.charts.analyticsPresentation.kpiCollection', 'Key metrics')\">\n @if (kpiTitle(); as heading) {\n <header class=\"analytics-presentation__kpi-header\">\n <h3>{{ heading }}</h3>\n @if (kpiSubtitle(); as description) {\n <p>{{ description }}</p>\n }\n </header>\n }\n <div class=\"analytics-presentation__kpi-grid\" role=\"list\">\n @for (item of kpiItems(); track item.trackId) {\n <div role=\"listitem\">\n <button\n class=\"analytics-presentation__kpi-card\"\n type=\"button\"\n [attr.aria-label]=\"kpiItemAriaLabel(item.label, item.formattedValue, item.metricLabel)\"\n (click)=\"handleKpiClick(item.row, item.label, item.value)\"\n >\n <strong>{{ item.formattedValue }}</strong>\n <span>{{ item.label }}</span>\n <small>{{ item.metricLabel }}</small>\n </button>\n </div>\n }\n </div>\n </section>\n }\n }\n</section>\n", styles: [":host{display:block;min-width:0}.analytics-presentation{display:grid;gap:12px;min-width:0}.analytics-presentation__toolbar{display:flex;flex-wrap:wrap;justify-content:flex-end;gap:4px}.analytics-presentation__toolbar button{min-height:40px;padding:0 14px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:999px;background:var(--md-sys-color-surface, var(--mat-sys-surface));color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;cursor:pointer}.analytics-presentation__toolbar button[aria-pressed=true]{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-primary-container, #d7e3ff);color:var(--md-sys-color-on-primary-container, #001b3e)}.analytics-presentation__toolbar button:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__state{min-height:220px;display:grid;place-content:center;gap:6px;padding:24px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);text-align:center}.analytics-presentation__state--error{border-color:var(--md-sys-color-error, #ba1a1a);color:var(--md-sys-color-error, #ba1a1a)}.analytics-presentation__loading-label{font-weight:600;background-image:var(--pdx-text-shimmer-gradient, linear-gradient(105deg, var(--md-sys-color-on-surface-variant, #44474e) 0%, var(--md-sys-color-on-surface-variant, #44474e) 28%, var(--md-sys-color-primary, #1263b4) 44%, var(--md-sys-color-tertiary, #7c3aed) 56%, var(--md-sys-color-on-surface-variant, #44474e) 72%, var(--md-sys-color-on-surface-variant, #44474e) 100%));background-size:260% 100%;background-position:200% 50%;-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent;animation:praxis-analytics-loading-shimmer var(--pdx-text-shimmer-duration, 2.4s) linear infinite}@media(prefers-reduced-motion:reduce),(forced-colors:active),print{.analytics-presentation__loading-label{animation:none;background-image:none;color:var(--pdx-text-shimmer-fallback-color, var(--md-sys-color-on-surface, #1a1c1e));-webkit-text-fill-color:currentColor}}@media(forced-colors:active){.analytics-presentation__loading-label{color:CanvasText!important;-webkit-text-fill-color:CanvasText!important}}@keyframes praxis-analytics-loading-shimmer{0%{background-position:200% 50%}to{background-position:-60% 50%}}.analytics-presentation__kpi{display:grid;gap:16px;min-width:0}.analytics-presentation__kpi-header{display:grid;gap:4px}.analytics-presentation__kpi-header h3,.analytics-presentation__kpi-header p{margin:0}.analytics-presentation__kpi-header h3{color:var(--md-sys-color-on-surface, #1a1c1e);font-size:1rem;font-weight:650}.analytics-presentation__kpi-header p{color:var(--md-sys-color-on-surface-variant, #44474e);font-size:.875rem}.analytics-presentation__kpi-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,176px),1fr));gap:12px;min-width:0}.analytics-presentation__kpi-card{display:grid;gap:6px;width:100%;height:100%;min-width:0;min-height:132px;align-content:center;padding:18px;border:1px solid var(--md-sys-color-outline-variant, #c4c7c5);border-radius:16px;background:var(--md-sys-color-surface-container-low, #f7f8fa);color:var(--md-sys-color-on-surface, #1a1c1e);font:inherit;text-align:left;cursor:pointer}.analytics-presentation__kpi-card:hover{border-color:var(--md-sys-color-primary, #1263b4);background:var(--md-sys-color-surface-container, #f0f1f3)}.analytics-presentation__kpi-card:focus-visible{outline:3px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 45%,transparent);outline-offset:2px}.analytics-presentation__kpi-card strong{overflow-wrap:anywhere;color:var(--md-sys-color-primary, #1263b4);font-size:clamp(1.75rem,5vw,2.5rem);font-variant-numeric:tabular-nums;line-height:1}.analytics-presentation__kpi-card span,.analytics-presentation__kpi-card small{overflow-wrap:anywhere}.analytics-presentation__kpi-card span{font-weight:600}.analytics-presentation__kpi-card small{color:var(--md-sys-color-on-surface-variant, #44474e)}@media(max-width:600px){.analytics-presentation__toolbar{justify-content:stretch}.analytics-presentation__toolbar button{flex:1 1 0}}\n"] }]
6348
7316
  }], ctorParameters: () => [], propDecorators: { analytics: [{ type: i0.Input, args: [{ isSignal: true, alias: "analytics", required: true }] }], projectionId: [{ type: i0.Input, args: [{ isSignal: true, alias: "projectionId", required: true }] }], availableFamilies: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableFamilies", required: false }] }], preferredFamily: [{ type: i0.Input, args: [{ isSignal: true, alias: "preferredFamily", required: false }] }], queryContext: [{ type: i0.Input, args: [{ isSignal: true, alias: "queryContext", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], subtitle: [{ type: i0.Input, args: [{ isSignal: true, alias: "subtitle", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], motion: [{ type: i0.Input, args: [{ isSignal: true, alias: "motion", required: false }] }], enableCustomization: [{ type: i0.Input, args: [{ isSignal: true, alias: "enableCustomization", required: false }] }], presentationChange: [{ type: i0.Output, args: ["presentationChange"] }], pointClick: [{ type: i0.Output, args: ["pointClick"] }], pointAction: [{ type: i0.Output, args: ["pointAction"] }], selectionChange: [{ type: i0.Output, args: ["selectionChange"] }], drillDown: [{ type: i0.Output, args: ["drillDown"] }], crossFilter: [{ type: i0.Output, args: ["crossFilter"] }], queryRequest: [{ type: i0.Output, args: ["queryRequest"] }], loadStateChange: [{ type: i0.Output, args: ["loadStateChange"] }] } });
6349
7317
  function stringifyValue(value) {
6350
7318
  return value === null || value === undefined ? undefined : String(value);
@@ -6609,6 +7577,7 @@ class EChartsOptionBuilderService {
6609
7577
  name: gauge.name,
6610
7578
  value: gauge.value,
6611
7579
  itemStyle: gaugeColor ? { color: gaugeColor } : undefined,
7580
+ ...(this.pointTooltipSuppression(config, config.series[0]?.id, config.series[0]?.name, gauge.data) ?? {}),
6612
7581
  }],
6613
7582
  };
6614
7583
  return {
@@ -6664,6 +7633,7 @@ class EChartsOptionBuilderService {
6664
7633
  name: slice.name,
6665
7634
  value: slice.value,
6666
7635
  itemStyle: slice.color ? { color: slice.color } : undefined,
7636
+ ...(this.pointTooltipSuppression(config, config.series[0]?.id, config.series[0]?.name, slice.data) ?? {}),
6667
7637
  })),
6668
7638
  };
6669
7639
  return {
@@ -6718,6 +7688,7 @@ class EChartsOptionBuilderService {
6718
7688
  name: slice.name,
6719
7689
  value: slice.value,
6720
7690
  itemStyle: slice.color ? { color: slice.color } : undefined,
7691
+ ...(this.pointTooltipSuppression(config, config.series[0]?.id, config.series[0]?.name, slice.data) ?? {}),
6721
7692
  })),
6722
7693
  };
6723
7694
  const baseOption = {
@@ -6788,6 +7759,7 @@ class EChartsOptionBuilderService {
6788
7759
  name: slice.name,
6789
7760
  value: slice.value,
6790
7761
  itemStyle: slice.color ? { color: slice.color } : undefined,
7762
+ ...(this.pointTooltipSuppression(config, config.series[0]?.id, config.series[0]?.name, slice.data) ?? {}),
6791
7763
  })),
6792
7764
  };
6793
7765
  return {
@@ -6806,6 +7778,122 @@ class EChartsOptionBuilderService {
6806
7778
  series: [treemapSeries],
6807
7779
  };
6808
7780
  }
7781
+ if (transformed.mode === 'heatmap' && transformed.heatmap) {
7782
+ const heatmap = transformed.heatmap;
7783
+ const format = this.primaryValueFormat(config);
7784
+ const labelsVisible = heatmap.labelsVisible ?? false;
7785
+ const basePrimaryColor = heatmap.color ?? palette[0] ?? '#1263b4';
7786
+ const baseSecondaryColor = palette[1] ?? '#0f766e';
7787
+ const baseAccentColor = palette[2] ?? '#f08c00';
7788
+ const isDarkSurface = this.isLightTextColor(textColor);
7789
+ const lowStopColor = isDarkSurface ? '#1e293b' : '#e2e8f0';
7790
+ const defaultCellBorderColor = isDarkSurface
7791
+ ? 'rgba(15, 23, 42, 0.55)'
7792
+ : 'rgba(255, 255, 255, 0.65)';
7793
+ const heatmapSeries = {
7794
+ id: heatmap.id,
7795
+ name: heatmap.name,
7796
+ type: 'heatmap',
7797
+ universalTransition,
7798
+ label: {
7799
+ show: labelsVisible,
7800
+ color: textColor,
7801
+ formatter: labelsVisible
7802
+ ? (params) => this.formatValue(params.value, format)
7803
+ : undefined,
7804
+ },
7805
+ emphasis: {
7806
+ itemStyle: {
7807
+ shadowBlur: 8,
7808
+ shadowColor: 'rgba(15, 23, 42, 0.28)',
7809
+ },
7810
+ },
7811
+ itemStyle: {
7812
+ borderWidth: 1,
7813
+ borderColor: config.theme?.backgroundColor ?? defaultCellBorderColor,
7814
+ borderRadius: 2,
7815
+ },
7816
+ data: heatmap.cells.map((cell) => ({
7817
+ ...cell.data,
7818
+ name: `${cell.xCategory} • ${cell.yCategory}`,
7819
+ value: cell.value,
7820
+ ...(this.pointTooltipSuppression(config, heatmap.id, heatmap.name, cell.data) ?? {}),
7821
+ })),
7822
+ };
7823
+ const hasChartTitle = this.hasText(config.title) || this.hasText(config.subtitle);
7824
+ return {
7825
+ ...motion,
7826
+ aria: accessibility,
7827
+ backgroundColor: config.theme?.backgroundColor,
7828
+ color: palette,
7829
+ title: this.buildTitle(config, 'left', textColor),
7830
+ tooltip: tooltipEnabled
7831
+ ? {
7832
+ trigger: 'item',
7833
+ confine: true,
7834
+ appendToBody: false,
7835
+ formatter: (params) => this.formatHeatmapTooltip(params, heatmap, format),
7836
+ }
7837
+ : undefined,
7838
+ legend: { show: false },
7839
+ grid: {
7840
+ top: hasChartTitle ? CARTESIAN_GRID_TOP_WITH_TITLE : CARTESIAN_GRID_TOP_WITHOUT_TITLE,
7841
+ right: 28,
7842
+ bottom: legendVisible ? 68 : 40,
7843
+ left: 56,
7844
+ outerBoundsMode: 'same',
7845
+ outerBoundsContain: 'axisLabel',
7846
+ },
7847
+ xAxis: {
7848
+ type: 'category',
7849
+ name: config.axes?.x?.label,
7850
+ nameTextStyle: { color: textColor },
7851
+ data: heatmap.xCategories,
7852
+ nameLocation: 'middle',
7853
+ nameGap: 24,
7854
+ splitArea: { show: true },
7855
+ axisLabel: {
7856
+ show: config.axes?.x?.labels?.visible ?? true,
7857
+ color: textColor,
7858
+ rotate: config.axes?.x?.labels?.rotate ?? 0,
7859
+ hideOverlap: true,
7860
+ formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
7861
+ },
7862
+ },
7863
+ yAxis: {
7864
+ type: 'category',
7865
+ name: config.axes?.y?.label,
7866
+ nameTextStyle: { color: textColor },
7867
+ data: heatmap.yCategories,
7868
+ nameLocation: 'middle',
7869
+ nameGap: 44,
7870
+ splitArea: { show: true },
7871
+ axisLabel: {
7872
+ show: config.axes?.y?.labels?.visible ?? true,
7873
+ color: textColor,
7874
+ hideOverlap: true,
7875
+ formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
7876
+ },
7877
+ },
7878
+ visualMap: {
7879
+ type: 'continuous',
7880
+ show: legendVisible,
7881
+ min: heatmap.min,
7882
+ max: heatmap.max,
7883
+ calculable: true,
7884
+ orient: 'horizontal',
7885
+ left: 'center',
7886
+ bottom: 10,
7887
+ itemWidth: 12,
7888
+ itemHeight: 140,
7889
+ textStyle: { color: textColor, fontSize: 11 },
7890
+ inRange: {
7891
+ color: [lowStopColor, baseSecondaryColor, basePrimaryColor, baseAccentColor],
7892
+ },
7893
+ },
7894
+ series: [heatmapSeries],
7895
+ };
7896
+ }
6809
7897
  if (transformed.mode === 'scatter') {
6810
7898
  const scatterSeries = transformed.series.map((series) => ({
6811
7899
  id: series.id,
@@ -6820,7 +7908,7 @@ class EChartsOptionBuilderService {
6820
7908
  : undefined,
6821
7909
  },
6822
7910
  itemStyle: series.color ? { color: series.color } : undefined,
6823
- data: series.points,
7911
+ data: series.points.map((point) => this.decorateSeriesPointWithQuickViewTooltipSuppression(config, series.id, series.name, point)),
6824
7912
  }));
6825
7913
  return {
6826
7914
  ...motion,
@@ -6956,7 +8044,7 @@ class EChartsOptionBuilderService {
6956
8044
  : undefined,
6957
8045
  },
6958
8046
  itemStyle: series.color ? { color: series.color } : undefined,
6959
- data: series.points,
8047
+ data: series.points.map((point) => this.decorateSeriesPointWithQuickViewTooltipSuppression(config, series.id, series.name, point)),
6960
8048
  })),
6961
8049
  };
6962
8050
  }
@@ -7020,14 +8108,18 @@ class EChartsOptionBuilderService {
7020
8108
  const title = this.i18n.resolve(config.title).trim();
7021
8109
  const seriesCount = transformed.mode === 'gauge'
7022
8110
  ? transformed.gauge ? 1 : 0
7023
- : transformed.slices.length
7024
- ? 1
7025
- : transformed.series.length;
8111
+ : transformed.mode === 'heatmap'
8112
+ ? transformed.heatmap ? 1 : 0
8113
+ : transformed.slices.length
8114
+ ? 1
8115
+ : transformed.series.length;
7026
8116
  const pointCount = transformed.mode === 'gauge'
7027
8117
  ? transformed.gauge ? 1 : 0
7028
- : transformed.slices.length
7029
- ? transformed.slices.length
7030
- : transformed.series.reduce((total, series) => total + series.points.length, 0);
8118
+ : transformed.mode === 'heatmap'
8119
+ ? transformed.heatmap?.cells.length ?? 0
8120
+ : transformed.slices.length
8121
+ ? transformed.slices.length
8122
+ : transformed.series.reduce((total, series) => total + series.points.length, 0);
7031
8123
  const params = {
7032
8124
  title,
7033
8125
  type: this.chartTypeLabel(config.type),
@@ -7061,6 +8153,7 @@ class EChartsOptionBuilderService {
7061
8153
  pyramid: 'Pyramid',
7062
8154
  treemap: 'Treemap',
7063
8155
  gauge: 'Gauge',
8156
+ heatmap: 'Heatmap',
7064
8157
  };
7065
8158
  return this.t(`praxis.charts.runtime.accessibility.chartType.${type}`, fallbacks[type]);
7066
8159
  }
@@ -7070,6 +8163,24 @@ class EChartsOptionBuilderService {
7070
8163
  resolveTextColor(config) {
7071
8164
  return config.theme?.textColor ?? '#4b5563';
7072
8165
  }
8166
+ isLightTextColor(color) {
8167
+ const trimmed = String(color || '').trim();
8168
+ const hex6 = /^#([0-9a-fA-F]{2})([0-9a-fA-F]{2})([0-9a-fA-F]{2})$/.exec(trimmed);
8169
+ if (hex6) {
8170
+ const r = Number.parseInt(hex6[1], 16) / 255;
8171
+ const g = Number.parseInt(hex6[2], 16) / 255;
8172
+ const b = Number.parseInt(hex6[3], 16) / 255;
8173
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.6;
8174
+ }
8175
+ const hex3 = /^#([0-9a-fA-F])([0-9a-fA-F])([0-9a-fA-F])$/.exec(trimmed);
8176
+ if (hex3) {
8177
+ const r = Number.parseInt(hex3[1] + hex3[1], 16) / 255;
8178
+ const g = Number.parseInt(hex3[2] + hex3[2], 16) / 255;
8179
+ const b = Number.parseInt(hex3[3] + hex3[3], 16) / 255;
8180
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.6;
8181
+ }
8182
+ return false;
8183
+ }
7073
8184
  hasText(value) {
7074
8185
  const text = this.resolveText(value);
7075
8186
  return !!text && text.trim().length > 0;
@@ -7203,24 +8314,106 @@ class EChartsOptionBuilderService {
7203
8314
  },
7204
8315
  ];
7205
8316
  }
7206
- primaryValueFormat(config) {
7207
- return config.series.find((series) => series.axis !== 'secondary')?.labels?.format
7208
- ?? config.axes?.y?.labels?.format
7209
- ?? config.series[0]?.labels?.format;
8317
+ primaryValueFormat(config) {
8318
+ return config.series.find((series) => series.axis !== 'secondary')?.labels?.format
8319
+ ?? config.axes?.y?.labels?.format
8320
+ ?? config.series[0]?.labels?.format;
8321
+ }
8322
+ buildCartesianTooltip(config) {
8323
+ const hasQuickView = hasChartQuickViewBinding(config);
8324
+ const tooltip = {
8325
+ trigger: config.theme?.tooltip?.trigger ?? 'axis',
8326
+ confine: true,
8327
+ appendToBody: false,
8328
+ valueFormatter: (value) => this.formatValue(value, this.primaryValueFormat(config)),
8329
+ };
8330
+ if (this.usesCanonicalStatsTime(config)) {
8331
+ return {
8332
+ ...tooltip,
8333
+ formatter: (params) => {
8334
+ if (hasQuickView && this.shouldSuppressTooltipForParams(params, config)) {
8335
+ return '';
8336
+ }
8337
+ return this.formatCanonicalTimeTooltip(params, config);
8338
+ },
8339
+ };
8340
+ }
8341
+ if (hasQuickView) {
8342
+ return {
8343
+ ...tooltip,
8344
+ formatter: (params) => {
8345
+ if (this.shouldSuppressTooltipForParams(params, config)) {
8346
+ return '';
8347
+ }
8348
+ return this.formatDefaultCartesianTooltip(params, config);
8349
+ },
8350
+ };
8351
+ }
8352
+ return tooltip;
8353
+ }
8354
+ pointTooltipSuppression(config, seriesId, seriesName, data) {
8355
+ const eligible = resolveChartPointQuickView(config, {
8356
+ seriesId,
8357
+ seriesName,
8358
+ data,
8359
+ });
8360
+ return eligible ? { tooltip: { show: false } } : undefined;
7210
8361
  }
7211
- buildCartesianTooltip(config) {
7212
- const tooltip = {
7213
- trigger: config.theme?.tooltip?.trigger ?? 'axis',
7214
- confine: true,
7215
- appendToBody: false,
7216
- valueFormatter: (value) => this.formatValue(value, this.primaryValueFormat(config)),
8362
+ decorateSeriesPointWithQuickViewTooltipSuppression(config, seriesId, seriesName, point) {
8363
+ if (!point || typeof point !== 'object' || Array.isArray(point)) {
8364
+ return point;
8365
+ }
8366
+ const suppression = this.pointTooltipSuppression(config, seriesId, seriesName, point);
8367
+ if (!suppression) {
8368
+ return point;
8369
+ }
8370
+ return {
8371
+ ...point,
8372
+ ...suppression,
7217
8373
  };
7218
- return this.usesCanonicalStatsTime(config)
7219
- ? {
7220
- ...tooltip,
7221
- formatter: (params) => this.formatCanonicalTimeTooltip(params, config),
7222
- }
7223
- : tooltip;
8374
+ }
8375
+ shouldSuppressTooltipForParams(params, config) {
8376
+ const items = (Array.isArray(params) ? params : [params])
8377
+ .filter((item) => !!item && typeof item === 'object');
8378
+ if (!items.length) {
8379
+ return false;
8380
+ }
8381
+ return items.some((item) => {
8382
+ const data = this.tooltipPointData(item['data']);
8383
+ const seriesId = item['seriesId'] == null ? undefined : String(item['seriesId']);
8384
+ const seriesName = item['seriesName'] == null ? undefined : String(item['seriesName']);
8385
+ return resolveChartPointQuickView(config, {
8386
+ seriesId,
8387
+ seriesName,
8388
+ data,
8389
+ }) !== null;
8390
+ });
8391
+ }
8392
+ formatDefaultCartesianTooltip(params, config) {
8393
+ const items = (Array.isArray(params) ? params : [params])
8394
+ .filter((item) => !!item && typeof item === 'object');
8395
+ if (!items.length) {
8396
+ return '';
8397
+ }
8398
+ const first = items[0];
8399
+ const header = this.nonEmptyText(first['axisValueLabel'])
8400
+ ?? this.nonEmptyText(first['name'])
8401
+ ?? '';
8402
+ const lines = items.map((item) => {
8403
+ const itemData = this.tooltipPointData(item['data']);
8404
+ const seriesId = item['seriesId'] == null ? undefined : String(item['seriesId']);
8405
+ const format = seriesId
8406
+ ? this.seriesLabelFormat(config, seriesId) ?? this.primaryValueFormat(config)
8407
+ : this.primaryValueFormat(config);
8408
+ const value = this.formatValue(item['value'] ?? itemData['value'], format);
8409
+ const seriesName = this.nonEmptyText(item['seriesName']);
8410
+ return seriesName
8411
+ ? `${escapeTooltipHtml(seriesName)}: ${escapeTooltipHtml(value)}`
8412
+ : escapeTooltipHtml(value);
8413
+ });
8414
+ return [escapeTooltipHtml(header), ...lines]
8415
+ .filter((part) => part.length > 0)
8416
+ .join('<br/>');
7224
8417
  }
7225
8418
  usesCanonicalStatsTime(config) {
7226
8419
  if (config.dataSource?.kind !== 'remote'
@@ -7282,6 +8475,37 @@ class EChartsOptionBuilderService {
7282
8475
  ? value
7283
8476
  : {};
7284
8477
  }
8478
+ formatHeatmapTooltip(params, heatmap, format) {
8479
+ const item = Array.isArray(params) ? params[0] : params;
8480
+ if (!item || typeof item !== 'object') {
8481
+ return '';
8482
+ }
8483
+ const record = item;
8484
+ const data = this.tooltipPointData(record['data']);
8485
+ const rawTuple = Array.isArray(record['value'])
8486
+ ? record['value']
8487
+ : (Array.isArray(data['value']) ? data['value'] : []);
8488
+ const xIdx = Number(rawTuple[0]);
8489
+ const yIdx = Number(rawTuple[1]);
8490
+ const rawVal = rawTuple.length >= 3 ? rawTuple[2] : record['value'];
8491
+ const xLabel = this.nonEmptyText(data['category'])
8492
+ ?? (Number.isInteger(xIdx) ? heatmap.xCategories[xIdx] : undefined)
8493
+ ?? '';
8494
+ const yLabel = this.nonEmptyText(data['secondaryCategory'])
8495
+ ?? (Number.isInteger(yIdx) ? heatmap.yCategories[yIdx] : undefined)
8496
+ ?? '';
8497
+ const header = [xLabel, yLabel].filter(Boolean).join(' • ')
8498
+ || this.nonEmptyText(record['name'])
8499
+ || '';
8500
+ const valueText = rawVal === null || rawVal === undefined
8501
+ ? this.t('praxis.charts.runtime.accessibility.notAvailable', 'Not available')
8502
+ : this.formatValue(rawVal, format);
8503
+ const seriesName = this.nonEmptyText(record['seriesName']) ?? heatmap.name;
8504
+ const line = seriesName
8505
+ ? `${escapeTooltipHtml(seriesName)}: ${escapeTooltipHtml(valueText)}`
8506
+ : escapeTooltipHtml(valueText);
8507
+ return [escapeTooltipHtml(header), line].filter(Boolean).join('<br/>');
8508
+ }
7285
8509
  nonEmptyText(value) {
7286
8510
  if (value === null || value === undefined) {
7287
8511
  return undefined;
@@ -7544,6 +8768,7 @@ use([
7544
8768
  FunnelChart,
7545
8769
  GaugeChart,
7546
8770
  GridComponent,
8771
+ HeatmapChart,
7547
8772
  LegendComponent,
7548
8773
  LineChart,
7549
8774
  PieChart,
@@ -7554,6 +8779,7 @@ use([
7554
8779
  TransformComponent,
7555
8780
  TreemapChart,
7556
8781
  UniversalTransition,
8782
+ VisualMapComponent,
7557
8783
  ]);
7558
8784
  class EChartsEngineAdapter {
7559
8785
  optionBuilder;
@@ -7589,6 +8815,9 @@ class EChartsEngineAdapter {
7589
8815
  this.lastStructuralSignature = nextStructuralSignature;
7590
8816
  this.detachHandlers();
7591
8817
  chart.off('click');
8818
+ chart.off('mouseover');
8819
+ chart.off('mouseout');
8820
+ chart.off('globalout');
7592
8821
  chart.on('click', (params) => {
7593
8822
  this.lastSeriesClickAt = Date.now();
7594
8823
  this.lastGridClick = undefined;
@@ -7602,6 +8831,45 @@ class EChartsEngineAdapter {
7602
8831
  data,
7603
8832
  });
7604
8833
  });
8834
+ chart.on('mouseover', (params) => {
8835
+ const data = pointData(params?.name, params?.data ?? params?.value);
8836
+ const category = this.resolvePointCategory(payload, data, params?.name);
8837
+ const quickViewTarget = resolveChartPointQuickView(payload.config, {
8838
+ seriesId: params?.seriesId,
8839
+ seriesName: params?.seriesName,
8840
+ data,
8841
+ });
8842
+ if (quickViewTarget) {
8843
+ chart.dispatchAction?.({ type: 'hideTip' });
8844
+ }
8845
+ const anchorKey = `${payload.config.id ?? 'chart'}:${params?.seriesId ?? params?.seriesIndex ?? 0}:${params?.dataIndex ?? category ?? ''}`;
8846
+ payload.onPointHoverEnter?.({
8847
+ chartId: payload.config.id,
8848
+ seriesId: params?.seriesId,
8849
+ seriesName: params?.seriesName,
8850
+ category,
8851
+ value: pointValue(params?.value ?? params?.data),
8852
+ data,
8853
+ anchorKey,
8854
+ getViewportRect: () => this.resolvePointViewportRect(host, chart, params),
8855
+ });
8856
+ });
8857
+ chart.on('mouseout', (params) => {
8858
+ const data = pointData(params?.name, params?.data ?? params?.value);
8859
+ payload.onPointHoverLeave?.({
8860
+ chartId: payload.config.id,
8861
+ seriesId: params?.seriesId,
8862
+ seriesName: params?.seriesName,
8863
+ category: this.resolvePointCategory(payload, data, params?.name),
8864
+ value: pointValue(params?.value ?? params?.data),
8865
+ data,
8866
+ });
8867
+ });
8868
+ chart.on('globalout', () => {
8869
+ payload.onPointHoverLeave?.({
8870
+ chartId: payload.config.id,
8871
+ });
8872
+ });
7605
8873
  const zrender = chart?.getZr?.();
7606
8874
  this.zrenderClickHandler = (event) => {
7607
8875
  const occurredAt = Date.now();
@@ -7643,6 +8911,9 @@ class EChartsEngineAdapter {
7643
8911
  disposeChart() {
7644
8912
  this.detachHandlers();
7645
8913
  this.chart?.off('click');
8914
+ this.chart?.off('mouseover');
8915
+ this.chart?.off('mouseout');
8916
+ this.chart?.off('globalout');
7646
8917
  this.chart?.dispose();
7647
8918
  this.chart = undefined;
7648
8919
  this.currentHost = undefined;
@@ -7650,6 +8921,111 @@ class EChartsEngineAdapter {
7650
8921
  this.lastSeriesClickAt = 0;
7651
8922
  this.lastGridClick = undefined;
7652
8923
  }
8924
+ resolvePointViewportRect(host, chart, params) {
8925
+ if (!host.isConnected) {
8926
+ return null;
8927
+ }
8928
+ const hostRect = host.getBoundingClientRect();
8929
+ if (hostRect.width <= 0 || hostRect.height <= 0) {
8930
+ return null;
8931
+ }
8932
+ const target = params?.event?.target;
8933
+ if (target && typeof target.getBoundingRect === 'function') {
8934
+ try {
8935
+ const rawRect = target.getBoundingRect();
8936
+ if (rawRect && Number.isFinite(rawRect.x) && Number.isFinite(rawRect.y)) {
8937
+ let localLeft = Number(rawRect.x);
8938
+ let localTop = Number(rawRect.y);
8939
+ let localWidth = Math.max(8, Number(rawRect.width) || 12);
8940
+ let localHeight = Math.max(8, Number(rawRect.height) || 12);
8941
+ if (typeof target.transformCoordToGlobal === 'function') {
8942
+ const p1 = target.transformCoordToGlobal(rawRect.x, rawRect.y);
8943
+ const p2 = target.transformCoordToGlobal(rawRect.x + (Number(rawRect.width) || 0), rawRect.y + (Number(rawRect.height) || 0));
8944
+ if (Array.isArray(p1)
8945
+ && Array.isArray(p2)
8946
+ && Number.isFinite(p1[0])
8947
+ && Number.isFinite(p1[1])
8948
+ && Number.isFinite(p2[0])
8949
+ && Number.isFinite(p2[1])) {
8950
+ localLeft = Math.min(p1[0], p2[0]);
8951
+ localTop = Math.min(p1[1], p2[1]);
8952
+ localWidth = Math.max(8, Math.abs(p2[0] - p1[0]));
8953
+ localHeight = Math.max(8, Math.abs(p2[1] - p1[1]));
8954
+ }
8955
+ }
8956
+ if (localWidth < hostRect.width || localHeight < hostRect.height) {
8957
+ const x = Math.round(hostRect.left + localLeft);
8958
+ const y = Math.round(hostRect.top + localTop);
8959
+ return {
8960
+ x,
8961
+ y,
8962
+ left: x,
8963
+ top: y,
8964
+ width: Math.round(localWidth),
8965
+ height: Math.round(localHeight),
8966
+ };
8967
+ }
8968
+ }
8969
+ }
8970
+ catch {
8971
+ // Fallback to pixel conversion / event offset below.
8972
+ }
8973
+ }
8974
+ const anyChart = chart;
8975
+ if (typeof anyChart?.convertToPixel === 'function') {
8976
+ try {
8977
+ const seriesIndex = Number.isInteger(params?.seriesIndex) ? params.seriesIndex : 0;
8978
+ const coordInput = Array.isArray(params?.value)
8979
+ ? params.value
8980
+ : Number.isInteger(params?.dataIndex)
8981
+ ? [params.dataIndex, pointValue(params?.value ?? params?.data)]
8982
+ : undefined;
8983
+ if (coordInput !== undefined) {
8984
+ const pixel = anyChart.convertToPixel({ seriesIndex }, coordInput)
8985
+ ?? anyChart.convertToPixel({ gridIndex: 0 }, coordInput);
8986
+ if (Array.isArray(pixel) && Number.isFinite(pixel[0]) && Number.isFinite(pixel[1])) {
8987
+ const x = Math.round(hostRect.left + Number(pixel[0]) - 6);
8988
+ const y = Math.round(hostRect.top + Number(pixel[1]) - 6);
8989
+ return {
8990
+ x,
8991
+ y,
8992
+ left: x,
8993
+ top: y,
8994
+ width: 12,
8995
+ height: 12,
8996
+ };
8997
+ }
8998
+ }
8999
+ }
9000
+ catch {
9001
+ // Fallback to pointer offset below.
9002
+ }
9003
+ }
9004
+ const offsetX = Number(params?.event?.offsetX);
9005
+ const offsetY = Number(params?.event?.offsetY);
9006
+ if (Number.isFinite(offsetX) && Number.isFinite(offsetY)) {
9007
+ const x = Math.round(hostRect.left + offsetX - 6);
9008
+ const y = Math.round(hostRect.top + offsetY - 6);
9009
+ return {
9010
+ x,
9011
+ y,
9012
+ left: x,
9013
+ top: y,
9014
+ width: 12,
9015
+ height: 12,
9016
+ };
9017
+ }
9018
+ const fallbackX = Math.round(hostRect.left + Math.min(24, Math.max(8, hostRect.width / 2)));
9019
+ const fallbackY = Math.round(hostRect.top + Math.min(24, Math.max(8, hostRect.height / 2)));
9020
+ return {
9021
+ x: fallbackX,
9022
+ y: fallbackY,
9023
+ left: fallbackX,
9024
+ top: fallbackY,
9025
+ width: 16,
9026
+ height: 16,
9027
+ };
9028
+ }
7653
9029
  resolveStructuralSignature(config) {
7654
9030
  const mode = config.type === 'pie' || config.type === 'donut'
7655
9031
  ? 'pie'
@@ -7659,9 +9035,11 @@ class EChartsEngineAdapter {
7659
9035
  ? 'treemap'
7660
9036
  : config.type === 'gauge'
7661
9037
  ? 'gauge'
7662
- : config.type === 'scatter'
7663
- ? 'scatter'
7664
- : 'cartesian';
9038
+ : config.type === 'heatmap'
9039
+ ? 'heatmap'
9040
+ : config.type === 'scatter'
9041
+ ? 'scatter'
9042
+ : 'cartesian';
7665
9043
  const orientation = config.orientation === 'horizontal' || config.type === 'horizontal-bar'
7666
9044
  ? 'horizontal'
7667
9045
  : 'vertical';
@@ -7732,7 +9110,64 @@ class EChartsEngineAdapter {
7732
9110
  const option = chart.getOption?.();
7733
9111
  const xAxis = firstOptionEntry(option?.xAxis);
7734
9112
  const yAxis = firstOptionEntry(option?.yAxis);
7735
- const horizontal = yAxis?.type === 'category';
9113
+ const isTwoDimensionalCategoryGrid = xAxis?.type === 'category' && yAxis?.type === 'category';
9114
+ if (isTwoDimensionalCategoryGrid) {
9115
+ const xCategories = Array.isArray(xAxis?.data) ? xAxis.data : [];
9116
+ const yCategories = Array.isArray(yAxis?.data) ? yAxis.data : [];
9117
+ if (!xCategories.length || !yCategories.length) {
9118
+ return;
9119
+ }
9120
+ const converted = chart.convertFromPixel({ gridIndex: 0 }, point);
9121
+ const xIndex = Math.round(Number(converted?.[0]));
9122
+ const yIndex = Math.round(Number(converted?.[1]));
9123
+ if (!Number.isInteger(xIndex)
9124
+ || xIndex < 0
9125
+ || xIndex >= xCategories.length
9126
+ || !Number.isInteger(yIndex)
9127
+ || yIndex < 0
9128
+ || yIndex >= yCategories.length) {
9129
+ return;
9130
+ }
9131
+ const xCategoryValue = xCategories[xIndex] == null ? undefined : String(xCategories[xIndex]);
9132
+ const yCategoryValue = yCategories[yIndex] == null ? undefined : String(yCategories[yIndex]);
9133
+ const categoryField = payload.config.axes?.x?.field
9134
+ ?? payload.config.series.find((series) => series.categoryField)?.categoryField;
9135
+ const secondaryField = payload.config.series.find((series) => series.secondaryCategoryField)?.secondaryCategoryField
9136
+ ?? (payload.config.axes?.y?.type === 'category' ? payload.config.axes.y.field : undefined);
9137
+ const signature = JSON.stringify({
9138
+ chartId: payload.config.id,
9139
+ category: xCategoryValue,
9140
+ secondaryCategory: yCategoryValue,
9141
+ xIndex,
9142
+ yIndex,
9143
+ });
9144
+ if (this.isDuplicateGridClick(signature, occurredAt)) {
9145
+ return;
9146
+ }
9147
+ const sourceRow = payload.data.find((row) => {
9148
+ const rowX = String((categoryField ? row[categoryField] : undefined) ?? row['label'] ?? '');
9149
+ const rowY = String((secondaryField ? row[secondaryField] : undefined) ?? row['secondaryLabel'] ?? '');
9150
+ return rowX === (xCategoryValue ?? '') && rowY === (yCategoryValue ?? '');
9151
+ });
9152
+ const data = {
9153
+ ...(sourceRow ?? {}),
9154
+ category: xCategoryValue,
9155
+ secondaryCategory: yCategoryValue,
9156
+ };
9157
+ if (categoryField && xCategoryValue !== undefined && data[categoryField] === undefined) {
9158
+ data[categoryField] = xCategoryValue;
9159
+ }
9160
+ if (secondaryField && yCategoryValue !== undefined && data[secondaryField] === undefined) {
9161
+ data[secondaryField] = yCategoryValue;
9162
+ }
9163
+ payload.onPointClick?.({
9164
+ chartId: payload.config.id,
9165
+ category: xCategoryValue,
9166
+ data,
9167
+ });
9168
+ return;
9169
+ }
9170
+ const horizontal = yAxis?.type === 'category' && xAxis?.type !== 'category';
7736
9171
  const categories = (horizontal ? yAxis?.data : xAxis?.data) ?? [];
7737
9172
  if (!Array.isArray(categories) || categories.length === 0) {
7738
9173
  return;
@@ -7799,7 +9234,7 @@ function firstOptionEntry(value) {
7799
9234
  }
7800
9235
  function pointValue(value) {
7801
9236
  if (Array.isArray(value)) {
7802
- return value.length > 1 ? value[1] : value[0];
9237
+ return value.length > 1 ? value[value.length - 1] : value[0];
7803
9238
  }
7804
9239
  if (value && typeof value === 'object' && 'value' in value) {
7805
9240
  return pointValue(value.value);
@@ -9696,6 +11131,20 @@ function resolveShowcaseStateMode(config, remoteRuntimeState = 'idle') {
9696
11131
  }
9697
11132
  return 'ready';
9698
11133
  }
11134
+ const INTERNAL_CHART_ROW_FIELDS = new Set([
11135
+ 'key',
11136
+ 'label',
11137
+ 'secondaryKey',
11138
+ 'secondaryLabel',
11139
+ 'compositeKey',
11140
+ 'coordinates',
11141
+ 'value',
11142
+ 'count',
11143
+ 'from',
11144
+ 'to',
11145
+ 'mode',
11146
+ 'requestMode',
11147
+ ]);
9699
11148
  function buildTableConfig(config, rows) {
9700
11149
  const tableConfig = createDefaultTableConfig();
9701
11150
  const toolbar = tableConfig.toolbar;
@@ -9711,6 +11160,7 @@ function buildTableConfig(config, rows) {
9711
11160
  const appearance = tableConfig.appearance;
9712
11161
  const categoryField = config.axes?.x?.field || config.series[0]?.categoryField;
9713
11162
  const categoryLabel = config.axes?.x?.label || categoryField;
11163
+ const secondaryCategoryField = config.series[0]?.secondaryCategoryField?.trim();
9714
11164
  const sampleRow = rows[0] || {};
9715
11165
  const seriesColumns = config.series
9716
11166
  .map((series) => {
@@ -9724,7 +11174,9 @@ function buildTableConfig(config, rows) {
9724
11174
  };
9725
11175
  })
9726
11176
  .filter((column) => column !== null);
9727
- const fallbackColumns = Object.keys(sampleRow).map((field) => ({
11177
+ const fallbackColumns = Object.keys(sampleRow)
11178
+ .filter((field) => !INTERNAL_CHART_ROW_FIELDS.has(field))
11179
+ .map((field) => ({
9728
11180
  field,
9729
11181
  header: field,
9730
11182
  }));
@@ -9734,7 +11186,18 @@ function buildTableConfig(config, rows) {
9734
11186
  header: categoryLabel || categoryField,
9735
11187
  }]
9736
11188
  : [];
9737
- const dedupedColumns = [...categoryColumn, ...seriesColumns, ...fallbackColumns].filter((column, index, source) => source.findIndex((candidate) => candidate.field === column.field) === index);
11189
+ const secondaryCategoryColumn = secondaryCategoryField
11190
+ ? [{
11191
+ field: secondaryCategoryField,
11192
+ header: secondaryCategoryField,
11193
+ }]
11194
+ : [];
11195
+ const dedupedColumns = [
11196
+ ...categoryColumn,
11197
+ ...secondaryCategoryColumn,
11198
+ ...seriesColumns,
11199
+ ...fallbackColumns,
11200
+ ].filter((column, index, source) => source.findIndex((candidate) => candidate.field === column.field) === index);
9738
11201
  tableConfig.columns = dedupedColumns;
9739
11202
  toolbar.visible = false;
9740
11203
  pagination.enabled = rows.length > 10;
@@ -10133,6 +11596,7 @@ const PRAXIS_CHARTS_EN_US = {
10133
11596
  'praxis.charts.runtime.accessibility.chartType.pyramid': 'Pyramid',
10134
11597
  'praxis.charts.runtime.accessibility.chartType.treemap': 'Treemap',
10135
11598
  'praxis.charts.runtime.accessibility.chartType.gauge': 'Gauge',
11599
+ 'praxis.charts.runtime.accessibility.chartType.heatmap': 'Heatmap',
10136
11600
  'praxis.charts.runtime.comparisonCurrent': 'Current',
10137
11601
  'praxis.charts.runtime.comparisonPrevious': 'Previous',
10138
11602
  'praxis.charts.widget.missingDocument': 'This widget does not have a canonical chart document yet. Runtime inputs are preserved until a canonical chartDocument is provided by the host.',
@@ -10173,6 +11637,7 @@ const PRAXIS_CHARTS_EN_US = {
10173
11637
  'praxis.charts.editor.field.comparisonMode': 'Comparison mode',
10174
11638
  'praxis.charts.editor.field.dimension': 'Dimension',
10175
11639
  'praxis.charts.editor.field.dimensionRole': 'Dimension role',
11640
+ 'praxis.charts.editor.field.dimensionExtraction': 'Temporal extraction',
10176
11641
  'praxis.charts.editor.field.metric': 'Metric',
10177
11642
  'praxis.charts.editor.field.metricLabel': 'Metric label',
10178
11643
  'praxis.charts.editor.field.metricAggregation': 'Aggregation',
@@ -10326,6 +11791,8 @@ const PRAXIS_CHARTS_EN_US = {
10326
11791
  'praxis.charts.editor.specialization.scatterHint': 'With one metric, scatter uses the first dimension as X and the metric as Y. With two metrics, the dimension identifies the grouped bucket and the metrics map to X and Y.',
10327
11792
  'praxis.charts.editor.specialization.gaugeTitle': 'Gauge scale',
10328
11793
  'praxis.charts.editor.specialization.gaugeHint': 'Gauge charts render exactly one bucket and one metric. Remote stats use group-by with limit 1; values outside the scale fail instead of being clamped.',
11794
+ 'praxis.charts.editor.specialization.heatmapTitle': 'Heatmap matrix guidance',
11795
+ 'praxis.charts.editor.specialization.heatmapHint': 'Heatmap charts render a 2D matrix requiring two dimensions (X and Y axes) and one metric, using group-by for remote stats.',
10329
11796
  'praxis.charts.editor.events.pointClickTitle': 'Point click',
10330
11797
  'praxis.charts.editor.events.selectionChangeTitle': 'Selection change',
10331
11798
  'praxis.charts.editor.events.drillDownTitle': 'Drill down',
@@ -10368,10 +11835,16 @@ const PRAXIS_CHARTS_EN_US = {
10368
11835
  'praxis.charts.editor.dimensionRole.segment': 'segment',
10369
11836
  'praxis.charts.editor.dimensionRole.time': 'time',
10370
11837
  'praxis.charts.editor.dimensionRole.value': 'value',
11838
+ 'praxis.charts.editor.dimensionExtraction.none': 'None',
11839
+ 'praxis.charts.editor.dimensionExtraction.HOUR_OF_DAY': 'Hour of day',
11840
+ 'praxis.charts.editor.dimensionExtraction.DAY_OF_WEEK': 'Day of week',
11841
+ 'praxis.charts.editor.dimensionExtraction.DAY_OF_MONTH': 'Day of month',
11842
+ 'praxis.charts.editor.dimensionExtraction.MONTH_OF_YEAR': 'Month of year',
10371
11843
  'praxis.charts.editor.metricAggregation.sum': 'sum',
10372
11844
  'praxis.charts.editor.metricAggregation.count': 'count',
10373
11845
  'praxis.charts.editor.metricAggregation.distinct-count': 'distinct count',
10374
11846
  'praxis.charts.editor.metricAggregation.avg': 'avg',
11847
+ 'praxis.charts.editor.metricAggregation.avg-per-cycle-occurrence': 'avg per cycle occurrence',
10375
11848
  'praxis.charts.editor.metricAggregation.min': 'min',
10376
11849
  'praxis.charts.editor.metricAggregation.max': 'max',
10377
11850
  'praxis.charts.editor.metricAxis.primary': 'primary',
@@ -10398,6 +11871,7 @@ const PRAXIS_CHARTS_EN_US = {
10398
11871
  'praxis.charts.editor.kind.pyramid': 'Pyramid',
10399
11872
  'praxis.charts.editor.kind.treemap': 'Treemap',
10400
11873
  'praxis.charts.editor.kind.gauge': 'Gauge',
11874
+ 'praxis.charts.editor.kind.heatmap': 'Heatmap',
10401
11875
  'praxis.charts.analyticsPresentation.selectorLabel': 'Analytics presentation',
10402
11876
  'praxis.charts.analyticsPresentation.chart': 'Chart',
10403
11877
  'praxis.charts.analyticsPresentation.table': 'Table',
@@ -10462,6 +11936,7 @@ const PRAXIS_CHARTS_PT_BR = {
10462
11936
  'praxis.charts.runtime.accessibility.chartType.pyramid': 'Pirâmide',
10463
11937
  'praxis.charts.runtime.accessibility.chartType.treemap': 'Mapa de árvore',
10464
11938
  'praxis.charts.runtime.accessibility.chartType.gauge': 'Velocímetro',
11939
+ 'praxis.charts.runtime.accessibility.chartType.heatmap': 'Mapa de calor',
10465
11940
  'praxis.charts.runtime.comparisonCurrent': 'Atual',
10466
11941
  'praxis.charts.runtime.comparisonPrevious': 'Anterior',
10467
11942
  'praxis.charts.widget.missingDocument': 'Este widget ainda não possui um documento canônico de gráfico. Os inputs runtime são preservados até o host fornecer um chartDocument canônico.',
@@ -10502,6 +11977,7 @@ const PRAXIS_CHARTS_PT_BR = {
10502
11977
  'praxis.charts.editor.field.comparisonMode': 'Modo de comparação',
10503
11978
  'praxis.charts.editor.field.dimension': 'Dimensão',
10504
11979
  'praxis.charts.editor.field.dimensionRole': 'Papel da dimensão',
11980
+ 'praxis.charts.editor.field.dimensionExtraction': 'Extração temporal',
10505
11981
  'praxis.charts.editor.field.metric': 'Métrica',
10506
11982
  'praxis.charts.editor.field.metricLabel': 'Rótulo da métrica',
10507
11983
  'praxis.charts.editor.field.metricAggregation': 'Agregação',
@@ -10655,6 +12131,8 @@ const PRAXIS_CHARTS_PT_BR = {
10655
12131
  'praxis.charts.editor.specialization.scatterHint': 'Com uma métrica, o scatter usa a primeira dimensão em X e a métrica em Y. Com duas métricas, a dimensão identifica o bucket agrupado e as métricas ocupam X e Y.',
10656
12132
  'praxis.charts.editor.specialization.gaugeTitle': 'Escala do velocímetro',
10657
12133
  'praxis.charts.editor.specialization.gaugeHint': 'O velocímetro renderiza exatamente um bucket e uma métrica. Stats remotos usam agrupar por com limite 1; valores fora da escala falham em vez de serem limitados silenciosamente.',
12134
+ 'praxis.charts.editor.specialization.heatmapTitle': 'Guia da matriz de calor (heatmap)',
12135
+ 'praxis.charts.editor.specialization.heatmapHint': 'O mapa de calor renderiza uma matriz 2D exigindo duas dimensões (eixos X e Y) e uma métrica, utilizando agrupar por para stats remotos.',
10658
12136
  'praxis.charts.editor.events.pointClickTitle': 'Clique no ponto',
10659
12137
  'praxis.charts.editor.events.selectionChangeTitle': 'Mudança de seleção',
10660
12138
  'praxis.charts.editor.events.drillDownTitle': 'Drill down',
@@ -10697,10 +12175,16 @@ const PRAXIS_CHARTS_PT_BR = {
10697
12175
  'praxis.charts.editor.dimensionRole.segment': 'segmento',
10698
12176
  'praxis.charts.editor.dimensionRole.time': 'tempo',
10699
12177
  'praxis.charts.editor.dimensionRole.value': 'valor',
12178
+ 'praxis.charts.editor.dimensionExtraction.none': 'Nenhuma',
12179
+ 'praxis.charts.editor.dimensionExtraction.HOUR_OF_DAY': 'Hora do dia',
12180
+ 'praxis.charts.editor.dimensionExtraction.DAY_OF_WEEK': 'Dia da semana',
12181
+ 'praxis.charts.editor.dimensionExtraction.DAY_OF_MONTH': 'Dia do mês',
12182
+ 'praxis.charts.editor.dimensionExtraction.MONTH_OF_YEAR': 'Mês do ano',
10700
12183
  'praxis.charts.editor.metricAggregation.sum': 'soma',
10701
12184
  'praxis.charts.editor.metricAggregation.count': 'contagem',
10702
12185
  'praxis.charts.editor.metricAggregation.distinct-count': 'contagem distinta',
10703
12186
  'praxis.charts.editor.metricAggregation.avg': 'média',
12187
+ 'praxis.charts.editor.metricAggregation.avg-per-cycle-occurrence': 'média por ocorrência do ciclo',
10704
12188
  'praxis.charts.editor.metricAggregation.min': 'mínimo',
10705
12189
  'praxis.charts.editor.metricAggregation.max': 'máximo',
10706
12190
  'praxis.charts.editor.metricAxis.primary': 'primário',
@@ -10727,6 +12211,7 @@ const PRAXIS_CHARTS_PT_BR = {
10727
12211
  'praxis.charts.editor.kind.pyramid': 'Pirâmide',
10728
12212
  'praxis.charts.editor.kind.treemap': 'Mapa de árvore',
10729
12213
  'praxis.charts.editor.kind.gauge': 'Velocímetro',
12214
+ 'praxis.charts.editor.kind.heatmap': 'Mapa de calor (Heatmap)',
10730
12215
  'praxis.charts.analyticsPresentation.selectorLabel': 'Apresentação analítica',
10731
12216
  'praxis.charts.analyticsPresentation.chart': 'Gráfico',
10732
12217
  'praxis.charts.analyticsPresentation.table': 'Tabela',
@@ -11610,6 +13095,76 @@ const PRAXIS_CHART_BACKEND_MOCK_COMBO = {
11610
13095
  },
11611
13096
  },
11612
13097
  };
13098
+ const PRAXIS_CHART_BACKEND_MOCK_HEATMAP_MATRIX = {
13099
+ schemaMeta: {
13100
+ schemaId: 'api/human-resources/vw-indicadores-incidentes|post|response|internal:false',
13101
+ schemaHash: 'published-hash-incident-heatmap-matrix-v1',
13102
+ resourcePath: 'api/human-resources/vw-indicadores-incidentes',
13103
+ operation: 'post',
13104
+ schemaType: 'response',
13105
+ source: 'backend',
13106
+ },
13107
+ widget: {
13108
+ kind: 'x-ui.chart',
13109
+ key: 'incident-weekday-hour-heatmap',
13110
+ canvasItem: { col: 1, row: 21, colSpan: 12, rowSpan: 5 },
13111
+ shell: {
13112
+ title: 'Matriz Dia da Semana x Hora',
13113
+ subtitle: 'Heatmap 2D com extracoes ciclicas e media por ocorrencia de ciclo',
13114
+ },
13115
+ chart: {
13116
+ version: '0.1.0',
13117
+ kind: 'heatmap',
13118
+ preset: 'distribution',
13119
+ chartId: 'incident-weekday-hour-heatmap',
13120
+ title: { key: 'charts.incidents.heatmap.title', fallback: 'Matriz Dia da Semana x Hora' },
13121
+ subtitle: {
13122
+ key: 'charts.incidents.heatmap.subtitle',
13123
+ fallback: 'Media por ocorrencia de ciclo em 7x24 via praxis.stats group-by',
13124
+ },
13125
+ sizing: { mode: 'fixed', height: 380 },
13126
+ source: {
13127
+ kind: 'praxis.stats',
13128
+ resource: '/api/human-resources/vw-indicadores-incidentes',
13129
+ operation: 'group-by',
13130
+ options: {
13131
+ timezone: 'America/Sao_Paulo',
13132
+ periodPreset: 'LAST_30_DAYS',
13133
+ fillGaps: true,
13134
+ orderBy: 'key-asc',
13135
+ limit: 168,
13136
+ },
13137
+ },
13138
+ dimensions: [
13139
+ {
13140
+ field: 'ocorridoEm',
13141
+ label: 'Dia da semana',
13142
+ role: 'category',
13143
+ extraction: 'DAY_OF_WEEK',
13144
+ },
13145
+ {
13146
+ field: 'ocorridoEm',
13147
+ label: 'Hora do dia',
13148
+ role: 'series',
13149
+ extraction: 'HOUR_OF_DAY',
13150
+ },
13151
+ ],
13152
+ metrics: [
13153
+ {
13154
+ field: 'count',
13155
+ label: 'Media por ocorrencia',
13156
+ aggregation: 'avg-per-cycle-occurrence',
13157
+ format: 'decimal',
13158
+ },
13159
+ ],
13160
+ legend: { enabled: false },
13161
+ tooltip: { enabled: true },
13162
+ theme: {
13163
+ palette: ['#e0f2fe', '#0284c7', '#1e3a8a'],
13164
+ },
13165
+ },
13166
+ },
13167
+ };
11613
13168
 
11614
13169
  function buildPraxisChartMockWidgetPage(adapter) {
11615
13170
  return {
@@ -12165,7 +13720,7 @@ class PraxisChartCompositionShowcaseComponent {
12165
13720
  </article>
12166
13721
  </div>
12167
13722
  </section>
12168
- `, isInline: true, styles: [":host{display:block}.showcase-shell{display:grid;gap:24px}.showcase-hero{display:grid;gap:20px;padding:28px;border-radius:28px;background:radial-gradient(circle at top right,rgba(18,99,180,.18),transparent 30%),linear-gradient(135deg,#071836f2,#1263b4c7);color:#f7fbff}.showcase-copy h2{margin:0 0 10px;font-size:clamp(1.8rem,3vw,2.6rem);line-height:1.05}.showcase-copy p{margin:0;max-width:52rem;color:#f7fbffe0}.showcase-eyebrow,.panel-kicker{margin:0 0 8px;font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:#f7fbffb8}.showcase-controls{display:flex;flex-wrap:wrap;gap:16px}.control-group{display:grid;gap:8px}.control-group span{font-size:.84rem;color:#f7fbffc2}.control-buttons{display:flex;flex-wrap:wrap;gap:8px}.control-buttons button{border:1px solid rgba(247,251,255,.22);background:#f7fbff14;color:#f7fbff;border-radius:999px;padding:10px 14px;cursor:pointer;transition:background .16s ease,transform .16s ease}.control-buttons button.active{background:#f7fbff;color:#0d2d5f}.showcase-grid{display:grid;gap:20px;grid-template-columns:minmax(0,1.3fr) minmax(320px,.9fr)}.runtime-panel,.payload-panel{display:grid;gap:16px;padding:20px;border-radius:24px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 76%,transparent);background:linear-gradient(180deg,#fffffff0,#f4f7fbf5)}.panel-header{display:flex;align-items:start;justify-content:space-between;gap:12px}.panel-header h3{margin:0;color:#142033}.panel-caption{margin:6px 0 0;color:#516074;font-size:.88rem;line-height:1.45}.panel-chip{border-radius:999px;padding:6px 10px;font-size:.76rem;background:#1263b41f;color:#1263b4}.payload-panel pre{margin:0;padding:16px;border-radius:18px;background:#09111f;color:#d7e6ff;overflow:auto;font-size:.84rem;line-height:1.5}@media(max-width:1080px){.showcase-grid{grid-template-columns:1fr}}\n"], dependencies: [{ kind: "component", type: DynamicWidgetPageComponent, selector: "praxis-dynamic-page", inputs: ["page", "context", "strictValidation", "enableCustomization", "authoringCapabilities", "showPageSettingsButton", "shellEditorComponent", "pageEditorComponent", "autoPersist", "pageIdentity", "componentInstanceId", "showWidgetAssistantButton"], outputs: ["pageChange", "persistenceChange", "pageAuthoringChange", "widgetEvent", "widgetSelectionChange", "widgetAssistantRequested", "widgetDiagnosticsChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
13723
+ `, isInline: true, styles: [":host{display:block}.showcase-shell{display:grid;gap:24px}.showcase-hero{display:grid;gap:20px;padding:28px;border-radius:28px;background:radial-gradient(circle at top right,rgba(18,99,180,.18),transparent 30%),linear-gradient(135deg,#071836f2,#1263b4c7);color:#f7fbff}.showcase-copy h2{margin:0 0 10px;font-size:clamp(1.8rem,3vw,2.6rem);line-height:1.05}.showcase-copy p{margin:0;max-width:52rem;color:#f7fbffe0}.showcase-eyebrow,.panel-kicker{margin:0 0 8px;font-size:.78rem;letter-spacing:.14em;text-transform:uppercase;color:#f7fbffb8}.showcase-controls{display:flex;flex-wrap:wrap;gap:16px}.control-group{display:grid;gap:8px}.control-group span{font-size:.84rem;color:#f7fbffc2}.control-buttons{display:flex;flex-wrap:wrap;gap:8px}.control-buttons button{border:1px solid rgba(247,251,255,.22);background:#f7fbff14;color:#f7fbff;border-radius:999px;padding:10px 14px;cursor:pointer;transition:background .16s ease,transform .16s ease}.control-buttons button.active{background:#f7fbff;color:#0d2d5f}.showcase-grid{display:grid;gap:20px;grid-template-columns:minmax(0,1.3fr) minmax(320px,.9fr)}.runtime-panel,.payload-panel{display:grid;gap:16px;padding:20px;border-radius:24px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 76%,transparent);background:linear-gradient(180deg,#fffffff0,#f4f7fbf5)}.panel-header{display:flex;align-items:start;justify-content:space-between;gap:12px}.panel-header h3{margin:0;color:#142033}.panel-caption{margin:6px 0 0;color:#516074;font-size:.88rem;line-height:1.45}.panel-chip{border-radius:999px;padding:6px 10px;font-size:.76rem;background:#1263b41f;color:#1263b4}.payload-panel pre{margin:0;padding:16px;border-radius:18px;background:#09111f;color:#d7e6ff;overflow:auto;font-size:.84rem;line-height:1.5}@media(max-width:1080px){.showcase-grid{grid-template-columns:1fr}}\n"], dependencies: [{ kind: "component", type: DynamicWidgetPageComponent, selector: "praxis-dynamic-page", inputs: ["page", "context", "strictValidation", "enableCustomization", "authoringCapabilities", "showPageSettingsButton", "shellEditorComponent", "pageEditorComponent", "autoPersist", "pageIdentity", "componentInstanceId", "showWidgetAssistantButton", "blockMutations"], outputs: ["pageChange", "persistenceChange", "pageAuthoringChange", "widgetEvent", "widgetSelectionChange", "widgetAssistantRequested", "widgetDiagnosticsChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
12169
13724
  }
12170
13725
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartCompositionShowcaseComponent, decorators: [{
12171
13726
  type: Component,
@@ -12324,7 +13879,7 @@ class ChartEditorPreviewMapperService {
12324
13879
  && localData.length > 0;
12325
13880
  const baseRows = hasLocalData
12326
13881
  ? localData.map((row) => ({ ...row }))
12327
- : this.buildRows(document, domainCategories);
13882
+ : this.buildRows(document, domainCategories, config);
12328
13883
  return {
12329
13884
  config,
12330
13885
  data: variationIndex % 2 === 1
@@ -12385,8 +13940,11 @@ class ChartEditorPreviewMapperService {
12385
13940
  return nextRow;
12386
13941
  });
12387
13942
  }
12388
- buildRows(document, domainCategories) {
12389
- const dimensionField = document.dimensions?.[0]?.field?.trim();
13943
+ buildRows(document, domainCategories, config) {
13944
+ const rawDimensionField = document.dimensions?.[0]?.field?.trim();
13945
+ const dimensionField = document.dimensions?.[0]?.extraction && config?.axes?.x?.field?.trim()
13946
+ ? config.axes.x.field.trim()
13947
+ : rawDimensionField;
12390
13948
  const metrics = document.metrics ?? [];
12391
13949
  if (!dimensionField || !metrics.length) {
12392
13950
  return [];
@@ -12435,6 +13993,30 @@ class ChartEditorPreviewMapperService {
12435
13993
  : cleanedDomainCategories.length > 0
12436
13994
  ? cleanedDomainCategories
12437
13995
  : ['Alpha', 'Beta', 'Gamma'];
13996
+ const resolvedSecondaryField = (config?.axes?.y?.type === 'category'
13997
+ ? config.axes.y.field?.trim()
13998
+ : config?.series?.[0]?.secondaryCategoryField?.trim()) || document.dimensions?.[1]?.field?.trim();
13999
+ if (document.kind === 'heatmap') {
14000
+ const secondaryField = resolvedSecondaryField && resolvedSecondaryField !== dimensionField
14001
+ ? resolvedSecondaryField
14002
+ : `${dimensionField}_secondary`;
14003
+ const primaryMetricField = metrics[0]?.field;
14004
+ if (!primaryMetricField) {
14005
+ return [];
14006
+ }
14007
+ const seriesSlots = ['08:00', '12:00', '18:00'];
14008
+ const rows = [];
14009
+ categories.slice(0, 3).forEach((category, xIdx) => {
14010
+ seriesSlots.forEach((slot, yIdx) => {
14011
+ rows.push({
14012
+ [dimensionField]: category,
14013
+ [secondaryField]: slot,
14014
+ [primaryMetricField]: (xIdx + 1) * 12 + yIdx * 8,
14015
+ });
14016
+ });
14017
+ });
14018
+ return rows;
14019
+ }
12438
14020
  return categories.map((category, index) => {
12439
14021
  const row = {
12440
14022
  [dimensionField]: category,
@@ -12503,6 +14085,7 @@ class PraxisChartConfigEditor {
12503
14085
  'pyramid',
12504
14086
  'treemap',
12505
14087
  'gauge',
14088
+ 'heatmap',
12506
14089
  ];
12507
14090
  sourceKinds = ['praxis.stats', 'derived'];
12508
14091
  operations = ['group-by', 'timeseries', 'distribution', 'comparison'];
@@ -12523,7 +14106,21 @@ class PraxisChartConfigEditor {
12523
14106
  'PREVIOUS_CALENDAR_PERIOD',
12524
14107
  ];
12525
14108
  dimensionRoles = ['category', 'series', 'segment', 'time', 'value'];
12526
- metricAggregations = ['sum', 'count', 'distinct-count', 'avg', 'min', 'max'];
14109
+ dimensionExtractions = [
14110
+ 'HOUR_OF_DAY',
14111
+ 'DAY_OF_WEEK',
14112
+ 'DAY_OF_MONTH',
14113
+ 'MONTH_OF_YEAR',
14114
+ ];
14115
+ metricAggregations = [
14116
+ 'sum',
14117
+ 'count',
14118
+ 'distinct-count',
14119
+ 'avg',
14120
+ 'avg-per-cycle-occurrence',
14121
+ 'min',
14122
+ 'max',
14123
+ ];
12527
14124
  metricAxes = ['primary', 'secondary'];
12528
14125
  metricSeriesKinds = ['bar', 'line', 'area'];
12529
14126
  motionPresets = ['subtle', 'standard', 'expressive'];
@@ -12756,6 +14353,26 @@ class PraxisChartConfigEditor {
12756
14353
  }
12757
14354
  setKind(value) {
12758
14355
  this.patchDocument((document) => {
14356
+ if (value === 'heatmap') {
14357
+ const currentDims = document.dimensions ?? [];
14358
+ const firstDim = currentDims[0] ?? { field: '', role: 'category' };
14359
+ const secondDim = currentDims[1]
14360
+ ? { ...currentDims[1], role: 'series' }
14361
+ : { field: '', role: 'series' };
14362
+ return {
14363
+ ...document,
14364
+ kind: value,
14365
+ gauge: undefined,
14366
+ dimensions: [firstDim, secondDim],
14367
+ metrics: document.metrics?.slice(0, 1),
14368
+ source: document.source.kind === 'praxis.stats'
14369
+ ? {
14370
+ ...document.source,
14371
+ operation: 'group-by',
14372
+ }
14373
+ : document.source,
14374
+ };
14375
+ }
12759
14376
  if (value !== 'gauge') {
12760
14377
  return {
12761
14378
  ...document,
@@ -12984,7 +14601,7 @@ class PraxisChartConfigEditor {
12984
14601
  void this.resolveSelectedResource(resource, option);
12985
14602
  }
12986
14603
  setOperation(value) {
12987
- if (this.doc().kind === 'gauge' && value !== 'group-by') {
14604
+ if ((this.doc().kind === 'gauge' || this.doc().kind === 'heatmap') && value !== 'group-by') {
12988
14605
  return;
12989
14606
  }
12990
14607
  this.patchDocument((document) => {
@@ -13122,18 +14739,36 @@ class PraxisChartConfigEditor {
13122
14739
  this.setGaugeScaleValue('max', value);
13123
14740
  }
13124
14741
  addDimension() {
14742
+ if (!this.canAddDimension()) {
14743
+ return;
14744
+ }
14745
+ this.patchDocument((document) => {
14746
+ const currentDims = document.dimensions ?? [];
14747
+ const nextRole = currentDims.length === 1 ? 'series' : 'category';
14748
+ return {
14749
+ ...document,
14750
+ dimensions: [
14751
+ ...currentDims,
14752
+ { field: '', role: nextRole },
14753
+ ],
14754
+ };
14755
+ });
14756
+ }
14757
+ removeDimension(index) {
14758
+ if (!this.canRemoveDimension()) {
14759
+ return;
14760
+ }
13125
14761
  this.patchDocument((document) => ({
13126
14762
  ...document,
13127
- dimensions: [
13128
- ...(document.dimensions ?? []),
13129
- { field: '', role: 'category' },
13130
- ],
14763
+ dimensions: (document.dimensions ?? []).filter((_, currentIndex) => currentIndex !== index),
13131
14764
  }));
13132
14765
  }
13133
- removeDimension(index) {
14766
+ setDimensionExtraction(index, extraction) {
13134
14767
  this.patchDocument((document) => ({
13135
14768
  ...document,
13136
- dimensions: (document.dimensions ?? []).filter((_, currentIndex) => currentIndex !== index),
14769
+ dimensions: (document.dimensions ?? []).map((dimension, currentIndex) => currentIndex === index
14770
+ ? { ...dimension, extraction: extraction || undefined }
14771
+ : dimension),
13137
14772
  }));
13138
14773
  }
13139
14774
  setDimensionField(index, field) {
@@ -13223,12 +14858,31 @@ class PraxisChartConfigEditor {
13223
14858
  }));
13224
14859
  }
13225
14860
  setMetricAggregation(index, aggregation) {
13226
- this.patchDocument((document) => ({
13227
- ...document,
13228
- metrics: (document.metrics ?? []).map((metric, currentIndex) => currentIndex === index
14861
+ this.patchDocument((document) => {
14862
+ const nextMetrics = (document.metrics ?? []).map((metric, currentIndex) => currentIndex === index
13229
14863
  ? { ...metric, aggregation }
13230
- : metric),
13231
- }));
14864
+ : metric);
14865
+ if (aggregation === 'avg-per-cycle-occurrence' && document.source.kind === 'praxis.stats') {
14866
+ const currentOpts = document.source.options ?? {};
14867
+ const hasPeriod = Boolean(currentOpts.periodPreset);
14868
+ return {
14869
+ ...document,
14870
+ metrics: nextMetrics,
14871
+ source: {
14872
+ ...document.source,
14873
+ options: {
14874
+ ...currentOpts,
14875
+ timezone: currentOpts.timezone || 'UTC',
14876
+ ...(hasPeriod ? {} : { periodPreset: 'LAST_30_DAYS' }),
14877
+ },
14878
+ },
14879
+ };
14880
+ }
14881
+ return {
14882
+ ...document,
14883
+ metrics: nextMetrics,
14884
+ };
14885
+ });
13232
14886
  }
13233
14887
  setMetricAxis(index, axis) {
13234
14888
  this.patchDocument((document) => ({
@@ -13450,7 +15104,7 @@ class PraxisChartConfigEditor {
13450
15104
  const availableOperations = !selectedResource
13451
15105
  ? this.resourceOptions().length ? this.operations : []
13452
15106
  : selectedResource.operations?.length ? selectedResource.operations : [];
13453
- if (this.doc().kind === 'gauge') {
15107
+ if (this.doc().kind === 'gauge' || this.doc().kind === 'heatmap') {
13454
15108
  return availableOperations.filter((operation) => operation === 'group-by');
13455
15109
  }
13456
15110
  return availableOperations;
@@ -13618,8 +15272,28 @@ class PraxisChartConfigEditor {
13618
15272
  metrics() {
13619
15273
  return this.doc().metrics ?? [];
13620
15274
  }
13621
- canAddMetric() {
15275
+ canAddDimension() {
15276
+ const count = this.dimensions().length;
13622
15277
  if (this.doc().kind === 'gauge') {
15278
+ return count < 1;
15279
+ }
15280
+ if (this.doc().kind === 'heatmap') {
15281
+ return count < 2;
15282
+ }
15283
+ if (this.doc().source.kind === 'praxis.stats' && this.doc().source.operation === 'group-by') {
15284
+ return count < 2;
15285
+ }
15286
+ return true;
15287
+ }
15288
+ canRemoveDimension() {
15289
+ const count = this.dimensions().length;
15290
+ if (this.doc().kind === 'heatmap') {
15291
+ return count > 2;
15292
+ }
15293
+ return count > 1;
15294
+ }
15295
+ canAddMetric() {
15296
+ if (this.doc().kind === 'gauge' || this.doc().kind === 'heatmap') {
13623
15297
  return false;
13624
15298
  }
13625
15299
  return this.doc().kind !== 'scatter' || this.metrics().length < 2;
@@ -13666,9 +15340,18 @@ class PraxisChartConfigEditor {
13666
15340
  metricAggregationOptions(fieldName) {
13667
15341
  const field = this.fieldOptions('metric').find((option) => option.field === fieldName) ??
13668
15342
  this.availableFields().find((option) => option.field === fieldName);
13669
- const availableAggregations = field?.aggregations?.length
15343
+ const hasTemporalExtraction = (this.doc().dimensions ?? []).some((dim) => Boolean(dim.extraction));
15344
+ const currentUsesCycleOccurrence = (this.doc().metrics ?? []).some((metric) => metric.aggregation === 'avg-per-cycle-occurrence');
15345
+ const baseAggregations = field?.aggregations?.length
13670
15346
  ? field.aggregations.filter((aggregation) => this.metricAggregations.includes(aggregation))
13671
- : this.metricAggregations;
15347
+ : this.metricAggregations.filter((aggregation) => aggregation !== 'avg-per-cycle-occurrence'
15348
+ || hasTemporalExtraction
15349
+ || currentUsesCycleOccurrence);
15350
+ const availableAggregations = (hasTemporalExtraction || currentUsesCycleOccurrence)
15351
+ && !baseAggregations.includes('avg-per-cycle-occurrence')
15352
+ && (this.doc().source.kind !== 'praxis.stats' || this.doc().source.operation === 'group-by')
15353
+ ? [...baseAggregations, 'avg-per-cycle-occurrence']
15354
+ : baseAggregations;
13672
15355
  return this.doc().source.kind === 'praxis.stats' && this.doc().source.operation === 'comparison'
13673
15356
  ? availableAggregations.filter((aggregation) => ['count', 'distinct-count', 'sum'].includes(aggregation))
13674
15357
  : availableAggregations;
@@ -13710,6 +15393,9 @@ class PraxisChartConfigEditor {
13710
15393
  showGaugePanel() {
13711
15394
  return this.doc().kind === 'gauge';
13712
15395
  }
15396
+ showHeatmapPanel() {
15397
+ return this.doc().kind === 'heatmap';
15398
+ }
13713
15399
  gaugeMinValue() {
13714
15400
  const min = this.doc().gauge?.scale.min;
13715
15401
  return min === undefined ? '' : String(min);
@@ -13922,7 +15608,9 @@ class PraxisChartConfigEditor {
13922
15608
  const isTemporal = doc.kind === 'line' ||
13923
15609
  doc.kind === 'area' ||
13924
15610
  (doc.source.kind === 'praxis.stats' && doc.source.operation === 'timeseries');
13925
- const isStacked = doc.kind === 'stacked-bar' || doc.kind === 'stacked-area';
15611
+ const isStacked = doc.kind === 'stacked-bar' ||
15612
+ doc.kind === 'stacked-area' ||
15613
+ doc.kind === 'heatmap';
13926
15614
  const tokenize = (raw) => String(raw || '')
13927
15615
  .trim()
13928
15616
  .normalize('NFD')
@@ -14080,7 +15768,9 @@ class PraxisChartConfigEditor {
14080
15768
  (!currentDimField ||
14081
15769
  currentDimField === 'category' ||
14082
15770
  (validDimSet.size > 0 && !validDimSet.has(currentDimField)));
14083
- const isStacked = this.doc().kind === 'stacked-bar' || this.doc().kind === 'stacked-area';
15771
+ const isStacked = this.doc().kind === 'stacked-bar' ||
15772
+ this.doc().kind === 'stacked-area' ||
15773
+ this.doc().kind === 'heatmap';
14084
15774
  const seriesNeedsCalibration = isStacked &&
14085
15775
  Boolean(rec.seriesField) &&
14086
15776
  (!currentSeriesField ||
@@ -14226,12 +15916,18 @@ class PraxisChartConfigEditor {
14226
15916
  && Number.isFinite(scaleMin)
14227
15917
  && Number.isFinite(scaleMax)
14228
15918
  && scaleMax > scaleMin;
15919
+ const fallbackDimensions = base.kind === 'heatmap'
15920
+ ? [
15921
+ validDimensions[0] ?? { field: 'category', role: 'category' },
15922
+ validDimensions[1] ?? { field: 'series', role: 'series' },
15923
+ ]
15924
+ : validDimensions.length
15925
+ ? validDimensions
15926
+ : [{ field: 'category', role: 'category' }];
14229
15927
  const syntheticContract = {
14230
15928
  ...structuredClone(base),
14231
15929
  source: { kind: 'derived' },
14232
- dimensions: validDimensions.length
14233
- ? validDimensions
14234
- : [{ field: 'category', role: 'category' }],
15930
+ dimensions: fallbackDimensions,
14235
15931
  metrics: fallbackMetrics,
14236
15932
  gauge: base.kind === 'gauge'
14237
15933
  ? {
@@ -14909,7 +16605,7 @@ class PraxisChartConfigEditor {
14909
16605
  }
14910
16606
  }
14911
16607
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartConfigEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
14912
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisChartConfigEditor, isStandalone: true, selector: "praxis-chart-config-editor", inputs: { documentInput: { classPropertyName: "documentInput", publicName: "document", isSignal: true, isRequired: false, transformFunction: null }, localDataInput: { classPropertyName: "localDataInput", publicName: "localData", isSignal: true, isRequired: false, transformFunction: null }, modeInput: { classPropertyName: "modeInput", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, readonlyInput: { classPropertyName: "readonlyInput", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, availableResourcesInput: { classPropertyName: "availableResourcesInput", publicName: "availableResources", isSignal: true, isRequired: false, transformFunction: null }, availableFieldsInput: { classPropertyName: "availableFieldsInput", publicName: "availableFields", isSignal: true, isRequired: false, transformFunction: null }, availableTargetsInput: { classPropertyName: "availableTargetsInput", publicName: "availableTargets", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { apply: "apply", save: "save", resetChange: "resetChange", documentChange: "documentChange", localDataChange: "localDataChange" }, providers: [providePraxisChartsI18n()], ngImport: i0, template: "<div class=\"editor-shell\" [class.editor-shell--preview-active]=\"activeSection() === 'preview'\">\n <div class=\"editor-nav\" role=\"toolbar\" [attr.aria-label]=\"t('praxis.charts.editor.sectionsNav', 'Se\u00E7\u00F5es do editor')\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.id]=\"'chart-editor-tab-' + section.id\"\n aria-controls=\"chart-editor-tabpanel\"\n [attr.aria-pressed]=\"activeSection() === section.id\"\n [tabIndex]=\"activeSection() === section.id ? 0 : -1\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n [class.editor-nav__preview-btn]=\"section.id === 'preview'\"\n [class.editor-nav__has-error]=\"sectionHasError(section.id)\"\n (click)=\"setSection(section.id)\"\n (keydown)=\"onSectionNavKeydown($event, $index)\"\n >\n <span>{{ t(section.labelKey, section.fallback) }}</span>\n @if (sectionIssueCount(section.id) > 0) {\n <span\n class=\"editor-nav__badge\"\n aria-hidden=\"true\"\n [class.editor-nav__badge--error]=\"sectionHasError(section.id)\"\n [attr.data-testid]=\"'chart-editor-section-badge-' + section.id\"\n >\n {{ sectionIssueCount(section.id) }}\n </span>\n }\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div\n class=\"editor-form\"\n id=\"chart-editor-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-labelledby]=\"'chart-editor-tab-' + activeSection()\"\n >\n @switch (activeSection()) {\n @case ('general') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.chartId', 'Chart ID') }}</mat-label>\n <input matInput [ngModel]=\"doc().chartId || ''\" (ngModelChange)=\"setChartId($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.kind', 'Kind') }}</mat-label>\n <mat-select [ngModel]=\"doc().kind\" (ngModelChange)=\"setKind($event)\" [disabled]=\"isReadonly()\">\n @for (kind of chartKinds; track kind) {\n <mat-option [value]=\"kind\">\n {{ t('praxis.charts.editor.kind.' + kind, kind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.title', 'Title') }}</mat-label>\n <input matInput data-testid=\"chart-editor-title-input\" [ngModel]=\"titleValue()\" (ngModelChange)=\"setTitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.subtitle', 'Subtitle') }}</mat-label>\n <input matInput [ngModel]=\"subtitleValue()\" (ngModelChange)=\"setSubtitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sizingMode', 'Sizing') }}</mat-label>\n <mat-select [ngModel]=\"sizingModeValue()\" (ngModelChange)=\"setSizingMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of sizingModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.sizing.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>{{ t('praxis.charts.editor.hint.sizingMode', 'Use fill-container only when the host widget provides a defined body height.') }}</mat-hint>\n </mat-form-field>\n\n @if (sizingModeValue() === 'fixed') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.height', 'Height') }}</mat-label>\n <input matInput [ngModel]=\"heightValue()\" (ngModelChange)=\"setSizingHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.height', 'Numbers are saved as pixels; CSS lengths such as 20rem are also accepted.') }}</mat-hint>\n </mat-form-field>\n }\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.minHeight', 'Minimum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMinHeightValue()\" (ngModelChange)=\"setSizingMinHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.minHeight', 'Set a readable minimum for compact dashboard widgets.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.maxHeight', 'Maximum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMaxHeightValue()\" (ngModelChange)=\"setSizingMaxHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.maxHeight', 'Leave empty unless the chart must stop growing inside a flexible layout.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.aspectRatio', 'Aspect ratio') }}</mat-label>\n <input matInput [ngModel]=\"sizingAspectRatioValue()\" (ngModelChange)=\"setSizingAspectRatio($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.aspectRatio', 'Optional. Use values such as 1.777 or 16 / 9.') }}</mat-hint>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('data') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\" data-testid=\"chart-editor-effective-origin\">\n {{ effectiveDataOriginSummary() }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sourceKind', 'Source') }}</mat-label>\n <mat-select data-testid=\"chart-editor-source-kind\" [ngModel]=\"doc().source.kind\" (ngModelChange)=\"setSourceKind($event)\" [disabled]=\"isReadonly()\">\n @for (sourceKind of sourceKinds; track sourceKind) {\n <mat-option [value]=\"sourceKind\">\n {{ t('praxis.charts.editor.sourceKind.' + (sourceKind === 'praxis.stats' ? 'praxisStats' : 'derived'), sourceKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (doc().source.kind === 'praxis.stats') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.resource', 'Resource') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-resource\"\n [ngModel]=\"resourceValue()\"\n (ngModelChange)=\"setResource($event)\"\n (openedChange)=\"setResourceSearchTerm('')\"\n [disabled]=\"isReadonly() || resourceCatalogUnavailable()\"\n >\n @if (selectedResourceOption(); as selectedRes) {\n <mat-select-trigger>\n <span class=\"editor-select-trigger\">\n <mat-icon class=\"editor-select-trigger__icon\" [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n <span class=\"editor-select-trigger__label\">{{ selectedRes.label }}</span>\n <span class=\"editor-select-trigger__code\">{{ selectedRes.path }}</span>\n </span>\n </mat-select-trigger>\n }\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n data-testid=\"chart-editor-resource-search\"\n [placeholder]=\"t('praxis.charts.editor.field.resourceSearchPlaceholder', 'Search by name, key, or endpoint...')\"\n [ngModel]=\"resourceSearchTerm()\"\n (ngModelChange)=\"setResourceSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (resource of filteredResourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <span class=\"editor-rich-option__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"resource.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ resource.label }}</span>\n @if (resource.group) {\n <span class=\"editor-rich-option__badge\">{{ resource.group }}</span>\n }\n @if (resource.readOnly) {\n <span class=\"editor-rich-option__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-rich-option__code\">\n {{ resource.path }}{{ resource.id && resource.id !== resource.path && resource.id !== resource.label ? ' \u00B7 ' + resource.id : '' }}\n </span>\n @if (resource.description) {\n <span class=\"editor-rich-option__description\">{{ resource.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n @if (resourceCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.resourceMissing', 'Resource catalog is required for governed praxis.stats authoring.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.operation', 'Operation') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-operation\"\n [ngModel]=\"doc().source.operation || 'group-by'\"\n (ngModelChange)=\"setOperation($event)\"\n [disabled]=\"isReadonly() || isResolvingResource() || operationCatalogUnavailable()\"\n >\n @for (operation of operationOptions(); track operation) {\n <mat-option [value]=\"operation\">\n {{ t('praxis.charts.editor.operation.' + (operation === 'group-by' ? 'groupBy' : operation), operation) }}\n </mat-option>\n }\n </mat-select>\n @if (isResolvingResource()) {\n <mat-hint data-testid=\"chart-editor-resolving-resource\">\n {{ t('praxis.charts.editor.catalog.resolvingResource', 'Resolving governed stats operations and fields for the selected resource...') }}\n </mat-hint>\n } @else if (operationCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.operationMissing', 'Selected resource does not publish authorable stats operations.') }}</mat-hint>\n }\n </mat-form-field>\n }\n </div>\n\n @if (doc().source.kind === 'praxis.stats' && selectedResourceOption(); as selectedRes) {\n <div class=\"editor-resource-summary\" data-testid=\"chart-editor-resource-summary\">\n <span class=\"editor-resource-summary__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-resource-summary__body\">\n <div class=\"editor-resource-summary__header\">\n <strong>{{ selectedRes.label }}</strong>\n @if (selectedRes.group) {\n <span class=\"editor-resource-summary__badge\">{{ selectedRes.group }}</span>\n }\n @if (selectedRes.readOnly) {\n <span class=\"editor-resource-summary__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-resource-summary__code\">\n {{ selectedRes.path }}{{ selectedRes.id && selectedRes.id !== selectedRes.path && selectedRes.id !== selectedRes.label ? ' \u00B7 ' + selectedRes.id : '' }}\n </span>\n @if (selectedRes.description) {\n <p class=\"editor-resource-summary__description\">{{ selectedRes.description }}</p>\n }\n </div>\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n @if (showTimeseriesControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.timeseriesTitle', 'Timeseries options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.granularity', 'Granularity') }}</mat-label>\n <mat-select [ngModel]=\"granularityValue()\" (ngModelChange)=\"setGranularity($event)\" [disabled]=\"isReadonly()\">\n @for (granularity of timeGranularities; track granularity) {\n <mat-option [value]=\"granularity\">\n {{ t('praxis.charts.editor.granularity.' + granularity, granularity) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-slide-toggle\n [ngModel]=\"fillGapsValue()\"\n (ngModelChange)=\"setFillGaps($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.fillGaps', 'Fill missing intervals') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showDistributionControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.distributionTitle', 'Distribution options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.distributionMode', 'Distribution mode') }}</mat-label>\n <mat-select data-testid=\"chart-editor-distribution-mode\" [ngModel]=\"distributionModeValue()\" (ngModelChange)=\"setDistributionMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of distributionModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.distributionMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (distributionModeValue() === 'histogram') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketSize', 'Bucket size') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-size\" [ngModel]=\"bucketSizeValue()\" (ngModelChange)=\"setBucketSize($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketCount', 'Bucket count') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-count\" [ngModel]=\"bucketCountValue()\" (ngModelChange)=\"setBucketCount($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n }\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComparisonControls()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-comparison-options\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comparisonTitle', 'Comparison period') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPeriodField', 'Period field') }}</mat-label>\n @if (fieldOptions('time').length) {\n <mat-select\n [ngModel]=\"comparisonPeriodFieldValue()\"\n (ngModelChange)=\"setComparisonPeriodField($event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('time', comparisonPeriodFieldValue()); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('time').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.comparisonPeriodFieldsMissing', 'No governed time-series field is available for comparison periods.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonTimezone', 'Timezone') }}</mat-label>\n <input\n matInput\n [ngModel]=\"comparisonPeriodTimezoneValue()\"\n (ngModelChange)=\"setComparisonPeriodTimezone($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPreset', 'Period preset') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodPresetValue()\"\n (ngModelChange)=\"setComparisonPeriodPreset($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (preset of comparisonPeriodPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.comparisonPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonMode', 'Comparison mode') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodModeValue()\"\n (ngModelChange)=\"setComparisonPeriodMode($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (mode of comparisonPeriodModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.comparisonMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @if (doc().source.kind === 'derived') {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-local-data\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.localData.title', 'Local dataset (inputs.data)') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.localData.caption', 'Edit the local records rendered when the data source is derived.') }}\n </p>\n\n @if (canConsolidateGaugeLocalData()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-consolidate-gauge\"\n (click)=\"consolidateGaugeLocalData()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.consolidateGauge', 'Consolidate to 1 bucket for gauge') }}\n </button>\n </div>\n }\n\n @if (localDataRows().length) {\n @for (row of localDataRows(); track $index; let rowIndex = $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-local-data-row-' + rowIndex\">\n <div class=\"editor-grid\">\n @for (dimField of localDataDimensionFields(); track dimField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ dimField }}</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'chart-local-data-dim-' + rowIndex + '-' + dimField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, dimField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, dimField, $event, 'dimension')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n @for (metricField of localDataMetricFields(); track metricField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ metricField }}</mat-label>\n <input\n matInput\n type=\"number\"\n [attr.data-testid]=\"'chart-local-data-metric-' + rowIndex + '-' + metricField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, metricField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, metricField, $event, 'metric')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n </div>\n <div class=\"editor-row-actions\">\n <button\n mat-button\n type=\"button\"\n [attr.data-testid]=\"'chart-local-data-remove-row-' + rowIndex\"\n (click)=\"removeLocalDataRow(rowIndex)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.removeRow', 'Remove record') }}\n </button>\n </div>\n </div>\n }\n } @else {\n <div class=\"editor-empty\" data-testid=\"chart-local-data-empty\">\n {{ t('praxis.charts.editor.localData.empty', 'No local records configured yet. Add a record to replace the synthetic preview sample.') }}\n </div>\n }\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-add-row\"\n (click)=\"addLocalDataRow()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.addRow', 'Add record') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n </div>\n }\n\n @case ('motion') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-slide-toggle\n [ngModel]=\"normalizedDocument().motion?.enabled !== false\"\n (ngModelChange)=\"setMotionEnabled($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.motionEnabled', 'Enable animations') }}\n </mat-slide-toggle>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.motionPreset', 'Motion preset') }}</mat-label>\n <mat-select\n [ngModel]=\"normalizedDocument().motion?.preset || 'standard'\"\n (ngModelChange)=\"setMotionPreset($event)\"\n [disabled]=\"isReadonly() || normalizedDocument().motion?.enabled === false\"\n >\n @for (preset of motionPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.motionPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <p class=\"editor-caption\" data-testid=\"chart-editor-motion-hint\">\n {{ t('praxis.charts.editor.motion.hint', 'Controls initial entrance timing and smooth transitions when filters or query context change, with automatic volume thresholds for high-density datasets.') }}\n </p>\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-simulate-motion\"\n (click)=\"simulateMotionTransition()\"\n [disabled]=\"normalizedDocument().motion?.enabled === false || !effectivePreview()\"\n >\n {{ t('praxis.charts.editor.motion.simulate', 'Simulate transition') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('appearance') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.featuresTitle', 'Display features') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-toggle-cluster\">\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('legend')\"\n (ngModelChange)=\"setFeatureEnabled('legend', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.legendEnabled', 'Show legend') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('labels')\"\n (ngModelChange)=\"setFeatureEnabled('labels', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.labelsEnabled', 'Show labels') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('tooltip')\"\n (ngModelChange)=\"setFeatureEnabled('tooltip', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.tooltipEnabled', 'Show tooltip') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.paletteTitle', 'Palette') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.themeVariant', 'Theme variant') }}</mat-label>\n <mat-select [ngModel]=\"themeVariantValue()\" (ngModelChange)=\"setThemeVariant($event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.themeVariant.none', 'No variant') }}\n </mat-option>\n @for (variant of themeVariants; track variant) {\n <mat-option [value]=\"variant\">\n {{ t('praxis.charts.editor.themeVariant.' + variant, variant) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteMode', 'Palette mode') }}</mat-label>\n <mat-select [ngModel]=\"paletteModeValue()\" (ngModelChange)=\"setPaletteMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of paletteModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.paletteMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n @if (canApplyDomainSemanticPalette()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-apply-domain-palette\"\n (click)=\"applyDomainSemanticPalette()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.appearance.applyDomainPaletteHint', 'Generates a custom palette with semantic colors (success, warning, danger, info) from the dimension categories.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.appearance.applyDomainPalette', 'Apply domain semantic colors') }}\n </button>\n </div>\n }\n\n @if (paletteModeValue() === 'token') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteToken', 'Palette token') }}</mat-label>\n <mat-select [ngModel]=\"paletteTokenValue()\" (ngModelChange)=\"setPaletteToken($event)\" [disabled]=\"isReadonly()\">\n @for (token of paletteTokens; track token) {\n <mat-option [value]=\"token\">\n {{ t('praxis.charts.editor.paletteToken.' + token, token) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of tokenPaletteSwatches(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n </span>\n }\n </div>\n } @else {\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of customPaletteColors(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <input\n type=\"color\"\n class=\"editor-palette-color-input\"\n [attr.data-testid]=\"'chart-editor-palette-color-' + $index\"\n [value]=\"swatchHexValue(swatch, $index)\"\n (input)=\"setCustomPaletteColorAt($index, $any($event.target).value)\"\n [disabled]=\"isReadonly()\"\n />\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n <button\n type=\"button\"\n class=\"editor-palette-swatch-remove\"\n [attr.data-testid]=\"'chart-editor-palette-remove-' + $index\"\n (click)=\"removeCustomPaletteColor($index)\"\n [disabled]=\"isReadonly()\"\n [attr.aria-label]=\"t('praxis.charts.editor.appearance.removePaletteColor', 'Remove color')\"\n >\n \u00D7\n </button>\n </span>\n }\n <button\n mat-stroked-button\n type=\"button\"\n class=\"editor-palette-add-btn\"\n data-testid=\"chart-editor-add-palette-color\"\n (click)=\"addCustomPaletteColor()\"\n [disabled]=\"isReadonly()\"\n >\n + {{ t('praxis.charts.editor.appearance.addPaletteColor', 'Add color') }}\n </button>\n </div>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.palette', 'Palette colors') }}</mat-label>\n <textarea\n matInput\n rows=\"2\"\n [ngModel]=\"paletteValue()\"\n (ngModelChange)=\"setPalette($event)\"\n [disabled]=\"isReadonly()\"\n ></textarea>\n </mat-form-field>\n }\n\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.paletteHint', 'Use a registered token or comma-separated colors to persist theme.palette in the canonical contract.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.surfaceTitle', 'Surface') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.surfaceMode', 'Surface mode') }}</mat-label>\n <mat-select [ngModel]=\"surfaceModeValue()\" (ngModelChange)=\"setSurfaceMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of surfaceModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.surface.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.surfaceHint', 'Use embedded for dashboard widgets and contained only when the chart must own its visual surface.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.statesTitle', 'State messages') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyTitle', 'Empty title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('empty')\" (ngModelChange)=\"setStateTitle('empty', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyDescription', 'Empty description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingTitle', 'Loading title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('loading')\" (ngModelChange)=\"setStateTitle('loading', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingDescription', 'Loading description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorTitle', 'Error title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('error')\" (ngModelChange)=\"setStateTitle('error', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorDescription', 'Error description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('error')\" (ngModelChange)=\"setStateDescription('error', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('analytics') {\n <div class=\"editor-stack\">\n @if (showGaugePanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-gauge-scale\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.gaugeTitle', 'Gauge scale') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.gaugeHint', 'Gauge charts render exactly one bucket and one metric. Remote stats use group-by with limit 1; values outside the scale fail instead of being clamped.') }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMin', 'Scale minimum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-min\"\n [ngModel]=\"gaugeMinValue()\"\n (ngModelChange)=\"setGaugeMin($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMax', 'Scale maximum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-max\"\n [ngModel]=\"gaugeMaxValue()\"\n (ngModelChange)=\"setGaugeMax($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComboPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comboTitle', 'Combo guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.comboHint', 'Combo charts require at least two metrics and allow per-metric axis and series kind mapping.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showPieDonutPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.pieDonutTitle', 'Composition guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.pieDonutHint', 'Pie, donut, funnel, pyramid and treemap charts keep one metric and use the first dimension as the category segment.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showScatterPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.scatterTitle', 'Scatter guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.scatterHint', 'With one metric, scatter uses the first dimension as X and the metric as Y. With two metrics, the dimension identifies the grouped bucket and the metrics map to X and Y.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (canApplySemanticAnalyticsMapping()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-smart-map-analytics\"\n (click)=\"applySemanticAnalyticsMapping()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.analytics.smartMapHint', 'Automatically selects the best dimension, metric, and aggregation from the governed field catalog.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.analytics.smartMap', 'Suggest dimension & metric from schema') }}\n </button>\n </div>\n }\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.dimensionsTitle', 'Dimensions') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (dimension of dimensions(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-dimension-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimension', 'Dimension') }}</mat-label>\n @if (fieldOptions('dimension').length) {\n <mat-select\n [ngModel]=\"dimension.field || ''\"\n (ngModelChange)=\"setDimensionField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('dimension', dimension.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('dimension').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.dimensionFieldsMissing', 'No governed dimensions are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-role-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionRole', 'Dimension role') }}</mat-label>\n <mat-select [ngModel]=\"dimension.role || 'category'\" (ngModelChange)=\"setDimensionRole($index, $event)\" [disabled]=\"isReadonly()\">\n @for (role of dimensionRoles; track role) {\n <mat-option [value]=\"role\">\n {{ t('praxis.charts.editor.dimensionRole.' + role, role) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" (click)=\"removeDimension($index)\" [disabled]=\"isReadonly() || dimensions().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeDimension', 'Remove dimension') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-dimension\" (click)=\"addDimension()\" [disabled]=\"isReadonly()\">\n {{ t('praxis.charts.editor.analytics.addDimension', 'Add dimension') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.metricsTitle', 'Metrics') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (metric of metrics(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-metric-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metric', 'Metric') }}</mat-label>\n @if (fieldOptions('metric').length) {\n <mat-select\n [ngModel]=\"metric.field || ''\"\n (ngModelChange)=\"setMetricField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('metric', metric.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('metric').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.metricFieldsMissing', 'No governed metrics are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-label-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricLabel', 'Metric label') }}</mat-label>\n <input matInput [ngModel]=\"metric.label || ''\" (ngModelChange)=\"setMetricLabel($index, $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-aggregation-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAggregation', 'Aggregation') }}</mat-label>\n <mat-select [ngModel]=\"metric.aggregation || 'sum'\" (ngModelChange)=\"setMetricAggregation($index, $event)\" [disabled]=\"isReadonly()\">\n @for (aggregation of metricAggregationOptions(metric.field); track aggregation) {\n <mat-option [value]=\"aggregation\">\n {{ t('praxis.charts.editor.metricAggregation.' + aggregation, aggregation) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (showMetricAxisControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-axis-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAxis', 'Axis') }}</mat-label>\n <mat-select [ngModel]=\"metric.axis || 'primary'\" (ngModelChange)=\"setMetricAxis($index, $event)\" [disabled]=\"isReadonly()\">\n @for (axis of metricAxes; track axis) {\n <mat-option [value]=\"axis\">\n {{ t('praxis.charts.editor.metricAxis.' + axis, axis) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n\n @if (showMetricSeriesKindControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-series-kind-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricSeriesKind', 'Series kind') }}</mat-label>\n <mat-select [ngModel]=\"metric.seriesKind || 'bar'\" (ngModelChange)=\"setMetricSeriesKind($index, $event)\" [disabled]=\"isReadonly()\">\n @for (seriesKind of metricSeriesKinds; track seriesKind) {\n <mat-option [value]=\"seriesKind\">\n {{ t('praxis.charts.editor.metricSeriesKind.' + seriesKind, seriesKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" [attr.data-testid]=\"'chart-editor-remove-metric-' + $index\" (click)=\"removeMetric($index)\" [disabled]=\"isReadonly() || metrics().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeMetric', 'Remove metric') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-metric\" (click)=\"addMetric()\" [disabled]=\"isReadonly() || !canAddMetric()\">\n {{ t('praxis.charts.editor.analytics.addMetric', 'Add metric') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('events') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-pointClick\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.pointClickTitle', 'Point click') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-action\" [ngModel]=\"eventAction('pointClick')\" (ngModelChange)=\"setEventAction('pointClick', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-target\" [ngModel]=\"eventTarget('pointClick')\" (ngModelChange)=\"setEventTarget('pointClick', $event)\" [disabled]=\"isReadonly() || !eventAction('pointClick') || targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')\">\n @for (target of targetOptions(eventAction('pointClick'), 'pointClick'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-pointClick-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('pointClick')\"\n (ngModelChange)=\"setEventMapping('pointClick', $event)\"\n [disabled]=\"isReadonly() || !eventAction('pointClick')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-selectionChange\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.selectionChangeTitle', 'Selection change') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-action\" [ngModel]=\"eventAction('selectionChange')\" (ngModelChange)=\"setEventAction('selectionChange', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-target\" [ngModel]=\"eventTarget('selectionChange')\" (ngModelChange)=\"setEventTarget('selectionChange', $event)\" [disabled]=\"isReadonly() || !eventAction('selectionChange') || targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')\">\n @for (target of targetOptions(eventAction('selectionChange'), 'selectionChange'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-selectionChange-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('selectionChange')\"\n (ngModelChange)=\"setEventMapping('selectionChange', $event)\"\n [disabled]=\"isReadonly() || !eventAction('selectionChange')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-drillDown\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.drillDownTitle', 'Drill down') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-action\" [ngModel]=\"eventAction('drillDown')\" (ngModelChange)=\"setEventAction('drillDown', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-target\" [ngModel]=\"eventTarget('drillDown')\" (ngModelChange)=\"setEventTarget('drillDown', $event)\" [disabled]=\"isReadonly() || !eventAction('drillDown') || targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')\">\n @for (target of targetOptions(eventAction('drillDown'), 'drillDown'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-drillDown-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('drillDown')\"\n (ngModelChange)=\"setEventMapping('drillDown', $event)\"\n [disabled]=\"isReadonly() || !eventAction('drillDown')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-crossFilter\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.crossFilterTitle', 'Cross filter') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-action\" [ngModel]=\"eventAction('crossFilter')\" (ngModelChange)=\"setEventAction('crossFilter', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-target\" [ngModel]=\"eventTarget('crossFilter')\" (ngModelChange)=\"setEventTarget('crossFilter', $event)\" [disabled]=\"isReadonly() || !eventAction('crossFilter') || targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')\">\n @for (target of targetOptions(eventAction('crossFilter'), 'crossFilter'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-crossFilter-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('crossFilter')\"\n (ngModelChange)=\"setEventMapping('crossFilter', $event)\"\n [disabled]=\"isReadonly() || !eventAction('crossFilter')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('preview') {\n <mat-card class=\"editor-card\">\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n }\n </div>\n\n <div class=\"editor-side\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.issues.title', 'Validation issues') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (issues().length) {\n <ul class=\"editor-issues\">\n @for (issue of issues(); track issueTrackBy($index, issue)) {\n <li class=\"editor-issue\" [class.editor-issue--warning]=\"issue.severity === 'warning'\">\n <div class=\"editor-issue__header\">\n <strong>{{ issue.field }}</strong>\n @if (issueTargetSection(issue); as targetSection) {\n <button\n mat-button\n type=\"button\"\n class=\"editor-issue__jump\"\n [attr.data-testid]=\"'chart-editor-issue-jump-' + issue.code\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(targetSection) })\"\n (click)=\"setSection(targetSection)\"\n >\n {{ sectionLabel(targetSection) }} \u2192\n </button>\n }\n </div>\n <span>{{ issue.message }}</span>\n </li>\n }\n </ul>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.issues.empty', 'No issues were identified.') }}\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.preview.title', 'Chart preview') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (!preview()) {\n <div\n class=\"editor-empty editor-empty--actionable editor-preview-degraded\"\n data-testid=\"chart-editor-preview-degraded-warning\"\n >\n <span>{{\n effectivePreview()\n ? t('praxis.charts.editor.preview.degradedNotice', 'Aesthetic simulation active with synthetic sample data while contract validation issues remain open.')\n : t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.')\n }}</span>\n @if (firstInvalidSection(); as invalidSection) {\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-preview-jump-invalid\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(invalidSection) })\"\n (click)=\"setSection(invalidSection)\"\n >\n {{ sectionLabel(invalidSection) }} \u2192\n </button>\n }\n </div>\n }\n @if (effectivePreview(); as chartPreview) {\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n <p class=\"editor-caption\" data-testid=\"chart-editor-preview-origin\">\n {{\n doc().source.kind === 'praxis.stats'\n ? t('praxis.charts.editor.preview.originStatsSynthetic', 'Previewing synthetic sample for governed backend stats (praxis.stats).')\n : chartPreview.dataOrigin === 'local'\n ? t('praxis.charts.editor.preview.originLocal', 'Previewing widget local dataset (inputs.data).')\n : t('praxis.charts.editor.preview.originSynthetic', 'Previewing synthetic sample data (no local dataset records).')\n }}\n </p>\n <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n }\n </mat-card-content>\n </mat-card>\n </div>\n </div>\n</div>\n", styles: [":host{display:block;min-width:0;container:praxis-chart-editor / inline-size;color:var(--md-sys-color-on-surface, #1a1b20)}.editor-shell{display:grid;gap:16px;min-width:0}.editor-nav{display:flex;gap:6px;flex-wrap:wrap;padding:4px;border-radius:14px;background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .04));border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent)}.editor-nav button{border-radius:10px;border-color:transparent;display:inline-flex;align-items:center;gap:6px}.editor-nav button.active{background:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 22%,transparent);color:var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4));border-color:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 48%,transparent);font-weight:600}.editor-nav__badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:999px;font-size:.7rem;font-weight:700;line-height:1;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 24%,transparent);color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface, inherit))}.editor-nav__badge--error{background:color-mix(in srgb,var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e)) 24%,transparent);color:var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e))}:host ::ng-deep .mat-mdc-form-field-hint-wrapper,:host ::ng-deep .mat-mdc-form-field-error-wrapper{position:relative!important;top:auto!important;left:auto!important;right:auto!important;padding-top:4px;line-height:1.35}:host ::ng-deep .mat-mdc-form-field-bottom-align:before{display:none}.editor-layout{display:grid;gap:16px;grid-template-columns:minmax(0,1.35fr) minmax(320px,.9fr);align-items:start}.editor-form,.editor-side{display:grid;gap:16px;min-width:0}.editor-card{box-sizing:border-box;width:100%;min-width:0;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 72%,transparent);background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .03))}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));align-items:start}.editor-toggle-cluster{display:flex;flex-wrap:wrap;align-items:center;gap:14px 24px}.editor-stack{display:grid;gap:14px}.editor-state-group{padding-top:14px;border-top:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 48%,transparent)}.editor-state-group:first-child{padding-top:0;border-top:0}.editor-palette-swatches{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.editor-palette-swatch-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 68%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 88%,transparent);font-size:.76rem}.editor-palette-swatch-dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(128,128,128,.35);flex-shrink:0}.editor-palette-color-input{width:22px;height:22px;padding:0;border:0;border-radius:50%;background:transparent;cursor:pointer}.editor-palette-swatch-code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.74rem;color:var(--md-sys-color-on-surface, inherit)}.editor-palette-swatch-remove{appearance:none;border:0;background:transparent;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.95rem;line-height:1;padding:0 2px;cursor:pointer}.editor-row-card{padding:14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 54%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 92%,rgba(18,99,180,.04))}.editor-row-actions{display:flex;justify-content:flex-end}.editor-field{width:100%}.editor-issues{display:grid;gap:10px;margin:0;padding:0;list-style:none}.editor-issue{padding:12px 14px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 10%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 24%,transparent)}.editor-issue--warning{background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 26%,transparent)}.editor-issue__header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:4px}.editor-issue strong{display:block;font-size:.84rem}.editor-issue__jump{min-height:26px;padding:0 8px;font-size:.76rem}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem;line-height:1.45}.editor-empty{padding:16px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 52%,transparent);border:1px dashed color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent);color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem}.editor-empty--actionable{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.editor-select-trigger{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:100%}.editor-select-trigger__icon{width:18px;height:18px;font-size:18px;flex-shrink:0;color:var(--md-sys-color-primary, #1263b4)}.editor-select-trigger__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.editor-select-trigger__code{flex-shrink:0;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,currentColor 8%,transparent);color:color-mix(in srgb,currentColor 72%,transparent)}::ng-deep .editor-select-search{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--md-sys-color-surface-container-highest, #f4f6fa);border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 45%,transparent)}::ng-deep .editor-select-search mat-icon{width:18px;height:18px;font-size:18px;color:var(--md-sys-color-on-surface-variant, #5a5d67);flex-shrink:0}::ng-deep .editor-select-search input{width:100%;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:.85rem}::ng-deep .mat-mdc-option.editor-rich-mat-option{min-height:54px;height:auto;padding-block:8px;align-items:flex-start}::ng-deep .editor-rich-option{display:flex;align-items:flex-start;gap:10px;min-width:0;width:100%;line-height:1.25}::ng-deep .editor-rich-option__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}::ng-deep .editor-rich-option__icon mat-icon{width:16px;height:16px;font-size:16px}::ng-deep .editor-rich-option__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 auto}::ng-deep .editor-rich-option__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}::ng-deep .editor-rich-option__label{font-weight:600;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__badge,.editor-resource-summary__badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:.68rem;font-weight:600;background:color-mix(in srgb,currentColor 10%,transparent);color:color-mix(in srgb,currentColor 78%,transparent);white-space:nowrap}::ng-deep .editor-rich-option__code,.editor-resource-summary__code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;color:var(--md-sys-color-primary, #1263b4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__description,.editor-resource-summary__description{margin:0;font-size:.75rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal}.editor-resource-summary{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 6%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent)}.editor-resource-summary__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}.editor-resource-summary__icon mat-icon{width:16px;height:16px;font-size:16px}.editor-resource-summary__body{display:grid;gap:2px;min-width:0;flex:1 1 auto}.editor-resource-summary__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}.editor-preview-degraded{margin-bottom:12px;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-style:solid;border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 32%,transparent)}@container praxis-chart-editor (min-width: 841px){.editor-nav__preview-btn:not(.active){display:none}}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}.editor-shell--preview-active .editor-form{display:none}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i3.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i3.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i3.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i3.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i7.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "directive", type: i7.MatSelectTrigger, selector: "mat-select-trigger" }, { kind: "component", type: i7.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i8.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }, { kind: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "resourceKey", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved", "chartDocumentPatchChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
16608
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.14", type: PraxisChartConfigEditor, isStandalone: true, selector: "praxis-chart-config-editor", inputs: { documentInput: { classPropertyName: "documentInput", publicName: "document", isSignal: true, isRequired: false, transformFunction: null }, localDataInput: { classPropertyName: "localDataInput", publicName: "localData", isSignal: true, isRequired: false, transformFunction: null }, modeInput: { classPropertyName: "modeInput", publicName: "mode", isSignal: true, isRequired: false, transformFunction: null }, readonlyInput: { classPropertyName: "readonlyInput", publicName: "readonly", isSignal: true, isRequired: false, transformFunction: null }, availableResourcesInput: { classPropertyName: "availableResourcesInput", publicName: "availableResources", isSignal: true, isRequired: false, transformFunction: null }, availableFieldsInput: { classPropertyName: "availableFieldsInput", publicName: "availableFields", isSignal: true, isRequired: false, transformFunction: null }, availableTargetsInput: { classPropertyName: "availableTargetsInput", publicName: "availableTargets", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { apply: "apply", save: "save", resetChange: "resetChange", documentChange: "documentChange", localDataChange: "localDataChange" }, providers: [providePraxisChartsI18n()], ngImport: i0, template: "<div class=\"editor-shell\" [class.editor-shell--preview-active]=\"activeSection() === 'preview'\">\n <div class=\"editor-nav\" role=\"toolbar\" [attr.aria-label]=\"t('praxis.charts.editor.sectionsNav', 'Se\u00E7\u00F5es do editor')\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.id]=\"'chart-editor-tab-' + section.id\"\n aria-controls=\"chart-editor-tabpanel\"\n [attr.aria-pressed]=\"activeSection() === section.id\"\n [tabIndex]=\"activeSection() === section.id ? 0 : -1\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n [class.editor-nav__preview-btn]=\"section.id === 'preview'\"\n [class.editor-nav__has-error]=\"sectionHasError(section.id)\"\n (click)=\"setSection(section.id)\"\n (keydown)=\"onSectionNavKeydown($event, $index)\"\n >\n <span>{{ t(section.labelKey, section.fallback) }}</span>\n @if (sectionIssueCount(section.id) > 0) {\n <span\n class=\"editor-nav__badge\"\n aria-hidden=\"true\"\n [class.editor-nav__badge--error]=\"sectionHasError(section.id)\"\n [attr.data-testid]=\"'chart-editor-section-badge-' + section.id\"\n >\n {{ sectionIssueCount(section.id) }}\n </span>\n }\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div\n class=\"editor-form\"\n id=\"chart-editor-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-labelledby]=\"'chart-editor-tab-' + activeSection()\"\n >\n @switch (activeSection()) {\n @case ('general') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.chartId', 'Chart ID') }}</mat-label>\n <input matInput [ngModel]=\"doc().chartId || ''\" (ngModelChange)=\"setChartId($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.kind', 'Kind') }}</mat-label>\n <mat-select [ngModel]=\"doc().kind\" (ngModelChange)=\"setKind($event)\" [disabled]=\"isReadonly()\">\n @for (kind of chartKinds; track kind) {\n <mat-option [value]=\"kind\">\n {{ t('praxis.charts.editor.kind.' + kind, kind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.title', 'Title') }}</mat-label>\n <input matInput data-testid=\"chart-editor-title-input\" [ngModel]=\"titleValue()\" (ngModelChange)=\"setTitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.subtitle', 'Subtitle') }}</mat-label>\n <input matInput [ngModel]=\"subtitleValue()\" (ngModelChange)=\"setSubtitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sizingMode', 'Sizing') }}</mat-label>\n <mat-select [ngModel]=\"sizingModeValue()\" (ngModelChange)=\"setSizingMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of sizingModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.sizing.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>{{ t('praxis.charts.editor.hint.sizingMode', 'Use fill-container only when the host widget provides a defined body height.') }}</mat-hint>\n </mat-form-field>\n\n @if (sizingModeValue() === 'fixed') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.height', 'Height') }}</mat-label>\n <input matInput [ngModel]=\"heightValue()\" (ngModelChange)=\"setSizingHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.height', 'Numbers are saved as pixels; CSS lengths such as 20rem are also accepted.') }}</mat-hint>\n </mat-form-field>\n }\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.minHeight', 'Minimum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMinHeightValue()\" (ngModelChange)=\"setSizingMinHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.minHeight', 'Set a readable minimum for compact dashboard widgets.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.maxHeight', 'Maximum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMaxHeightValue()\" (ngModelChange)=\"setSizingMaxHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.maxHeight', 'Leave empty unless the chart must stop growing inside a flexible layout.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.aspectRatio', 'Aspect ratio') }}</mat-label>\n <input matInput [ngModel]=\"sizingAspectRatioValue()\" (ngModelChange)=\"setSizingAspectRatio($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.aspectRatio', 'Optional. Use values such as 1.777 or 16 / 9.') }}</mat-hint>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('data') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\" data-testid=\"chart-editor-effective-origin\">\n {{ effectiveDataOriginSummary() }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sourceKind', 'Source') }}</mat-label>\n <mat-select data-testid=\"chart-editor-source-kind\" [ngModel]=\"doc().source.kind\" (ngModelChange)=\"setSourceKind($event)\" [disabled]=\"isReadonly()\">\n @for (sourceKind of sourceKinds; track sourceKind) {\n <mat-option [value]=\"sourceKind\">\n {{ t('praxis.charts.editor.sourceKind.' + (sourceKind === 'praxis.stats' ? 'praxisStats' : 'derived'), sourceKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (doc().source.kind === 'praxis.stats') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.resource', 'Resource') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-resource\"\n [ngModel]=\"resourceValue()\"\n (ngModelChange)=\"setResource($event)\"\n (openedChange)=\"setResourceSearchTerm('')\"\n [disabled]=\"isReadonly() || resourceCatalogUnavailable()\"\n >\n @if (selectedResourceOption(); as selectedRes) {\n <mat-select-trigger>\n <span class=\"editor-select-trigger\">\n <mat-icon class=\"editor-select-trigger__icon\" [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n <span class=\"editor-select-trigger__label\">{{ selectedRes.label }}</span>\n <span class=\"editor-select-trigger__code\">{{ selectedRes.path }}</span>\n </span>\n </mat-select-trigger>\n }\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n data-testid=\"chart-editor-resource-search\"\n [placeholder]=\"t('praxis.charts.editor.field.resourceSearchPlaceholder', 'Search by name, key, or endpoint...')\"\n [ngModel]=\"resourceSearchTerm()\"\n (ngModelChange)=\"setResourceSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (resource of filteredResourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <span class=\"editor-rich-option__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"resource.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ resource.label }}</span>\n @if (resource.group) {\n <span class=\"editor-rich-option__badge\">{{ resource.group }}</span>\n }\n @if (resource.readOnly) {\n <span class=\"editor-rich-option__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-rich-option__code\">\n {{ resource.path }}{{ resource.id && resource.id !== resource.path && resource.id !== resource.label ? ' \u00B7 ' + resource.id : '' }}\n </span>\n @if (resource.description) {\n <span class=\"editor-rich-option__description\">{{ resource.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n @if (resourceCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.resourceMissing', 'Resource catalog is required for governed praxis.stats authoring.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.operation', 'Operation') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-operation\"\n [ngModel]=\"doc().source.operation || 'group-by'\"\n (ngModelChange)=\"setOperation($event)\"\n [disabled]=\"isReadonly() || isResolvingResource() || operationCatalogUnavailable()\"\n >\n @for (operation of operationOptions(); track operation) {\n <mat-option [value]=\"operation\">\n {{ t('praxis.charts.editor.operation.' + (operation === 'group-by' ? 'groupBy' : operation), operation) }}\n </mat-option>\n }\n </mat-select>\n @if (isResolvingResource()) {\n <mat-hint data-testid=\"chart-editor-resolving-resource\">\n {{ t('praxis.charts.editor.catalog.resolvingResource', 'Resolving governed stats operations and fields for the selected resource...') }}\n </mat-hint>\n } @else if (operationCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.operationMissing', 'Selected resource does not publish authorable stats operations.') }}</mat-hint>\n }\n </mat-form-field>\n }\n </div>\n\n @if (doc().source.kind === 'praxis.stats' && selectedResourceOption(); as selectedRes) {\n <div class=\"editor-resource-summary\" data-testid=\"chart-editor-resource-summary\">\n <span class=\"editor-resource-summary__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-resource-summary__body\">\n <div class=\"editor-resource-summary__header\">\n <strong>{{ selectedRes.label }}</strong>\n @if (selectedRes.group) {\n <span class=\"editor-resource-summary__badge\">{{ selectedRes.group }}</span>\n }\n @if (selectedRes.readOnly) {\n <span class=\"editor-resource-summary__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-resource-summary__code\">\n {{ selectedRes.path }}{{ selectedRes.id && selectedRes.id !== selectedRes.path && selectedRes.id !== selectedRes.label ? ' \u00B7 ' + selectedRes.id : '' }}\n </span>\n @if (selectedRes.description) {\n <p class=\"editor-resource-summary__description\">{{ selectedRes.description }}</p>\n }\n </div>\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n @if (showTimeseriesControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.timeseriesTitle', 'Timeseries options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.granularity', 'Granularity') }}</mat-label>\n <mat-select [ngModel]=\"granularityValue()\" (ngModelChange)=\"setGranularity($event)\" [disabled]=\"isReadonly()\">\n @for (granularity of timeGranularities; track granularity) {\n <mat-option [value]=\"granularity\">\n {{ t('praxis.charts.editor.granularity.' + granularity, granularity) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-slide-toggle\n [ngModel]=\"fillGapsValue()\"\n (ngModelChange)=\"setFillGaps($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.fillGaps', 'Fill missing intervals') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showDistributionControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.distributionTitle', 'Distribution options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.distributionMode', 'Distribution mode') }}</mat-label>\n <mat-select data-testid=\"chart-editor-distribution-mode\" [ngModel]=\"distributionModeValue()\" (ngModelChange)=\"setDistributionMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of distributionModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.distributionMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (distributionModeValue() === 'histogram') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketSize', 'Bucket size') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-size\" [ngModel]=\"bucketSizeValue()\" (ngModelChange)=\"setBucketSize($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketCount', 'Bucket count') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-count\" [ngModel]=\"bucketCountValue()\" (ngModelChange)=\"setBucketCount($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n }\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComparisonControls()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-comparison-options\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comparisonTitle', 'Comparison period') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPeriodField', 'Period field') }}</mat-label>\n @if (fieldOptions('time').length) {\n <mat-select\n [ngModel]=\"comparisonPeriodFieldValue()\"\n (ngModelChange)=\"setComparisonPeriodField($event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('time', comparisonPeriodFieldValue()); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('time').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.comparisonPeriodFieldsMissing', 'No governed time-series field is available for comparison periods.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonTimezone', 'Timezone') }}</mat-label>\n <input\n matInput\n [ngModel]=\"comparisonPeriodTimezoneValue()\"\n (ngModelChange)=\"setComparisonPeriodTimezone($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPreset', 'Period preset') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodPresetValue()\"\n (ngModelChange)=\"setComparisonPeriodPreset($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (preset of comparisonPeriodPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.comparisonPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonMode', 'Comparison mode') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodModeValue()\"\n (ngModelChange)=\"setComparisonPeriodMode($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (mode of comparisonPeriodModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.comparisonMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @if (doc().source.kind === 'derived') {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-local-data\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.localData.title', 'Local dataset (inputs.data)') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.localData.caption', 'Edit the local records rendered when the data source is derived.') }}\n </p>\n\n @if (canConsolidateGaugeLocalData()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-consolidate-gauge\"\n (click)=\"consolidateGaugeLocalData()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.consolidateGauge', 'Consolidate to 1 bucket for gauge') }}\n </button>\n </div>\n }\n\n @if (localDataRows().length) {\n @for (row of localDataRows(); track $index; let rowIndex = $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-local-data-row-' + rowIndex\">\n <div class=\"editor-grid\">\n @for (dimField of localDataDimensionFields(); track dimField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ dimField }}</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'chart-local-data-dim-' + rowIndex + '-' + dimField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, dimField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, dimField, $event, 'dimension')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n @for (metricField of localDataMetricFields(); track metricField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ metricField }}</mat-label>\n <input\n matInput\n type=\"number\"\n [attr.data-testid]=\"'chart-local-data-metric-' + rowIndex + '-' + metricField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, metricField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, metricField, $event, 'metric')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n </div>\n <div class=\"editor-row-actions\">\n <button\n mat-button\n type=\"button\"\n [attr.data-testid]=\"'chart-local-data-remove-row-' + rowIndex\"\n (click)=\"removeLocalDataRow(rowIndex)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.removeRow', 'Remove record') }}\n </button>\n </div>\n </div>\n }\n } @else {\n <div class=\"editor-empty\" data-testid=\"chart-local-data-empty\">\n {{ t('praxis.charts.editor.localData.empty', 'No local records configured yet. Add a record to replace the synthetic preview sample.') }}\n </div>\n }\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-add-row\"\n (click)=\"addLocalDataRow()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.addRow', 'Add record') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n </div>\n }\n\n @case ('motion') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-slide-toggle\n [ngModel]=\"normalizedDocument().motion?.enabled !== false\"\n (ngModelChange)=\"setMotionEnabled($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.motionEnabled', 'Enable animations') }}\n </mat-slide-toggle>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.motionPreset', 'Motion preset') }}</mat-label>\n <mat-select\n [ngModel]=\"normalizedDocument().motion?.preset || 'standard'\"\n (ngModelChange)=\"setMotionPreset($event)\"\n [disabled]=\"isReadonly() || normalizedDocument().motion?.enabled === false\"\n >\n @for (preset of motionPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.motionPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <p class=\"editor-caption\" data-testid=\"chart-editor-motion-hint\">\n {{ t('praxis.charts.editor.motion.hint', 'Controls initial entrance timing and smooth transitions when filters or query context change, with automatic volume thresholds for high-density datasets.') }}\n </p>\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-simulate-motion\"\n (click)=\"simulateMotionTransition()\"\n [disabled]=\"normalizedDocument().motion?.enabled === false || !effectivePreview()\"\n >\n {{ t('praxis.charts.editor.motion.simulate', 'Simulate transition') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('appearance') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.featuresTitle', 'Display features') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-toggle-cluster\">\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('legend')\"\n (ngModelChange)=\"setFeatureEnabled('legend', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.legendEnabled', 'Show legend') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('labels')\"\n (ngModelChange)=\"setFeatureEnabled('labels', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.labelsEnabled', 'Show labels') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('tooltip')\"\n (ngModelChange)=\"setFeatureEnabled('tooltip', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.tooltipEnabled', 'Show tooltip') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.paletteTitle', 'Palette') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.themeVariant', 'Theme variant') }}</mat-label>\n <mat-select [ngModel]=\"themeVariantValue()\" (ngModelChange)=\"setThemeVariant($event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.themeVariant.none', 'No variant') }}\n </mat-option>\n @for (variant of themeVariants; track variant) {\n <mat-option [value]=\"variant\">\n {{ t('praxis.charts.editor.themeVariant.' + variant, variant) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteMode', 'Palette mode') }}</mat-label>\n <mat-select [ngModel]=\"paletteModeValue()\" (ngModelChange)=\"setPaletteMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of paletteModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.paletteMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n @if (canApplyDomainSemanticPalette()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-apply-domain-palette\"\n (click)=\"applyDomainSemanticPalette()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.appearance.applyDomainPaletteHint', 'Generates a custom palette with semantic colors (success, warning, danger, info) from the dimension categories.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.appearance.applyDomainPalette', 'Apply domain semantic colors') }}\n </button>\n </div>\n }\n\n @if (paletteModeValue() === 'token') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteToken', 'Palette token') }}</mat-label>\n <mat-select [ngModel]=\"paletteTokenValue()\" (ngModelChange)=\"setPaletteToken($event)\" [disabled]=\"isReadonly()\">\n @for (token of paletteTokens; track token) {\n <mat-option [value]=\"token\">\n {{ t('praxis.charts.editor.paletteToken.' + token, token) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of tokenPaletteSwatches(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n </span>\n }\n </div>\n } @else {\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of customPaletteColors(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <input\n type=\"color\"\n class=\"editor-palette-color-input\"\n [attr.data-testid]=\"'chart-editor-palette-color-' + $index\"\n [value]=\"swatchHexValue(swatch, $index)\"\n (input)=\"setCustomPaletteColorAt($index, $any($event.target).value)\"\n [disabled]=\"isReadonly()\"\n />\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n <button\n type=\"button\"\n class=\"editor-palette-swatch-remove\"\n [attr.data-testid]=\"'chart-editor-palette-remove-' + $index\"\n (click)=\"removeCustomPaletteColor($index)\"\n [disabled]=\"isReadonly()\"\n [attr.aria-label]=\"t('praxis.charts.editor.appearance.removePaletteColor', 'Remove color')\"\n >\n \u00D7\n </button>\n </span>\n }\n <button\n mat-stroked-button\n type=\"button\"\n class=\"editor-palette-add-btn\"\n data-testid=\"chart-editor-add-palette-color\"\n (click)=\"addCustomPaletteColor()\"\n [disabled]=\"isReadonly()\"\n >\n + {{ t('praxis.charts.editor.appearance.addPaletteColor', 'Add color') }}\n </button>\n </div>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.palette', 'Palette colors') }}</mat-label>\n <textarea\n matInput\n rows=\"2\"\n [ngModel]=\"paletteValue()\"\n (ngModelChange)=\"setPalette($event)\"\n [disabled]=\"isReadonly()\"\n ></textarea>\n </mat-form-field>\n }\n\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.paletteHint', 'Use a registered token or comma-separated colors to persist theme.palette in the canonical contract.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.surfaceTitle', 'Surface') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.surfaceMode', 'Surface mode') }}</mat-label>\n <mat-select [ngModel]=\"surfaceModeValue()\" (ngModelChange)=\"setSurfaceMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of surfaceModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.surface.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.surfaceHint', 'Use embedded for dashboard widgets and contained only when the chart must own its visual surface.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.statesTitle', 'State messages') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyTitle', 'Empty title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('empty')\" (ngModelChange)=\"setStateTitle('empty', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyDescription', 'Empty description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingTitle', 'Loading title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('loading')\" (ngModelChange)=\"setStateTitle('loading', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingDescription', 'Loading description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorTitle', 'Error title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('error')\" (ngModelChange)=\"setStateTitle('error', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorDescription', 'Error description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('error')\" (ngModelChange)=\"setStateDescription('error', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('analytics') {\n <div class=\"editor-stack\">\n @if (showGaugePanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-gauge-scale\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.gaugeTitle', 'Gauge scale') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.gaugeHint', 'Gauge charts render exactly one bucket and one metric. Remote stats use group-by with limit 1; values outside the scale fail instead of being clamped.') }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMin', 'Scale minimum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-min\"\n [ngModel]=\"gaugeMinValue()\"\n (ngModelChange)=\"setGaugeMin($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMax', 'Scale maximum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-max\"\n [ngModel]=\"gaugeMaxValue()\"\n (ngModelChange)=\"setGaugeMax($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComboPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comboTitle', 'Combo guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.comboHint', 'Combo charts require at least two metrics and allow per-metric axis and series kind mapping.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showPieDonutPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.pieDonutTitle', 'Composition guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.pieDonutHint', 'Pie, donut, funnel, pyramid and treemap charts keep one metric and use the first dimension as the category segment.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showScatterPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.scatterTitle', 'Scatter guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.scatterHint', 'With one metric, scatter uses the first dimension as X and the metric as Y. With two metrics, the dimension identifies the grouped bucket and the metrics map to X and Y.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showHeatmapPanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-heatmap-panel\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.heatmapTitle', 'Heatmap matrix guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.heatmapHint', 'Heatmap charts render a 2D matrix requiring two dimensions (X and Y axes) and one metric, using group-by for remote stats.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (canApplySemanticAnalyticsMapping()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-smart-map-analytics\"\n (click)=\"applySemanticAnalyticsMapping()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.analytics.smartMapHint', 'Automatically selects the best dimension, metric, and aggregation from the governed field catalog.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.analytics.smartMap', 'Suggest dimension & metric from schema') }}\n </button>\n </div>\n }\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.dimensionsTitle', 'Dimensions') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (dimension of dimensions(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-dimension-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimension', 'Dimension') }}</mat-label>\n @if (fieldOptions('dimension').length) {\n <mat-select\n [ngModel]=\"dimension.field || ''\"\n (ngModelChange)=\"setDimensionField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('dimension', dimension.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('dimension').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.dimensionFieldsMissing', 'No governed dimensions are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-role-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionRole', 'Dimension role') }}</mat-label>\n <mat-select [ngModel]=\"dimension.role || 'category'\" (ngModelChange)=\"setDimensionRole($index, $event)\" [disabled]=\"isReadonly()\">\n @for (role of dimensionRoles; track role) {\n <mat-option [value]=\"role\">\n {{ t('praxis.charts.editor.dimensionRole.' + role, role) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-extraction-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionExtraction', 'Temporal extraction') }}</mat-label>\n <mat-select [ngModel]=\"dimension.extraction || ''\" (ngModelChange)=\"setDimensionExtraction($index, $event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.dimensionExtraction.none', 'None') }}\n </mat-option>\n @for (extraction of dimensionExtractions; track extraction) {\n <mat-option [value]=\"extraction\">\n {{ t('praxis.charts.editor.dimensionExtraction.' + extraction, extraction) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" (click)=\"removeDimension($index)\" [disabled]=\"isReadonly() || !canRemoveDimension()\">\n {{ t('praxis.charts.editor.analytics.removeDimension', 'Remove dimension') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-dimension\" (click)=\"addDimension()\" [disabled]=\"isReadonly() || !canAddDimension()\">\n {{ t('praxis.charts.editor.analytics.addDimension', 'Add dimension') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.metricsTitle', 'Metrics') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (metric of metrics(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-metric-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metric', 'Metric') }}</mat-label>\n @if (fieldOptions('metric').length) {\n <mat-select\n [ngModel]=\"metric.field || ''\"\n (ngModelChange)=\"setMetricField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('metric', metric.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('metric').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.metricFieldsMissing', 'No governed metrics are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-label-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricLabel', 'Metric label') }}</mat-label>\n <input matInput [ngModel]=\"metric.label || ''\" (ngModelChange)=\"setMetricLabel($index, $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-aggregation-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAggregation', 'Aggregation') }}</mat-label>\n <mat-select [ngModel]=\"metric.aggregation || 'sum'\" (ngModelChange)=\"setMetricAggregation($index, $event)\" [disabled]=\"isReadonly()\">\n @for (aggregation of metricAggregationOptions(metric.field); track aggregation) {\n <mat-option [value]=\"aggregation\">\n {{ t('praxis.charts.editor.metricAggregation.' + aggregation, aggregation) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (showMetricAxisControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-axis-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAxis', 'Axis') }}</mat-label>\n <mat-select [ngModel]=\"metric.axis || 'primary'\" (ngModelChange)=\"setMetricAxis($index, $event)\" [disabled]=\"isReadonly()\">\n @for (axis of metricAxes; track axis) {\n <mat-option [value]=\"axis\">\n {{ t('praxis.charts.editor.metricAxis.' + axis, axis) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n\n @if (showMetricSeriesKindControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-series-kind-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricSeriesKind', 'Series kind') }}</mat-label>\n <mat-select [ngModel]=\"metric.seriesKind || 'bar'\" (ngModelChange)=\"setMetricSeriesKind($index, $event)\" [disabled]=\"isReadonly()\">\n @for (seriesKind of metricSeriesKinds; track seriesKind) {\n <mat-option [value]=\"seriesKind\">\n {{ t('praxis.charts.editor.metricSeriesKind.' + seriesKind, seriesKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" [attr.data-testid]=\"'chart-editor-remove-metric-' + $index\" (click)=\"removeMetric($index)\" [disabled]=\"isReadonly() || metrics().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeMetric', 'Remove metric') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-metric\" (click)=\"addMetric()\" [disabled]=\"isReadonly() || !canAddMetric()\">\n {{ t('praxis.charts.editor.analytics.addMetric', 'Add metric') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('events') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-pointClick\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.pointClickTitle', 'Point click') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-action\" [ngModel]=\"eventAction('pointClick')\" (ngModelChange)=\"setEventAction('pointClick', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-target\" [ngModel]=\"eventTarget('pointClick')\" (ngModelChange)=\"setEventTarget('pointClick', $event)\" [disabled]=\"isReadonly() || !eventAction('pointClick') || targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')\">\n @for (target of targetOptions(eventAction('pointClick'), 'pointClick'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-pointClick-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('pointClick')\"\n (ngModelChange)=\"setEventMapping('pointClick', $event)\"\n [disabled]=\"isReadonly() || !eventAction('pointClick')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-selectionChange\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.selectionChangeTitle', 'Selection change') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-action\" [ngModel]=\"eventAction('selectionChange')\" (ngModelChange)=\"setEventAction('selectionChange', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-target\" [ngModel]=\"eventTarget('selectionChange')\" (ngModelChange)=\"setEventTarget('selectionChange', $event)\" [disabled]=\"isReadonly() || !eventAction('selectionChange') || targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')\">\n @for (target of targetOptions(eventAction('selectionChange'), 'selectionChange'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-selectionChange-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('selectionChange')\"\n (ngModelChange)=\"setEventMapping('selectionChange', $event)\"\n [disabled]=\"isReadonly() || !eventAction('selectionChange')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-drillDown\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.drillDownTitle', 'Drill down') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-action\" [ngModel]=\"eventAction('drillDown')\" (ngModelChange)=\"setEventAction('drillDown', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-target\" [ngModel]=\"eventTarget('drillDown')\" (ngModelChange)=\"setEventTarget('drillDown', $event)\" [disabled]=\"isReadonly() || !eventAction('drillDown') || targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')\">\n @for (target of targetOptions(eventAction('drillDown'), 'drillDown'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-drillDown-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('drillDown')\"\n (ngModelChange)=\"setEventMapping('drillDown', $event)\"\n [disabled]=\"isReadonly() || !eventAction('drillDown')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-crossFilter\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.crossFilterTitle', 'Cross filter') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-action\" [ngModel]=\"eventAction('crossFilter')\" (ngModelChange)=\"setEventAction('crossFilter', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-target\" [ngModel]=\"eventTarget('crossFilter')\" (ngModelChange)=\"setEventTarget('crossFilter', $event)\" [disabled]=\"isReadonly() || !eventAction('crossFilter') || targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')\">\n @for (target of targetOptions(eventAction('crossFilter'), 'crossFilter'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-crossFilter-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('crossFilter')\"\n (ngModelChange)=\"setEventMapping('crossFilter', $event)\"\n [disabled]=\"isReadonly() || !eventAction('crossFilter')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('preview') {\n <mat-card class=\"editor-card\">\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n }\n </div>\n\n <div class=\"editor-side\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.issues.title', 'Validation issues') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (issues().length) {\n <ul class=\"editor-issues\">\n @for (issue of issues(); track issueTrackBy($index, issue)) {\n <li class=\"editor-issue\" [class.editor-issue--warning]=\"issue.severity === 'warning'\">\n <div class=\"editor-issue__header\">\n <strong>{{ issue.field }}</strong>\n @if (issueTargetSection(issue); as targetSection) {\n <button\n mat-button\n type=\"button\"\n class=\"editor-issue__jump\"\n [attr.data-testid]=\"'chart-editor-issue-jump-' + issue.code\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(targetSection) })\"\n (click)=\"setSection(targetSection)\"\n >\n {{ sectionLabel(targetSection) }} \u2192\n </button>\n }\n </div>\n <span>{{ issue.message }}</span>\n </li>\n }\n </ul>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.issues.empty', 'No issues were identified.') }}\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.preview.title', 'Chart preview') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (!preview()) {\n <div\n class=\"editor-empty editor-empty--actionable editor-preview-degraded\"\n data-testid=\"chart-editor-preview-degraded-warning\"\n >\n <span>{{\n effectivePreview()\n ? t('praxis.charts.editor.preview.degradedNotice', 'Aesthetic simulation active with synthetic sample data while contract validation issues remain open.')\n : t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.')\n }}</span>\n @if (firstInvalidSection(); as invalidSection) {\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-preview-jump-invalid\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(invalidSection) })\"\n (click)=\"setSection(invalidSection)\"\n >\n {{ sectionLabel(invalidSection) }} \u2192\n </button>\n }\n </div>\n }\n @if (effectivePreview(); as chartPreview) {\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n <p class=\"editor-caption\" data-testid=\"chart-editor-preview-origin\">\n {{\n doc().source.kind === 'praxis.stats'\n ? t('praxis.charts.editor.preview.originStatsSynthetic', 'Previewing synthetic sample for governed backend stats (praxis.stats).')\n : chartPreview.dataOrigin === 'local'\n ? t('praxis.charts.editor.preview.originLocal', 'Previewing widget local dataset (inputs.data).')\n : t('praxis.charts.editor.preview.originSynthetic', 'Previewing synthetic sample data (no local dataset records).')\n }}\n </p>\n <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n }\n </mat-card-content>\n </mat-card>\n </div>\n </div>\n</div>\n", styles: [":host{display:block;min-width:0;container:praxis-chart-editor / inline-size;color:var(--md-sys-color-on-surface, #1a1b20)}.editor-shell{display:grid;gap:16px;min-width:0}.editor-nav{display:flex;gap:6px;flex-wrap:wrap;padding:4px;border-radius:14px;background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .04));border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent)}.editor-nav button{border-radius:10px;border-color:transparent;display:inline-flex;align-items:center;gap:6px}.editor-nav button.active{background:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 22%,transparent);color:var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4));border-color:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 48%,transparent);font-weight:600}.editor-nav__badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:999px;font-size:.7rem;font-weight:700;line-height:1;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 24%,transparent);color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface, inherit))}.editor-nav__badge--error{background:color-mix(in srgb,var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e)) 24%,transparent);color:var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e))}:host ::ng-deep .mat-mdc-form-field-hint-wrapper,:host ::ng-deep .mat-mdc-form-field-error-wrapper{position:relative!important;top:auto!important;left:auto!important;right:auto!important;padding-top:4px;line-height:1.35}:host ::ng-deep .mat-mdc-form-field-bottom-align:before{display:none}.editor-layout{display:grid;gap:16px;grid-template-columns:minmax(0,1.35fr) minmax(320px,.9fr);align-items:start}.editor-form,.editor-side{display:grid;gap:16px;min-width:0}.editor-card{box-sizing:border-box;width:100%;min-width:0;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 72%,transparent);background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .03))}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));align-items:start}.editor-toggle-cluster{display:flex;flex-wrap:wrap;align-items:center;gap:14px 24px}.editor-stack{display:grid;gap:14px}.editor-state-group{padding-top:14px;border-top:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 48%,transparent)}.editor-state-group:first-child{padding-top:0;border-top:0}.editor-palette-swatches{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.editor-palette-swatch-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 68%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 88%,transparent);font-size:.76rem}.editor-palette-swatch-dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(128,128,128,.35);flex-shrink:0}.editor-palette-color-input{width:22px;height:22px;padding:0;border:0;border-radius:50%;background:transparent;cursor:pointer}.editor-palette-swatch-code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.74rem;color:var(--md-sys-color-on-surface, inherit)}.editor-palette-swatch-remove{appearance:none;border:0;background:transparent;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.95rem;line-height:1;padding:0 2px;cursor:pointer}.editor-row-card{padding:14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 54%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 92%,rgba(18,99,180,.04))}.editor-row-actions{display:flex;justify-content:flex-end}.editor-field{width:100%}.editor-issues{display:grid;gap:10px;margin:0;padding:0;list-style:none}.editor-issue{padding:12px 14px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 10%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 24%,transparent)}.editor-issue--warning{background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 26%,transparent)}.editor-issue__header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:4px}.editor-issue strong{display:block;font-size:.84rem}.editor-issue__jump{min-height:26px;padding:0 8px;font-size:.76rem}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem;line-height:1.45}.editor-empty{padding:16px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 52%,transparent);border:1px dashed color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent);color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem}.editor-empty--actionable{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.editor-select-trigger{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:100%}.editor-select-trigger__icon{width:18px;height:18px;font-size:18px;flex-shrink:0;color:var(--md-sys-color-primary, #1263b4)}.editor-select-trigger__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.editor-select-trigger__code{flex-shrink:0;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,currentColor 8%,transparent);color:color-mix(in srgb,currentColor 72%,transparent)}::ng-deep .editor-select-search{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--md-sys-color-surface-container-highest, #f4f6fa);border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 45%,transparent)}::ng-deep .editor-select-search mat-icon{width:18px;height:18px;font-size:18px;color:var(--md-sys-color-on-surface-variant, #5a5d67);flex-shrink:0}::ng-deep .editor-select-search input{width:100%;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:.85rem}::ng-deep .mat-mdc-option.editor-rich-mat-option{min-height:54px;height:auto;padding-block:8px;align-items:flex-start}::ng-deep .editor-rich-option{display:flex;align-items:flex-start;gap:10px;min-width:0;width:100%;line-height:1.25}::ng-deep .editor-rich-option__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}::ng-deep .editor-rich-option__icon mat-icon{width:16px;height:16px;font-size:16px}::ng-deep .editor-rich-option__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 auto}::ng-deep .editor-rich-option__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}::ng-deep .editor-rich-option__label{font-weight:600;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__badge,.editor-resource-summary__badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:.68rem;font-weight:600;background:color-mix(in srgb,currentColor 10%,transparent);color:color-mix(in srgb,currentColor 78%,transparent);white-space:nowrap}::ng-deep .editor-rich-option__code,.editor-resource-summary__code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;color:var(--md-sys-color-primary, #1263b4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__description,.editor-resource-summary__description{margin:0;font-size:.75rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal}.editor-resource-summary{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 6%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent)}.editor-resource-summary__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}.editor-resource-summary__icon mat-icon{width:16px;height:16px;font-size:16px}.editor-resource-summary__body{display:grid;gap:2px;min-width:0;flex:1 1 auto}.editor-resource-summary__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}.editor-preview-degraded{margin-bottom:12px;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-style:solid;border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 32%,transparent)}@container praxis-chart-editor (min-width: 841px){.editor-nav__preview-btn:not(.active){display:none}}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}.editor-shell--preview-active .editor-form{display:none}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"], dependencies: [{ kind: "ngmodule", type: FormsModule }, { kind: "directive", type: i1$3.DefaultValueAccessor, selector: "input:not([type=checkbox])[formControlName],textarea[formControlName],input:not([type=checkbox])[formControl],textarea[formControl],input:not([type=checkbox])[ngModel],textarea[ngModel],[ngDefaultControl]" }, { kind: "directive", type: i1$3.NumberValueAccessor, selector: "input[type=number][formControlName],input[type=number][formControl],input[type=number][ngModel]" }, { kind: "directive", type: i1$3.NgControlStatus, selector: "[formControlName],[ngModel],[formControl]" }, { kind: "directive", type: i1$3.NgModel, selector: "[ngModel]:not([formControlName]):not([formControl])", inputs: ["name", "disabled", "ngModel", "ngModelOptions"], outputs: ["ngModelChange"], exportAs: ["ngModel"] }, { kind: "ngmodule", type: MatButtonModule }, { kind: "component", type: i2.MatButton, selector: " button[matButton], a[matButton], button[mat-button], button[mat-raised-button], button[mat-flat-button], button[mat-stroked-button], a[mat-button], a[mat-raised-button], a[mat-flat-button], a[mat-stroked-button] ", inputs: ["matButton"], exportAs: ["matButton", "matAnchor"] }, { kind: "ngmodule", type: MatCardModule }, { kind: "component", type: i3.MatCard, selector: "mat-card", inputs: ["appearance"], exportAs: ["matCard"] }, { kind: "directive", type: i3.MatCardContent, selector: "mat-card-content" }, { kind: "component", type: i3.MatCardHeader, selector: "mat-card-header" }, { kind: "directive", type: i3.MatCardTitle, selector: "mat-card-title, [mat-card-title], [matCardTitle]" }, { kind: "ngmodule", type: MatFormFieldModule }, { kind: "component", type: i4.MatFormField, selector: "mat-form-field", inputs: ["hideRequiredMarker", "color", "floatLabel", "appearance", "subscriptSizing", "hintLabel"], exportAs: ["matFormField"] }, { kind: "directive", type: i4.MatLabel, selector: "mat-label" }, { kind: "directive", type: i4.MatHint, selector: "mat-hint", inputs: ["align", "id"] }, { kind: "ngmodule", type: MatIconModule }, { kind: "component", type: i5.MatIcon, selector: "mat-icon", inputs: ["color", "inline", "svgIcon", "fontSet", "fontIcon"], exportAs: ["matIcon"] }, { kind: "ngmodule", type: MatInputModule }, { kind: "directive", type: i6.MatInput, selector: "input[matInput], textarea[matInput], select[matNativeControl], input[matNativeControl], textarea[matNativeControl]", inputs: ["disabled", "id", "placeholder", "name", "required", "type", "errorStateMatcher", "aria-describedby", "value", "readonly", "disabledInteractive"], exportAs: ["matInput"] }, { kind: "ngmodule", type: MatSelectModule }, { kind: "component", type: i7.MatSelect, selector: "mat-select", inputs: ["aria-describedby", "panelClass", "disabled", "disableRipple", "tabIndex", "hideSingleSelectionIndicator", "placeholder", "required", "multiple", "disableOptionCentering", "compareWith", "value", "aria-label", "aria-labelledby", "errorStateMatcher", "typeaheadDebounceInterval", "sortComparator", "id", "panelWidth", "canSelectNullableOptions"], outputs: ["openedChange", "opened", "closed", "selectionChange", "valueChange"], exportAs: ["matSelect"] }, { kind: "directive", type: i7.MatSelectTrigger, selector: "mat-select-trigger" }, { kind: "component", type: i7.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i8.MatSlideToggle, selector: "mat-slide-toggle", inputs: ["name", "id", "labelPosition", "aria-label", "aria-labelledby", "aria-describedby", "required", "color", "disabled", "disableRipple", "tabIndex", "checked", "hideIcon", "disabledInteractive"], outputs: ["change", "toggleChange"], exportAs: ["matSlideToggle"] }, { kind: "directive", type: PraxisIconDirective, selector: "mat-icon[praxisIcon]", inputs: ["praxisIcon"] }, { kind: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "resourceKey", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved", "chartDocumentPatchChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
14913
16609
  }
14914
16610
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartConfigEditor, decorators: [{
14915
16611
  type: Component,
@@ -14924,7 +16620,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
14924
16620
  MatSlideToggleModule,
14925
16621
  PraxisIconDirective,
14926
16622
  PraxisChartComponent
14927
- ], providers: [providePraxisChartsI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"editor-shell\" [class.editor-shell--preview-active]=\"activeSection() === 'preview'\">\n <div class=\"editor-nav\" role=\"toolbar\" [attr.aria-label]=\"t('praxis.charts.editor.sectionsNav', 'Se\u00E7\u00F5es do editor')\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.id]=\"'chart-editor-tab-' + section.id\"\n aria-controls=\"chart-editor-tabpanel\"\n [attr.aria-pressed]=\"activeSection() === section.id\"\n [tabIndex]=\"activeSection() === section.id ? 0 : -1\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n [class.editor-nav__preview-btn]=\"section.id === 'preview'\"\n [class.editor-nav__has-error]=\"sectionHasError(section.id)\"\n (click)=\"setSection(section.id)\"\n (keydown)=\"onSectionNavKeydown($event, $index)\"\n >\n <span>{{ t(section.labelKey, section.fallback) }}</span>\n @if (sectionIssueCount(section.id) > 0) {\n <span\n class=\"editor-nav__badge\"\n aria-hidden=\"true\"\n [class.editor-nav__badge--error]=\"sectionHasError(section.id)\"\n [attr.data-testid]=\"'chart-editor-section-badge-' + section.id\"\n >\n {{ sectionIssueCount(section.id) }}\n </span>\n }\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div\n class=\"editor-form\"\n id=\"chart-editor-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-labelledby]=\"'chart-editor-tab-' + activeSection()\"\n >\n @switch (activeSection()) {\n @case ('general') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.chartId', 'Chart ID') }}</mat-label>\n <input matInput [ngModel]=\"doc().chartId || ''\" (ngModelChange)=\"setChartId($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.kind', 'Kind') }}</mat-label>\n <mat-select [ngModel]=\"doc().kind\" (ngModelChange)=\"setKind($event)\" [disabled]=\"isReadonly()\">\n @for (kind of chartKinds; track kind) {\n <mat-option [value]=\"kind\">\n {{ t('praxis.charts.editor.kind.' + kind, kind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.title', 'Title') }}</mat-label>\n <input matInput data-testid=\"chart-editor-title-input\" [ngModel]=\"titleValue()\" (ngModelChange)=\"setTitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.subtitle', 'Subtitle') }}</mat-label>\n <input matInput [ngModel]=\"subtitleValue()\" (ngModelChange)=\"setSubtitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sizingMode', 'Sizing') }}</mat-label>\n <mat-select [ngModel]=\"sizingModeValue()\" (ngModelChange)=\"setSizingMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of sizingModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.sizing.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>{{ t('praxis.charts.editor.hint.sizingMode', 'Use fill-container only when the host widget provides a defined body height.') }}</mat-hint>\n </mat-form-field>\n\n @if (sizingModeValue() === 'fixed') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.height', 'Height') }}</mat-label>\n <input matInput [ngModel]=\"heightValue()\" (ngModelChange)=\"setSizingHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.height', 'Numbers are saved as pixels; CSS lengths such as 20rem are also accepted.') }}</mat-hint>\n </mat-form-field>\n }\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.minHeight', 'Minimum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMinHeightValue()\" (ngModelChange)=\"setSizingMinHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.minHeight', 'Set a readable minimum for compact dashboard widgets.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.maxHeight', 'Maximum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMaxHeightValue()\" (ngModelChange)=\"setSizingMaxHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.maxHeight', 'Leave empty unless the chart must stop growing inside a flexible layout.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.aspectRatio', 'Aspect ratio') }}</mat-label>\n <input matInput [ngModel]=\"sizingAspectRatioValue()\" (ngModelChange)=\"setSizingAspectRatio($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.aspectRatio', 'Optional. Use values such as 1.777 or 16 / 9.') }}</mat-hint>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('data') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\" data-testid=\"chart-editor-effective-origin\">\n {{ effectiveDataOriginSummary() }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sourceKind', 'Source') }}</mat-label>\n <mat-select data-testid=\"chart-editor-source-kind\" [ngModel]=\"doc().source.kind\" (ngModelChange)=\"setSourceKind($event)\" [disabled]=\"isReadonly()\">\n @for (sourceKind of sourceKinds; track sourceKind) {\n <mat-option [value]=\"sourceKind\">\n {{ t('praxis.charts.editor.sourceKind.' + (sourceKind === 'praxis.stats' ? 'praxisStats' : 'derived'), sourceKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (doc().source.kind === 'praxis.stats') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.resource', 'Resource') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-resource\"\n [ngModel]=\"resourceValue()\"\n (ngModelChange)=\"setResource($event)\"\n (openedChange)=\"setResourceSearchTerm('')\"\n [disabled]=\"isReadonly() || resourceCatalogUnavailable()\"\n >\n @if (selectedResourceOption(); as selectedRes) {\n <mat-select-trigger>\n <span class=\"editor-select-trigger\">\n <mat-icon class=\"editor-select-trigger__icon\" [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n <span class=\"editor-select-trigger__label\">{{ selectedRes.label }}</span>\n <span class=\"editor-select-trigger__code\">{{ selectedRes.path }}</span>\n </span>\n </mat-select-trigger>\n }\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n data-testid=\"chart-editor-resource-search\"\n [placeholder]=\"t('praxis.charts.editor.field.resourceSearchPlaceholder', 'Search by name, key, or endpoint...')\"\n [ngModel]=\"resourceSearchTerm()\"\n (ngModelChange)=\"setResourceSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (resource of filteredResourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <span class=\"editor-rich-option__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"resource.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ resource.label }}</span>\n @if (resource.group) {\n <span class=\"editor-rich-option__badge\">{{ resource.group }}</span>\n }\n @if (resource.readOnly) {\n <span class=\"editor-rich-option__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-rich-option__code\">\n {{ resource.path }}{{ resource.id && resource.id !== resource.path && resource.id !== resource.label ? ' \u00B7 ' + resource.id : '' }}\n </span>\n @if (resource.description) {\n <span class=\"editor-rich-option__description\">{{ resource.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n @if (resourceCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.resourceMissing', 'Resource catalog is required for governed praxis.stats authoring.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.operation', 'Operation') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-operation\"\n [ngModel]=\"doc().source.operation || 'group-by'\"\n (ngModelChange)=\"setOperation($event)\"\n [disabled]=\"isReadonly() || isResolvingResource() || operationCatalogUnavailable()\"\n >\n @for (operation of operationOptions(); track operation) {\n <mat-option [value]=\"operation\">\n {{ t('praxis.charts.editor.operation.' + (operation === 'group-by' ? 'groupBy' : operation), operation) }}\n </mat-option>\n }\n </mat-select>\n @if (isResolvingResource()) {\n <mat-hint data-testid=\"chart-editor-resolving-resource\">\n {{ t('praxis.charts.editor.catalog.resolvingResource', 'Resolving governed stats operations and fields for the selected resource...') }}\n </mat-hint>\n } @else if (operationCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.operationMissing', 'Selected resource does not publish authorable stats operations.') }}</mat-hint>\n }\n </mat-form-field>\n }\n </div>\n\n @if (doc().source.kind === 'praxis.stats' && selectedResourceOption(); as selectedRes) {\n <div class=\"editor-resource-summary\" data-testid=\"chart-editor-resource-summary\">\n <span class=\"editor-resource-summary__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-resource-summary__body\">\n <div class=\"editor-resource-summary__header\">\n <strong>{{ selectedRes.label }}</strong>\n @if (selectedRes.group) {\n <span class=\"editor-resource-summary__badge\">{{ selectedRes.group }}</span>\n }\n @if (selectedRes.readOnly) {\n <span class=\"editor-resource-summary__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-resource-summary__code\">\n {{ selectedRes.path }}{{ selectedRes.id && selectedRes.id !== selectedRes.path && selectedRes.id !== selectedRes.label ? ' \u00B7 ' + selectedRes.id : '' }}\n </span>\n @if (selectedRes.description) {\n <p class=\"editor-resource-summary__description\">{{ selectedRes.description }}</p>\n }\n </div>\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n @if (showTimeseriesControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.timeseriesTitle', 'Timeseries options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.granularity', 'Granularity') }}</mat-label>\n <mat-select [ngModel]=\"granularityValue()\" (ngModelChange)=\"setGranularity($event)\" [disabled]=\"isReadonly()\">\n @for (granularity of timeGranularities; track granularity) {\n <mat-option [value]=\"granularity\">\n {{ t('praxis.charts.editor.granularity.' + granularity, granularity) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-slide-toggle\n [ngModel]=\"fillGapsValue()\"\n (ngModelChange)=\"setFillGaps($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.fillGaps', 'Fill missing intervals') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showDistributionControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.distributionTitle', 'Distribution options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.distributionMode', 'Distribution mode') }}</mat-label>\n <mat-select data-testid=\"chart-editor-distribution-mode\" [ngModel]=\"distributionModeValue()\" (ngModelChange)=\"setDistributionMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of distributionModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.distributionMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (distributionModeValue() === 'histogram') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketSize', 'Bucket size') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-size\" [ngModel]=\"bucketSizeValue()\" (ngModelChange)=\"setBucketSize($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketCount', 'Bucket count') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-count\" [ngModel]=\"bucketCountValue()\" (ngModelChange)=\"setBucketCount($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n }\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComparisonControls()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-comparison-options\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comparisonTitle', 'Comparison period') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPeriodField', 'Period field') }}</mat-label>\n @if (fieldOptions('time').length) {\n <mat-select\n [ngModel]=\"comparisonPeriodFieldValue()\"\n (ngModelChange)=\"setComparisonPeriodField($event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('time', comparisonPeriodFieldValue()); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('time').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.comparisonPeriodFieldsMissing', 'No governed time-series field is available for comparison periods.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonTimezone', 'Timezone') }}</mat-label>\n <input\n matInput\n [ngModel]=\"comparisonPeriodTimezoneValue()\"\n (ngModelChange)=\"setComparisonPeriodTimezone($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPreset', 'Period preset') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodPresetValue()\"\n (ngModelChange)=\"setComparisonPeriodPreset($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (preset of comparisonPeriodPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.comparisonPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonMode', 'Comparison mode') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodModeValue()\"\n (ngModelChange)=\"setComparisonPeriodMode($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (mode of comparisonPeriodModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.comparisonMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @if (doc().source.kind === 'derived') {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-local-data\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.localData.title', 'Local dataset (inputs.data)') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.localData.caption', 'Edit the local records rendered when the data source is derived.') }}\n </p>\n\n @if (canConsolidateGaugeLocalData()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-consolidate-gauge\"\n (click)=\"consolidateGaugeLocalData()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.consolidateGauge', 'Consolidate to 1 bucket for gauge') }}\n </button>\n </div>\n }\n\n @if (localDataRows().length) {\n @for (row of localDataRows(); track $index; let rowIndex = $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-local-data-row-' + rowIndex\">\n <div class=\"editor-grid\">\n @for (dimField of localDataDimensionFields(); track dimField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ dimField }}</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'chart-local-data-dim-' + rowIndex + '-' + dimField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, dimField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, dimField, $event, 'dimension')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n @for (metricField of localDataMetricFields(); track metricField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ metricField }}</mat-label>\n <input\n matInput\n type=\"number\"\n [attr.data-testid]=\"'chart-local-data-metric-' + rowIndex + '-' + metricField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, metricField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, metricField, $event, 'metric')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n </div>\n <div class=\"editor-row-actions\">\n <button\n mat-button\n type=\"button\"\n [attr.data-testid]=\"'chart-local-data-remove-row-' + rowIndex\"\n (click)=\"removeLocalDataRow(rowIndex)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.removeRow', 'Remove record') }}\n </button>\n </div>\n </div>\n }\n } @else {\n <div class=\"editor-empty\" data-testid=\"chart-local-data-empty\">\n {{ t('praxis.charts.editor.localData.empty', 'No local records configured yet. Add a record to replace the synthetic preview sample.') }}\n </div>\n }\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-add-row\"\n (click)=\"addLocalDataRow()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.addRow', 'Add record') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n </div>\n }\n\n @case ('motion') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-slide-toggle\n [ngModel]=\"normalizedDocument().motion?.enabled !== false\"\n (ngModelChange)=\"setMotionEnabled($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.motionEnabled', 'Enable animations') }}\n </mat-slide-toggle>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.motionPreset', 'Motion preset') }}</mat-label>\n <mat-select\n [ngModel]=\"normalizedDocument().motion?.preset || 'standard'\"\n (ngModelChange)=\"setMotionPreset($event)\"\n [disabled]=\"isReadonly() || normalizedDocument().motion?.enabled === false\"\n >\n @for (preset of motionPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.motionPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <p class=\"editor-caption\" data-testid=\"chart-editor-motion-hint\">\n {{ t('praxis.charts.editor.motion.hint', 'Controls initial entrance timing and smooth transitions when filters or query context change, with automatic volume thresholds for high-density datasets.') }}\n </p>\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-simulate-motion\"\n (click)=\"simulateMotionTransition()\"\n [disabled]=\"normalizedDocument().motion?.enabled === false || !effectivePreview()\"\n >\n {{ t('praxis.charts.editor.motion.simulate', 'Simulate transition') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('appearance') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.featuresTitle', 'Display features') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-toggle-cluster\">\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('legend')\"\n (ngModelChange)=\"setFeatureEnabled('legend', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.legendEnabled', 'Show legend') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('labels')\"\n (ngModelChange)=\"setFeatureEnabled('labels', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.labelsEnabled', 'Show labels') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('tooltip')\"\n (ngModelChange)=\"setFeatureEnabled('tooltip', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.tooltipEnabled', 'Show tooltip') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.paletteTitle', 'Palette') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.themeVariant', 'Theme variant') }}</mat-label>\n <mat-select [ngModel]=\"themeVariantValue()\" (ngModelChange)=\"setThemeVariant($event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.themeVariant.none', 'No variant') }}\n </mat-option>\n @for (variant of themeVariants; track variant) {\n <mat-option [value]=\"variant\">\n {{ t('praxis.charts.editor.themeVariant.' + variant, variant) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteMode', 'Palette mode') }}</mat-label>\n <mat-select [ngModel]=\"paletteModeValue()\" (ngModelChange)=\"setPaletteMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of paletteModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.paletteMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n @if (canApplyDomainSemanticPalette()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-apply-domain-palette\"\n (click)=\"applyDomainSemanticPalette()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.appearance.applyDomainPaletteHint', 'Generates a custom palette with semantic colors (success, warning, danger, info) from the dimension categories.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.appearance.applyDomainPalette', 'Apply domain semantic colors') }}\n </button>\n </div>\n }\n\n @if (paletteModeValue() === 'token') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteToken', 'Palette token') }}</mat-label>\n <mat-select [ngModel]=\"paletteTokenValue()\" (ngModelChange)=\"setPaletteToken($event)\" [disabled]=\"isReadonly()\">\n @for (token of paletteTokens; track token) {\n <mat-option [value]=\"token\">\n {{ t('praxis.charts.editor.paletteToken.' + token, token) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of tokenPaletteSwatches(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n </span>\n }\n </div>\n } @else {\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of customPaletteColors(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <input\n type=\"color\"\n class=\"editor-palette-color-input\"\n [attr.data-testid]=\"'chart-editor-palette-color-' + $index\"\n [value]=\"swatchHexValue(swatch, $index)\"\n (input)=\"setCustomPaletteColorAt($index, $any($event.target).value)\"\n [disabled]=\"isReadonly()\"\n />\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n <button\n type=\"button\"\n class=\"editor-palette-swatch-remove\"\n [attr.data-testid]=\"'chart-editor-palette-remove-' + $index\"\n (click)=\"removeCustomPaletteColor($index)\"\n [disabled]=\"isReadonly()\"\n [attr.aria-label]=\"t('praxis.charts.editor.appearance.removePaletteColor', 'Remove color')\"\n >\n \u00D7\n </button>\n </span>\n }\n <button\n mat-stroked-button\n type=\"button\"\n class=\"editor-palette-add-btn\"\n data-testid=\"chart-editor-add-palette-color\"\n (click)=\"addCustomPaletteColor()\"\n [disabled]=\"isReadonly()\"\n >\n + {{ t('praxis.charts.editor.appearance.addPaletteColor', 'Add color') }}\n </button>\n </div>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.palette', 'Palette colors') }}</mat-label>\n <textarea\n matInput\n rows=\"2\"\n [ngModel]=\"paletteValue()\"\n (ngModelChange)=\"setPalette($event)\"\n [disabled]=\"isReadonly()\"\n ></textarea>\n </mat-form-field>\n }\n\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.paletteHint', 'Use a registered token or comma-separated colors to persist theme.palette in the canonical contract.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.surfaceTitle', 'Surface') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.surfaceMode', 'Surface mode') }}</mat-label>\n <mat-select [ngModel]=\"surfaceModeValue()\" (ngModelChange)=\"setSurfaceMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of surfaceModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.surface.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.surfaceHint', 'Use embedded for dashboard widgets and contained only when the chart must own its visual surface.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.statesTitle', 'State messages') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyTitle', 'Empty title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('empty')\" (ngModelChange)=\"setStateTitle('empty', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyDescription', 'Empty description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingTitle', 'Loading title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('loading')\" (ngModelChange)=\"setStateTitle('loading', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingDescription', 'Loading description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorTitle', 'Error title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('error')\" (ngModelChange)=\"setStateTitle('error', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorDescription', 'Error description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('error')\" (ngModelChange)=\"setStateDescription('error', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('analytics') {\n <div class=\"editor-stack\">\n @if (showGaugePanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-gauge-scale\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.gaugeTitle', 'Gauge scale') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.gaugeHint', 'Gauge charts render exactly one bucket and one metric. Remote stats use group-by with limit 1; values outside the scale fail instead of being clamped.') }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMin', 'Scale minimum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-min\"\n [ngModel]=\"gaugeMinValue()\"\n (ngModelChange)=\"setGaugeMin($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMax', 'Scale maximum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-max\"\n [ngModel]=\"gaugeMaxValue()\"\n (ngModelChange)=\"setGaugeMax($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComboPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comboTitle', 'Combo guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.comboHint', 'Combo charts require at least two metrics and allow per-metric axis and series kind mapping.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showPieDonutPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.pieDonutTitle', 'Composition guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.pieDonutHint', 'Pie, donut, funnel, pyramid and treemap charts keep one metric and use the first dimension as the category segment.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showScatterPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.scatterTitle', 'Scatter guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.scatterHint', 'With one metric, scatter uses the first dimension as X and the metric as Y. With two metrics, the dimension identifies the grouped bucket and the metrics map to X and Y.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (canApplySemanticAnalyticsMapping()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-smart-map-analytics\"\n (click)=\"applySemanticAnalyticsMapping()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.analytics.smartMapHint', 'Automatically selects the best dimension, metric, and aggregation from the governed field catalog.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.analytics.smartMap', 'Suggest dimension & metric from schema') }}\n </button>\n </div>\n }\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.dimensionsTitle', 'Dimensions') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (dimension of dimensions(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-dimension-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimension', 'Dimension') }}</mat-label>\n @if (fieldOptions('dimension').length) {\n <mat-select\n [ngModel]=\"dimension.field || ''\"\n (ngModelChange)=\"setDimensionField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('dimension', dimension.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('dimension').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.dimensionFieldsMissing', 'No governed dimensions are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-role-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionRole', 'Dimension role') }}</mat-label>\n <mat-select [ngModel]=\"dimension.role || 'category'\" (ngModelChange)=\"setDimensionRole($index, $event)\" [disabled]=\"isReadonly()\">\n @for (role of dimensionRoles; track role) {\n <mat-option [value]=\"role\">\n {{ t('praxis.charts.editor.dimensionRole.' + role, role) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" (click)=\"removeDimension($index)\" [disabled]=\"isReadonly() || dimensions().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeDimension', 'Remove dimension') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-dimension\" (click)=\"addDimension()\" [disabled]=\"isReadonly()\">\n {{ t('praxis.charts.editor.analytics.addDimension', 'Add dimension') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.metricsTitle', 'Metrics') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (metric of metrics(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-metric-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metric', 'Metric') }}</mat-label>\n @if (fieldOptions('metric').length) {\n <mat-select\n [ngModel]=\"metric.field || ''\"\n (ngModelChange)=\"setMetricField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('metric', metric.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('metric').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.metricFieldsMissing', 'No governed metrics are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-label-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricLabel', 'Metric label') }}</mat-label>\n <input matInput [ngModel]=\"metric.label || ''\" (ngModelChange)=\"setMetricLabel($index, $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-aggregation-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAggregation', 'Aggregation') }}</mat-label>\n <mat-select [ngModel]=\"metric.aggregation || 'sum'\" (ngModelChange)=\"setMetricAggregation($index, $event)\" [disabled]=\"isReadonly()\">\n @for (aggregation of metricAggregationOptions(metric.field); track aggregation) {\n <mat-option [value]=\"aggregation\">\n {{ t('praxis.charts.editor.metricAggregation.' + aggregation, aggregation) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (showMetricAxisControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-axis-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAxis', 'Axis') }}</mat-label>\n <mat-select [ngModel]=\"metric.axis || 'primary'\" (ngModelChange)=\"setMetricAxis($index, $event)\" [disabled]=\"isReadonly()\">\n @for (axis of metricAxes; track axis) {\n <mat-option [value]=\"axis\">\n {{ t('praxis.charts.editor.metricAxis.' + axis, axis) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n\n @if (showMetricSeriesKindControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-series-kind-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricSeriesKind', 'Series kind') }}</mat-label>\n <mat-select [ngModel]=\"metric.seriesKind || 'bar'\" (ngModelChange)=\"setMetricSeriesKind($index, $event)\" [disabled]=\"isReadonly()\">\n @for (seriesKind of metricSeriesKinds; track seriesKind) {\n <mat-option [value]=\"seriesKind\">\n {{ t('praxis.charts.editor.metricSeriesKind.' + seriesKind, seriesKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" [attr.data-testid]=\"'chart-editor-remove-metric-' + $index\" (click)=\"removeMetric($index)\" [disabled]=\"isReadonly() || metrics().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeMetric', 'Remove metric') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-metric\" (click)=\"addMetric()\" [disabled]=\"isReadonly() || !canAddMetric()\">\n {{ t('praxis.charts.editor.analytics.addMetric', 'Add metric') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('events') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-pointClick\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.pointClickTitle', 'Point click') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-action\" [ngModel]=\"eventAction('pointClick')\" (ngModelChange)=\"setEventAction('pointClick', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-target\" [ngModel]=\"eventTarget('pointClick')\" (ngModelChange)=\"setEventTarget('pointClick', $event)\" [disabled]=\"isReadonly() || !eventAction('pointClick') || targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')\">\n @for (target of targetOptions(eventAction('pointClick'), 'pointClick'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-pointClick-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('pointClick')\"\n (ngModelChange)=\"setEventMapping('pointClick', $event)\"\n [disabled]=\"isReadonly() || !eventAction('pointClick')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-selectionChange\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.selectionChangeTitle', 'Selection change') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-action\" [ngModel]=\"eventAction('selectionChange')\" (ngModelChange)=\"setEventAction('selectionChange', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-target\" [ngModel]=\"eventTarget('selectionChange')\" (ngModelChange)=\"setEventTarget('selectionChange', $event)\" [disabled]=\"isReadonly() || !eventAction('selectionChange') || targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')\">\n @for (target of targetOptions(eventAction('selectionChange'), 'selectionChange'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-selectionChange-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('selectionChange')\"\n (ngModelChange)=\"setEventMapping('selectionChange', $event)\"\n [disabled]=\"isReadonly() || !eventAction('selectionChange')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-drillDown\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.drillDownTitle', 'Drill down') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-action\" [ngModel]=\"eventAction('drillDown')\" (ngModelChange)=\"setEventAction('drillDown', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-target\" [ngModel]=\"eventTarget('drillDown')\" (ngModelChange)=\"setEventTarget('drillDown', $event)\" [disabled]=\"isReadonly() || !eventAction('drillDown') || targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')\">\n @for (target of targetOptions(eventAction('drillDown'), 'drillDown'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-drillDown-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('drillDown')\"\n (ngModelChange)=\"setEventMapping('drillDown', $event)\"\n [disabled]=\"isReadonly() || !eventAction('drillDown')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-crossFilter\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.crossFilterTitle', 'Cross filter') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-action\" [ngModel]=\"eventAction('crossFilter')\" (ngModelChange)=\"setEventAction('crossFilter', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-target\" [ngModel]=\"eventTarget('crossFilter')\" (ngModelChange)=\"setEventTarget('crossFilter', $event)\" [disabled]=\"isReadonly() || !eventAction('crossFilter') || targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')\">\n @for (target of targetOptions(eventAction('crossFilter'), 'crossFilter'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-crossFilter-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('crossFilter')\"\n (ngModelChange)=\"setEventMapping('crossFilter', $event)\"\n [disabled]=\"isReadonly() || !eventAction('crossFilter')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('preview') {\n <mat-card class=\"editor-card\">\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n }\n </div>\n\n <div class=\"editor-side\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.issues.title', 'Validation issues') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (issues().length) {\n <ul class=\"editor-issues\">\n @for (issue of issues(); track issueTrackBy($index, issue)) {\n <li class=\"editor-issue\" [class.editor-issue--warning]=\"issue.severity === 'warning'\">\n <div class=\"editor-issue__header\">\n <strong>{{ issue.field }}</strong>\n @if (issueTargetSection(issue); as targetSection) {\n <button\n mat-button\n type=\"button\"\n class=\"editor-issue__jump\"\n [attr.data-testid]=\"'chart-editor-issue-jump-' + issue.code\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(targetSection) })\"\n (click)=\"setSection(targetSection)\"\n >\n {{ sectionLabel(targetSection) }} \u2192\n </button>\n }\n </div>\n <span>{{ issue.message }}</span>\n </li>\n }\n </ul>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.issues.empty', 'No issues were identified.') }}\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.preview.title', 'Chart preview') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (!preview()) {\n <div\n class=\"editor-empty editor-empty--actionable editor-preview-degraded\"\n data-testid=\"chart-editor-preview-degraded-warning\"\n >\n <span>{{\n effectivePreview()\n ? t('praxis.charts.editor.preview.degradedNotice', 'Aesthetic simulation active with synthetic sample data while contract validation issues remain open.')\n : t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.')\n }}</span>\n @if (firstInvalidSection(); as invalidSection) {\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-preview-jump-invalid\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(invalidSection) })\"\n (click)=\"setSection(invalidSection)\"\n >\n {{ sectionLabel(invalidSection) }} \u2192\n </button>\n }\n </div>\n }\n @if (effectivePreview(); as chartPreview) {\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n <p class=\"editor-caption\" data-testid=\"chart-editor-preview-origin\">\n {{\n doc().source.kind === 'praxis.stats'\n ? t('praxis.charts.editor.preview.originStatsSynthetic', 'Previewing synthetic sample for governed backend stats (praxis.stats).')\n : chartPreview.dataOrigin === 'local'\n ? t('praxis.charts.editor.preview.originLocal', 'Previewing widget local dataset (inputs.data).')\n : t('praxis.charts.editor.preview.originSynthetic', 'Previewing synthetic sample data (no local dataset records).')\n }}\n </p>\n <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n }\n </mat-card-content>\n </mat-card>\n </div>\n </div>\n</div>\n", styles: [":host{display:block;min-width:0;container:praxis-chart-editor / inline-size;color:var(--md-sys-color-on-surface, #1a1b20)}.editor-shell{display:grid;gap:16px;min-width:0}.editor-nav{display:flex;gap:6px;flex-wrap:wrap;padding:4px;border-radius:14px;background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .04));border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent)}.editor-nav button{border-radius:10px;border-color:transparent;display:inline-flex;align-items:center;gap:6px}.editor-nav button.active{background:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 22%,transparent);color:var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4));border-color:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 48%,transparent);font-weight:600}.editor-nav__badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:999px;font-size:.7rem;font-weight:700;line-height:1;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 24%,transparent);color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface, inherit))}.editor-nav__badge--error{background:color-mix(in srgb,var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e)) 24%,transparent);color:var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e))}:host ::ng-deep .mat-mdc-form-field-hint-wrapper,:host ::ng-deep .mat-mdc-form-field-error-wrapper{position:relative!important;top:auto!important;left:auto!important;right:auto!important;padding-top:4px;line-height:1.35}:host ::ng-deep .mat-mdc-form-field-bottom-align:before{display:none}.editor-layout{display:grid;gap:16px;grid-template-columns:minmax(0,1.35fr) minmax(320px,.9fr);align-items:start}.editor-form,.editor-side{display:grid;gap:16px;min-width:0}.editor-card{box-sizing:border-box;width:100%;min-width:0;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 72%,transparent);background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .03))}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));align-items:start}.editor-toggle-cluster{display:flex;flex-wrap:wrap;align-items:center;gap:14px 24px}.editor-stack{display:grid;gap:14px}.editor-state-group{padding-top:14px;border-top:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 48%,transparent)}.editor-state-group:first-child{padding-top:0;border-top:0}.editor-palette-swatches{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.editor-palette-swatch-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 68%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 88%,transparent);font-size:.76rem}.editor-palette-swatch-dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(128,128,128,.35);flex-shrink:0}.editor-palette-color-input{width:22px;height:22px;padding:0;border:0;border-radius:50%;background:transparent;cursor:pointer}.editor-palette-swatch-code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.74rem;color:var(--md-sys-color-on-surface, inherit)}.editor-palette-swatch-remove{appearance:none;border:0;background:transparent;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.95rem;line-height:1;padding:0 2px;cursor:pointer}.editor-row-card{padding:14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 54%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 92%,rgba(18,99,180,.04))}.editor-row-actions{display:flex;justify-content:flex-end}.editor-field{width:100%}.editor-issues{display:grid;gap:10px;margin:0;padding:0;list-style:none}.editor-issue{padding:12px 14px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 10%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 24%,transparent)}.editor-issue--warning{background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 26%,transparent)}.editor-issue__header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:4px}.editor-issue strong{display:block;font-size:.84rem}.editor-issue__jump{min-height:26px;padding:0 8px;font-size:.76rem}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem;line-height:1.45}.editor-empty{padding:16px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 52%,transparent);border:1px dashed color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent);color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem}.editor-empty--actionable{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.editor-select-trigger{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:100%}.editor-select-trigger__icon{width:18px;height:18px;font-size:18px;flex-shrink:0;color:var(--md-sys-color-primary, #1263b4)}.editor-select-trigger__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.editor-select-trigger__code{flex-shrink:0;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,currentColor 8%,transparent);color:color-mix(in srgb,currentColor 72%,transparent)}::ng-deep .editor-select-search{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--md-sys-color-surface-container-highest, #f4f6fa);border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 45%,transparent)}::ng-deep .editor-select-search mat-icon{width:18px;height:18px;font-size:18px;color:var(--md-sys-color-on-surface-variant, #5a5d67);flex-shrink:0}::ng-deep .editor-select-search input{width:100%;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:.85rem}::ng-deep .mat-mdc-option.editor-rich-mat-option{min-height:54px;height:auto;padding-block:8px;align-items:flex-start}::ng-deep .editor-rich-option{display:flex;align-items:flex-start;gap:10px;min-width:0;width:100%;line-height:1.25}::ng-deep .editor-rich-option__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}::ng-deep .editor-rich-option__icon mat-icon{width:16px;height:16px;font-size:16px}::ng-deep .editor-rich-option__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 auto}::ng-deep .editor-rich-option__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}::ng-deep .editor-rich-option__label{font-weight:600;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__badge,.editor-resource-summary__badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:.68rem;font-weight:600;background:color-mix(in srgb,currentColor 10%,transparent);color:color-mix(in srgb,currentColor 78%,transparent);white-space:nowrap}::ng-deep .editor-rich-option__code,.editor-resource-summary__code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;color:var(--md-sys-color-primary, #1263b4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__description,.editor-resource-summary__description{margin:0;font-size:.75rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal}.editor-resource-summary{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 6%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent)}.editor-resource-summary__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}.editor-resource-summary__icon mat-icon{width:16px;height:16px;font-size:16px}.editor-resource-summary__body{display:grid;gap:2px;min-width:0;flex:1 1 auto}.editor-resource-summary__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}.editor-preview-degraded{margin-bottom:12px;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-style:solid;border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 32%,transparent)}@container praxis-chart-editor (min-width: 841px){.editor-nav__preview-btn:not(.active){display:none}}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}.editor-shell--preview-active .editor-form{display:none}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"] }]
16623
+ ], providers: [providePraxisChartsI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"editor-shell\" [class.editor-shell--preview-active]=\"activeSection() === 'preview'\">\n <div class=\"editor-nav\" role=\"toolbar\" [attr.aria-label]=\"t('praxis.charts.editor.sectionsNav', 'Se\u00E7\u00F5es do editor')\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.id]=\"'chart-editor-tab-' + section.id\"\n aria-controls=\"chart-editor-tabpanel\"\n [attr.aria-pressed]=\"activeSection() === section.id\"\n [tabIndex]=\"activeSection() === section.id ? 0 : -1\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n [class.editor-nav__preview-btn]=\"section.id === 'preview'\"\n [class.editor-nav__has-error]=\"sectionHasError(section.id)\"\n (click)=\"setSection(section.id)\"\n (keydown)=\"onSectionNavKeydown($event, $index)\"\n >\n <span>{{ t(section.labelKey, section.fallback) }}</span>\n @if (sectionIssueCount(section.id) > 0) {\n <span\n class=\"editor-nav__badge\"\n aria-hidden=\"true\"\n [class.editor-nav__badge--error]=\"sectionHasError(section.id)\"\n [attr.data-testid]=\"'chart-editor-section-badge-' + section.id\"\n >\n {{ sectionIssueCount(section.id) }}\n </span>\n }\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div\n class=\"editor-form\"\n id=\"chart-editor-tabpanel\"\n role=\"tabpanel\"\n [attr.aria-labelledby]=\"'chart-editor-tab-' + activeSection()\"\n >\n @switch (activeSection()) {\n @case ('general') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.chartId', 'Chart ID') }}</mat-label>\n <input matInput [ngModel]=\"doc().chartId || ''\" (ngModelChange)=\"setChartId($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.kind', 'Kind') }}</mat-label>\n <mat-select [ngModel]=\"doc().kind\" (ngModelChange)=\"setKind($event)\" [disabled]=\"isReadonly()\">\n @for (kind of chartKinds; track kind) {\n <mat-option [value]=\"kind\">\n {{ t('praxis.charts.editor.kind.' + kind, kind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.title', 'Title') }}</mat-label>\n <input matInput data-testid=\"chart-editor-title-input\" [ngModel]=\"titleValue()\" (ngModelChange)=\"setTitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.subtitle', 'Subtitle') }}</mat-label>\n <input matInput [ngModel]=\"subtitleValue()\" (ngModelChange)=\"setSubtitle($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sizingMode', 'Sizing') }}</mat-label>\n <mat-select [ngModel]=\"sizingModeValue()\" (ngModelChange)=\"setSizingMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of sizingModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.sizing.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n <mat-hint>{{ t('praxis.charts.editor.hint.sizingMode', 'Use fill-container only when the host widget provides a defined body height.') }}</mat-hint>\n </mat-form-field>\n\n @if (sizingModeValue() === 'fixed') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.height', 'Height') }}</mat-label>\n <input matInput [ngModel]=\"heightValue()\" (ngModelChange)=\"setSizingHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.height', 'Numbers are saved as pixels; CSS lengths such as 20rem are also accepted.') }}</mat-hint>\n </mat-form-field>\n }\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.minHeight', 'Minimum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMinHeightValue()\" (ngModelChange)=\"setSizingMinHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.minHeight', 'Set a readable minimum for compact dashboard widgets.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.maxHeight', 'Maximum height') }}</mat-label>\n <input matInput [ngModel]=\"sizingMaxHeightValue()\" (ngModelChange)=\"setSizingMaxHeight($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.maxHeight', 'Leave empty unless the chart must stop growing inside a flexible layout.') }}</mat-hint>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.aspectRatio', 'Aspect ratio') }}</mat-label>\n <input matInput [ngModel]=\"sizingAspectRatioValue()\" (ngModelChange)=\"setSizingAspectRatio($event)\" [disabled]=\"isReadonly()\" />\n <mat-hint>{{ t('praxis.charts.editor.hint.aspectRatio', 'Optional. Use values such as 1.777 or 16 / 9.') }}</mat-hint>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('data') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\" data-testid=\"chart-editor-effective-origin\">\n {{ effectiveDataOriginSummary() }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.sourceKind', 'Source') }}</mat-label>\n <mat-select data-testid=\"chart-editor-source-kind\" [ngModel]=\"doc().source.kind\" (ngModelChange)=\"setSourceKind($event)\" [disabled]=\"isReadonly()\">\n @for (sourceKind of sourceKinds; track sourceKind) {\n <mat-option [value]=\"sourceKind\">\n {{ t('praxis.charts.editor.sourceKind.' + (sourceKind === 'praxis.stats' ? 'praxisStats' : 'derived'), sourceKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (doc().source.kind === 'praxis.stats') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.resource', 'Resource') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-resource\"\n [ngModel]=\"resourceValue()\"\n (ngModelChange)=\"setResource($event)\"\n (openedChange)=\"setResourceSearchTerm('')\"\n [disabled]=\"isReadonly() || resourceCatalogUnavailable()\"\n >\n @if (selectedResourceOption(); as selectedRes) {\n <mat-select-trigger>\n <span class=\"editor-select-trigger\">\n <mat-icon class=\"editor-select-trigger__icon\" [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n <span class=\"editor-select-trigger__label\">{{ selectedRes.label }}</span>\n <span class=\"editor-select-trigger__code\">{{ selectedRes.path }}</span>\n </span>\n </mat-select-trigger>\n }\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n data-testid=\"chart-editor-resource-search\"\n [placeholder]=\"t('praxis.charts.editor.field.resourceSearchPlaceholder', 'Search by name, key, or endpoint...')\"\n [ngModel]=\"resourceSearchTerm()\"\n (ngModelChange)=\"setResourceSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (resource of filteredResourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <span class=\"editor-rich-option__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"resource.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ resource.label }}</span>\n @if (resource.group) {\n <span class=\"editor-rich-option__badge\">{{ resource.group }}</span>\n }\n @if (resource.readOnly) {\n <span class=\"editor-rich-option__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-rich-option__code\">\n {{ resource.path }}{{ resource.id && resource.id !== resource.path && resource.id !== resource.label ? ' \u00B7 ' + resource.id : '' }}\n </span>\n @if (resource.description) {\n <span class=\"editor-rich-option__description\">{{ resource.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n @if (resourceCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.resourceMissing', 'Resource catalog is required for governed praxis.stats authoring.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.operation', 'Operation') }}</mat-label>\n <mat-select\n data-testid=\"chart-editor-operation\"\n [ngModel]=\"doc().source.operation || 'group-by'\"\n (ngModelChange)=\"setOperation($event)\"\n [disabled]=\"isReadonly() || isResolvingResource() || operationCatalogUnavailable()\"\n >\n @for (operation of operationOptions(); track operation) {\n <mat-option [value]=\"operation\">\n {{ t('praxis.charts.editor.operation.' + (operation === 'group-by' ? 'groupBy' : operation), operation) }}\n </mat-option>\n }\n </mat-select>\n @if (isResolvingResource()) {\n <mat-hint data-testid=\"chart-editor-resolving-resource\">\n {{ t('praxis.charts.editor.catalog.resolvingResource', 'Resolving governed stats operations and fields for the selected resource...') }}\n </mat-hint>\n } @else if (operationCatalogUnavailable()) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.operationMissing', 'Selected resource does not publish authorable stats operations.') }}</mat-hint>\n }\n </mat-form-field>\n }\n </div>\n\n @if (doc().source.kind === 'praxis.stats' && selectedResourceOption(); as selectedRes) {\n <div class=\"editor-resource-summary\" data-testid=\"chart-editor-resource-summary\">\n <span class=\"editor-resource-summary__icon\" aria-hidden=\"true\">\n <mat-icon [praxisIcon]=\"selectedRes.icon || 'api'\"></mat-icon>\n </span>\n <div class=\"editor-resource-summary__body\">\n <div class=\"editor-resource-summary__header\">\n <strong>{{ selectedRes.label }}</strong>\n @if (selectedRes.group) {\n <span class=\"editor-resource-summary__badge\">{{ selectedRes.group }}</span>\n }\n @if (selectedRes.readOnly) {\n <span class=\"editor-resource-summary__badge\">{{ t('praxis.charts.editor.catalog.readOnly', 'Read-only') }}</span>\n }\n </div>\n <span class=\"editor-resource-summary__code\">\n {{ selectedRes.path }}{{ selectedRes.id && selectedRes.id !== selectedRes.path && selectedRes.id !== selectedRes.label ? ' \u00B7 ' + selectedRes.id : '' }}\n </span>\n @if (selectedRes.description) {\n <p class=\"editor-resource-summary__description\">{{ selectedRes.description }}</p>\n }\n </div>\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n @if (showTimeseriesControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.timeseriesTitle', 'Timeseries options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.granularity', 'Granularity') }}</mat-label>\n <mat-select [ngModel]=\"granularityValue()\" (ngModelChange)=\"setGranularity($event)\" [disabled]=\"isReadonly()\">\n @for (granularity of timeGranularities; track granularity) {\n <mat-option [value]=\"granularity\">\n {{ t('praxis.charts.editor.granularity.' + granularity, granularity) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-slide-toggle\n [ngModel]=\"fillGapsValue()\"\n (ngModelChange)=\"setFillGaps($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.fillGaps', 'Fill missing intervals') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showDistributionControls()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.distributionTitle', 'Distribution options') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.distributionMode', 'Distribution mode') }}</mat-label>\n <mat-select data-testid=\"chart-editor-distribution-mode\" [ngModel]=\"distributionModeValue()\" (ngModelChange)=\"setDistributionMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of distributionModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.distributionMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (distributionModeValue() === 'histogram') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketSize', 'Bucket size') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-size\" [ngModel]=\"bucketSizeValue()\" (ngModelChange)=\"setBucketSize($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.bucketCount', 'Bucket count') }}</mat-label>\n <input matInput data-testid=\"chart-editor-bucket-count\" [ngModel]=\"bucketCountValue()\" (ngModelChange)=\"setBucketCount($event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n }\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComparisonControls()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-comparison-options\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comparisonTitle', 'Comparison period') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPeriodField', 'Period field') }}</mat-label>\n @if (fieldOptions('time').length) {\n <mat-select\n [ngModel]=\"comparisonPeriodFieldValue()\"\n (ngModelChange)=\"setComparisonPeriodField($event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('time', comparisonPeriodFieldValue()); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('time').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.comparisonPeriodFieldsMissing', 'No governed time-series field is available for comparison periods.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonTimezone', 'Timezone') }}</mat-label>\n <input\n matInput\n [ngModel]=\"comparisonPeriodTimezoneValue()\"\n (ngModelChange)=\"setComparisonPeriodTimezone($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonPreset', 'Period preset') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodPresetValue()\"\n (ngModelChange)=\"setComparisonPeriodPreset($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (preset of comparisonPeriodPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.comparisonPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.comparisonMode', 'Comparison mode') }}</mat-label>\n <mat-select\n [ngModel]=\"comparisonPeriodModeValue()\"\n (ngModelChange)=\"setComparisonPeriodMode($event)\"\n [disabled]=\"isReadonly()\"\n >\n @for (mode of comparisonPeriodModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.comparisonMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n }\n\n @if (doc().source.kind === 'derived') {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-local-data\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.localData.title', 'Local dataset (inputs.data)') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.localData.caption', 'Edit the local records rendered when the data source is derived.') }}\n </p>\n\n @if (canConsolidateGaugeLocalData()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-consolidate-gauge\"\n (click)=\"consolidateGaugeLocalData()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.consolidateGauge', 'Consolidate to 1 bucket for gauge') }}\n </button>\n </div>\n }\n\n @if (localDataRows().length) {\n @for (row of localDataRows(); track $index; let rowIndex = $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-local-data-row-' + rowIndex\">\n <div class=\"editor-grid\">\n @for (dimField of localDataDimensionFields(); track dimField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ dimField }}</mat-label>\n <input\n matInput\n [attr.data-testid]=\"'chart-local-data-dim-' + rowIndex + '-' + dimField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, dimField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, dimField, $event, 'dimension')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n @for (metricField of localDataMetricFields(); track metricField) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ metricField }}</mat-label>\n <input\n matInput\n type=\"number\"\n [attr.data-testid]=\"'chart-local-data-metric-' + rowIndex + '-' + metricField\"\n [ngModel]=\"localDataCellStringValue(rowIndex, metricField)\"\n (ngModelChange)=\"setLocalDataCellValue(rowIndex, metricField, $event, 'metric')\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n }\n </div>\n <div class=\"editor-row-actions\">\n <button\n mat-button\n type=\"button\"\n [attr.data-testid]=\"'chart-local-data-remove-row-' + rowIndex\"\n (click)=\"removeLocalDataRow(rowIndex)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.removeRow', 'Remove record') }}\n </button>\n </div>\n </div>\n }\n } @else {\n <div class=\"editor-empty\" data-testid=\"chart-local-data-empty\">\n {{ t('praxis.charts.editor.localData.empty', 'No local records configured yet. Add a record to replace the synthetic preview sample.') }}\n </div>\n }\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-local-data-add-row\"\n (click)=\"addLocalDataRow()\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.localData.addRow', 'Add record') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n </div>\n }\n\n @case ('motion') {\n <mat-card class=\"editor-card\">\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-slide-toggle\n [ngModel]=\"normalizedDocument().motion?.enabled !== false\"\n (ngModelChange)=\"setMotionEnabled($event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.motionEnabled', 'Enable animations') }}\n </mat-slide-toggle>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.motionPreset', 'Motion preset') }}</mat-label>\n <mat-select\n [ngModel]=\"normalizedDocument().motion?.preset || 'standard'\"\n (ngModelChange)=\"setMotionPreset($event)\"\n [disabled]=\"isReadonly() || normalizedDocument().motion?.enabled === false\"\n >\n @for (preset of motionPresets; track preset) {\n <mat-option [value]=\"preset\">\n {{ t('praxis.charts.editor.motionPreset.' + preset, preset) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <p class=\"editor-caption\" data-testid=\"chart-editor-motion-hint\">\n {{ t('praxis.charts.editor.motion.hint', 'Controls initial entrance timing and smooth transitions when filters or query context change, with automatic volume thresholds for high-density datasets.') }}\n </p>\n\n <div>\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-simulate-motion\"\n (click)=\"simulateMotionTransition()\"\n [disabled]=\"normalizedDocument().motion?.enabled === false || !effectivePreview()\"\n >\n {{ t('praxis.charts.editor.motion.simulate', 'Simulate transition') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @case ('appearance') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.featuresTitle', 'Display features') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-toggle-cluster\">\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('legend')\"\n (ngModelChange)=\"setFeatureEnabled('legend', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.legendEnabled', 'Show legend') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('labels')\"\n (ngModelChange)=\"setFeatureEnabled('labels', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.labelsEnabled', 'Show labels') }}\n </mat-slide-toggle>\n\n <mat-slide-toggle\n [ngModel]=\"featureEnabled('tooltip')\"\n (ngModelChange)=\"setFeatureEnabled('tooltip', $event)\"\n [disabled]=\"isReadonly()\"\n >\n {{ t('praxis.charts.editor.field.tooltipEnabled', 'Show tooltip') }}\n </mat-slide-toggle>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.paletteTitle', 'Palette') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.themeVariant', 'Theme variant') }}</mat-label>\n <mat-select [ngModel]=\"themeVariantValue()\" (ngModelChange)=\"setThemeVariant($event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.themeVariant.none', 'No variant') }}\n </mat-option>\n @for (variant of themeVariants; track variant) {\n <mat-option [value]=\"variant\">\n {{ t('praxis.charts.editor.themeVariant.' + variant, variant) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteMode', 'Palette mode') }}</mat-label>\n <mat-select [ngModel]=\"paletteModeValue()\" (ngModelChange)=\"setPaletteMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of paletteModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.paletteMode.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n @if (canApplyDomainSemanticPalette()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-apply-domain-palette\"\n (click)=\"applyDomainSemanticPalette()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.appearance.applyDomainPaletteHint', 'Generates a custom palette with semantic colors (success, warning, danger, info) from the dimension categories.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.appearance.applyDomainPalette', 'Apply domain semantic colors') }}\n </button>\n </div>\n }\n\n @if (paletteModeValue() === 'token') {\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.paletteToken', 'Palette token') }}</mat-label>\n <mat-select [ngModel]=\"paletteTokenValue()\" (ngModelChange)=\"setPaletteToken($event)\" [disabled]=\"isReadonly()\">\n @for (token of paletteTokens; track token) {\n <mat-option [value]=\"token\">\n {{ t('praxis.charts.editor.paletteToken.' + token, token) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of tokenPaletteSwatches(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n </span>\n }\n </div>\n } @else {\n <div class=\"editor-palette-swatches\" data-testid=\"chart-editor-palette-swatches\">\n @for (swatch of customPaletteColors(); track $index) {\n <span class=\"editor-palette-swatch-chip\" [title]=\"swatch\">\n <span class=\"editor-palette-swatch-dot\" [style.background]=\"swatch\"></span>\n <input\n type=\"color\"\n class=\"editor-palette-color-input\"\n [attr.data-testid]=\"'chart-editor-palette-color-' + $index\"\n [value]=\"swatchHexValue(swatch, $index)\"\n (input)=\"setCustomPaletteColorAt($index, $any($event.target).value)\"\n [disabled]=\"isReadonly()\"\n />\n <code class=\"editor-palette-swatch-code\">{{ swatch }}</code>\n <button\n type=\"button\"\n class=\"editor-palette-swatch-remove\"\n [attr.data-testid]=\"'chart-editor-palette-remove-' + $index\"\n (click)=\"removeCustomPaletteColor($index)\"\n [disabled]=\"isReadonly()\"\n [attr.aria-label]=\"t('praxis.charts.editor.appearance.removePaletteColor', 'Remove color')\"\n >\n \u00D7\n </button>\n </span>\n }\n <button\n mat-stroked-button\n type=\"button\"\n class=\"editor-palette-add-btn\"\n data-testid=\"chart-editor-add-palette-color\"\n (click)=\"addCustomPaletteColor()\"\n [disabled]=\"isReadonly()\"\n >\n + {{ t('praxis.charts.editor.appearance.addPaletteColor', 'Add color') }}\n </button>\n </div>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.palette', 'Palette colors') }}</mat-label>\n <textarea\n matInput\n rows=\"2\"\n [ngModel]=\"paletteValue()\"\n (ngModelChange)=\"setPalette($event)\"\n [disabled]=\"isReadonly()\"\n ></textarea>\n </mat-form-field>\n }\n\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.paletteHint', 'Use a registered token or comma-separated colors to persist theme.palette in the canonical contract.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.surfaceTitle', 'Surface') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.surfaceMode', 'Surface mode') }}</mat-label>\n <mat-select [ngModel]=\"surfaceModeValue()\" (ngModelChange)=\"setSurfaceMode($event)\" [disabled]=\"isReadonly()\">\n @for (mode of surfaceModes; track mode) {\n <mat-option [value]=\"mode\">\n {{ t('praxis.charts.editor.surface.' + mode, mode) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.appearance.surfaceHint', 'Use embedded for dashboard widgets and contained only when the chart must own its visual surface.') }}\n </p>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.appearance.statesTitle', 'State messages') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyTitle', 'Empty title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('empty')\" (ngModelChange)=\"setStateTitle('empty', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.emptyDescription', 'Empty description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingTitle', 'Loading title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('loading')\" (ngModelChange)=\"setStateTitle('loading', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.loadingDescription', 'Loading description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-state-group\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorTitle', 'Error title') }}</mat-label>\n <input matInput [ngModel]=\"stateTitle('error')\" (ngModelChange)=\"setStateTitle('error', $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.errorDescription', 'Error description') }}</mat-label>\n <textarea matInput rows=\"1\" [ngModel]=\"stateDescription('error')\" (ngModelChange)=\"setStateDescription('error', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('analytics') {\n <div class=\"editor-stack\">\n @if (showGaugePanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-gauge-scale\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.gaugeTitle', 'Gauge scale') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.gaugeHint', 'Gauge charts render exactly one bucket and one metric. Remote stats use group-by with limit 1; values outside the scale fail instead of being clamped.') }}\n </p>\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMin', 'Scale minimum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-min\"\n [ngModel]=\"gaugeMinValue()\"\n (ngModelChange)=\"setGaugeMin($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.gaugeMax', 'Scale maximum') }}</mat-label>\n <input\n matInput\n type=\"number\"\n data-testid=\"chart-editor-gauge-max\"\n [ngModel]=\"gaugeMaxValue()\"\n (ngModelChange)=\"setGaugeMax($event)\"\n [disabled]=\"isReadonly()\"\n />\n </mat-form-field>\n </div>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showComboPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.comboTitle', 'Combo guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.comboHint', 'Combo charts require at least two metrics and allow per-metric axis and series kind mapping.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showPieDonutPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.pieDonutTitle', 'Composition guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.pieDonutHint', 'Pie, donut, funnel, pyramid and treemap charts keep one metric and use the first dimension as the category segment.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showScatterPanel()) {\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.scatterTitle', 'Scatter guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.scatterHint', 'With one metric, scatter uses the first dimension as X and the metric as Y. With two metrics, the dimension identifies the grouped bucket and the metrics map to X and Y.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (showHeatmapPanel()) {\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-heatmap-panel\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.specialization.heatmapTitle', 'Heatmap matrix guidance') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.specialization.heatmapHint', 'Heatmap charts render a 2D matrix requiring two dimensions (X and Y axes) and one metric, using group-by for remote stats.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n\n @if (canApplySemanticAnalyticsMapping()) {\n <div class=\"editor-row-actions\">\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-smart-map-analytics\"\n (click)=\"applySemanticAnalyticsMapping()\"\n [disabled]=\"isReadonly()\"\n [title]=\"t('praxis.charts.editor.analytics.smartMapHint', 'Automatically selects the best dimension, metric, and aggregation from the governed field catalog.')\"\n >\n <mat-icon aria-hidden=\"true\">auto_fix_high</mat-icon>\n {{ t('praxis.charts.editor.analytics.smartMap', 'Suggest dimension & metric from schema') }}\n </button>\n </div>\n }\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.dimensionsTitle', 'Dimensions') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (dimension of dimensions(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-dimension-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimension', 'Dimension') }}</mat-label>\n @if (fieldOptions('dimension').length) {\n <mat-select\n [ngModel]=\"dimension.field || ''\"\n (ngModelChange)=\"setDimensionField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('dimension', dimension.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('dimension').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.dimensionFieldsMissing', 'No governed dimensions are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-role-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionRole', 'Dimension role') }}</mat-label>\n <mat-select [ngModel]=\"dimension.role || 'category'\" (ngModelChange)=\"setDimensionRole($index, $event)\" [disabled]=\"isReadonly()\">\n @for (role of dimensionRoles; track role) {\n <mat-option [value]=\"role\">\n {{ t('praxis.charts.editor.dimensionRole.' + role, role) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-dimension-extraction-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.dimensionExtraction', 'Temporal extraction') }}</mat-label>\n <mat-select [ngModel]=\"dimension.extraction || ''\" (ngModelChange)=\"setDimensionExtraction($index, $event)\" [disabled]=\"isReadonly()\">\n <mat-option [value]=\"''\">\n {{ t('praxis.charts.editor.dimensionExtraction.none', 'None') }}\n </mat-option>\n @for (extraction of dimensionExtractions; track extraction) {\n <mat-option [value]=\"extraction\">\n {{ t('praxis.charts.editor.dimensionExtraction.' + extraction, extraction) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" (click)=\"removeDimension($index)\" [disabled]=\"isReadonly() || !canRemoveDimension()\">\n {{ t('praxis.charts.editor.analytics.removeDimension', 'Remove dimension') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-dimension\" (click)=\"addDimension()\" [disabled]=\"isReadonly() || !canAddDimension()\">\n {{ t('praxis.charts.editor.analytics.addDimension', 'Add dimension') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.analytics.metricsTitle', 'Metrics') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-stack\">\n @for (metric of metrics(); track $index) {\n <div class=\"editor-row-card\" [attr.data-testid]=\"'chart-editor-metric-row-' + $index\">\n <div class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-field-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metric', 'Metric') }}</mat-label>\n @if (fieldOptions('metric').length) {\n <mat-select\n [ngModel]=\"metric.field || ''\"\n (ngModelChange)=\"setMetricField($index, $event)\"\n (openedChange)=\"setFieldSearchTerm('')\"\n [disabled]=\"isReadonly()\"\n >\n <div class=\"editor-select-search\" (click)=\"$event.stopPropagation()\">\n <mat-icon aria-hidden=\"true\">search</mat-icon>\n <input\n type=\"search\"\n [placeholder]=\"t('praxis.charts.editor.field.fieldSearchPlaceholder', 'Search field...')\"\n [ngModel]=\"fieldSearchTerm()\"\n (ngModelChange)=\"setFieldSearchTerm($event)\"\n (keydown)=\"onSelectSearchKeydown($event)\"\n />\n </div>\n @for (field of filteredFieldOptions('metric', metric.field); track field.field) {\n <mat-option [value]=\"field.field\" class=\"editor-rich-mat-option\">\n <div class=\"editor-rich-option\">\n <div class=\"editor-rich-option__body\">\n <div class=\"editor-rich-option__header\">\n <span class=\"editor-rich-option__label\">{{ field.label || field.field }}</span>\n @if (field.type) {\n <span class=\"editor-rich-option__badge\">{{ field.type }}</span>\n }\n </div>\n @if (field.label && field.label !== field.field) {\n <span class=\"editor-rich-option__code\">{{ field.field }}</span>\n }\n @if (field.description) {\n <span class=\"editor-rich-option__description\">{{ field.description }}</span>\n }\n </div>\n </div>\n </mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\n }\n @if (!fieldOptions('metric').length) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.metricFieldsMissing', 'No governed metrics are available for the current resource and operation.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-label-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricLabel', 'Metric label') }}</mat-label>\n <input matInput [ngModel]=\"metric.label || ''\" (ngModelChange)=\"setMetricLabel($index, $event)\" [disabled]=\"isReadonly()\" />\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-aggregation-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAggregation', 'Aggregation') }}</mat-label>\n <mat-select [ngModel]=\"metric.aggregation || 'sum'\" (ngModelChange)=\"setMetricAggregation($index, $event)\" [disabled]=\"isReadonly()\">\n @for (aggregation of metricAggregationOptions(metric.field); track aggregation) {\n <mat-option [value]=\"aggregation\">\n {{ t('praxis.charts.editor.metricAggregation.' + aggregation, aggregation) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n @if (showMetricAxisControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-axis-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricAxis', 'Axis') }}</mat-label>\n <mat-select [ngModel]=\"metric.axis || 'primary'\" (ngModelChange)=\"setMetricAxis($index, $event)\" [disabled]=\"isReadonly()\">\n @for (axis of metricAxes; track axis) {\n <mat-option [value]=\"axis\">\n {{ t('praxis.charts.editor.metricAxis.' + axis, axis) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n\n @if (showMetricSeriesKindControls()) {\n <mat-form-field class=\"editor-field\" appearance=\"outline\" [attr.data-testid]=\"'chart-editor-metric-series-kind-' + $index\">\n <mat-label>{{ t('praxis.charts.editor.field.metricSeriesKind', 'Series kind') }}</mat-label>\n <mat-select [ngModel]=\"metric.seriesKind || 'bar'\" (ngModelChange)=\"setMetricSeriesKind($index, $event)\" [disabled]=\"isReadonly()\">\n @for (seriesKind of metricSeriesKinds; track seriesKind) {\n <mat-option [value]=\"seriesKind\">\n {{ t('praxis.charts.editor.metricSeriesKind.' + seriesKind, seriesKind) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n }\n </div>\n\n <div class=\"editor-row-actions\">\n <button mat-button type=\"button\" [attr.data-testid]=\"'chart-editor-remove-metric-' + $index\" (click)=\"removeMetric($index)\" [disabled]=\"isReadonly() || metrics().length <= 1\">\n {{ t('praxis.charts.editor.analytics.removeMetric', 'Remove metric') }}\n </button>\n </div>\n </div>\n }\n\n <div>\n <button mat-stroked-button type=\"button\" data-testid=\"chart-editor-add-metric\" (click)=\"addMetric()\" [disabled]=\"isReadonly() || !canAddMetric()\">\n {{ t('praxis.charts.editor.analytics.addMetric', 'Add metric') }}\n </button>\n </div>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('events') {\n <div class=\"editor-stack\">\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-pointClick\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.pointClickTitle', 'Point click') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-action\" [ngModel]=\"eventAction('pointClick')\" (ngModelChange)=\"setEventAction('pointClick', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-pointClick-target\" [ngModel]=\"eventTarget('pointClick')\" (ngModelChange)=\"setEventTarget('pointClick', $event)\" [disabled]=\"isReadonly() || !eventAction('pointClick') || targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')\">\n @for (target of targetOptions(eventAction('pointClick'), 'pointClick'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('pointClick'), 'pointClick')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-pointClick-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('pointClick')\"\n (ngModelChange)=\"setEventMapping('pointClick', $event)\"\n [disabled]=\"isReadonly() || !eventAction('pointClick')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-selectionChange\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.selectionChangeTitle', 'Selection change') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-action\" [ngModel]=\"eventAction('selectionChange')\" (ngModelChange)=\"setEventAction('selectionChange', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-selectionChange-target\" [ngModel]=\"eventTarget('selectionChange')\" (ngModelChange)=\"setEventTarget('selectionChange', $event)\" [disabled]=\"isReadonly() || !eventAction('selectionChange') || targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')\">\n @for (target of targetOptions(eventAction('selectionChange'), 'selectionChange'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('selectionChange'), 'selectionChange')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-selectionChange-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('selectionChange')\"\n (ngModelChange)=\"setEventMapping('selectionChange', $event)\"\n [disabled]=\"isReadonly() || !eventAction('selectionChange')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-drillDown\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.drillDownTitle', 'Drill down') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-action\" [ngModel]=\"eventAction('drillDown')\" (ngModelChange)=\"setEventAction('drillDown', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-drillDown-target\" [ngModel]=\"eventTarget('drillDown')\" (ngModelChange)=\"setEventTarget('drillDown', $event)\" [disabled]=\"isReadonly() || !eventAction('drillDown') || targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')\">\n @for (target of targetOptions(eventAction('drillDown'), 'drillDown'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('drillDown'), 'drillDown')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-drillDown-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('drillDown')\"\n (ngModelChange)=\"setEventMapping('drillDown', $event)\"\n [disabled]=\"isReadonly() || !eventAction('drillDown')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\" data-testid=\"chart-editor-event-crossFilter\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.events.crossFilterTitle', 'Cross filter') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content class=\"editor-grid\">\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventAction', 'Action') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-action\" [ngModel]=\"eventAction('crossFilter')\" (ngModelChange)=\"setEventAction('crossFilter', $event)\" [disabled]=\"isReadonly()\">\n <mat-option value=\"\">{{ t('praxis.charts.editor.events.none', 'None') }}</mat-option>\n @for (action of eventActionOptions; track action) {\n <mat-option [value]=\"action\">\n {{ t('praxis.charts.editor.eventAction.' + action, action) }}\n </mat-option>\n }\n </mat-select>\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventTarget', 'Target') }}</mat-label>\n <mat-select data-testid=\"chart-editor-event-crossFilter-target\" [ngModel]=\"eventTarget('crossFilter')\" (ngModelChange)=\"setEventTarget('crossFilter', $event)\" [disabled]=\"isReadonly() || !eventAction('crossFilter') || targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')\">\n @for (target of targetOptions(eventAction('crossFilter'), 'crossFilter'); track target.id) {\n <mat-option [value]=\"target.id\">{{ target.label }}</mat-option>\n }\n </mat-select>\n @if (targetCatalogUnavailable(eventAction('crossFilter'), 'crossFilter')) {\n <mat-hint>{{ t('praxis.charts.editor.catalog.targetMissing', 'Target catalog is required for this governed event action.') }}</mat-hint>\n }\n </mat-form-field>\n\n <mat-form-field class=\"editor-field\" appearance=\"outline\">\n <mat-label>{{ t('praxis.charts.editor.field.eventMapping', 'Mapping') }}</mat-label>\n <textarea\n matInput\n data-testid=\"chart-editor-event-crossFilter-mapping\"\n rows=\"4\"\n [ngModel]=\"eventMappingText('crossFilter')\"\n (ngModelChange)=\"setEventMapping('crossFilter', $event)\"\n [disabled]=\"isReadonly() || !eventAction('crossFilter')\"\n ></textarea>\n </mat-form-field>\n </mat-card-content>\n </mat-card>\n </div>\n }\n\n @case ('preview') {\n <mat-card class=\"editor-card\">\n <mat-card-content>\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n </mat-card-content>\n </mat-card>\n }\n }\n </div>\n\n <div class=\"editor-side\">\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.issues.title', 'Validation issues') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (issues().length) {\n <ul class=\"editor-issues\">\n @for (issue of issues(); track issueTrackBy($index, issue)) {\n <li class=\"editor-issue\" [class.editor-issue--warning]=\"issue.severity === 'warning'\">\n <div class=\"editor-issue__header\">\n <strong>{{ issue.field }}</strong>\n @if (issueTargetSection(issue); as targetSection) {\n <button\n mat-button\n type=\"button\"\n class=\"editor-issue__jump\"\n [attr.data-testid]=\"'chart-editor-issue-jump-' + issue.code\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(targetSection) })\"\n (click)=\"setSection(targetSection)\"\n >\n {{ sectionLabel(targetSection) }} \u2192\n </button>\n }\n </div>\n <span>{{ issue.message }}</span>\n </li>\n }\n </ul>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.issues.empty', 'No issues were identified.') }}\n </div>\n }\n </mat-card-content>\n </mat-card>\n\n <mat-card class=\"editor-card\">\n <mat-card-header>\n <mat-card-title>{{ t('praxis.charts.editor.preview.title', 'Chart preview') }}</mat-card-title>\n </mat-card-header>\n <mat-card-content>\n @if (!preview()) {\n <div\n class=\"editor-empty editor-empty--actionable editor-preview-degraded\"\n data-testid=\"chart-editor-preview-degraded-warning\"\n >\n <span>{{\n effectivePreview()\n ? t('praxis.charts.editor.preview.degradedNotice', 'Aesthetic simulation active with synthetic sample data while contract validation issues remain open.')\n : t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.')\n }}</span>\n @if (firstInvalidSection(); as invalidSection) {\n <button\n mat-stroked-button\n type=\"button\"\n data-testid=\"chart-editor-preview-jump-invalid\"\n [attr.aria-label]=\"t('praxis.charts.editor.issues.jumpToSection', 'Go to {{ section }}', { section: sectionLabel(invalidSection) })\"\n (click)=\"setSection(invalidSection)\"\n >\n {{ sectionLabel(invalidSection) }} \u2192\n </button>\n }\n </div>\n }\n @if (effectivePreview(); as chartPreview) {\n <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n <p class=\"editor-caption\" data-testid=\"chart-editor-preview-origin\">\n {{\n doc().source.kind === 'praxis.stats'\n ? t('praxis.charts.editor.preview.originStatsSynthetic', 'Previewing synthetic sample for governed backend stats (praxis.stats).')\n : chartPreview.dataOrigin === 'local'\n ? t('praxis.charts.editor.preview.originLocal', 'Previewing widget local dataset (inputs.data).')\n : t('praxis.charts.editor.preview.originSynthetic', 'Previewing synthetic sample data (no local dataset records).')\n }}\n </p>\n <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n }\n </mat-card-content>\n </mat-card>\n </div>\n </div>\n</div>\n", styles: [":host{display:block;min-width:0;container:praxis-chart-editor / inline-size;color:var(--md-sys-color-on-surface, #1a1b20)}.editor-shell{display:grid;gap:16px;min-width:0}.editor-nav{display:flex;gap:6px;flex-wrap:wrap;padding:4px;border-radius:14px;background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .04));border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent)}.editor-nav button{border-radius:10px;border-color:transparent;display:inline-flex;align-items:center;gap:6px}.editor-nav button.active{background:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 22%,transparent);color:var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4));border-color:color-mix(in srgb,var(--pfx-settings-panel-primary, var(--md-sys-color-primary, #1263b4)) 48%,transparent);font-weight:600}.editor-nav__badge{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;padding:0 5px;margin-left:6px;border-radius:999px;font-size:.7rem;font-weight:700;line-height:1;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 24%,transparent);color:var(--pfx-settings-panel-fg, var(--md-sys-color-on-surface, inherit))}.editor-nav__badge--error{background:color-mix(in srgb,var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e)) 24%,transparent);color:var(--pfx-settings-panel-error, var(--md-sys-color-error, #b3261e))}:host ::ng-deep .mat-mdc-form-field-hint-wrapper,:host ::ng-deep .mat-mdc-form-field-error-wrapper{position:relative!important;top:auto!important;left:auto!important;right:auto!important;padding-top:4px;line-height:1.35}:host ::ng-deep .mat-mdc-form-field-bottom-align:before{display:none}.editor-layout{display:grid;gap:16px;grid-template-columns:minmax(0,1.35fr) minmax(320px,.9fr);align-items:start}.editor-form,.editor-side{display:grid;gap:16px;min-width:0}.editor-card{box-sizing:border-box;width:100%;min-width:0;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 72%,transparent);background:var(--md-sys-color-surface-container-low, rgba(18, 99, 180, .03))}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));align-items:start}.editor-toggle-cluster{display:flex;flex-wrap:wrap;align-items:center;gap:14px 24px}.editor-stack{display:grid;gap:14px}.editor-state-group{padding-top:14px;border-top:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 48%,transparent)}.editor-state-group:first-child{padding-top:0;border-top:0}.editor-palette-swatches{display:flex;flex-wrap:wrap;align-items:center;gap:8px}.editor-palette-swatch-chip{display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 68%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 88%,transparent);font-size:.76rem}.editor-palette-swatch-dot{width:16px;height:16px;border-radius:50%;border:1px solid rgba(128,128,128,.35);flex-shrink:0}.editor-palette-color-input{width:22px;height:22px;padding:0;border:0;border-radius:50%;background:transparent;cursor:pointer}.editor-palette-swatch-code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.74rem;color:var(--md-sys-color-on-surface, inherit)}.editor-palette-swatch-remove{appearance:none;border:0;background:transparent;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.95rem;line-height:1;padding:0 2px;cursor:pointer}.editor-row-card{padding:14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 54%,transparent);background:color-mix(in srgb,var(--pfx-settings-panel-surface, var(--md-sys-color-surface-container, transparent)) 92%,rgba(18,99,180,.04))}.editor-row-actions{display:flex;justify-content:flex-end}.editor-field{width:100%}.editor-issues{display:grid;gap:10px;margin:0;padding:0;list-style:none}.editor-issue{padding:12px 14px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 10%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 24%,transparent)}.editor-issue--warning{background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 26%,transparent)}.editor-issue__header{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap;margin-bottom:4px}.editor-issue strong{display:block;font-size:.84rem}.editor-issue__jump{min-height:26px;padding:0 8px;font-size:.76rem}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem;line-height:1.45}.editor-empty{padding:16px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 52%,transparent);border:1px dashed color-mix(in srgb,var(--md-sys-color-outline-variant, var(--md-sys-color-outline, #c5c7ce)) 64%,transparent);color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.88rem}.editor-empty--actionable{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}.editor-select-trigger{display:inline-flex;align-items:center;gap:8px;min-width:0;max-width:100%}.editor-select-trigger__icon{width:18px;height:18px;font-size:18px;flex-shrink:0;color:var(--md-sys-color-primary, #1263b4)}.editor-select-trigger__label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500}.editor-select-trigger__code{flex-shrink:0;max-width:45%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;padding:1px 6px;border-radius:6px;background:color-mix(in srgb,currentColor 8%,transparent);color:color-mix(in srgb,currentColor 72%,transparent)}::ng-deep .editor-select-search{position:sticky;top:0;z-index:2;display:flex;align-items:center;gap:8px;padding:8px 12px;background:var(--md-sys-color-surface-container-highest, #f4f6fa);border-bottom:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 45%,transparent)}::ng-deep .editor-select-search mat-icon{width:18px;height:18px;font-size:18px;color:var(--md-sys-color-on-surface-variant, #5a5d67);flex-shrink:0}::ng-deep .editor-select-search input{width:100%;border:0;outline:none;background:transparent;color:inherit;font:inherit;font-size:.85rem}::ng-deep .mat-mdc-option.editor-rich-mat-option{min-height:54px;height:auto;padding-block:8px;align-items:flex-start}::ng-deep .editor-rich-option{display:flex;align-items:flex-start;gap:10px;min-width:0;width:100%;line-height:1.25}::ng-deep .editor-rich-option__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}::ng-deep .editor-rich-option__icon mat-icon{width:16px;height:16px;font-size:16px}::ng-deep .editor-rich-option__body{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1 1 auto}::ng-deep .editor-rich-option__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap;min-width:0}::ng-deep .editor-rich-option__label{font-weight:600;font-size:.88rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__badge,.editor-resource-summary__badge{display:inline-flex;align-items:center;padding:1px 6px;border-radius:999px;font-size:.68rem;font-weight:600;background:color-mix(in srgb,currentColor 10%,transparent);color:color-mix(in srgb,currentColor 78%,transparent);white-space:nowrap}::ng-deep .editor-rich-option__code,.editor-resource-summary__code{font-family:var(--pdx-font-mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace);font-size:.72rem;color:var(--md-sys-color-primary, #1263b4);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}::ng-deep .editor-rich-option__description,.editor-resource-summary__description{margin:0;font-size:.75rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;white-space:normal}.editor-resource-summary{display:flex;align-items:flex-start;gap:10px;padding:10px 12px;border-radius:12px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 6%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent)}.editor-resource-summary__icon{width:28px;height:28px;border-radius:8px;background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 12%,transparent);color:var(--md-sys-color-primary, #1263b4);display:inline-flex;align-items:center;justify-content:center;flex-shrink:0;margin-top:1px}.editor-resource-summary__icon mat-icon{width:16px;height:16px;font-size:16px}.editor-resource-summary__body{display:grid;gap:2px;min-width:0;flex:1 1 auto}.editor-resource-summary__header{display:flex;align-items:center;gap:6px;flex-wrap:wrap}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}.editor-preview-degraded{margin-bottom:12px;background:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 10%,transparent);border-style:solid;border-color:color-mix(in srgb,var(--md-sys-color-tertiary, #f59e0b) 32%,transparent)}@container praxis-chart-editor (min-width: 841px){.editor-nav__preview-btn:not(.active){display:none}}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}.editor-shell--preview-active .editor-form{display:none}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"] }]
14928
16624
  }], ctorParameters: () => [], propDecorators: { documentInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "document", required: false }] }], localDataInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "localData", required: false }] }], modeInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "mode", required: false }] }], readonlyInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], availableResourcesInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableResources", required: false }] }], availableFieldsInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableFields", required: false }] }], availableTargetsInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "availableTargets", required: false }] }], apply: [{ type: i0.Output, args: ["apply"] }], save: [{ type: i0.Output, args: ["save"] }], resetChange: [{ type: i0.Output, args: ["resetChange"] }], documentChange: [{ type: i0.Output, args: ["documentChange"] }], localDataChange: [{ type: i0.Output, args: ["localDataChange"] }] } });
14929
16625
 
14930
16626
  class PraxisChartWidgetConfigEditor {
@@ -15668,14 +17364,15 @@ const PRAXIS_CHART_AUTHORING_COVERAGE = [
15668
17364
  ];
15669
17365
 
15670
17366
  const chartStatsOperations = ['group-by', 'timeseries', 'distribution', 'comparison'];
15671
- const chartKinds = ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter', 'funnel', 'pyramid', 'treemap', 'gauge'];
17367
+ const chartKinds = ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter', 'funnel', 'pyramid', 'treemap', 'gauge', 'heatmap'];
17368
+ const chartComparisonPeriodPresets = ['TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'LAST_30_DAYS', 'THIS_MONTH', 'LAST_MONTH', 'THIS_QUARTER', 'THIS_YEAR'];
15672
17369
  const chartComparisonPeriodSchema = {
15673
17370
  type: 'object',
15674
17371
  required: ['field', 'timezone', 'preset', 'mode'],
15675
17372
  properties: {
15676
17373
  field: { type: 'string' },
15677
17374
  timezone: { type: 'string' },
15678
- preset: { enum: ['TODAY', 'YESTERDAY', 'LAST_7_DAYS', 'LAST_30_DAYS', 'THIS_MONTH', 'LAST_MONTH', 'THIS_QUARTER', 'THIS_YEAR'] },
17375
+ preset: { enum: chartComparisonPeriodPresets },
15679
17376
  mode: { enum: ['PREVIOUS_ALIGNED', 'PREVIOUS_CALENDAR_PERIOD'] },
15680
17377
  },
15681
17378
  };
@@ -15693,6 +17390,16 @@ const chartDocumentSchema = {
15693
17390
  },
15694
17391
  },
15695
17392
  },
17393
+ {
17394
+ if: { properties: { kind: { const: 'heatmap' } }, required: ['kind'] },
17395
+ then: {
17396
+ required: ['dimensions', 'metrics'],
17397
+ properties: {
17398
+ dimensions: { type: 'array', minItems: 2, maxItems: 2 },
17399
+ metrics: { type: 'array', minItems: 1, maxItems: 1 },
17400
+ },
17401
+ },
17402
+ },
15696
17403
  {
15697
17404
  if: { properties: { kind: { const: 'gauge' } }, required: ['kind'] },
15698
17405
  then: {
@@ -15761,6 +17468,10 @@ const chartDocumentSchema = {
15761
17468
  options: {
15762
17469
  type: 'object',
15763
17470
  properties: {
17471
+ timezone: { type: 'string' },
17472
+ periodField: { type: 'string' },
17473
+ periodPreset: { enum: chartComparisonPeriodPresets },
17474
+ fillGaps: { type: 'boolean' },
15764
17475
  comparisonPeriod: chartComparisonPeriodSchema,
15765
17476
  },
15766
17477
  },
@@ -15882,8 +17593,9 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
15882
17593
  required: ['field'],
15883
17594
  properties: {
15884
17595
  field: { type: 'string' },
17596
+ sourceField: { type: 'string' },
15885
17597
  label: { oneOf: [{ type: 'string' }, { type: 'object' }] },
15886
- aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count'] },
17598
+ aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count', 'avg-per-cycle-occurrence'] },
15887
17599
  seriesKind: { enum: ['bar', 'line', 'area'] },
15888
17600
  axis: { enum: ['primary', 'secondary'] },
15889
17601
  color: { type: 'string' },
@@ -15900,8 +17612,9 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
15900
17612
  required: ['field'],
15901
17613
  properties: {
15902
17614
  field: { type: 'string' },
17615
+ sourceField: { type: 'string' },
15903
17616
  label: { oneOf: [{ type: 'string' }, { type: 'object' }] },
15904
- aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count'] },
17617
+ aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count', 'avg-per-cycle-occurrence'] },
15905
17618
  seriesKind: { enum: ['bar', 'line', 'area'] },
15906
17619
  axis: { enum: ['primary', 'secondary'] },
15907
17620
  color: { type: 'string' },
@@ -15980,8 +17693,8 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
15980
17693
  operation: { enum: chartStatsOperations },
15981
17694
  limit: { type: 'integer', minimum: 1 },
15982
17695
  comparisonPeriod: chartComparisonPeriodSchema,
15983
- dimensions: { type: 'array', items: { type: 'object', required: ['field'], properties: { field: { type: 'string' }, role: { type: 'string' } } } },
15984
- metrics: { type: 'array', items: { type: 'object', required: ['field'], properties: { field: { type: 'string' }, label: { oneOf: [{ type: 'string' }, { type: 'object' }] }, aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count'] }, seriesKind: { enum: ['bar', 'line', 'area'] }, axis: { enum: ['primary', 'secondary'] }, color: { type: 'string' }, format: { type: 'string' }, afterField: { type: 'string' } } } },
17696
+ dimensions: { type: 'array', items: { type: 'object', required: ['field'], properties: { field: { type: 'string' }, role: { type: 'string' }, extraction: { type: 'string' }, alias: { type: 'string' } } } },
17697
+ metrics: { type: 'array', items: { type: 'object', required: ['field'], properties: { field: { type: 'string' }, sourceField: { type: 'string' }, label: { oneOf: [{ type: 'string' }, { type: 'object' }] }, aggregation: { enum: ['sum', 'avg', 'min', 'max', 'count', 'distinct-count', 'avg-per-cycle-occurrence'] }, seriesKind: { enum: ['bar', 'line', 'area'] }, axis: { enum: ['primary', 'secondary'] }, color: { type: 'string' }, format: { type: 'string' }, afterField: { type: 'string' } } } },
15985
17698
  },
15986
17699
  },
15987
17700
  effects: [{ kind: 'compile-domain-patch', handler: 'chart-data-resource-bind', handlerContract: {
@@ -16353,4 +18066,4 @@ function createResourceStatsDataSource(config) {
16353
18066
  * Generated bundle index. Do not edit.
16354
18067
  */
16355
18068
 
16356
- export { AnalyticsChartConfigAdapterService, AnalyticsChartContractService, ChartContractNormalizerService, ChartContractValidationService, ChartEditorDefaultsService, ChartEditorPreviewMapperService, ChartResourceCapabilityCatalogAdapter, PRAXIS_ANALYTICS_PRESENTATION_AUTHORING_MANIFEST, PRAXIS_ANALYTICS_PRESENTATION_COMPONENT_METADATA, PRAXIS_CHARTS_AUTHORING_MANIFEST, PRAXIS_CHARTS_I18N, PRAXIS_CHART_AUTHORING_COVERAGE, PRAXIS_CHART_BACKEND_MOCK_BAR, PRAXIS_CHART_BACKEND_MOCK_COMBO, PRAXIS_CHART_BACKEND_MOCK_DONUT, PRAXIS_CHART_BACKEND_MOCK_HORIZONTAL_BAR, PRAXIS_CHART_BACKEND_MOCK_MULTI_METRIC_BAR, PRAXIS_CHART_BACKEND_MOCK_SCATTER, PRAXIS_CHART_BACKEND_MOCK_STACKED_AREA, PRAXIS_CHART_BACKEND_MOCK_TIMESERIES, PRAXIS_CHART_COMPONENT_METADATA, PRAXIS_CHART_DRILLDOWN_DATA_BY_MONTH, PRAXIS_CHART_DRILLDOWN_PANEL_METADATA, PRAXIS_CHART_ENGINE, PRAXIS_CHART_ENGINE_FACTORY, PRAXIS_CHART_PALETTE_TOKENS, PRAXIS_CHART_STATE_PROBE_COMPONENT_METADATA, PRAXIS_CHART_THEME_VARIANTS, PRAXIS_MICRO_VISUALIZATION_COMPONENT_METADATA, PRAXIS_X_UI_CHART_AUTHORABLE_VERSION, PraxisAnalyticsPresentationComponent, PraxisChartBackendPayloadAdapterService, PraxisChartCanonicalContractMapperService, PraxisChartComponent, PraxisChartCompositionShowcaseComponent, PraxisChartConfigEditor, PraxisChartDataTransformerService, PraxisChartDrilldownPanelComponent, PraxisChartSchemaMapperService, PraxisChartStateProbeComponent, PraxisChartStatsApiService, PraxisChartWidgetConfigEditor, PraxisMicroVisualizationComponent, buildPraxisChartInteractiveCanvasPage, buildPraxisChartInteractiveWidgetPage, buildPraxisChartMockCanvasPage, buildPraxisChartMockWidgetPage, createPraxisChartsI18nConfig, createResourceStatsDataSource, isPraxisChartPaletteToken, providePraxisAnalyticsPresentationMetadata, providePraxisChartDrilldownPanelMetadata, providePraxisChartStateProbeMetadata, providePraxisCharts, providePraxisChartsI18n, providePraxisChartsMetadata, providePraxisMicroVisualizationMetadata, resolvePraxisChartPaletteToken, resolvePraxisChartsText };
18069
+ export { AnalyticsChartConfigAdapterService, AnalyticsChartContractService, ChartContractNormalizerService, ChartContractValidationService, ChartEditorDefaultsService, ChartEditorPreviewMapperService, ChartResourceCapabilityCatalogAdapter, PRAXIS_ANALYTICS_PRESENTATION_AUTHORING_MANIFEST, PRAXIS_ANALYTICS_PRESENTATION_COMPONENT_METADATA, PRAXIS_CHARTS_AUTHORING_MANIFEST, PRAXIS_CHARTS_I18N, PRAXIS_CHART_AUTHORING_COVERAGE, PRAXIS_CHART_BACKEND_MOCK_BAR, PRAXIS_CHART_BACKEND_MOCK_COMBO, PRAXIS_CHART_BACKEND_MOCK_DONUT, PRAXIS_CHART_BACKEND_MOCK_HEATMAP_MATRIX, PRAXIS_CHART_BACKEND_MOCK_HORIZONTAL_BAR, PRAXIS_CHART_BACKEND_MOCK_MULTI_METRIC_BAR, PRAXIS_CHART_BACKEND_MOCK_SCATTER, PRAXIS_CHART_BACKEND_MOCK_STACKED_AREA, PRAXIS_CHART_BACKEND_MOCK_TIMESERIES, PRAXIS_CHART_COMPONENT_METADATA, PRAXIS_CHART_DRILLDOWN_DATA_BY_MONTH, PRAXIS_CHART_DRILLDOWN_PANEL_METADATA, PRAXIS_CHART_ENGINE, PRAXIS_CHART_ENGINE_FACTORY, PRAXIS_CHART_PALETTE_TOKENS, PRAXIS_CHART_STATE_PROBE_COMPONENT_METADATA, PRAXIS_CHART_THEME_VARIANTS, PRAXIS_MICRO_VISUALIZATION_COMPONENT_METADATA, PRAXIS_X_UI_CHART_AUTHORABLE_VERSION, PraxisAnalyticsPresentationComponent, PraxisChartBackendPayloadAdapterService, PraxisChartCanonicalContractMapperService, PraxisChartComponent, PraxisChartCompositionShowcaseComponent, PraxisChartConfigEditor, PraxisChartDataTransformerService, PraxisChartDrilldownPanelComponent, PraxisChartSchemaMapperService, PraxisChartStateProbeComponent, PraxisChartStatsApiService, PraxisChartWidgetConfigEditor, PraxisMicroVisualizationComponent, buildPraxisChartInteractiveCanvasPage, buildPraxisChartInteractiveWidgetPage, buildPraxisChartMockCanvasPage, buildPraxisChartMockWidgetPage, createPraxisChartsI18nConfig, createResourceStatsDataSource, isPraxisChartPaletteToken, providePraxisAnalyticsPresentationMetadata, providePraxisChartDrilldownPanelMetadata, providePraxisChartStateProbeMetadata, providePraxisCharts, providePraxisChartsI18n, providePraxisChartsMetadata, providePraxisMicroVisualizationMetadata, resolvePraxisChartPaletteToken, resolvePraxisChartsText };