@praxisui/charts 9.0.24 → 9.0.26

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, computed, afterNextRender, effect, ChangeDetectionStrategy, Component, ViewChild, Input, Injector, ENVIRONMENT_INITIALIZER } from '@angular/core';
3
3
  import * as i1 from '@praxisui/core';
4
- import { buildApiUrl, API_URL, PraxisI18nService, SETTINGS_PANEL_BRIDGE, normalizePraxisDataQueryContext, resolvePraxisFilterCriteria, BUILTIN_PAGE_THEME_PRESETS, PraxisIconButtonComponent, normalizePraxisPresentationVisualization, providePraxisI18n, ComponentMetadataRegistry, ResourceDiscoveryService, SETTINGS_PANEL_DATA, createDefaultTableConfig, PRAXIS_TABLE_DETAIL_INLINE_RENDERERS, AnalyticsStatsRequestBuilderService, DynamicWidgetPageComponent } from '@praxisui/core';
4
+ import { buildApiUrl, API_URL, PraxisI18nService, SETTINGS_PANEL_BRIDGE, normalizePraxisDataQueryContext, resolvePraxisFilterCriteria, BUILTIN_PAGE_THEME_PRESETS, PraxisIconButtonComponent, normalizePraxisPresentationVisualization, AnalyticsStatsRequestBuilderService, providePraxisI18n, AnalyticsPresentationResolver, ComponentMetadataRegistry, ResourceDiscoveryService, SETTINGS_PANEL_DATA, createDefaultTableConfig, PRAXIS_TABLE_DETAIL_INLINE_RENDERERS, DynamicWidgetPageComponent } from '@praxisui/core';
5
5
  import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6
6
  import { throwError, map, of, isObservable, from, firstValueFrom, timeout, BehaviorSubject, Subscription } from 'rxjs';
7
7
  import * as i1$2 from '@angular/material/tooltip';
@@ -9,8 +9,9 @@ import { MatTooltipModule } from '@angular/material/tooltip';
9
9
  import * as i1$1 from '@angular/common/http';
10
10
  import { HttpErrorResponse } from '@angular/common/http';
11
11
  import { catchError } from 'rxjs/operators';
12
+ import { AnalyticsTableConfigAdapterService, AnalyticsTableStatsApiService, PraxisTable } from '@praxisui/table';
12
13
  import { use, init } from 'echarts/core';
13
- import { BarChart, LineChart, PieChart, ScatterChart } from 'echarts/charts';
14
+ import { BarChart, FunnelChart, LineChart, PieChart, ScatterChart } from 'echarts/charts';
14
15
  import { AriaComponent, DatasetComponent, GridComponent, LegendComponent, TitleComponent, TooltipComponent, TransformComponent } from 'echarts/components';
15
16
  import { CanvasRenderer } from 'echarts/renderers';
16
17
  import * as i1$3 from '@angular/forms';
@@ -27,7 +28,6 @@ import * as i6 from '@angular/material/select';
27
28
  import { MatSelectModule } from '@angular/material/select';
28
29
  import * as i7 from '@angular/material/slide-toggle';
29
30
  import { MatSlideToggleModule } from '@angular/material/slide-toggle';
30
- import { PraxisTable } from '@praxisui/table';
31
31
 
32
32
  const PRAXIS_CHART_PALETTE_TOKENS = {
33
33
  'brand-primary': ['#1263b4', '#0f766e', '#f08c00', '#c92a2a', '#7b61ff'],
@@ -79,7 +79,10 @@ const PRAXIS_CHART_THEME_VARIANTS = {
79
79
  class PraxisChartDataTransformerService {
80
80
  transform(config, rows) {
81
81
  if (config.type === 'pie' || config.type === 'donut') {
82
- return this.transformPie(config, rows);
82
+ return this.transformCategorySlices(config, rows, 'pie');
83
+ }
84
+ if (config.type === 'funnel' || config.type === 'pyramid') {
85
+ return this.transformCategorySlices(config, rows, 'funnel');
83
86
  }
84
87
  if (config.type === 'scatter') {
85
88
  return this.transformScatter(config, rows);
@@ -109,12 +112,12 @@ class PraxisChartDataTransformerService {
109
112
  hasData: categories.length > 0 && series.some((item) => item.points.some((point) => this.hasPointValue(point))),
110
113
  };
111
114
  }
112
- transformPie(config, rows) {
115
+ transformCategorySlices(config, rows, mode) {
113
116
  const seriesConfig = config.series[0];
114
117
  const categoryField = seriesConfig?.categoryField ?? config.axes?.x?.field;
115
118
  if (!seriesConfig || !categoryField || !rows.length) {
116
119
  return {
117
- mode: 'pie',
120
+ mode,
118
121
  categories: [],
119
122
  series: [],
120
123
  slices: [],
@@ -122,18 +125,18 @@ class PraxisChartDataTransformerService {
122
125
  };
123
126
  }
124
127
  const slices = this.buildCategoryBuckets(config, rows, categoryField)
125
- .map((bucket) => {
128
+ .flatMap((bucket) => {
126
129
  const value = bucket.rows.reduce((sum, row) => sum + this.extractMetricValue(row, seriesConfig), 0);
127
- return {
128
- id: `${seriesConfig.id}:${bucket.identity}`,
129
- name: bucket.label,
130
- value,
131
- color: seriesConfig.color,
132
- data: this.buildPointData(bucket.rows[0], value),
133
- };
130
+ return value < 0 ? [] : [{
131
+ id: `${seriesConfig.id}:${bucket.identity}`,
132
+ name: bucket.label,
133
+ value,
134
+ color: seriesConfig.color,
135
+ data: this.buildPointData(bucket.rows[0], value),
136
+ }];
134
137
  });
135
138
  return {
136
- mode: 'pie',
139
+ mode,
137
140
  categories: slices.map((slice) => slice.name),
138
141
  series: [],
139
142
  slices,
@@ -491,7 +494,7 @@ class ChartContractNormalizerService {
491
494
  })),
492
495
  };
493
496
  }
494
- if ((document.kind === 'pie' || document.kind === 'donut' || this.isDistribution(document)) && document.metrics?.length) {
497
+ if ((this.isSingleMetricCategoryChart(document) || this.isDistribution(document)) && document.metrics?.length) {
495
498
  nextDocument = {
496
499
  ...nextDocument,
497
500
  metrics: document.metrics.slice(0, 1),
@@ -538,6 +541,12 @@ class ChartContractNormalizerService {
538
541
  isDistribution(document) {
539
542
  return document.source.kind === 'praxis.stats' && document.source.operation === 'distribution';
540
543
  }
544
+ isSingleMetricCategoryChart(document) {
545
+ return document.kind === 'pie'
546
+ || document.kind === 'donut'
547
+ || document.kind === 'funnel'
548
+ || document.kind === 'pyramid';
549
+ }
541
550
  normalizeCssSizeValue(value) {
542
551
  if (typeof value !== 'string') {
543
552
  return value;
@@ -721,19 +730,20 @@ class ChartContractValidationService {
721
730
  }
722
731
  validateKinds(document, issues) {
723
732
  const metricCount = document.metrics?.length ?? 0;
733
+ const categorySliceChart = this.isCategorySliceChart(document.kind);
724
734
  document.dimensions?.forEach((dimension, index) => {
725
735
  if (!dimension.field?.trim()) {
726
736
  issues.push(this.error('dimension-field-required', `dimensions[${index}].field`, 'x-ui.chart dimension.field is required.'));
727
737
  }
728
738
  });
729
- if (document.kind !== 'pie' && document.kind !== 'donut' && !document.dimensions?.length) {
739
+ if (!categorySliceChart && !document.dimensions?.length) {
730
740
  issues.push(this.error('missing-dimension', 'dimensions', 'x-ui.chart cartesian charts require at least one dimension.'));
731
741
  }
732
- if ((document.kind === 'pie' || document.kind === 'donut') && !document.dimensions?.[0]?.field) {
733
- issues.push(this.error('pie-missing-dimension', 'dimensions[0].field', 'x-ui.chart pie/donut charts require a first dimension for category mapping.'));
742
+ if (categorySliceChart && !document.dimensions?.[0]?.field) {
743
+ issues.push(this.error('category-slice-missing-dimension', 'dimensions[0].field', 'x-ui.chart pie, donut, funnel and pyramid charts require a first dimension for category mapping.'));
734
744
  }
735
- if ((document.kind === 'pie' || document.kind === 'donut') && metricCount > 1) {
736
- issues.push(this.error('pie-multi-metric', 'metrics', 'x-ui.chart pie/donut charts support a single metric in @praxisui/charts.'));
745
+ if (categorySliceChart && metricCount !== 1) {
746
+ issues.push(this.error('category-slice-single-metric', 'metrics', 'x-ui.chart pie, donut, funnel and pyramid charts require exactly one metric in @praxisui/charts.'));
737
747
  }
738
748
  if (document.kind === 'combo' && metricCount < 2) {
739
749
  issues.push(this.error('combo-min-metrics', 'metrics', 'x-ui.chart combo charts require at least two metrics.'));
@@ -758,6 +768,9 @@ class ChartContractValidationService {
758
768
  issues.push(this.error('combo-operation-unsupported', 'source.operation', 'x-ui.chart combo charts over praxis.stats support only group-by or timeseries operations in @praxisui/charts.'));
759
769
  }
760
770
  }
771
+ isCategorySliceChart(kind) {
772
+ return kind === 'pie' || kind === 'donut' || kind === 'funnel' || kind === 'pyramid';
773
+ }
761
774
  validateDistribution(document, issues) {
762
775
  if (document.source.kind !== 'praxis.stats'
763
776
  || document.source.operation !== 'distribution') {
@@ -928,7 +941,7 @@ class PraxisChartCanonicalContractMapperService {
928
941
  const secondaryMetric = contract.metrics?.find((metric) => metric.axis === 'secondary');
929
942
  const metricCount = (contract.metrics?.length ?? 0)
930
943
  * (contract.source.kind === 'praxis.stats' && contract.source.operation === 'comparison' ? 2 : 1);
931
- if (contract.kind === 'pie' || contract.kind === 'donut') {
944
+ if (this.isCategorySliceChart(contract.kind)) {
932
945
  return {
933
946
  x: {
934
947
  field: firstDimension?.field,
@@ -984,7 +997,7 @@ class PraxisChartCanonicalContractMapperService {
984
997
  name: period ? `${baseName} (${this.comparisonPeriodLabel(period)})` : baseName,
985
998
  type: this.resolveSeriesType(contract.kind, metric.seriesKind, index),
986
999
  axis: metric.axis ?? 'primary',
987
- categoryField: comparison || contract.kind === 'pie' || contract.kind === 'donut'
1000
+ categoryField: comparison || this.isCategorySliceChart(contract.kind)
988
1001
  ? firstDimension?.field
989
1002
  : undefined,
990
1003
  metric: {
@@ -1078,7 +1091,7 @@ class PraxisChartCanonicalContractMapperService {
1078
1091
  tooltip: {
1079
1092
  ...(variantTheme?.tooltip ?? {}),
1080
1093
  enabled: this.resolveToggle(contract.tooltip, true),
1081
- trigger: contract.kind === 'pie' || contract.kind === 'donut' || contract.kind === 'scatter' ? 'item' : 'axis',
1094
+ trigger: this.isCategorySliceChart(contract.kind) || contract.kind === 'scatter' ? 'item' : 'axis',
1082
1095
  },
1083
1096
  surface,
1084
1097
  backgroundColor: surface?.background ?? variantTheme?.backgroundColor,
@@ -1148,6 +1161,9 @@ class PraxisChartCanonicalContractMapperService {
1148
1161
  }
1149
1162
  return contract.orientation;
1150
1163
  }
1164
+ isCategorySliceChart(kind) {
1165
+ return kind === 'pie' || kind === 'donut' || kind === 'funnel' || kind === 'pyramid';
1166
+ }
1151
1167
  resolveSeriesType(chartKind, seriesKind, index) {
1152
1168
  if (chartKind === 'combo') {
1153
1169
  return seriesKind ?? (index === 0 ? 'bar' : 'line');
@@ -1767,6 +1783,7 @@ class PraxisChartComponent {
1767
1783
  surfaceBorderColor = computed(() => this.effectiveConfig().theme?.surface?.borderColor ?? null, ...(ngDevMode ? [{ debugName: "surfaceBorderColor" }] : /* istanbul ignore next */ []));
1768
1784
  surfaceBorderWidth = computed(() => this.toCssSize(this.effectiveConfig().theme?.surface?.borderWidth) ?? null, ...(ngDevMode ? [{ debugName: "surfaceBorderWidth" }] : /* istanbul ignore next */ []));
1769
1785
  surfaceBorderRadius = computed(() => this.toCssSize(this.effectiveConfig().theme?.surface?.borderRadius) ?? null, ...(ngDevMode ? [{ debugName: "surfaceBorderRadius" }] : /* istanbul ignore next */ []));
1786
+ surfaceTextColor = computed(() => this.effectiveConfig().theme?.textColor ?? null, ...(ngDevMode ? [{ debugName: "surfaceTextColor" }] : /* istanbul ignore next */ []));
1770
1787
  renderConfig = computed(() => {
1771
1788
  const config = this.effectiveConfig();
1772
1789
  const explicitData = this.data();
@@ -2436,6 +2453,7 @@ class PraxisChartComponent {
2436
2453
  [style.--praxis-chart-config-surface-border]="surfaceBorderColor()"
2437
2454
  [style.--praxis-chart-config-surface-border-width]="surfaceBorderWidth()"
2438
2455
  [style.--praxis-chart-config-surface-radius]="surfaceBorderRadius()"
2456
+ [style.--praxis-chart-config-text-color]="surfaceTextColor()"
2439
2457
  >
2440
2458
  @if (canOpenConfigEditor()) {
2441
2459
  <button
@@ -2496,7 +2514,7 @@ class PraxisChartComponent {
2496
2514
  <div #chartHost class="praxis-chart-host"></div>
2497
2515
  }
2498
2516
  </section>
2499
- `, 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.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{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{position:absolute;top:10px;right:10px;z-index:3;--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-host{width:100%;height:100%;min-height:inherit}.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-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--md-sys-color-on-surface, #1a1b20)}.praxis-chart-state-description{font-size:.925rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);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}}\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 });
2517
+ `, 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.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{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{position:absolute;top:10px;right:10px;z-index:3;--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-host{width:100%;height:100%;min-height:inherit}.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-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}}\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 });
2500
2518
  }
2501
2519
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartComponent, decorators: [{
2502
2520
  type: Component,
@@ -2516,6 +2534,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
2516
2534
  [style.--praxis-chart-config-surface-border]="surfaceBorderColor()"
2517
2535
  [style.--praxis-chart-config-surface-border-width]="surfaceBorderWidth()"
2518
2536
  [style.--praxis-chart-config-surface-radius]="surfaceBorderRadius()"
2537
+ [style.--praxis-chart-config-text-color]="surfaceTextColor()"
2519
2538
  >
2520
2539
  @if (canOpenConfigEditor()) {
2521
2540
  <button
@@ -2576,7 +2595,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
2576
2595
  <div #chartHost class="praxis-chart-host"></div>
2577
2596
  }
2578
2597
  </section>
2579
- `, 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.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{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{position:absolute;top:10px;right:10px;z-index:3;--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-host{width:100%;height:100%;min-height:inherit}.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-copy{display:grid;gap:6px;justify-items:center}.praxis-chart-state-title{font-size:1rem;font-weight:600;color:var(--md-sys-color-on-surface, #1a1b20)}.praxis-chart-state-description{font-size:.925rem;color:var(--md-sys-color-on-surface-variant, #5a5d67);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}}\n"] }]
2598
+ `, 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.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{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{position:absolute;top:10px;right:10px;z-index:3;--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-host{width:100%;height:100%;min-height:inherit}.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-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}}\n"] }]
2580
2599
  }], ctorParameters: () => [], propDecorators: { config: [{ type: i0.Input, args: [{ isSignal: true, alias: "config", 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"] }], chartHost: [{ type: i0.ViewChild, args: ['chartHost', { isSignal: true }] }] } });
2581
2600
  function mergeRemoteQueryContext(config, queryContext, runtimeFilters) {
2582
2601
  const dataSource = config.dataSource;
@@ -3684,830 +3703,196 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
3684
3703
  `, styles: [":host{display:block}.probe-shell{display:grid;gap:12px;min-height:220px;padding:18px;border-radius:20px;background:linear-gradient(180deg,#0b111ff5,#0b111fe0),radial-gradient(circle at top right,rgba(18,99,180,.32),transparent 35%);color:#d7e6ff}.probe-header h3,.probe-eyebrow{margin:0}.probe-eyebrow{font-size:.75rem;letter-spacing:.12em;text-transform:uppercase;color:#d7e6ffb3}.probe-empty{color:#d7e6ffc7}pre{margin:0;overflow:auto;white-space:pre-wrap;word-break:break-word;font-size:.84rem;line-height:1.45}\n"] }]
3685
3704
  }], propDecorators: { title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }] } });
3686
3705
 
3687
- const PRAXIS_CHART_DEFAULT_PALETTE = ['#1263b4', '#0f766e', '#f08c00', '#c92a2a', '#7b61ff'];
3688
- const TITLE_TOP = 18;
3689
- const TITLE_LEFT = 24;
3690
- const CARTESIAN_GRID_TOP_WITH_TITLE = 96;
3691
- const CARTESIAN_GRID_TOP_WITHOUT_TITLE = 48;
3692
- const CARTESIAN_GRID_BOTTOM_WITH_LEGEND = 64;
3693
- const CARTESIAN_GRID_BOTTOM_WITHOUT_LEGEND = 40;
3694
- const DEFAULT_VALUE_LOCALE = 'pt-BR';
3695
- class EChartsOptionBuilderService {
3696
- transformer;
3697
- constructor(transformer) {
3698
- this.transformer = transformer;
3706
+ class AnalyticsChartConfigAdapterService {
3707
+ i18n;
3708
+ statsBuilder = new AnalyticsStatsRequestBuilderService();
3709
+ constructor(i18n) {
3710
+ this.i18n = i18n;
3699
3711
  }
3700
- build(config, rows) {
3701
- const transformed = this.transformer.transform(config, rows);
3702
- const palette = config.theme?.palette?.length ? config.theme.palette : PRAXIS_CHART_DEFAULT_PALETTE;
3703
- const tooltipEnabled = config.theme?.tooltip?.enabled ?? true;
3704
- const legendVisible = config.theme?.legend?.visible ?? true;
3705
- const textColor = this.resolveTextColor(config);
3706
- if (transformed.mode === 'pie') {
3707
- const hasChartTitle = this.hasText(config.title) || this.hasText(config.subtitle);
3708
- const labelsVisible = config.series[0]?.labels?.visible ?? transformed.slices.length <= 4;
3709
- const pieSeries = {
3710
- type: 'pie',
3711
- radius: config.type === 'donut'
3712
- ? labelsVisible ? ['32%', '52%'] : ['42%', '64%']
3713
- : labelsVisible ? '54%' : '66%',
3714
- center: ['50%', hasChartTitle ? '54%' : '50%'],
3715
- avoidLabelOverlap: true,
3716
- label: {
3717
- show: labelsVisible,
3718
- color: textColor,
3719
- overflow: 'truncate',
3720
- width: 120,
3721
- formatter: labelsVisible
3722
- ? (params) => {
3723
- const formatted = this.formatValue(params.value, config.series[0]?.labels?.format);
3724
- return params.name ? `${params.name}: ${formatted}` : formatted;
3725
- }
3726
- : undefined,
3727
- },
3728
- labelLine: labelsVisible ? { length: 14, length2: 12, lineStyle: { color: textColor } } : undefined,
3729
- data: transformed.slices.map((slice) => ({
3730
- ...(slice.data || {}),
3731
- name: slice.name,
3732
- value: slice.value,
3733
- itemStyle: slice.color ? { color: slice.color } : undefined,
3734
- })),
3735
- };
3736
- return {
3737
- backgroundColor: config.theme?.backgroundColor,
3738
- color: palette,
3739
- title: this.buildTitle(config, 'center', textColor),
3740
- tooltip: tooltipEnabled
3741
- ? {
3742
- trigger: 'item',
3743
- valueFormatter: (value) => this.formatValue(value, config.series[0]?.labels?.format),
3744
- }
3745
- : undefined,
3746
- legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
3747
- series: [pieSeries],
3748
- };
3712
+ toPraxisChartConfig(projection, options) {
3713
+ const chartType = this.resolveChartType(projection);
3714
+ const orientation = chartType === 'horizontal-bar' ? 'horizontal' : undefined;
3715
+ const dimension = projection.bindings.primaryDimension;
3716
+ const metrics = projection.bindings.primaryMetrics ?? [];
3717
+ if (!dimension?.field) {
3718
+ throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension for projection "${projection.id}".`);
3749
3719
  }
3750
- if (transformed.mode === 'scatter') {
3751
- const scatterSeries = transformed.series.map((series) => ({
3752
- id: series.id,
3753
- name: series.name,
3754
- type: 'scatter',
3755
- label: {
3756
- show: series.labelsVisible,
3757
- color: textColor,
3758
- formatter: series.labelsVisible
3759
- ? (params) => this.formatValue(params.value, this.seriesLabelFormat(config, series.id))
3760
- : undefined,
3761
- },
3762
- itemStyle: series.color ? { color: series.color } : undefined,
3763
- data: series.points,
3764
- }));
3765
- return {
3766
- backgroundColor: config.theme?.backgroundColor,
3767
- color: palette,
3768
- title: this.buildTitle(config, 'left', textColor),
3769
- tooltip: tooltipEnabled
3720
+ if (!metrics.length) {
3721
+ throw new Error(`AnalyticsChartConfigAdapterService requires at least one metric for projection "${projection.id}".`);
3722
+ }
3723
+ const crossFilter = Boolean(projection.interactions?.crossFilter);
3724
+ const keyFilterField = dimension.keyFilterField?.trim();
3725
+ if (crossFilter && !keyFilterField) {
3726
+ throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension.keyFilterField when crossFilter is enabled for projection "${projection.id}".`);
3727
+ }
3728
+ return {
3729
+ id: projection.id,
3730
+ type: chartType,
3731
+ orientation,
3732
+ title: options?.title,
3733
+ subtitle: options?.subtitle,
3734
+ height: options?.height,
3735
+ axes: this.buildAxes(projection, chartType),
3736
+ series: this.buildSeries(projection, chartType),
3737
+ dataSource: this.buildDataSource(projection),
3738
+ interactions: {
3739
+ pointClick: Boolean(projection.interactions?.pointSelection || projection.interactions?.drillDown),
3740
+ drillDown: Boolean(projection.interactions?.drillDown),
3741
+ selection: Boolean(projection.interactions?.pointSelection),
3742
+ crossFilter,
3743
+ ...(crossFilter
3770
3744
  ? {
3771
- trigger: 'item',
3772
- valueFormatter: (value) => this.formatValue(value, config.axes?.y?.labels?.format),
3745
+ eventActions: {
3746
+ crossFilter: {
3747
+ action: 'emit',
3748
+ mapping: { key: keyFilterField },
3749
+ },
3750
+ },
3773
3751
  }
3774
- : undefined,
3775
- legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
3776
- grid: this.buildGrid(config, { legendVisible }),
3777
- xAxis: {
3778
- type: config.axes?.x?.type ?? 'value',
3779
- name: config.axes?.x?.label,
3780
- nameLocation: 'middle',
3781
- nameGap: 32,
3782
- axisLabel: {
3783
- show: config.axes?.x?.labels?.visible ?? true,
3784
- color: textColor,
3785
- rotate: config.axes?.x?.labels?.rotate ?? 0,
3786
- hideOverlap: true,
3787
- formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
3788
- },
3789
- },
3790
- yAxis: {
3791
- type: config.axes?.y?.type ?? 'value',
3792
- name: config.axes?.y?.label,
3793
- nameLocation: 'middle',
3794
- nameGap: 40,
3795
- min: config.axes?.y?.min,
3796
- max: config.axes?.y?.max,
3797
- axisLabel: {
3798
- show: config.axes?.y?.labels?.visible ?? true,
3799
- color: textColor,
3800
- hideOverlap: true,
3801
- formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
3802
- },
3752
+ : {}),
3753
+ },
3754
+ };
3755
+ }
3756
+ buildAxes(projection, chartType) {
3757
+ const dimension = projection.bindings.primaryDimension;
3758
+ const metrics = this.getDisplayMetrics(projection);
3759
+ const firstMetric = metrics[0];
3760
+ if (this.isCategorySliceChart(chartType)) {
3761
+ return {
3762
+ x: {
3763
+ field: dimension.field,
3764
+ label: dimension.label ?? undefined,
3803
3765
  },
3804
- series: scatterSeries,
3805
3766
  };
3806
3767
  }
3807
- const horizontal = config.orientation === 'horizontal' || config.type === 'horizontal-bar';
3808
- const xAxisType = this.usesCanonicalStatsTime(config)
3809
- ? 'time'
3810
- : config.axes?.x?.type ?? 'category';
3811
3768
  return {
3812
- backgroundColor: config.theme?.backgroundColor,
3813
- color: palette,
3814
- title: this.buildTitle(config, 'left', textColor),
3815
- tooltip: tooltipEnabled ? this.buildCartesianTooltip(config) : undefined,
3816
- legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
3817
- grid: this.buildGrid(config, { horizontal, legendVisible }),
3818
- xAxis: horizontal
3819
- ? {
3820
- type: config.axes?.y?.type ?? 'value',
3821
- name: config.axes?.y?.label,
3822
- nameLocation: 'middle',
3823
- nameGap: 32,
3824
- min: config.axes?.y?.min,
3825
- max: config.axes?.y?.max,
3826
- axisLabel: {
3827
- show: config.axes?.y?.labels?.visible ?? true,
3828
- color: textColor,
3829
- hideOverlap: true,
3830
- formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
3831
- },
3832
- }
3833
- : {
3834
- type: xAxisType,
3835
- name: config.axes?.x?.label,
3836
- data: xAxisType === 'time' ? undefined : transformed.categories,
3837
- nameLocation: 'middle',
3838
- nameGap: 18,
3839
- axisLabel: {
3840
- show: config.axes?.x?.labels?.visible ?? true,
3841
- color: textColor,
3842
- rotate: config.axes?.x?.labels?.rotate ?? 0,
3843
- hideOverlap: true,
3844
- formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
3769
+ x: {
3770
+ field: dimension.field,
3771
+ label: dimension.label ?? undefined,
3772
+ type: dimension.role === 'time' ? 'time' : 'category',
3773
+ },
3774
+ y: {
3775
+ label: metrics.length === 1
3776
+ ? firstMetric?.label ?? firstMetric?.field
3777
+ : undefined,
3778
+ type: 'value',
3779
+ },
3780
+ };
3781
+ }
3782
+ buildSeries(projection, chartType) {
3783
+ const dimension = projection.bindings.primaryDimension;
3784
+ const comparison = projection.source.operation === 'comparison';
3785
+ return this.getDisplayMetrics(projection).flatMap((metric, index) => comparison
3786
+ ? ['current', 'previous'].map((period) => ({
3787
+ id: `${projection.id}.${metric.field}.${period}`,
3788
+ name: `${metric.label ?? metric.field} (${this.comparisonPeriodLabel(period)})`,
3789
+ type: chartType,
3790
+ categoryField: dimension.field,
3791
+ metric: { field: this.comparisonMetricField(metric.field, period), aggregation: this.mapAggregation(metric.aggregation) },
3792
+ }))
3793
+ : [{
3794
+ id: `${projection.id}.${metric.field}.${index + 1}`,
3795
+ name: metric.label ?? metric.field,
3796
+ type: chartType,
3797
+ categoryField: this.isCategorySliceChart(chartType) ? dimension.field : undefined,
3798
+ metric: {
3799
+ field: metric.field,
3800
+ aggregation: this.mapAggregation(metric.aggregation),
3801
+ label: metric.label ?? undefined,
3845
3802
  },
3846
- },
3847
- yAxis: horizontal
3848
- ? {
3849
- type: config.axes?.x?.type ?? 'category',
3850
- name: config.axes?.x?.label,
3851
- data: transformed.categories,
3852
- axisLabel: {
3853
- show: config.axes?.x?.labels?.visible ?? true,
3854
- color: textColor,
3855
- rotate: config.axes?.x?.labels?.rotate ?? 0,
3856
- hideOverlap: true,
3857
- overflow: 'truncate',
3858
- width: 160,
3859
- formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
3860
- },
3861
- }
3862
- : this.buildCartesianYAxis(config),
3863
- series: transformed.series.map((series) => ({
3864
- id: series.id,
3865
- name: series.name,
3866
- type: series.type,
3867
- yAxisIndex: series.axis === 'secondary' ? 1 : 0,
3868
- stack: series.stack,
3869
- smooth: series.smooth,
3870
- areaStyle: series.area ? {} : undefined,
3871
- label: {
3872
- show: series.labelsVisible,
3873
- color: textColor,
3874
- formatter: series.labelsVisible
3875
- ? (params) => this.formatValue(params.value, this.seriesLabelFormat(config, series.id))
3876
- : undefined,
3877
- },
3878
- itemStyle: series.color ? { color: series.color } : undefined,
3879
- data: series.points,
3880
- })),
3881
- };
3882
- }
3883
- resolveText(value) {
3884
- if (!value)
3885
- return undefined;
3886
- if (typeof value === 'string')
3887
- return value;
3888
- if (typeof value === 'object' && value && 'text' in value) {
3889
- const text = value.text;
3890
- return text || undefined;
3891
- }
3892
- if (typeof value === 'object' && value && 'fallback' in value) {
3893
- const fallback = value.fallback;
3894
- return fallback || undefined;
3895
- }
3896
- return undefined;
3897
- }
3898
- resolveTextColor(config) {
3899
- return config.theme?.textColor ?? '#4b5563';
3900
- }
3901
- hasText(value) {
3902
- const text = this.resolveText(value);
3903
- return !!text && text.trim().length > 0;
3803
+ smooth: chartType === 'line' || chartType === 'area',
3804
+ }]);
3904
3805
  }
3905
- buildTitle(config, align, textColor = this.resolveTextColor(config)) {
3906
- const title = this.resolveText(config.title);
3907
- const subtitle = this.resolveText(config.subtitle);
3908
- const hasTitle = !!title || !!subtitle;
3806
+ buildDataSource(projection) {
3807
+ const executionPlan = this.statsBuilder.buildExecutionPlan(projection);
3909
3808
  return {
3910
- show: hasTitle,
3911
- text: title,
3912
- subtext: subtitle,
3913
- top: TITLE_TOP,
3914
- left: align === 'center' ? 'center' : TITLE_LEFT,
3915
- right: TITLE_LEFT,
3916
- textStyle: {
3917
- fontSize: 18,
3918
- fontWeight: 600,
3919
- lineHeight: 24,
3920
- color: textColor,
3921
- overflow: 'truncate',
3922
- },
3923
- subtextStyle: {
3924
- fontSize: 12,
3925
- lineHeight: 18,
3926
- color: textColor,
3927
- overflow: 'truncate',
3809
+ kind: 'remote',
3810
+ resourcePath: executionPlan.resourcePath,
3811
+ schemaId: executionPlan.resourcePath,
3812
+ query: {
3813
+ sourceKind: 'praxis.stats',
3814
+ statsOperation: executionPlan.operation,
3815
+ statsPath: executionPlan.statsPath,
3816
+ statsRequest: executionPlan.statsRequest,
3817
+ dimensions: executionPlan.dimensions,
3818
+ metrics: projection.source.operation === 'comparison'
3819
+ ? this.getDisplayMetrics(projection).flatMap((metric) => ['current', 'previous'].map((period) => ({
3820
+ field: this.comparisonMetricField(metric.field, period),
3821
+ aggregation: this.mapAggregation(metric.aggregation),
3822
+ alias: this.comparisonMetricField(metric.field, period),
3823
+ })))
3824
+ : this.mapExecutionMetrics(executionPlan),
3825
+ sort: executionPlan.sort,
3826
+ limit: executionPlan.limit,
3928
3827
  },
3929
3828
  };
3930
3829
  }
3931
- buildLegend(config, visible, fallbackPosition, textColor = this.resolveTextColor(config)) {
3932
- if (!visible) {
3933
- return { show: false };
3830
+ resolveChartType(projection) {
3831
+ if (projection.intent === 'trend' && projection.source.operation === 'timeseries') {
3832
+ return 'line';
3934
3833
  }
3935
- const position = config.theme?.legend?.position ?? fallbackPosition;
3936
- const base = {
3937
- show: true,
3938
- type: 'scroll',
3939
- pageIconSize: 10,
3940
- itemWidth: 18,
3941
- itemHeight: 10,
3942
- textStyle: {
3943
- color: textColor,
3944
- },
3945
- formatter: (name) => truncateLegendText(name),
3946
- };
3947
- if (position === 'top') {
3948
- return {
3949
- ...base,
3950
- orient: 'horizontal',
3951
- top: this.hasText(config.title) || this.hasText(config.subtitle) ? 70 : 12,
3952
- left: 'center',
3953
- right: TITLE_LEFT,
3954
- };
3834
+ if (projection.intent === 'ranking' && projection.source.operation === 'group-by') {
3835
+ return 'horizontal-bar';
3955
3836
  }
3956
- if (position === 'left' || position === 'right') {
3957
- return {
3958
- ...base,
3959
- orient: 'vertical',
3960
- top: 88,
3961
- bottom: 28,
3962
- [position]: 10,
3963
- };
3837
+ if (projection.intent === 'composition') {
3838
+ return 'pie';
3964
3839
  }
3965
- return {
3966
- ...base,
3967
- orient: 'horizontal',
3968
- bottom: 16,
3969
- left: TITLE_LEFT,
3970
- right: TITLE_LEFT,
3971
- };
3840
+ if (projection.intent === 'distribution') {
3841
+ return 'bar';
3842
+ }
3843
+ return 'bar';
3972
3844
  }
3973
- buildGrid(config, options = {}) {
3974
- const hasChartTitle = this.hasText(config.title) || this.hasText(config.subtitle);
3975
- const legendPosition = config.theme?.legend?.position ?? 'bottom';
3976
- const sideLegend = options.legendVisible && (legendPosition === 'left' || legendPosition === 'right');
3977
- return {
3978
- top: hasChartTitle ? CARTESIAN_GRID_TOP_WITH_TITLE : CARTESIAN_GRID_TOP_WITHOUT_TITLE,
3979
- right: sideLegend && legendPosition === 'right' ? 160 : 32,
3980
- bottom: options.legendVisible && legendPosition === 'bottom'
3981
- ? CARTESIAN_GRID_BOTTOM_WITH_LEGEND
3982
- : CARTESIAN_GRID_BOTTOM_WITHOUT_LEGEND,
3983
- left: sideLegend && legendPosition === 'left'
3984
- ? 168
3985
- : options.horizontal ? 136 : 56,
3986
- // ECharts deprecou containLabel; esta combinacao preserva o mesmo comportamento
3987
- // sem depender da feature legacy de grid.
3988
- outerBoundsMode: 'same',
3989
- outerBoundsContain: 'axisLabel',
3990
- };
3845
+ isCategorySliceChart(chartType) {
3846
+ return chartType === 'pie' || chartType === 'donut' || chartType === 'funnel' || chartType === 'pyramid';
3991
3847
  }
3992
- buildCartesianYAxis(config) {
3993
- const textColor = this.resolveTextColor(config);
3994
- const primaryAxis = {
3995
- type: config.axes?.y?.type ?? 'value',
3996
- name: config.axes?.y?.label,
3997
- nameLocation: 'middle',
3998
- nameGap: 40,
3999
- min: config.axes?.y?.min,
4000
- max: config.axes?.y?.max,
4001
- axisLabel: {
4002
- show: config.axes?.y?.labels?.visible ?? true,
4003
- color: textColor,
4004
- hideOverlap: true,
4005
- formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
4006
- },
4007
- };
4008
- if (!config.axes?.ySecondary) {
4009
- return primaryAxis;
3848
+ mapAggregation(aggregation) {
3849
+ const normalizedAggregation = (aggregation ?? '').toLowerCase();
3850
+ switch (normalizedAggregation) {
3851
+ case 'avg':
3852
+ case 'min':
3853
+ case 'max':
3854
+ case 'count':
3855
+ case 'distinct-count':
3856
+ case 'sum':
3857
+ return normalizedAggregation;
3858
+ case '':
3859
+ return undefined;
3860
+ default:
3861
+ throw new Error(`Analytics aggregation "${aggregation}" is not supported in @praxisui/charts.`);
4010
3862
  }
3863
+ }
3864
+ mapExecutionMetrics(executionPlan) {
3865
+ return executionPlan.metrics.map((metric) => ({
3866
+ field: metric.field,
3867
+ aggregation: this.mapAggregation(metric.aggregation),
3868
+ alias: metric.alias,
3869
+ }));
3870
+ }
3871
+ getDisplayMetrics(projection) {
4011
3872
  return [
4012
- primaryAxis,
4013
- {
4014
- type: config.axes.ySecondary.type ?? 'value',
4015
- name: config.axes.ySecondary.label,
4016
- nameLocation: 'middle',
4017
- nameGap: 40,
4018
- min: config.axes.ySecondary.min,
4019
- max: config.axes.ySecondary.max,
4020
- position: config.axes.ySecondary.position ?? 'right',
4021
- axisLabel: {
4022
- show: config.axes.ySecondary.labels?.visible ?? true,
4023
- color: textColor,
4024
- hideOverlap: true,
4025
- formatter: this.axisLabelFormatter(config.axes.ySecondary.labels?.format),
4026
- },
4027
- },
3873
+ ...(projection.bindings.primaryMetrics ?? []),
3874
+ ...(projection.bindings.secondaryMetrics ?? []),
4028
3875
  ];
4029
3876
  }
4030
- primaryValueFormat(config) {
4031
- return config.series.find((series) => series.axis !== 'secondary')?.labels?.format
4032
- ?? config.axes?.y?.labels?.format
4033
- ?? config.series[0]?.labels?.format;
4034
- }
4035
- buildCartesianTooltip(config) {
4036
- const tooltip = {
4037
- trigger: config.theme?.tooltip?.trigger ?? 'axis',
4038
- confine: true,
4039
- appendToBody: false,
4040
- valueFormatter: (value) => this.formatValue(value, this.primaryValueFormat(config)),
4041
- };
4042
- return this.usesCanonicalStatsTime(config)
4043
- ? {
4044
- ...tooltip,
4045
- formatter: (params) => this.formatCanonicalTimeTooltip(params, config),
4046
- }
4047
- : tooltip;
3877
+ comparisonMetricField(metricField, period) {
3878
+ return `__praxisComparison_${metricField}_${period}`;
4048
3879
  }
4049
- usesCanonicalStatsTime(config) {
4050
- if (config.dataSource?.kind !== 'remote'
4051
- || config.dataSource.query?.sourceKind !== 'praxis.stats') {
4052
- return false;
4053
- }
4054
- return config.axes?.x?.type === 'time'
4055
- || config.dataSource.query.statsOperation === 'timeseries';
3880
+ comparisonPeriodLabel(period) {
3881
+ const key = period === 'current'
3882
+ ? 'praxis.charts.runtime.comparisonCurrent'
3883
+ : 'praxis.charts.runtime.comparisonPrevious';
3884
+ const fallback = period === 'current' ? 'Current' : 'Previous';
3885
+ return this.i18n?.t(key, undefined, fallback, 'charts') ?? fallback;
4056
3886
  }
4057
- formatCanonicalTimeTooltip(params, config) {
4058
- const items = (Array.isArray(params) ? params : [params])
4059
- .filter((item) => !!item && typeof item === 'object');
4060
- if (!items.length) {
4061
- return '';
4062
- }
4063
- const first = items[0];
4064
- const data = this.tooltipPointData(first['data']);
4065
- const header = this.formatCanonicalTimeTooltipHeader(first, data, config);
4066
- const lines = items.map((item) => {
4067
- const itemData = this.tooltipPointData(item['data']);
4068
- const seriesId = item['seriesId'] === null || item['seriesId'] === undefined
4069
- ? undefined
4070
- : String(item['seriesId']);
4071
- const format = seriesId
4072
- ? this.seriesLabelFormat(config, seriesId) ?? this.primaryValueFormat(config)
4073
- : this.primaryValueFormat(config);
4074
- const value = this.formatValue(item['value'] ?? itemData['value'], format);
4075
- const seriesName = item['seriesName'] === null || item['seriesName'] === undefined
4076
- ? ''
4077
- : String(item['seriesName']);
4078
- return seriesName
4079
- ? `${escapeTooltipHtml(seriesName)}: ${escapeTooltipHtml(value)}`
4080
- : escapeTooltipHtml(value);
4081
- });
4082
- return [escapeTooltipHtml(header), ...lines]
4083
- .filter((part) => part.length > 0)
4084
- .join('<br/>');
4085
- }
4086
- formatCanonicalTimeTooltipHeader(params, data, config) {
4087
- const dateFormat = config.axes?.x?.labels?.format;
4088
- const label = this.nonEmptyText(data['label']);
4089
- const start = this.nonEmptyText(data['start']);
4090
- const end = this.nonEmptyText(data['end']);
4091
- const formattedStart = start ? this.formatValue(start, dateFormat) : '';
4092
- const formattedEnd = end ? this.formatValue(end, dateFormat) : '';
4093
- const interval = formattedStart && formattedEnd && formattedStart !== formattedEnd
4094
- ? `${formattedStart} – ${formattedEnd}`
4095
- : formattedStart || formattedEnd;
4096
- const fallback = this.nonEmptyText(params['axisValueLabel'])
4097
- ?? this.nonEmptyText(params['name'])
4098
- ?? '';
4099
- if (label && interval && label !== formattedStart && label !== interval) {
4100
- return `${label} · ${interval}`;
4101
- }
4102
- return interval || label || fallback;
4103
- }
4104
- tooltipPointData(value) {
4105
- return value && typeof value === 'object' && !Array.isArray(value)
4106
- ? value
4107
- : {};
4108
- }
4109
- nonEmptyText(value) {
4110
- if (value === null || value === undefined) {
4111
- return undefined;
4112
- }
4113
- const text = String(value).trim();
4114
- return text || undefined;
4115
- }
4116
- seriesLabelFormat(config, seriesId) {
4117
- return config.series.find((series) => series.id === seriesId)?.labels?.format;
4118
- }
4119
- axisLabelFormatter(format) {
4120
- return format ? (value) => this.formatValue(value, format) : undefined;
4121
- }
4122
- formatValue(value, format) {
4123
- const normalizedValue = Array.isArray(value) ? value[value.length - 1] : value;
4124
- if (normalizedValue === null || normalizedValue === undefined || normalizedValue === '') {
4125
- return '';
4126
- }
4127
- if (!format) {
4128
- return String(normalizedValue);
4129
- }
4130
- const formattedDate = this.formatDateValue(normalizedValue, format);
4131
- if (formattedDate !== null) {
4132
- return formattedDate;
4133
- }
4134
- const numeric = typeof normalizedValue === 'number'
4135
- ? normalizedValue
4136
- : Number(String(normalizedValue).replace(',', '.'));
4137
- if (!Number.isFinite(numeric)) {
4138
- return String(normalizedValue);
4139
- }
4140
- const currency = this.parseCurrencyFormat(format);
4141
- if (currency) {
4142
- return new Intl.NumberFormat(DEFAULT_VALUE_LOCALE, {
4143
- style: 'currency',
4144
- currency: currency.code,
4145
- currencyDisplay: currency.display,
4146
- minimumFractionDigits: currency.decimals,
4147
- maximumFractionDigits: currency.decimals,
4148
- useGrouping: currency.useGrouping,
4149
- }).format(numeric);
4150
- }
4151
- const number = this.parseNumberFormat(format);
4152
- if (number) {
4153
- return new Intl.NumberFormat(DEFAULT_VALUE_LOCALE, {
4154
- minimumFractionDigits: number.minimumFractionDigits,
4155
- maximumFractionDigits: number.maximumFractionDigits,
4156
- useGrouping: number.useGrouping,
4157
- }).format(numeric);
4158
- }
4159
- return String(normalizedValue);
4160
- }
4161
- formatDateValue(value, format) {
4162
- if (!this.isDateFormat(format)) {
4163
- return null;
4164
- }
4165
- const parts = this.readDateParts(value);
4166
- if (!parts) {
4167
- return null;
4168
- }
4169
- const day = String(parts.day);
4170
- const day2 = day.padStart(2, '0');
4171
- const month = String(parts.month);
4172
- const month2 = month.padStart(2, '0');
4173
- const year = String(parts.year);
4174
- const year2 = year.slice(-2);
4175
- const date = new Date(parts.year, parts.month - 1, parts.day);
4176
- const monthShort = new Intl.DateTimeFormat(DEFAULT_VALUE_LOCALE, { month: 'short' }).format(date);
4177
- const monthLong = new Intl.DateTimeFormat(DEFAULT_VALUE_LOCALE, { month: 'long' }).format(date);
4178
- return format
4179
- .replace(/yyyy/g, year)
4180
- .replace(/yy/g, year2)
4181
- .replace(/MMMM/g, monthLong)
4182
- .replace(/MMM/g, monthShort)
4183
- .replace(/MM/g, month2)
4184
- .replace(/M/g, month)
4185
- .replace(/dd/g, day2)
4186
- .replace(/d/g, day);
4187
- }
4188
- isDateFormat(format) {
4189
- return /(^|[^A-Za-z])(d{1,2}|M{1,4}|y{2,4})(?=$|[^A-Za-z])/.test(format);
4190
- }
4191
- readDateParts(value) {
4192
- if (value instanceof Date) {
4193
- return Number.isNaN(value.getTime())
4194
- ? null
4195
- : { year: value.getFullYear(), month: value.getMonth() + 1, day: value.getDate() };
4196
- }
4197
- if (typeof value === 'number') {
4198
- const date = new Date(value);
4199
- return Number.isNaN(date.getTime())
4200
- ? null
4201
- : { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() };
4202
- }
4203
- const raw = String(value).trim();
4204
- const isoDate = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$/.exec(raw);
4205
- if (isoDate) {
4206
- return {
4207
- year: Number(isoDate[1]),
4208
- month: Number(isoDate[2]),
4209
- day: Number(isoDate[3]),
4210
- };
4211
- }
4212
- const brazilianDate = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(raw);
4213
- if (brazilianDate) {
4214
- return {
4215
- year: Number(brazilianDate[3]),
4216
- month: Number(brazilianDate[2]),
4217
- day: Number(brazilianDate[1]),
4218
- };
4219
- }
4220
- const parsed = new Date(raw);
4221
- return Number.isNaN(parsed.getTime())
4222
- ? null
4223
- : { year: parsed.getFullYear(), month: parsed.getMonth() + 1, day: parsed.getDate() };
4224
- }
4225
- parseCurrencyFormat(format) {
4226
- const parts = format.split('|').map((part) => part.trim()).filter(Boolean);
4227
- const code = parts[0]?.toUpperCase();
4228
- if (!code || !/^[A-Z]{3}$/.test(code)) {
4229
- return null;
4230
- }
4231
- const display = this.normalizeCurrencyDisplay(parts[1]);
4232
- const decimals = this.clampDecimals(Number.parseInt(parts[2] ?? '2', 10), 2);
4233
- return {
4234
- code,
4235
- display,
4236
- decimals,
4237
- useGrouping: !parts.some((part) => part.toLowerCase() === 'nosep'),
4238
- };
4239
- }
4240
- normalizeCurrencyDisplay(value) {
4241
- if (value === 'code' || value === 'name' || value === 'narrowSymbol') {
4242
- return value;
4243
- }
4244
- return 'symbol';
4245
- }
4246
- parseNumberFormat(format) {
4247
- const [pattern, ...modifiers] = format.split('|').map((part) => part.trim());
4248
- const match = /^(\d+)\.(\d+)-(\d+)$/.exec(pattern);
4249
- if (!match) {
4250
- return null;
4251
- }
4252
- return {
4253
- minimumFractionDigits: this.clampDecimals(Number.parseInt(match[2], 10), 0),
4254
- maximumFractionDigits: this.clampDecimals(Number.parseInt(match[3], 10), 0),
4255
- useGrouping: !modifiers.some((part) => part.toLowerCase() === 'nosep'),
4256
- };
4257
- }
4258
- clampDecimals(value, fallback) {
4259
- if (!Number.isFinite(value)) {
4260
- return fallback;
4261
- }
4262
- return Math.min(Math.max(value, 0), 20);
4263
- }
4264
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, deps: [{ token: PraxisChartDataTransformerService }], target: i0.ɵɵFactoryTarget.Injectable });
4265
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, providedIn: 'root' });
3887
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, deps: [{ token: i1.PraxisI18nService, optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
3888
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, providedIn: 'root' });
4266
3889
  }
4267
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, decorators: [{
3890
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, decorators: [{
4268
3891
  type: Injectable,
4269
3892
  args: [{ providedIn: 'root' }]
4270
- }], ctorParameters: () => [{ type: PraxisChartDataTransformerService }] });
4271
- function truncateLegendText(name) {
4272
- const value = String(name ?? '').trim();
4273
- if (value.length <= 28) {
4274
- return value;
4275
- }
4276
- return `${value.slice(0, 25)}...`;
4277
- }
4278
- function escapeTooltipHtml(value) {
4279
- return String(value ?? '')
4280
- .replace(/&/g, '&amp;')
4281
- .replace(/</g, '&lt;')
4282
- .replace(/>/g, '&gt;')
4283
- .replace(/"/g, '&quot;')
4284
- .replace(/'/g, '&#39;');
4285
- }
4286
-
4287
- use([
4288
- AriaComponent,
4289
- BarChart,
4290
- CanvasRenderer,
4291
- DatasetComponent,
4292
- GridComponent,
4293
- LegendComponent,
4294
- LineChart,
4295
- PieChart,
4296
- ScatterChart,
4297
- TitleComponent,
4298
- TooltipComponent,
4299
- TransformComponent,
4300
- ]);
4301
- class EChartsEngineAdapter {
4302
- optionBuilder;
4303
- chart;
4304
- currentHost;
4305
- clickHost;
4306
- domClickHandler;
4307
- zrenderClickHandler;
4308
- lastSeriesClickAt = 0;
4309
- lastGridClick;
4310
- constructor(optionBuilder) {
4311
- this.optionBuilder = optionBuilder;
4312
- }
4313
- render(host, payload) {
4314
- if (this.chart && this.currentHost !== host) {
4315
- this.disposeChart();
4316
- }
4317
- if (!this.chart) {
4318
- this.chart = this.createChart(host);
4319
- this.currentHost = host;
4320
- }
4321
- const option = this.optionBuilder.build(payload.config, payload.data);
4322
- const chart = this.chart;
4323
- chart.setOption(option, true);
4324
- this.detachHandlers();
4325
- chart.off('click');
4326
- chart.on('click', (params) => {
4327
- this.lastSeriesClickAt = Date.now();
4328
- this.lastGridClick = undefined;
4329
- const data = pointData(params?.name, params?.data ?? params?.value);
4330
- payload.onPointClick?.({
4331
- chartId: payload.config.id,
4332
- seriesId: params?.seriesId,
4333
- seriesName: params?.seriesName,
4334
- category: this.resolvePointCategory(payload, data, params?.name),
4335
- value: pointValue(params?.value ?? params?.data),
4336
- data,
4337
- });
4338
- });
4339
- const zrender = chart?.getZr?.();
4340
- this.zrenderClickHandler = (event) => {
4341
- window.setTimeout(() => {
4342
- if (Date.now() - this.lastSeriesClickAt < 80) {
4343
- return;
4344
- }
4345
- this.emitCategoryClickFromGrid(event, payload);
4346
- }, 0);
4347
- };
4348
- zrender?.on?.('click', this.zrenderClickHandler);
4349
- this.domClickHandler = (event) => {
4350
- window.setTimeout(() => {
4351
- if (Date.now() - this.lastSeriesClickAt < 80) {
4352
- return;
4353
- }
4354
- const rect = host.getBoundingClientRect();
4355
- const offsetX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left;
4356
- const offsetY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top;
4357
- this.emitCategoryClickFromGrid({
4358
- offsetX,
4359
- offsetY,
4360
- }, payload);
4361
- }, 0);
4362
- };
4363
- host.addEventListener('click', this.domClickHandler, true);
4364
- this.clickHost = host;
4365
- }
4366
- resize() {
4367
- this.chart?.resize();
4368
- }
4369
- destroy() {
4370
- this.disposeChart();
4371
- }
4372
- createChart(host) {
4373
- return init(host);
4374
- }
4375
- disposeChart() {
4376
- this.detachHandlers();
4377
- this.chart?.off('click');
4378
- this.chart?.dispose();
4379
- this.chart = undefined;
4380
- this.currentHost = undefined;
4381
- this.lastSeriesClickAt = 0;
4382
- this.lastGridClick = undefined;
4383
- }
4384
- detachHandlers() {
4385
- const zrender = this.chart?.getZr?.();
4386
- if (zrender && this.zrenderClickHandler) {
4387
- zrender.off?.('click', this.zrenderClickHandler);
4388
- }
4389
- if (this.clickHost && this.domClickHandler) {
4390
- this.clickHost.removeEventListener('click', this.domClickHandler, true);
4391
- }
4392
- this.clickHost = undefined;
4393
- this.domClickHandler = undefined;
4394
- this.zrenderClickHandler = undefined;
4395
- }
4396
- resolvePointCategory(payload, data, fallback) {
4397
- const categoryField = payload.config.axes?.x?.field
4398
- ?? payload.config.series.find((series) => series.categoryField)?.categoryField;
4399
- const dataSource = payload.config.dataSource;
4400
- const usesCanonicalStatsTime = dataSource?.kind === 'remote'
4401
- && dataSource.query?.sourceKind === 'praxis.stats';
4402
- const usesCanonicalStatsTimePoint = usesCanonicalStatsTime
4403
- && (payload.config.axes?.x?.type === 'time'
4404
- || (dataSource?.kind === 'remote' && dataSource.query?.statsOperation === 'timeseries'));
4405
- const candidate = usesCanonicalStatsTimePoint && data['label'] !== null
4406
- && data['label'] !== undefined
4407
- && data['label'] !== ''
4408
- ? data['label']
4409
- : categoryField && data[categoryField] !== null && data[categoryField] !== undefined
4410
- ? data[categoryField]
4411
- : fallback;
4412
- return candidate === null || candidate === undefined
4413
- ? undefined
4414
- : String(candidate);
4415
- }
4416
- emitCategoryClickFromGrid(event, payload) {
4417
- const chart = this.chart;
4418
- if (!chart || typeof chart.containPixel !== 'function' || typeof chart.convertFromPixel !== 'function') {
4419
- return;
4420
- }
4421
- const point = [event?.offsetX, event?.offsetY];
4422
- if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])) {
4423
- return;
4424
- }
4425
- if (!chart.containPixel({ gridIndex: 0 }, point)) {
4426
- return;
4427
- }
4428
- const option = chart.getOption?.();
4429
- const xAxis = firstOptionEntry(option?.xAxis);
4430
- const yAxis = firstOptionEntry(option?.yAxis);
4431
- const horizontal = yAxis?.type === 'category';
4432
- const categories = (horizontal ? yAxis?.data : xAxis?.data) ?? [];
4433
- if (!Array.isArray(categories) || categories.length === 0) {
4434
- return;
4435
- }
4436
- const converted = chart.convertFromPixel({ gridIndex: 0 }, point);
4437
- const categoryIndex = Math.round(Number(horizontal ? converted?.[1] : converted?.[0]));
4438
- if (!Number.isInteger(categoryIndex) || categoryIndex < 0 || categoryIndex >= categories.length) {
4439
- return;
4440
- }
4441
- const category = categories[categoryIndex];
4442
- const categoryValue = category == null ? undefined : String(category);
4443
- const categoryField = payload.config.axes?.x?.field
4444
- ?? payload.config.series.find((series) => series.categoryField)?.categoryField;
4445
- const signature = JSON.stringify({
4446
- chartId: payload.config.id,
4447
- category: categoryValue,
4448
- horizontal,
4449
- categoryIndex,
4450
- });
4451
- if (this.isDuplicateGridClick(signature)) {
4452
- return;
4453
- }
4454
- const usesCanonicalStatsRows = payload.config.dataSource?.kind === 'remote'
4455
- && payload.config.dataSource.query?.sourceKind === 'praxis.stats';
4456
- const fallbackSourceRow = !usesCanonicalStatsRows
4457
- && categoryField
4458
- && categoryValue !== undefined
4459
- ? payload.data.find((row) => String(row[categoryField] ?? '') === categoryValue)
4460
- : undefined;
4461
- const sourceRow = usesCanonicalStatsRows
4462
- ? payload.data[categoryIndex]
4463
- : fallbackSourceRow;
4464
- const data = {
4465
- ...(sourceRow ?? {}),
4466
- category: categoryValue,
4467
- };
4468
- if (categoryField && categoryValue !== undefined) {
4469
- data[categoryField] = categoryValue;
4470
- }
4471
- payload.onPointClick?.({
4472
- chartId: payload.config.id,
4473
- category: categoryValue,
4474
- data,
4475
- });
4476
- }
4477
- isDuplicateGridClick(signature) {
4478
- const now = Date.now();
4479
- const duplicate = this.lastGridClick?.signature === signature
4480
- && now - this.lastGridClick.at < 80;
4481
- this.lastGridClick = { signature, at: now };
4482
- return duplicate;
4483
- }
4484
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter, deps: [{ token: EChartsOptionBuilderService }], target: i0.ɵɵFactoryTarget.Injectable });
4485
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter });
4486
- }
4487
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter, decorators: [{
4488
- type: Injectable
4489
- }], ctorParameters: () => [{ type: EChartsOptionBuilderService }] });
4490
- function firstOptionEntry(value) {
4491
- return Array.isArray(value) ? value[0] : value;
4492
- }
4493
- function pointValue(value) {
4494
- if (Array.isArray(value)) {
4495
- return value.length > 1 ? value[1] : value[0];
4496
- }
4497
- if (value && typeof value === 'object' && 'value' in value) {
4498
- return pointValue(value.value);
4499
- }
4500
- return value;
4501
- }
4502
- function pointData(category, value) {
4503
- if (value && typeof value === 'object' && !Array.isArray(value)) {
4504
- return value;
4505
- }
4506
- return {
4507
- category,
4508
- value: pointValue(value),
4509
- };
4510
- }
3893
+ }], ctorParameters: () => [{ type: i1.PraxisI18nService, decorators: [{
3894
+ type: Optional
3895
+ }] }] });
4511
3896
 
4512
3897
  const PRAXIS_CHARTS_EN_US = {
4513
3898
  'praxis.charts.runtime.editChart': 'Edit chart settings',
@@ -4645,7 +4030,7 @@ const PRAXIS_CHARTS_EN_US = {
4645
4030
  'praxis.charts.editor.paletteToken.brand-balanced': 'Brand balanced',
4646
4031
  'praxis.charts.editor.paletteToken.status': 'Status',
4647
4032
  'praxis.charts.editor.paletteToken.executive': 'Executive',
4648
- 'praxis.charts.editor.specialization.pieDonutHint': 'Pie and donut charts keep only the first metric and use the first dimension as the category segment.',
4033
+ 'praxis.charts.editor.specialization.pieDonutHint': 'Pie, donut, funnel and pyramid charts keep one metric and use the first dimension as the category segment.',
4649
4034
  'praxis.charts.editor.specialization.scatterTitle': 'Scatter guidance',
4650
4035
  'praxis.charts.editor.specialization.scatterHint': 'Scatter charts use the first dimension as X and the first metric as Y.',
4651
4036
  'praxis.charts.editor.events.pointClickTitle': 'Point click',
@@ -4714,6 +4099,17 @@ const PRAXIS_CHARTS_EN_US = {
4714
4099
  'praxis.charts.editor.kind.pie': 'Pie',
4715
4100
  'praxis.charts.editor.kind.donut': 'Donut',
4716
4101
  'praxis.charts.editor.kind.scatter': 'Scatter',
4102
+ 'praxis.charts.editor.kind.funnel': 'Funnel',
4103
+ 'praxis.charts.editor.kind.pyramid': 'Pyramid',
4104
+ 'praxis.charts.analyticsPresentation.selectorLabel': 'Analytics presentation',
4105
+ 'praxis.charts.analyticsPresentation.chart': 'Chart',
4106
+ 'praxis.charts.analyticsPresentation.table': 'Table',
4107
+ 'praxis.charts.analyticsPresentation.unavailable': 'Presentation unavailable',
4108
+ 'praxis.charts.analyticsPresentation.loading': 'Loading analytics…',
4109
+ 'praxis.charts.analyticsPresentation.empty': 'No data available.',
4110
+ 'praxis.charts.analyticsPresentation.projectionMissing': 'Analytics projection "{projectionId}" was not found.',
4111
+ 'praxis.charts.analyticsPresentation.noRenderer': 'Analytics projection "{projectionId}" has no eligible installed renderer.',
4112
+ 'praxis.charts.analyticsPresentation.familyIneligible': 'Presentation family "{family}" is not eligible for projection "{projectionId}".',
4717
4113
  };
4718
4114
 
4719
4115
  const PRAXIS_CHARTS_PT_BR = {
@@ -4852,7 +4248,7 @@ const PRAXIS_CHARTS_PT_BR = {
4852
4248
  'praxis.charts.editor.paletteToken.brand-balanced': 'Marca balanceada',
4853
4249
  'praxis.charts.editor.paletteToken.status': 'Status',
4854
4250
  'praxis.charts.editor.paletteToken.executive': 'Executiva',
4855
- 'praxis.charts.editor.specialization.pieDonutHint': 'Gráficos de pizza (pie) e rosca (donut) mantêm apenas a primeira métrica e usam a primeira dimensão como segmento categórico.',
4251
+ 'praxis.charts.editor.specialization.pieDonutHint': 'Gráficos de pizza, rosca, funil e pirâmide mantêm uma métrica e usam a primeira dimensão como segmento categórico.',
4856
4252
  'praxis.charts.editor.specialization.scatterTitle': 'Guia de dispersão (scatter)',
4857
4253
  'praxis.charts.editor.specialization.scatterHint': 'Gráficos de dispersão usam a primeira dimensão como eixo X e a primeira métrica como eixo Y.',
4858
4254
  'praxis.charts.editor.events.pointClickTitle': 'Clique no ponto',
@@ -4921,56 +4317,1121 @@ const PRAXIS_CHARTS_PT_BR = {
4921
4317
  'praxis.charts.editor.kind.pie': 'Pizza (Pie)',
4922
4318
  'praxis.charts.editor.kind.donut': 'Rosca (Donut)',
4923
4319
  'praxis.charts.editor.kind.scatter': 'Dispersão (Scatter)',
4320
+ 'praxis.charts.editor.kind.funnel': 'Funil',
4321
+ 'praxis.charts.editor.kind.pyramid': 'Pirâmide',
4322
+ 'praxis.charts.analyticsPresentation.selectorLabel': 'Apresentação analítica',
4323
+ 'praxis.charts.analyticsPresentation.chart': 'Gráfico',
4324
+ 'praxis.charts.analyticsPresentation.table': 'Tabela',
4325
+ 'praxis.charts.analyticsPresentation.unavailable': 'Apresentação indisponível',
4326
+ 'praxis.charts.analyticsPresentation.loading': 'Carregando dados analíticos…',
4327
+ 'praxis.charts.analyticsPresentation.empty': 'Nenhum dado disponível.',
4328
+ 'praxis.charts.analyticsPresentation.projectionMissing': 'A projection analítica "{projectionId}" não foi encontrada.',
4329
+ 'praxis.charts.analyticsPresentation.noRenderer': 'A projection analítica "{projectionId}" não possui renderer instalado elegível.',
4330
+ 'praxis.charts.analyticsPresentation.familyIneligible': 'A família de apresentação "{family}" não é elegível para a projection "{projectionId}".',
4924
4331
  };
4925
4332
 
4926
- const PRAXIS_CHARTS_I18N = new InjectionToken('PRAXIS_CHARTS_I18N', {
4927
- factory: () => ({}),
4928
- });
4929
- function createPraxisChartsI18nConfig(options = {}) {
4930
- const localeDictionaries = {
4931
- 'pt-BR': {
4932
- ...PRAXIS_CHARTS_PT_BR,
4933
- ...(options.dictionaries?.['pt-BR'] ?? {}),
4934
- },
4935
- 'en-US': {
4936
- ...PRAXIS_CHARTS_EN_US,
4937
- ...(options.dictionaries?.['en-US'] ?? {}),
4938
- },
4939
- };
4940
- for (const [locale, dictionary] of Object.entries(options.dictionaries ?? {})) {
4941
- if (locale === 'pt-BR' || locale === 'en-US') {
4942
- continue;
4333
+ const PRAXIS_CHARTS_I18N = new InjectionToken('PRAXIS_CHARTS_I18N', {
4334
+ factory: () => ({}),
4335
+ });
4336
+ function createPraxisChartsI18nConfig(options = {}) {
4337
+ const localeDictionaries = {
4338
+ 'pt-BR': {
4339
+ ...PRAXIS_CHARTS_PT_BR,
4340
+ ...(options.dictionaries?.['pt-BR'] ?? {}),
4341
+ },
4342
+ 'en-US': {
4343
+ ...PRAXIS_CHARTS_EN_US,
4344
+ ...(options.dictionaries?.['en-US'] ?? {}),
4345
+ },
4346
+ };
4347
+ for (const [locale, dictionary] of Object.entries(options.dictionaries ?? {})) {
4348
+ if (locale === 'pt-BR' || locale === 'en-US') {
4349
+ continue;
4350
+ }
4351
+ localeDictionaries[locale] = {
4352
+ ...(localeDictionaries[locale] ?? {}),
4353
+ ...dictionary,
4354
+ };
4355
+ }
4356
+ return {
4357
+ locale: options.locale,
4358
+ fallbackLocale: options.fallbackLocale ?? 'pt-BR',
4359
+ namespaces: {
4360
+ charts: localeDictionaries,
4361
+ },
4362
+ };
4363
+ }
4364
+ function providePraxisChartsI18n(options = {}) {
4365
+ return [
4366
+ {
4367
+ provide: PRAXIS_CHARTS_I18N,
4368
+ useValue: {},
4369
+ },
4370
+ providePraxisI18n(createPraxisChartsI18nConfig(options)),
4371
+ ];
4372
+ }
4373
+ function resolvePraxisChartsText(value, fallback) {
4374
+ if (typeof value === 'string') {
4375
+ return { text: value };
4376
+ }
4377
+ if (value?.key || value?.text) {
4378
+ return value;
4379
+ }
4380
+ return { text: fallback ?? '' };
4381
+ }
4382
+
4383
+ const INSTALLED_FAMILIES = ['chart', 'analytic-table'];
4384
+ class PraxisAnalyticsPresentationComponent {
4385
+ analytics = input.required(...(ngDevMode ? [{ debugName: "analytics" }] : /* istanbul ignore next */ []));
4386
+ projectionId = input.required(...(ngDevMode ? [{ debugName: "projectionId" }] : /* istanbul ignore next */ []));
4387
+ availableFamilies = input(INSTALLED_FAMILIES, ...(ngDevMode ? [{ debugName: "availableFamilies" }] : /* istanbul ignore next */ []));
4388
+ preferredFamily = input(null, ...(ngDevMode ? [{ debugName: "preferredFamily" }] : /* istanbul ignore next */ []));
4389
+ queryContext = input(null, ...(ngDevMode ? [{ debugName: "queryContext" }] : /* istanbul ignore next */ []));
4390
+ title = input(undefined, ...(ngDevMode ? [{ debugName: "title" }] : /* istanbul ignore next */ []));
4391
+ subtitle = input(undefined, ...(ngDevMode ? [{ debugName: "subtitle" }] : /* istanbul ignore next */ []));
4392
+ enableCustomization = input(false, ...(ngDevMode ? [{ debugName: "enableCustomization" }] : /* istanbul ignore next */ []));
4393
+ presentationChange = output();
4394
+ pointClick = output();
4395
+ pointAction = output();
4396
+ selectionChange = output();
4397
+ drillDown = output();
4398
+ crossFilter = output();
4399
+ queryRequest = output();
4400
+ loadStateChange = output();
4401
+ resolver = inject(AnalyticsPresentationResolver);
4402
+ i18n = inject(PraxisI18nService);
4403
+ chartAdapter = inject(AnalyticsChartConfigAdapterService);
4404
+ tableAdapter = inject(AnalyticsTableConfigAdapterService);
4405
+ tableStats = inject(AnalyticsTableStatsApiService);
4406
+ temporaryFamily = signal(null, ...(ngDevMode ? [{ debugName: "temporaryFamily" }] : /* istanbul ignore next */ []));
4407
+ tableRows = signal([], ...(ngDevMode ? [{ debugName: "tableRows" }] : /* istanbul ignore next */ []));
4408
+ tableState = signal('idle', ...(ngDevMode ? [{ debugName: "tableState" }] : /* istanbul ignore next */ []));
4409
+ tableDiagnostic = signal(null, ...(ngDevMode ? [{ debugName: "tableDiagnostic" }] : /* istanbul ignore next */ []));
4410
+ tableRequestGeneration = 0;
4411
+ previousProjectionId = null;
4412
+ resolved = computed(() => this.resolvePresentation(), ...(ngDevMode ? [{ debugName: "resolved" }] : /* istanbul ignore next */ []));
4413
+ activeFamily = computed(() => this.resolved().family, ...(ngDevMode ? [{ debugName: "activeFamily" }] : /* istanbul ignore next */ []));
4414
+ eligibleFamilies = computed(() => this.resolved().eligibleFamilies, ...(ngDevMode ? [{ debugName: "eligibleFamilies" }] : /* istanbul ignore next */ []));
4415
+ diagnostic = computed(() => this.resolved().diagnostic ?? this.tableDiagnostic(), ...(ngDevMode ? [{ debugName: "diagnostic" }] : /* istanbul ignore next */ []));
4416
+ chartConfig = computed(() => {
4417
+ const projection = this.resolved().projection;
4418
+ return projection
4419
+ ? this.chartAdapter.toPraxisChartConfig(projection, { title: this.title(), subtitle: this.subtitle() })
4420
+ : null;
4421
+ }, ...(ngDevMode ? [{ debugName: "chartConfig" }] : /* istanbul ignore next */ []));
4422
+ tableConfig = computed(() => {
4423
+ const projection = this.resolved().projection;
4424
+ return projection
4425
+ ? this.tableAdapter.toTableConfig(projection, { title: this.title(), subtitle: this.subtitle() })
4426
+ : null;
4427
+ }, ...(ngDevMode ? [{ debugName: "tableConfig" }] : /* istanbul ignore next */ []));
4428
+ isTableLoading = computed(() => this.tableState() === 'loading', ...(ngDevMode ? [{ debugName: "isTableLoading" }] : /* istanbul ignore next */ []));
4429
+ isTableEmpty = computed(() => this.tableState() === 'ready' && this.tableRows().length === 0, ...(ngDevMode ? [{ debugName: "isTableEmpty" }] : /* istanbul ignore next */ []));
4430
+ constructor() {
4431
+ effect(() => {
4432
+ const projectionId = this.projectionId();
4433
+ if (this.previousProjectionId !== projectionId) {
4434
+ this.previousProjectionId = projectionId;
4435
+ this.temporaryFamily.set(null);
4436
+ }
4437
+ });
4438
+ effect((onCleanup) => {
4439
+ const resolved = this.resolved();
4440
+ const queryContext = this.queryContext();
4441
+ const generation = ++this.tableRequestGeneration;
4442
+ onCleanup(() => {
4443
+ if (this.tableRequestGeneration === generation) {
4444
+ this.tableRequestGeneration += 1;
4445
+ }
4446
+ });
4447
+ this.tableRows.set([]);
4448
+ this.tableDiagnostic.set(null);
4449
+ this.tableState.set('idle');
4450
+ if (resolved.family !== 'analytic-table' || !resolved.projection) {
4451
+ return;
4452
+ }
4453
+ this.tableState.set('loading');
4454
+ void this.tableStats.execute(resolved.projection, queryContext).then((rows) => {
4455
+ if (this.tableRequestGeneration !== generation)
4456
+ return;
4457
+ this.tableRows.set(rows);
4458
+ this.tableState.set('ready');
4459
+ }, (error) => {
4460
+ if (this.tableRequestGeneration !== generation)
4461
+ return;
4462
+ this.tableRows.set([]);
4463
+ this.tableState.set('error');
4464
+ this.tableDiagnostic.set(normalizeError$1(error));
4465
+ });
4466
+ });
4467
+ }
4468
+ selectFamily(family) {
4469
+ if (!this.eligibleFamilies().includes(family)) {
4470
+ this.tableDiagnostic.set(this.message('praxis.charts.analyticsPresentation.familyIneligible', 'Presentation family "{family}" is not eligible for projection "{projectionId}".', { family, projectionId: this.projectionId() }));
4471
+ return;
4472
+ }
4473
+ this.tableDiagnostic.set(null);
4474
+ this.temporaryFamily.set(family);
4475
+ this.presentationChange.emit({
4476
+ projectionId: this.projectionId(),
4477
+ family,
4478
+ source: 'temporary-user-choice',
4479
+ });
4480
+ }
4481
+ handleTableRowClick(event) {
4482
+ const row = event?.row;
4483
+ const projection = this.resolved().projection;
4484
+ if (!row || !projection)
4485
+ return;
4486
+ const dimensionField = projection.bindings.primaryDimension?.field;
4487
+ const metricField = projection.bindings.primaryMetrics[0]?.field;
4488
+ this.pointClick.emit({
4489
+ chartId: projection.id,
4490
+ category: dimensionField ? stringifyValue(row[dimensionField]) : undefined,
4491
+ value: metricField ? row[metricField] : undefined,
4492
+ data: row,
4493
+ });
4494
+ }
4495
+ resolvePresentation() {
4496
+ const projectionId = this.projectionId();
4497
+ const projection = this.analytics().projections.find((candidate) => candidate.id === projectionId) ?? null;
4498
+ if (!projection) {
4499
+ return {
4500
+ projection: null,
4501
+ family: null,
4502
+ eligibleFamilies: [],
4503
+ diagnostic: this.message('praxis.charts.analyticsPresentation.projectionMissing', 'Analytics projection "{projectionId}" was not found.', { projectionId }),
4504
+ };
4505
+ }
4506
+ const authorized = this.availableFamilies().filter((family) => INSTALLED_FAMILIES.includes(family));
4507
+ const eligibleFamilies = authorized.filter((family) => this.isEligible(projection, family));
4508
+ if (!eligibleFamilies.length) {
4509
+ return {
4510
+ projection,
4511
+ family: null,
4512
+ eligibleFamilies,
4513
+ diagnostic: this.message('praxis.charts.analyticsPresentation.noRenderer', 'Analytics projection "{projectionId}" has no eligible installed renderer.', { projectionId }),
4514
+ };
4515
+ }
4516
+ const requestedFamily = this.temporaryFamily() ?? this.preferredFamily();
4517
+ if (requestedFamily && !eligibleFamilies.includes(requestedFamily)) {
4518
+ return {
4519
+ projection,
4520
+ family: null,
4521
+ eligibleFamilies,
4522
+ diagnostic: this.message('praxis.charts.analyticsPresentation.familyIneligible', 'Presentation family "{family}" is not eligible for projection "{projectionId}".', { family: requestedFamily, projectionId }),
4523
+ };
4524
+ }
4525
+ try {
4526
+ const decision = this.resolver.resolve({ projections: [projection] }, { availableFamilies: eligibleFamilies, preferFamily: requestedFamily });
4527
+ return { projection, family: decision.family, eligibleFamilies, diagnostic: null };
4528
+ }
4529
+ catch (error) {
4530
+ return { projection, family: null, eligibleFamilies, diagnostic: normalizeError$1(error) };
4531
+ }
4532
+ }
4533
+ isEligible(projection, family) {
4534
+ try {
4535
+ return this.resolver.resolve({ projections: [projection] }, { availableFamilies: [family], preferFamily: family }).family === family;
4536
+ }
4537
+ catch {
4538
+ return false;
4539
+ }
4540
+ }
4541
+ t(key, fallback) {
4542
+ return this.i18n.resolve(resolvePraxisChartsText({ key, text: fallback }, fallback));
4543
+ }
4544
+ message(key, fallback, values) {
4545
+ return Object.entries(values).reduce((message, [name, value]) => message.replaceAll(`{${name}}`, value), this.t(key, fallback));
4546
+ }
4547
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisAnalyticsPresentationComponent, deps: [], target: i0.ɵɵFactoryTarget.Component });
4548
+ 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 }, 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 </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 (isTableLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isTableEmpty()) {\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]=\"tableRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\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, #fff);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)}@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", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved"] }, { kind: "component", type: PraxisTable, selector: "praxis-table", inputs: ["config", "resourcePath", "data", "tableId", "componentInstanceId", "configPersistenceStrategy", "title", "subtitle", "icon", "autoDelete", "notifyIfOutdated", "snoozeMs", "autoOpenSettingsOnOutdated", "crudContext", "filterCriteria", "queryContext", "aiContext", "aiAssistantVoiceInputMode", "aiAssistantVoiceLanguage", "horizontalScroll", "enableCustomization", "authoringCapability", "dense"], outputs: ["rowClick", "widgetEvent", "resourceEvent", "rowDoubleClick", "rowExpansionChange", "rowAction", "toolbarAction", "bulkAction", "exportAction", "columnReorder", "columnReorderAttempt", "columnResize", "beforeDelete", "afterDelete", "deleteError", "beforeBulkDelete", "afterBulkDelete", "bulkDeleteError", "schemaStatusChange", "configChange", "metadataChange", "loadingStateChange", "collectionLinksChange", "selectionChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
4549
+ }
4550
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisAnalyticsPresentationComponent, decorators: [{
4551
+ type: Component,
4552
+ args: [{ selector: 'praxis-analytics-presentation', standalone: true, 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 </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 (isTableLoading()) {\n <section class=\"analytics-presentation__state\" role=\"status\">{{ t('praxis.charts.analyticsPresentation.loading', 'Loading analytics\u2026') }}</section>\n } @else if (isTableEmpty()) {\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]=\"tableRows()\"\n [tableId]=\"projectionId() + '-analytic-table'\"\n (rowClick)=\"handleTableRowClick($event)\"\n />\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, #fff);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)}@media(max-width:600px){.analytics-presentation__toolbar{justify-content:stretch}.analytics-presentation__toolbar button{flex:1 1 0}}\n"] }]
4553
+ }], 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 }] }], 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"] }] } });
4554
+ function normalizeError$1(error) {
4555
+ return error instanceof Error && error.message.trim() ? error.message.trim() : 'Analytics presentation failed.';
4556
+ }
4557
+ function stringifyValue(value) {
4558
+ return value === null || value === undefined ? undefined : String(value);
4559
+ }
4560
+
4561
+ const PRAXIS_CHART_DEFAULT_PALETTE = ['#1263b4', '#0f766e', '#f08c00', '#c92a2a', '#7b61ff'];
4562
+ const TITLE_TOP = 18;
4563
+ const TITLE_LEFT = 24;
4564
+ const CARTESIAN_GRID_TOP_WITH_TITLE = 96;
4565
+ const CARTESIAN_GRID_TOP_WITHOUT_TITLE = 48;
4566
+ const CARTESIAN_GRID_BOTTOM_WITH_LEGEND = 64;
4567
+ const CARTESIAN_GRID_BOTTOM_WITHOUT_LEGEND = 40;
4568
+ const DEFAULT_VALUE_LOCALE = 'pt-BR';
4569
+ class EChartsOptionBuilderService {
4570
+ transformer;
4571
+ constructor(transformer) {
4572
+ this.transformer = transformer;
4573
+ }
4574
+ build(config, rows) {
4575
+ const transformed = this.transformer.transform(config, rows);
4576
+ const palette = config.theme?.palette?.length ? config.theme.palette : PRAXIS_CHART_DEFAULT_PALETTE;
4577
+ const tooltipEnabled = config.theme?.tooltip?.enabled ?? true;
4578
+ const legendVisible = config.theme?.legend?.visible ?? true;
4579
+ const textColor = this.resolveTextColor(config);
4580
+ if (transformed.mode === 'pie') {
4581
+ const hasChartTitle = this.hasText(config.title) || this.hasText(config.subtitle);
4582
+ const labelsVisible = config.series[0]?.labels?.visible ?? transformed.slices.length <= 4;
4583
+ const pieSeries = {
4584
+ type: 'pie',
4585
+ radius: config.type === 'donut'
4586
+ ? labelsVisible ? ['32%', '52%'] : ['42%', '64%']
4587
+ : labelsVisible ? '54%' : '66%',
4588
+ center: ['50%', hasChartTitle ? '54%' : '50%'],
4589
+ avoidLabelOverlap: true,
4590
+ label: {
4591
+ show: labelsVisible,
4592
+ color: textColor,
4593
+ overflow: 'truncate',
4594
+ width: 120,
4595
+ formatter: labelsVisible
4596
+ ? (params) => {
4597
+ const formatted = this.formatValue(params.value, config.series[0]?.labels?.format);
4598
+ return params.name ? `${params.name}: ${formatted}` : formatted;
4599
+ }
4600
+ : undefined,
4601
+ },
4602
+ labelLine: labelsVisible ? { length: 14, length2: 12, lineStyle: { color: textColor } } : undefined,
4603
+ data: transformed.slices.map((slice) => ({
4604
+ ...(slice.data || {}),
4605
+ name: slice.name,
4606
+ value: slice.value,
4607
+ itemStyle: slice.color ? { color: slice.color } : undefined,
4608
+ })),
4609
+ };
4610
+ return {
4611
+ backgroundColor: config.theme?.backgroundColor,
4612
+ color: palette,
4613
+ title: this.buildTitle(config, 'center', textColor),
4614
+ tooltip: tooltipEnabled
4615
+ ? {
4616
+ trigger: 'item',
4617
+ valueFormatter: (value) => this.formatValue(value, config.series[0]?.labels?.format),
4618
+ }
4619
+ : undefined,
4620
+ legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
4621
+ series: [pieSeries],
4622
+ };
4623
+ }
4624
+ if (transformed.mode === 'funnel') {
4625
+ const labelsVisible = config.series[0]?.labels?.visible ?? true;
4626
+ const slices = config.type === 'pyramid'
4627
+ ? [...transformed.slices].reverse()
4628
+ : transformed.slices;
4629
+ const funnelSeries = {
4630
+ type: 'funnel',
4631
+ sort: 'none',
4632
+ funnelAlign: 'center',
4633
+ left: '10%',
4634
+ top: this.hasText(config.title) || this.hasText(config.subtitle) ? 88 : 32,
4635
+ bottom: legendVisible ? 56 : 24,
4636
+ width: '80%',
4637
+ minSize: '0%',
4638
+ maxSize: '100%',
4639
+ gap: 2,
4640
+ label: {
4641
+ show: labelsVisible,
4642
+ color: textColor,
4643
+ overflow: 'truncate',
4644
+ width: 160,
4645
+ formatter: labelsVisible
4646
+ ? (params) => {
4647
+ const formatted = this.formatValue(params.value, config.series[0]?.labels?.format);
4648
+ return params.name ? `${params.name}: ${formatted}` : formatted;
4649
+ }
4650
+ : undefined,
4651
+ },
4652
+ labelLine: labelsVisible ? { length: 12, lineStyle: { color: textColor } } : undefined,
4653
+ data: slices.map((slice) => ({
4654
+ ...(slice.data || {}),
4655
+ name: slice.name,
4656
+ value: slice.value,
4657
+ itemStyle: slice.color ? { color: slice.color } : undefined,
4658
+ })),
4659
+ };
4660
+ return {
4661
+ backgroundColor: config.theme?.backgroundColor,
4662
+ color: palette,
4663
+ title: this.buildTitle(config, 'center', textColor),
4664
+ tooltip: tooltipEnabled
4665
+ ? {
4666
+ trigger: 'item',
4667
+ valueFormatter: (value) => this.formatValue(value, config.series[0]?.labels?.format),
4668
+ }
4669
+ : undefined,
4670
+ legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
4671
+ series: [funnelSeries],
4672
+ };
4673
+ }
4674
+ if (transformed.mode === 'scatter') {
4675
+ const scatterSeries = transformed.series.map((series) => ({
4676
+ id: series.id,
4677
+ name: series.name,
4678
+ type: 'scatter',
4679
+ label: {
4680
+ show: series.labelsVisible,
4681
+ color: textColor,
4682
+ formatter: series.labelsVisible
4683
+ ? (params) => this.formatValue(params.value, this.seriesLabelFormat(config, series.id))
4684
+ : undefined,
4685
+ },
4686
+ itemStyle: series.color ? { color: series.color } : undefined,
4687
+ data: series.points,
4688
+ }));
4689
+ return {
4690
+ backgroundColor: config.theme?.backgroundColor,
4691
+ color: palette,
4692
+ title: this.buildTitle(config, 'left', textColor),
4693
+ tooltip: tooltipEnabled
4694
+ ? {
4695
+ trigger: 'item',
4696
+ valueFormatter: (value) => this.formatValue(value, config.axes?.y?.labels?.format),
4697
+ }
4698
+ : undefined,
4699
+ legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
4700
+ grid: this.buildGrid(config, { legendVisible }),
4701
+ xAxis: {
4702
+ type: config.axes?.x?.type ?? 'value',
4703
+ name: config.axes?.x?.label,
4704
+ nameLocation: 'middle',
4705
+ nameGap: 32,
4706
+ axisLabel: {
4707
+ show: config.axes?.x?.labels?.visible ?? true,
4708
+ color: textColor,
4709
+ rotate: config.axes?.x?.labels?.rotate ?? 0,
4710
+ hideOverlap: true,
4711
+ formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
4712
+ },
4713
+ },
4714
+ yAxis: {
4715
+ type: config.axes?.y?.type ?? 'value',
4716
+ name: config.axes?.y?.label,
4717
+ nameLocation: 'middle',
4718
+ nameGap: 40,
4719
+ min: config.axes?.y?.min,
4720
+ max: config.axes?.y?.max,
4721
+ axisLabel: {
4722
+ show: config.axes?.y?.labels?.visible ?? true,
4723
+ color: textColor,
4724
+ hideOverlap: true,
4725
+ formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
4726
+ },
4727
+ },
4728
+ series: scatterSeries,
4729
+ };
4730
+ }
4731
+ const horizontal = config.orientation === 'horizontal' || config.type === 'horizontal-bar';
4732
+ const xAxisType = this.usesCanonicalStatsTime(config)
4733
+ ? 'time'
4734
+ : config.axes?.x?.type ?? 'category';
4735
+ return {
4736
+ backgroundColor: config.theme?.backgroundColor,
4737
+ color: palette,
4738
+ title: this.buildTitle(config, 'left', textColor),
4739
+ tooltip: tooltipEnabled ? this.buildCartesianTooltip(config) : undefined,
4740
+ legend: this.buildLegend(config, legendVisible, 'bottom', textColor),
4741
+ grid: this.buildGrid(config, { horizontal, legendVisible }),
4742
+ xAxis: horizontal
4743
+ ? {
4744
+ type: config.axes?.y?.type ?? 'value',
4745
+ name: config.axes?.y?.label,
4746
+ nameLocation: 'middle',
4747
+ nameGap: 32,
4748
+ min: config.axes?.y?.min,
4749
+ max: config.axes?.y?.max,
4750
+ axisLabel: {
4751
+ show: config.axes?.y?.labels?.visible ?? true,
4752
+ color: textColor,
4753
+ hideOverlap: true,
4754
+ formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
4755
+ },
4756
+ }
4757
+ : {
4758
+ type: xAxisType,
4759
+ name: config.axes?.x?.label,
4760
+ data: xAxisType === 'time' ? undefined : transformed.categories,
4761
+ nameLocation: 'middle',
4762
+ nameGap: 18,
4763
+ axisLabel: {
4764
+ show: config.axes?.x?.labels?.visible ?? true,
4765
+ color: textColor,
4766
+ rotate: config.axes?.x?.labels?.rotate ?? 0,
4767
+ hideOverlap: true,
4768
+ formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
4769
+ },
4770
+ },
4771
+ yAxis: horizontal
4772
+ ? {
4773
+ type: config.axes?.x?.type ?? 'category',
4774
+ name: config.axes?.x?.label,
4775
+ data: transformed.categories,
4776
+ axisLabel: {
4777
+ show: config.axes?.x?.labels?.visible ?? true,
4778
+ color: textColor,
4779
+ rotate: config.axes?.x?.labels?.rotate ?? 0,
4780
+ hideOverlap: true,
4781
+ overflow: 'truncate',
4782
+ width: 160,
4783
+ formatter: this.axisLabelFormatter(config.axes?.x?.labels?.format),
4784
+ },
4785
+ }
4786
+ : this.buildCartesianYAxis(config),
4787
+ series: transformed.series.map((series) => ({
4788
+ id: series.id,
4789
+ name: series.name,
4790
+ type: series.type,
4791
+ yAxisIndex: series.axis === 'secondary' ? 1 : 0,
4792
+ stack: series.stack,
4793
+ smooth: series.smooth,
4794
+ areaStyle: series.area ? {} : undefined,
4795
+ label: {
4796
+ show: series.labelsVisible,
4797
+ color: textColor,
4798
+ formatter: series.labelsVisible
4799
+ ? (params) => this.formatValue(params.value, this.seriesLabelFormat(config, series.id))
4800
+ : undefined,
4801
+ },
4802
+ itemStyle: series.color ? { color: series.color } : undefined,
4803
+ data: series.points,
4804
+ })),
4805
+ };
4806
+ }
4807
+ resolveText(value) {
4808
+ if (!value)
4809
+ return undefined;
4810
+ if (typeof value === 'string')
4811
+ return value;
4812
+ if (typeof value === 'object' && value && 'text' in value) {
4813
+ const text = value.text;
4814
+ return text || undefined;
4815
+ }
4816
+ if (typeof value === 'object' && value && 'fallback' in value) {
4817
+ const fallback = value.fallback;
4818
+ return fallback || undefined;
4819
+ }
4820
+ return undefined;
4821
+ }
4822
+ resolveTextColor(config) {
4823
+ return config.theme?.textColor ?? '#4b5563';
4824
+ }
4825
+ hasText(value) {
4826
+ const text = this.resolveText(value);
4827
+ return !!text && text.trim().length > 0;
4828
+ }
4829
+ buildTitle(config, align, textColor = this.resolveTextColor(config)) {
4830
+ const title = this.resolveText(config.title);
4831
+ const subtitle = this.resolveText(config.subtitle);
4832
+ const hasTitle = !!title || !!subtitle;
4833
+ return {
4834
+ show: hasTitle,
4835
+ text: title,
4836
+ subtext: subtitle,
4837
+ top: TITLE_TOP,
4838
+ left: align === 'center' ? 'center' : TITLE_LEFT,
4839
+ right: TITLE_LEFT,
4840
+ textStyle: {
4841
+ fontSize: 18,
4842
+ fontWeight: 600,
4843
+ lineHeight: 24,
4844
+ color: textColor,
4845
+ overflow: 'truncate',
4846
+ },
4847
+ subtextStyle: {
4848
+ fontSize: 12,
4849
+ lineHeight: 18,
4850
+ color: textColor,
4851
+ overflow: 'truncate',
4852
+ },
4853
+ };
4854
+ }
4855
+ buildLegend(config, visible, fallbackPosition, textColor = this.resolveTextColor(config)) {
4856
+ if (!visible) {
4857
+ return { show: false };
4858
+ }
4859
+ const position = config.theme?.legend?.position ?? fallbackPosition;
4860
+ const base = {
4861
+ show: true,
4862
+ type: 'scroll',
4863
+ pageIconSize: 10,
4864
+ itemWidth: 18,
4865
+ itemHeight: 10,
4866
+ textStyle: {
4867
+ color: textColor,
4868
+ },
4869
+ formatter: (name) => truncateLegendText(name),
4870
+ };
4871
+ if (position === 'top') {
4872
+ return {
4873
+ ...base,
4874
+ orient: 'horizontal',
4875
+ top: this.hasText(config.title) || this.hasText(config.subtitle) ? 70 : 12,
4876
+ left: 'center',
4877
+ right: TITLE_LEFT,
4878
+ };
4879
+ }
4880
+ if (position === 'left' || position === 'right') {
4881
+ return {
4882
+ ...base,
4883
+ orient: 'vertical',
4884
+ top: 88,
4885
+ bottom: 28,
4886
+ [position]: 10,
4887
+ };
4888
+ }
4889
+ return {
4890
+ ...base,
4891
+ orient: 'horizontal',
4892
+ bottom: 16,
4893
+ left: TITLE_LEFT,
4894
+ right: TITLE_LEFT,
4895
+ };
4896
+ }
4897
+ buildGrid(config, options = {}) {
4898
+ const hasChartTitle = this.hasText(config.title) || this.hasText(config.subtitle);
4899
+ const legendPosition = config.theme?.legend?.position ?? 'bottom';
4900
+ const sideLegend = options.legendVisible && (legendPosition === 'left' || legendPosition === 'right');
4901
+ return {
4902
+ top: hasChartTitle ? CARTESIAN_GRID_TOP_WITH_TITLE : CARTESIAN_GRID_TOP_WITHOUT_TITLE,
4903
+ right: sideLegend && legendPosition === 'right' ? 160 : 32,
4904
+ bottom: options.legendVisible && legendPosition === 'bottom'
4905
+ ? CARTESIAN_GRID_BOTTOM_WITH_LEGEND
4906
+ : CARTESIAN_GRID_BOTTOM_WITHOUT_LEGEND,
4907
+ left: sideLegend && legendPosition === 'left'
4908
+ ? 168
4909
+ : options.horizontal ? 136 : 56,
4910
+ // ECharts deprecou containLabel; esta combinacao preserva o mesmo comportamento
4911
+ // sem depender da feature legacy de grid.
4912
+ outerBoundsMode: 'same',
4913
+ outerBoundsContain: 'axisLabel',
4914
+ };
4915
+ }
4916
+ buildCartesianYAxis(config) {
4917
+ const textColor = this.resolveTextColor(config);
4918
+ const primaryAxis = {
4919
+ type: config.axes?.y?.type ?? 'value',
4920
+ name: config.axes?.y?.label,
4921
+ nameLocation: 'middle',
4922
+ nameGap: 40,
4923
+ min: config.axes?.y?.min,
4924
+ max: config.axes?.y?.max,
4925
+ axisLabel: {
4926
+ show: config.axes?.y?.labels?.visible ?? true,
4927
+ color: textColor,
4928
+ hideOverlap: true,
4929
+ formatter: this.axisLabelFormatter(config.axes?.y?.labels?.format),
4930
+ },
4931
+ };
4932
+ if (!config.axes?.ySecondary) {
4933
+ return primaryAxis;
4934
+ }
4935
+ return [
4936
+ primaryAxis,
4937
+ {
4938
+ type: config.axes.ySecondary.type ?? 'value',
4939
+ name: config.axes.ySecondary.label,
4940
+ nameLocation: 'middle',
4941
+ nameGap: 40,
4942
+ min: config.axes.ySecondary.min,
4943
+ max: config.axes.ySecondary.max,
4944
+ position: config.axes.ySecondary.position ?? 'right',
4945
+ axisLabel: {
4946
+ show: config.axes.ySecondary.labels?.visible ?? true,
4947
+ color: textColor,
4948
+ hideOverlap: true,
4949
+ formatter: this.axisLabelFormatter(config.axes.ySecondary.labels?.format),
4950
+ },
4951
+ },
4952
+ ];
4953
+ }
4954
+ primaryValueFormat(config) {
4955
+ return config.series.find((series) => series.axis !== 'secondary')?.labels?.format
4956
+ ?? config.axes?.y?.labels?.format
4957
+ ?? config.series[0]?.labels?.format;
4958
+ }
4959
+ buildCartesianTooltip(config) {
4960
+ const tooltip = {
4961
+ trigger: config.theme?.tooltip?.trigger ?? 'axis',
4962
+ confine: true,
4963
+ appendToBody: false,
4964
+ valueFormatter: (value) => this.formatValue(value, this.primaryValueFormat(config)),
4965
+ };
4966
+ return this.usesCanonicalStatsTime(config)
4967
+ ? {
4968
+ ...tooltip,
4969
+ formatter: (params) => this.formatCanonicalTimeTooltip(params, config),
4970
+ }
4971
+ : tooltip;
4972
+ }
4973
+ usesCanonicalStatsTime(config) {
4974
+ if (config.dataSource?.kind !== 'remote'
4975
+ || config.dataSource.query?.sourceKind !== 'praxis.stats') {
4976
+ return false;
4977
+ }
4978
+ return config.axes?.x?.type === 'time'
4979
+ || config.dataSource.query.statsOperation === 'timeseries';
4980
+ }
4981
+ formatCanonicalTimeTooltip(params, config) {
4982
+ const items = (Array.isArray(params) ? params : [params])
4983
+ .filter((item) => !!item && typeof item === 'object');
4984
+ if (!items.length) {
4985
+ return '';
4986
+ }
4987
+ const first = items[0];
4988
+ const data = this.tooltipPointData(first['data']);
4989
+ const header = this.formatCanonicalTimeTooltipHeader(first, data, config);
4990
+ const lines = items.map((item) => {
4991
+ const itemData = this.tooltipPointData(item['data']);
4992
+ const seriesId = item['seriesId'] === null || item['seriesId'] === undefined
4993
+ ? undefined
4994
+ : String(item['seriesId']);
4995
+ const format = seriesId
4996
+ ? this.seriesLabelFormat(config, seriesId) ?? this.primaryValueFormat(config)
4997
+ : this.primaryValueFormat(config);
4998
+ const value = this.formatValue(item['value'] ?? itemData['value'], format);
4999
+ const seriesName = item['seriesName'] === null || item['seriesName'] === undefined
5000
+ ? ''
5001
+ : String(item['seriesName']);
5002
+ return seriesName
5003
+ ? `${escapeTooltipHtml(seriesName)}: ${escapeTooltipHtml(value)}`
5004
+ : escapeTooltipHtml(value);
5005
+ });
5006
+ return [escapeTooltipHtml(header), ...lines]
5007
+ .filter((part) => part.length > 0)
5008
+ .join('<br/>');
5009
+ }
5010
+ formatCanonicalTimeTooltipHeader(params, data, config) {
5011
+ const dateFormat = config.axes?.x?.labels?.format;
5012
+ const label = this.nonEmptyText(data['label']);
5013
+ const start = this.nonEmptyText(data['start']);
5014
+ const end = this.nonEmptyText(data['end']);
5015
+ const formattedStart = start ? this.formatValue(start, dateFormat) : '';
5016
+ const formattedEnd = end ? this.formatValue(end, dateFormat) : '';
5017
+ const interval = formattedStart && formattedEnd && formattedStart !== formattedEnd
5018
+ ? `${formattedStart} – ${formattedEnd}`
5019
+ : formattedStart || formattedEnd;
5020
+ const fallback = this.nonEmptyText(params['axisValueLabel'])
5021
+ ?? this.nonEmptyText(params['name'])
5022
+ ?? '';
5023
+ if (label && interval && label !== formattedStart && label !== interval) {
5024
+ return `${label} · ${interval}`;
5025
+ }
5026
+ return interval || label || fallback;
5027
+ }
5028
+ tooltipPointData(value) {
5029
+ return value && typeof value === 'object' && !Array.isArray(value)
5030
+ ? value
5031
+ : {};
5032
+ }
5033
+ nonEmptyText(value) {
5034
+ if (value === null || value === undefined) {
5035
+ return undefined;
5036
+ }
5037
+ const text = String(value).trim();
5038
+ return text || undefined;
5039
+ }
5040
+ seriesLabelFormat(config, seriesId) {
5041
+ return config.series.find((series) => series.id === seriesId)?.labels?.format;
5042
+ }
5043
+ axisLabelFormatter(format) {
5044
+ return format ? (value) => this.formatValue(value, format) : undefined;
5045
+ }
5046
+ formatValue(value, format) {
5047
+ const normalizedValue = Array.isArray(value) ? value[value.length - 1] : value;
5048
+ if (normalizedValue === null || normalizedValue === undefined || normalizedValue === '') {
5049
+ return '';
5050
+ }
5051
+ if (!format) {
5052
+ return String(normalizedValue);
5053
+ }
5054
+ const formattedDate = this.formatDateValue(normalizedValue, format);
5055
+ if (formattedDate !== null) {
5056
+ return formattedDate;
5057
+ }
5058
+ const numeric = typeof normalizedValue === 'number'
5059
+ ? normalizedValue
5060
+ : Number(String(normalizedValue).replace(',', '.'));
5061
+ if (!Number.isFinite(numeric)) {
5062
+ return String(normalizedValue);
5063
+ }
5064
+ const currency = this.parseCurrencyFormat(format);
5065
+ if (currency) {
5066
+ return new Intl.NumberFormat(DEFAULT_VALUE_LOCALE, {
5067
+ style: 'currency',
5068
+ currency: currency.code,
5069
+ currencyDisplay: currency.display,
5070
+ minimumFractionDigits: currency.decimals,
5071
+ maximumFractionDigits: currency.decimals,
5072
+ useGrouping: currency.useGrouping,
5073
+ }).format(numeric);
5074
+ }
5075
+ const number = this.parseNumberFormat(format);
5076
+ if (number) {
5077
+ return new Intl.NumberFormat(DEFAULT_VALUE_LOCALE, {
5078
+ minimumFractionDigits: number.minimumFractionDigits,
5079
+ maximumFractionDigits: number.maximumFractionDigits,
5080
+ useGrouping: number.useGrouping,
5081
+ }).format(numeric);
5082
+ }
5083
+ return String(normalizedValue);
5084
+ }
5085
+ formatDateValue(value, format) {
5086
+ if (!this.isDateFormat(format)) {
5087
+ return null;
5088
+ }
5089
+ const parts = this.readDateParts(value);
5090
+ if (!parts) {
5091
+ return null;
5092
+ }
5093
+ const day = String(parts.day);
5094
+ const day2 = day.padStart(2, '0');
5095
+ const month = String(parts.month);
5096
+ const month2 = month.padStart(2, '0');
5097
+ const year = String(parts.year);
5098
+ const year2 = year.slice(-2);
5099
+ const date = new Date(parts.year, parts.month - 1, parts.day);
5100
+ const monthShort = new Intl.DateTimeFormat(DEFAULT_VALUE_LOCALE, { month: 'short' }).format(date);
5101
+ const monthLong = new Intl.DateTimeFormat(DEFAULT_VALUE_LOCALE, { month: 'long' }).format(date);
5102
+ return format
5103
+ .replace(/yyyy/g, year)
5104
+ .replace(/yy/g, year2)
5105
+ .replace(/MMMM/g, monthLong)
5106
+ .replace(/MMM/g, monthShort)
5107
+ .replace(/MM/g, month2)
5108
+ .replace(/M/g, month)
5109
+ .replace(/dd/g, day2)
5110
+ .replace(/d/g, day);
5111
+ }
5112
+ isDateFormat(format) {
5113
+ return /(^|[^A-Za-z])(d{1,2}|M{1,4}|y{2,4})(?=$|[^A-Za-z])/.test(format);
5114
+ }
5115
+ readDateParts(value) {
5116
+ if (value instanceof Date) {
5117
+ return Number.isNaN(value.getTime())
5118
+ ? null
5119
+ : { year: value.getFullYear(), month: value.getMonth() + 1, day: value.getDate() };
5120
+ }
5121
+ if (typeof value === 'number') {
5122
+ const date = new Date(value);
5123
+ return Number.isNaN(date.getTime())
5124
+ ? null
5125
+ : { year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate() };
5126
+ }
5127
+ const raw = String(value).trim();
5128
+ const isoDate = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s].*)?$/.exec(raw);
5129
+ if (isoDate) {
5130
+ return {
5131
+ year: Number(isoDate[1]),
5132
+ month: Number(isoDate[2]),
5133
+ day: Number(isoDate[3]),
5134
+ };
5135
+ }
5136
+ const brazilianDate = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(raw);
5137
+ if (brazilianDate) {
5138
+ return {
5139
+ year: Number(brazilianDate[3]),
5140
+ month: Number(brazilianDate[2]),
5141
+ day: Number(brazilianDate[1]),
5142
+ };
5143
+ }
5144
+ const parsed = new Date(raw);
5145
+ return Number.isNaN(parsed.getTime())
5146
+ ? null
5147
+ : { year: parsed.getFullYear(), month: parsed.getMonth() + 1, day: parsed.getDate() };
5148
+ }
5149
+ parseCurrencyFormat(format) {
5150
+ const parts = format.split('|').map((part) => part.trim()).filter(Boolean);
5151
+ const code = parts[0]?.toUpperCase();
5152
+ if (!code || !/^[A-Z]{3}$/.test(code)) {
5153
+ return null;
5154
+ }
5155
+ const display = this.normalizeCurrencyDisplay(parts[1]);
5156
+ const decimals = this.clampDecimals(Number.parseInt(parts[2] ?? '2', 10), 2);
5157
+ return {
5158
+ code,
5159
+ display,
5160
+ decimals,
5161
+ useGrouping: !parts.some((part) => part.toLowerCase() === 'nosep'),
5162
+ };
5163
+ }
5164
+ normalizeCurrencyDisplay(value) {
5165
+ if (value === 'code' || value === 'name' || value === 'narrowSymbol') {
5166
+ return value;
5167
+ }
5168
+ return 'symbol';
5169
+ }
5170
+ parseNumberFormat(format) {
5171
+ const [pattern, ...modifiers] = format.split('|').map((part) => part.trim());
5172
+ const match = /^(\d+)\.(\d+)-(\d+)$/.exec(pattern);
5173
+ if (!match) {
5174
+ return null;
5175
+ }
5176
+ return {
5177
+ minimumFractionDigits: this.clampDecimals(Number.parseInt(match[2], 10), 0),
5178
+ maximumFractionDigits: this.clampDecimals(Number.parseInt(match[3], 10), 0),
5179
+ useGrouping: !modifiers.some((part) => part.toLowerCase() === 'nosep'),
5180
+ };
5181
+ }
5182
+ clampDecimals(value, fallback) {
5183
+ if (!Number.isFinite(value)) {
5184
+ return fallback;
5185
+ }
5186
+ return Math.min(Math.max(value, 0), 20);
5187
+ }
5188
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, deps: [{ token: PraxisChartDataTransformerService }], target: i0.ɵɵFactoryTarget.Injectable });
5189
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, providedIn: 'root' });
5190
+ }
5191
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsOptionBuilderService, decorators: [{
5192
+ type: Injectable,
5193
+ args: [{ providedIn: 'root' }]
5194
+ }], ctorParameters: () => [{ type: PraxisChartDataTransformerService }] });
5195
+ function truncateLegendText(name) {
5196
+ const value = String(name ?? '').trim();
5197
+ if (value.length <= 28) {
5198
+ return value;
5199
+ }
5200
+ return `${value.slice(0, 25)}...`;
5201
+ }
5202
+ function escapeTooltipHtml(value) {
5203
+ return String(value ?? '')
5204
+ .replace(/&/g, '&amp;')
5205
+ .replace(/</g, '&lt;')
5206
+ .replace(/>/g, '&gt;')
5207
+ .replace(/"/g, '&quot;')
5208
+ .replace(/'/g, '&#39;');
5209
+ }
5210
+
5211
+ use([
5212
+ AriaComponent,
5213
+ BarChart,
5214
+ CanvasRenderer,
5215
+ DatasetComponent,
5216
+ FunnelChart,
5217
+ GridComponent,
5218
+ LegendComponent,
5219
+ LineChart,
5220
+ PieChart,
5221
+ ScatterChart,
5222
+ TitleComponent,
5223
+ TooltipComponent,
5224
+ TransformComponent,
5225
+ ]);
5226
+ class EChartsEngineAdapter {
5227
+ optionBuilder;
5228
+ chart;
5229
+ currentHost;
5230
+ clickHost;
5231
+ domClickHandler;
5232
+ zrenderClickHandler;
5233
+ lastSeriesClickAt = 0;
5234
+ lastGridClick;
5235
+ constructor(optionBuilder) {
5236
+ this.optionBuilder = optionBuilder;
5237
+ }
5238
+ render(host, payload) {
5239
+ if (this.chart && this.currentHost !== host) {
5240
+ this.disposeChart();
4943
5241
  }
4944
- localeDictionaries[locale] = {
4945
- ...(localeDictionaries[locale] ?? {}),
4946
- ...dictionary,
5242
+ if (!this.chart) {
5243
+ this.chart = this.createChart(host);
5244
+ this.currentHost = host;
5245
+ }
5246
+ const option = this.optionBuilder.build(payload.config, payload.data);
5247
+ const chart = this.chart;
5248
+ chart.setOption(option, true);
5249
+ this.detachHandlers();
5250
+ chart.off('click');
5251
+ chart.on('click', (params) => {
5252
+ this.lastSeriesClickAt = Date.now();
5253
+ this.lastGridClick = undefined;
5254
+ const data = pointData(params?.name, params?.data ?? params?.value);
5255
+ payload.onPointClick?.({
5256
+ chartId: payload.config.id,
5257
+ seriesId: params?.seriesId,
5258
+ seriesName: params?.seriesName,
5259
+ category: this.resolvePointCategory(payload, data, params?.name),
5260
+ value: pointValue(params?.value ?? params?.data),
5261
+ data,
5262
+ });
5263
+ });
5264
+ const zrender = chart?.getZr?.();
5265
+ this.zrenderClickHandler = (event) => {
5266
+ window.setTimeout(() => {
5267
+ if (Date.now() - this.lastSeriesClickAt < 80) {
5268
+ return;
5269
+ }
5270
+ this.emitCategoryClickFromGrid(event, payload);
5271
+ }, 0);
5272
+ };
5273
+ zrender?.on?.('click', this.zrenderClickHandler);
5274
+ this.domClickHandler = (event) => {
5275
+ window.setTimeout(() => {
5276
+ if (Date.now() - this.lastSeriesClickAt < 80) {
5277
+ return;
5278
+ }
5279
+ const rect = host.getBoundingClientRect();
5280
+ const offsetX = Number.isFinite(event.offsetX) ? event.offsetX : event.clientX - rect.left;
5281
+ const offsetY = Number.isFinite(event.offsetY) ? event.offsetY : event.clientY - rect.top;
5282
+ this.emitCategoryClickFromGrid({
5283
+ offsetX,
5284
+ offsetY,
5285
+ }, payload);
5286
+ }, 0);
4947
5287
  };
5288
+ host.addEventListener('click', this.domClickHandler, true);
5289
+ this.clickHost = host;
4948
5290
  }
4949
- return {
4950
- locale: options.locale,
4951
- fallbackLocale: options.fallbackLocale ?? 'pt-BR',
4952
- namespaces: {
4953
- charts: localeDictionaries,
4954
- },
4955
- };
5291
+ resize() {
5292
+ this.chart?.resize();
5293
+ }
5294
+ destroy() {
5295
+ this.disposeChart();
5296
+ }
5297
+ createChart(host) {
5298
+ return init(host);
5299
+ }
5300
+ disposeChart() {
5301
+ this.detachHandlers();
5302
+ this.chart?.off('click');
5303
+ this.chart?.dispose();
5304
+ this.chart = undefined;
5305
+ this.currentHost = undefined;
5306
+ this.lastSeriesClickAt = 0;
5307
+ this.lastGridClick = undefined;
5308
+ }
5309
+ detachHandlers() {
5310
+ const zrender = this.chart?.getZr?.();
5311
+ if (zrender && this.zrenderClickHandler) {
5312
+ zrender.off?.('click', this.zrenderClickHandler);
5313
+ }
5314
+ if (this.clickHost && this.domClickHandler) {
5315
+ this.clickHost.removeEventListener('click', this.domClickHandler, true);
5316
+ }
5317
+ this.clickHost = undefined;
5318
+ this.domClickHandler = undefined;
5319
+ this.zrenderClickHandler = undefined;
5320
+ }
5321
+ resolvePointCategory(payload, data, fallback) {
5322
+ const categoryField = payload.config.axes?.x?.field
5323
+ ?? payload.config.series.find((series) => series.categoryField)?.categoryField;
5324
+ const dataSource = payload.config.dataSource;
5325
+ const usesCanonicalStatsTime = dataSource?.kind === 'remote'
5326
+ && dataSource.query?.sourceKind === 'praxis.stats';
5327
+ const usesCanonicalStatsTimePoint = usesCanonicalStatsTime
5328
+ && (payload.config.axes?.x?.type === 'time'
5329
+ || (dataSource?.kind === 'remote' && dataSource.query?.statsOperation === 'timeseries'));
5330
+ const candidate = usesCanonicalStatsTimePoint && data['label'] !== null
5331
+ && data['label'] !== undefined
5332
+ && data['label'] !== ''
5333
+ ? data['label']
5334
+ : categoryField && data[categoryField] !== null && data[categoryField] !== undefined
5335
+ ? data[categoryField]
5336
+ : fallback;
5337
+ return candidate === null || candidate === undefined
5338
+ ? undefined
5339
+ : String(candidate);
5340
+ }
5341
+ emitCategoryClickFromGrid(event, payload) {
5342
+ const chart = this.chart;
5343
+ if (!chart || typeof chart.containPixel !== 'function' || typeof chart.convertFromPixel !== 'function') {
5344
+ return;
5345
+ }
5346
+ const point = [event?.offsetX, event?.offsetY];
5347
+ if (!Number.isFinite(point[0]) || !Number.isFinite(point[1])) {
5348
+ return;
5349
+ }
5350
+ if (!chart.containPixel({ gridIndex: 0 }, point)) {
5351
+ return;
5352
+ }
5353
+ const option = chart.getOption?.();
5354
+ const xAxis = firstOptionEntry(option?.xAxis);
5355
+ const yAxis = firstOptionEntry(option?.yAxis);
5356
+ const horizontal = yAxis?.type === 'category';
5357
+ const categories = (horizontal ? yAxis?.data : xAxis?.data) ?? [];
5358
+ if (!Array.isArray(categories) || categories.length === 0) {
5359
+ return;
5360
+ }
5361
+ const converted = chart.convertFromPixel({ gridIndex: 0 }, point);
5362
+ const categoryIndex = Math.round(Number(horizontal ? converted?.[1] : converted?.[0]));
5363
+ if (!Number.isInteger(categoryIndex) || categoryIndex < 0 || categoryIndex >= categories.length) {
5364
+ return;
5365
+ }
5366
+ const category = categories[categoryIndex];
5367
+ const categoryValue = category == null ? undefined : String(category);
5368
+ const categoryField = payload.config.axes?.x?.field
5369
+ ?? payload.config.series.find((series) => series.categoryField)?.categoryField;
5370
+ const signature = JSON.stringify({
5371
+ chartId: payload.config.id,
5372
+ category: categoryValue,
5373
+ horizontal,
5374
+ categoryIndex,
5375
+ });
5376
+ if (this.isDuplicateGridClick(signature)) {
5377
+ return;
5378
+ }
5379
+ const usesCanonicalStatsRows = payload.config.dataSource?.kind === 'remote'
5380
+ && payload.config.dataSource.query?.sourceKind === 'praxis.stats';
5381
+ const fallbackSourceRow = !usesCanonicalStatsRows
5382
+ && categoryField
5383
+ && categoryValue !== undefined
5384
+ ? payload.data.find((row) => String(row[categoryField] ?? '') === categoryValue)
5385
+ : undefined;
5386
+ const sourceRow = usesCanonicalStatsRows
5387
+ ? payload.data[categoryIndex]
5388
+ : fallbackSourceRow;
5389
+ const data = {
5390
+ ...(sourceRow ?? {}),
5391
+ category: categoryValue,
5392
+ };
5393
+ if (categoryField && categoryValue !== undefined) {
5394
+ data[categoryField] = categoryValue;
5395
+ }
5396
+ payload.onPointClick?.({
5397
+ chartId: payload.config.id,
5398
+ category: categoryValue,
5399
+ data,
5400
+ });
5401
+ }
5402
+ isDuplicateGridClick(signature) {
5403
+ const now = Date.now();
5404
+ const duplicate = this.lastGridClick?.signature === signature
5405
+ && now - this.lastGridClick.at < 80;
5406
+ this.lastGridClick = { signature, at: now };
5407
+ return duplicate;
5408
+ }
5409
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter, deps: [{ token: EChartsOptionBuilderService }], target: i0.ɵɵFactoryTarget.Injectable });
5410
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter });
4956
5411
  }
4957
- function providePraxisChartsI18n(options = {}) {
4958
- return [
4959
- {
4960
- provide: PRAXIS_CHARTS_I18N,
4961
- useValue: {},
4962
- },
4963
- providePraxisI18n(createPraxisChartsI18nConfig(options)),
4964
- ];
5412
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: EChartsEngineAdapter, decorators: [{
5413
+ type: Injectable
5414
+ }], ctorParameters: () => [{ type: EChartsOptionBuilderService }] });
5415
+ function firstOptionEntry(value) {
5416
+ return Array.isArray(value) ? value[0] : value;
4965
5417
  }
4966
- function resolvePraxisChartsText(value, fallback) {
4967
- if (typeof value === 'string') {
4968
- return { text: value };
5418
+ function pointValue(value) {
5419
+ if (Array.isArray(value)) {
5420
+ return value.length > 1 ? value[1] : value[0];
4969
5421
  }
4970
- if (value?.key || value?.text) {
5422
+ if (value && typeof value === 'object' && 'value' in value) {
5423
+ return pointValue(value.value);
5424
+ }
5425
+ return value;
5426
+ }
5427
+ function pointData(category, value) {
5428
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
4971
5429
  return value;
4972
5430
  }
4973
- return { text: fallback ?? '' };
5431
+ return {
5432
+ category,
5433
+ value: pointValue(value),
5434
+ };
4974
5435
  }
4975
5436
 
4976
5437
  class ChartEditorDefaultsService {
@@ -5977,6 +6438,8 @@ class PraxisChartConfigEditor {
5977
6438
  'pie',
5978
6439
  'donut',
5979
6440
  'scatter',
6441
+ 'funnel',
6442
+ 'pyramid',
5980
6443
  ];
5981
6444
  sourceKinds = ['praxis.stats', 'derived'];
5982
6445
  operations = ['group-by', 'timeseries', 'distribution', 'comparison'];
@@ -6821,7 +7284,10 @@ class PraxisChartConfigEditor {
6821
7284
  return this.doc().kind === 'combo';
6822
7285
  }
6823
7286
  showPieDonutPanel() {
6824
- return this.doc().kind === 'pie' || this.doc().kind === 'donut';
7287
+ return this.doc().kind === 'pie'
7288
+ || this.doc().kind === 'donut'
7289
+ || this.doc().kind === 'funnel'
7290
+ || this.doc().kind === 'pyramid';
6825
7291
  }
6826
7292
  showScatterPanel() {
6827
7293
  return this.doc().kind === 'scatter';
@@ -7137,7 +7603,7 @@ class PraxisChartConfigEditor {
7137
7603
  this.documentChange.emit(structuredClone(this.normalizedDocument()));
7138
7604
  }
7139
7605
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartConfigEditor, deps: [], target: i0.ɵɵFactoryTarget.Component });
7140
- 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 }, 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" }, providers: [providePraxisChartsI18n()], ngImport: i0, template: "<div class=\"editor-shell\">\n <div class=\"editor-nav\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n (click)=\"setSection(section.id)\"\n >\n {{ t(section.labelKey, section.fallback) }}\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div class=\"editor-form\">\n <mat-card class=\"editor-card\">\n <mat-card-content>\n @switch (activeSection()) {\n @case ('general') {\n <div 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 </div>\n }\n\n @case ('data') {\n <div 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.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 data-testid=\"chart-editor-resource\" [ngModel]=\"resourceValue()\" (ngModelChange)=\"setResource($event)\" [disabled]=\"isReadonly() || resourceCatalogUnavailable()\">\n @for (resource of resourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\">{{ resource.label }}</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() || 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 (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 (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 [disabled]=\"isReadonly()\"\n >\n @for (field of fieldOptions('time'); track field.field) {\n <mat-option [value]=\"field.field\">{{ field.label || field.field }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\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 </div>\n }\n\n @case ('motion') {\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\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-grid\">\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 <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\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 } @else {\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=\"3\"\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-row-card\">\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=\"2\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [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 (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 and donut charts keep only the first 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', 'Scatter charts use the first dimension as X and the first metric as Y, so keep both fields mapped.') }}\n </p>\n </mat-card-content>\n </mat-card>\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 [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('dimension'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\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 [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('metric'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\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()\">\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 <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n }\n }\n </mat-card-content>\n </mat-card>\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\">\n <strong>{{ issue.field }}</strong>\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(); 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 <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.') }}\n </div>\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:18px;min-width:0}.editor-nav{display:flex;gap:8px;flex-wrap:wrap}.editor-nav button.active{background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent);color:var(--md-sys-color-primary, #1263b4)}.editor-layout{display:grid;gap:18px;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:20px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 72%,transparent);background:linear-gradient(180deg,#1263b408,#1263b400)}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.editor-stack{display:grid;gap:14px}.editor-row-card{padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 54%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface, #fff) 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:14px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 8%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 18%,transparent)}.editor-issue strong{display:block;margin-bottom:4px}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.92rem}.editor-empty{padding:18px;border-radius:14px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 78%,transparent)}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}}@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.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: MatInputModule }, { kind: "directive", type: i5.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: i6.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: "component", type: i6.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i7.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: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7606
+ 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 }, 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" }, providers: [providePraxisChartsI18n()], ngImport: i0, template: "<div class=\"editor-shell\">\n <div class=\"editor-nav\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n (click)=\"setSection(section.id)\"\n >\n {{ t(section.labelKey, section.fallback) }}\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div class=\"editor-form\">\n <mat-card class=\"editor-card\">\n <mat-card-content>\n @switch (activeSection()) {\n @case ('general') {\n <div 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 </div>\n }\n\n @case ('data') {\n <div 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.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 data-testid=\"chart-editor-resource\" [ngModel]=\"resourceValue()\" (ngModelChange)=\"setResource($event)\" [disabled]=\"isReadonly() || resourceCatalogUnavailable()\">\n @for (resource of resourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\">{{ resource.label }}</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() || 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 (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 (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 [disabled]=\"isReadonly()\"\n >\n @for (field of fieldOptions('time'); track field.field) {\n <mat-option [value]=\"field.field\">{{ field.label || field.field }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\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 </div>\n }\n\n @case ('motion') {\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\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-grid\">\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 <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\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 } @else {\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=\"3\"\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-row-card\">\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=\"2\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [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 (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 and pyramid 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', 'Scatter charts use the first dimension as X and the first metric as Y, so keep both fields mapped.') }}\n </p>\n </mat-card-content>\n </mat-card>\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 [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('dimension'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\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 [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('metric'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\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()\">\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 <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n }\n }\n </mat-card-content>\n </mat-card>\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\">\n <strong>{{ issue.field }}</strong>\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(); 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 <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.') }}\n </div>\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:18px;min-width:0}.editor-nav{display:flex;gap:8px;flex-wrap:wrap}.editor-nav button.active{background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent);color:var(--md-sys-color-primary, #1263b4)}.editor-layout{display:grid;gap:18px;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:20px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 72%,transparent);background:linear-gradient(180deg,#1263b408,#1263b400)}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.editor-stack{display:grid;gap:14px}.editor-row-card{padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 54%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface, #fff) 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:14px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 8%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 18%,transparent)}.editor-issue strong{display:block;margin-bottom:4px}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.92rem}.editor-empty{padding:18px;border-radius:14px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 78%,transparent)}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}}@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.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: MatInputModule }, { kind: "directive", type: i5.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: i6.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: "component", type: i6.MatOption, selector: "mat-option", inputs: ["value", "id", "disabled"], outputs: ["onSelectionChange"], exportAs: ["matOption"] }, { kind: "ngmodule", type: MatSlideToggleModule }, { kind: "component", type: i7.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: "component", type: PraxisChartComponent, selector: "praxis-chart", inputs: ["config", "data", "chartDocument", "filterCriteria", "queryContext", "remoteDataResolver", "enableCustomization", "availableResources", "availableFields", "availableTargets"], outputs: ["pointClick", "pointAction", "selectionChange", "drillDown", "crossFilter", "queryRequest", "loadStateChange", "chartDocumentApplied", "chartDocumentSaved"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
7141
7607
  }
7142
7608
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: PraxisChartConfigEditor, decorators: [{
7143
7609
  type: Component,
@@ -7150,7 +7616,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
7150
7616
  MatSelectModule,
7151
7617
  MatSlideToggleModule,
7152
7618
  PraxisChartComponent
7153
- ], providers: [providePraxisChartsI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"editor-shell\">\n <div class=\"editor-nav\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n (click)=\"setSection(section.id)\"\n >\n {{ t(section.labelKey, section.fallback) }}\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div class=\"editor-form\">\n <mat-card class=\"editor-card\">\n <mat-card-content>\n @switch (activeSection()) {\n @case ('general') {\n <div 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 </div>\n }\n\n @case ('data') {\n <div 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.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 data-testid=\"chart-editor-resource\" [ngModel]=\"resourceValue()\" (ngModelChange)=\"setResource($event)\" [disabled]=\"isReadonly() || resourceCatalogUnavailable()\">\n @for (resource of resourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\">{{ resource.label }}</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() || 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 (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 (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 [disabled]=\"isReadonly()\"\n >\n @for (field of fieldOptions('time'); track field.field) {\n <mat-option [value]=\"field.field\">{{ field.label || field.field }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\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 </div>\n }\n\n @case ('motion') {\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\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-grid\">\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 <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\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 } @else {\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=\"3\"\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-row-card\">\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=\"2\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [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 (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 and donut charts keep only the first 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', 'Scatter charts use the first dimension as X and the first metric as Y, so keep both fields mapped.') }}\n </p>\n </mat-card-content>\n </mat-card>\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 [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('dimension'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\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 [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('metric'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\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()\">\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 <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n }\n }\n </mat-card-content>\n </mat-card>\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\">\n <strong>{{ issue.field }}</strong>\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(); 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 <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.') }}\n </div>\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:18px;min-width:0}.editor-nav{display:flex;gap:8px;flex-wrap:wrap}.editor-nav button.active{background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent);color:var(--md-sys-color-primary, #1263b4)}.editor-layout{display:grid;gap:18px;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:20px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 72%,transparent);background:linear-gradient(180deg,#1263b408,#1263b400)}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.editor-stack{display:grid;gap:14px}.editor-row-card{padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 54%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface, #fff) 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:14px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 8%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 18%,transparent)}.editor-issue strong{display:block;margin-bottom:4px}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.92rem}.editor-empty{padding:18px;border-radius:14px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 78%,transparent)}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"] }]
7619
+ ], providers: [providePraxisChartsI18n()], changeDetection: ChangeDetectionStrategy.OnPush, template: "<div class=\"editor-shell\">\n <div class=\"editor-nav\">\n @for (section of sections; track section.id) {\n <button\n mat-stroked-button\n type=\"button\"\n [attr.data-testid]=\"'chart-editor-section-' + section.id\"\n [class.active]=\"activeSection() === section.id\"\n (click)=\"setSection(section.id)\"\n >\n {{ t(section.labelKey, section.fallback) }}\n </button>\n }\n </div>\n\n <div class=\"editor-layout\">\n <div class=\"editor-form\">\n <mat-card class=\"editor-card\">\n <mat-card-content>\n @switch (activeSection()) {\n @case ('general') {\n <div 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 </div>\n }\n\n @case ('data') {\n <div 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.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 data-testid=\"chart-editor-resource\" [ngModel]=\"resourceValue()\" (ngModelChange)=\"setResource($event)\" [disabled]=\"isReadonly() || resourceCatalogUnavailable()\">\n @for (resource of resourceOptions(); track resource.id) {\n <mat-option [value]=\"resource.path\">{{ resource.label }}</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() || 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 (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 (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 [disabled]=\"isReadonly()\"\n >\n @for (field of fieldOptions('time'); track field.field) {\n <mat-option [value]=\"field.field\">{{ field.label || field.field }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"comparisonPeriodFieldValue()\" [disabled]=\"true\"></mat-select>\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 </div>\n }\n\n @case ('motion') {\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\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-grid\">\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 <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\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 } @else {\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=\"3\"\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-row-card\">\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=\"2\" [ngModel]=\"stateDescription('empty')\" (ngModelChange)=\"setStateDescription('empty', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [ngModel]=\"stateDescription('loading')\" (ngModelChange)=\"setStateDescription('loading', $event)\" [disabled]=\"isReadonly()\"></textarea>\n </mat-form-field>\n </div>\n </div>\n\n <div class=\"editor-row-card\">\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=\"2\" [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 (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 and pyramid 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', 'Scatter charts use the first dimension as X and the first metric as Y, so keep both fields mapped.') }}\n </p>\n </mat-card-content>\n </mat-card>\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 [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('dimension'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"dimension.field || ''\" (ngModelChange)=\"setDimensionField($index, $event)\" [disabled]=\"true\"></mat-select>\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 [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"isReadonly()\">\n @for (field of fieldOptions('metric'); track field.field) {\n <mat-option [value]=\"field.field\">{{ fieldOptionLabel(field) }}</mat-option>\n }\n </mat-select>\n } @else {\n <mat-select [ngModel]=\"metric.field || ''\" (ngModelChange)=\"setMetricField($index, $event)\" [disabled]=\"true\"></mat-select>\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()\">\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 <p class=\"editor-caption\">\n {{ t('praxis.charts.editor.preview.caption', 'Local preview derived from the canonical contract without remote calls.') }}\n </p>\n }\n }\n </mat-card-content>\n </mat-card>\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\">\n <strong>{{ issue.field }}</strong>\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(); 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 <praxis-chart [config]=\"chartPreview.config\" [data]=\"chartPreview.data\"></praxis-chart>\n } @else {\n <div class=\"editor-empty\">\n {{ t('praxis.charts.editor.preview.invalid', 'Preview is unavailable while the contract has blocking errors.') }}\n </div>\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:18px;min-width:0}.editor-nav{display:flex;gap:8px;flex-wrap:wrap}.editor-nav button.active{background:color-mix(in srgb,var(--md-sys-color-primary, #1263b4) 18%,transparent);color:var(--md-sys-color-primary, #1263b4)}.editor-layout{display:grid;gap:18px;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:20px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 72%,transparent);background:linear-gradient(180deg,#1263b408,#1263b400)}.editor-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(220px,1fr))}.editor-stack{display:grid;gap:14px}.editor-row-card{padding:14px;border-radius:16px;border:1px solid color-mix(in srgb,var(--md-sys-color-outline, #c5c7ce) 54%,transparent);background:color-mix(in srgb,var(--md-sys-color-surface, #fff) 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:14px;background:color-mix(in srgb,var(--md-sys-color-error, #b3261e) 8%,transparent);border:1px solid color-mix(in srgb,var(--md-sys-color-error, #b3261e) 18%,transparent)}.editor-issue strong{display:block;margin-bottom:4px}.editor-caption{margin:0 0 12px;color:var(--md-sys-color-on-surface-variant, #5a5d67);font-size:.92rem}.editor-empty{padding:18px;border-radius:14px;background:color-mix(in srgb,var(--md-sys-color-surface-variant, #eceff4) 78%,transparent)}.editor-side praxis-chart{display:block;width:100%;min-width:0;max-width:100%}@container praxis-chart-editor (max-width: 840px){.editor-layout{grid-template-columns:minmax(0,1fr)}}@media(max-width:960px){.editor-layout{grid-template-columns:minmax(0,1fr)}}\n"] }]
7154
7620
  }], ctorParameters: () => [], propDecorators: { documentInput: [{ type: i0.Input, args: [{ isSignal: true, alias: "document", 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"] }] } });
7155
7621
 
7156
7622
  var praxisChartConfigEditor = /*#__PURE__*/Object.freeze({
@@ -8361,6 +8827,68 @@ function providePraxisChartShowcaseWidgetMetadata() {
8361
8827
  };
8362
8828
  }
8363
8829
 
8830
+ const PRAXIS_ANALYTICS_PRESENTATION_COMPONENT_METADATA = {
8831
+ id: 'praxis-analytics-presentation',
8832
+ componentType: 'praxis-analytics-presentation',
8833
+ displayName: 'Praxis Analytics Presentation',
8834
+ selector: 'praxis-analytics-presentation',
8835
+ component: PraxisAnalyticsPresentationComponent,
8836
+ friendlyName: 'Apresentação analítica governada',
8837
+ description: 'Alterna renderers elegíveis para uma projection x-ui.analytics selecionada por id, preservando o query context.',
8838
+ icon: 'monitoring',
8839
+ tags: ['analytics', 'chart', 'table', 'projection', 'dashboard'],
8840
+ lib: '@praxisui/charts',
8841
+ authoringManifestRef: {
8842
+ componentId: 'praxis-analytics-presentation',
8843
+ version: '1.0.0',
8844
+ source: 'PRAXIS_ANALYTICS_PRESENTATION_AUTHORING_MANIFEST',
8845
+ },
8846
+ inputs: [
8847
+ {
8848
+ name: 'analytics',
8849
+ type: 'PraxisXUiAnalytics',
8850
+ description: 'Contrato x-ui.analytics que contém a projection canônica.',
8851
+ default: {
8852
+ projections: [{
8853
+ id: 'analytics-presentation-sample',
8854
+ intent: 'ranking',
8855
+ source: { kind: 'praxis.stats', resource: '/api/sample', operation: 'group-by' },
8856
+ bindings: {
8857
+ primaryDimension: { field: 'category', role: 'category', label: 'Categoria' },
8858
+ primaryMetrics: [{ field: 'value', aggregation: 'sum', label: 'Valor' }],
8859
+ },
8860
+ presentationHints: { preferredFamilies: ['chart', 'analytic-table'] },
8861
+ }],
8862
+ },
8863
+ },
8864
+ { name: 'projectionId', type: 'string', description: 'Identidade exata da projection; labels não são usados para seleção.', default: 'analytics-presentation-sample' },
8865
+ { name: 'availableFamilies', type: 'AnalyticsPresentationFamily[]', description: 'Famílias autorizadas após capabilities e renderers instalados.', default: ['chart', 'analytic-table'] },
8866
+ { name: 'preferredFamily', type: 'AnalyticsPresentationFamily | null', description: 'Preferência persistida pelo host; a escolha temporária do usuário tem precedência.', default: null },
8867
+ { name: 'queryContext', type: 'PraxisDataQueryContext | null', description: 'Contexto governado compartilhado por chart e analytic-table.', default: null },
8868
+ { name: 'title', type: 'PraxisTextValue', description: 'Título compartilhado pelos renderers.' },
8869
+ { name: 'subtitle', type: 'PraxisTextValue', description: 'Subtítulo compartilhado pelos renderers.' },
8870
+ { name: 'enableCustomization', type: 'boolean', description: 'Habilita authoring interno do chart quando aplicável.', default: false },
8871
+ ],
8872
+ outputs: [
8873
+ { name: 'presentationChange', type: 'PraxisAnalyticsPresentationChange', description: 'Escolha temporária do usuário; o host decide se persiste.' },
8874
+ { name: 'pointClick', type: 'PraxisChartPointEvent', description: 'Evidência de ponto/linha preservada entre renderers.' },
8875
+ { name: 'pointAction', type: 'PraxisChartPointActionEvent', description: 'Ação configurada emitida pelo chart.' },
8876
+ { name: 'selectionChange', type: 'PraxisChartSelectionEvent', description: 'Seleção semântica emitida pelo chart.' },
8877
+ { name: 'drillDown', type: 'PraxisChartDrillDownEvent', description: 'Drill-down emitido pelo chart.' },
8878
+ { name: 'crossFilter', type: 'PraxisChartCrossFilterEvent', description: 'Cross-filter emitido pelo chart.' },
8879
+ { name: 'queryRequest', type: 'PraxisChartQueryRequestEvent', description: 'Observação da consulta remota do chart.' },
8880
+ { name: 'loadStateChange', type: 'PraxisChartLoadState', description: 'Estado de carga do chart.' },
8881
+ ],
8882
+ };
8883
+ function providePraxisAnalyticsPresentationMetadata() {
8884
+ return {
8885
+ provide: ENVIRONMENT_INITIALIZER,
8886
+ multi: true,
8887
+ useFactory: (registry) => () => registry.register(PRAXIS_ANALYTICS_PRESENTATION_COMPONENT_METADATA),
8888
+ deps: [ComponentMetadataRegistry],
8889
+ };
8890
+ }
8891
+
8364
8892
  const PRAXIS_TABLE_CHART_DETAIL_RENDERER = {
8365
8893
  nodeType: 'chartRef',
8366
8894
  renderMode: 'inline',
@@ -8395,6 +8923,7 @@ function providePraxisCharts(options = {}) {
8395
8923
  providePraxisChartDrilldownPanelMetadata(),
8396
8924
  providePraxisChartStateProbeMetadata(),
8397
8925
  providePraxisChartShowcaseWidgetMetadata(),
8926
+ providePraxisAnalyticsPresentationMetadata(),
8398
8927
  {
8399
8928
  provide: PRAXIS_TABLE_DETAIL_INLINE_RENDERERS,
8400
8929
  multi: true,
@@ -8569,194 +9098,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImpo
8569
9098
  args: [{ providedIn: 'root' }]
8570
9099
  }], ctorParameters: () => [{ type: PraxisChartSchemaMapperService }, { type: PraxisChartCanonicalContractMapperService }] });
8571
9100
 
8572
- class AnalyticsChartConfigAdapterService {
8573
- i18n;
8574
- statsBuilder = new AnalyticsStatsRequestBuilderService();
8575
- constructor(i18n) {
8576
- this.i18n = i18n;
8577
- }
8578
- toPraxisChartConfig(projection, options) {
8579
- const chartType = this.resolveChartType(projection);
8580
- const orientation = chartType === 'horizontal-bar' ? 'horizontal' : undefined;
8581
- const dimension = projection.bindings.primaryDimension;
8582
- const metrics = projection.bindings.primaryMetrics ?? [];
8583
- if (!dimension?.field) {
8584
- throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension for projection "${projection.id}".`);
8585
- }
8586
- if (!metrics.length) {
8587
- throw new Error(`AnalyticsChartConfigAdapterService requires at least one metric for projection "${projection.id}".`);
8588
- }
8589
- const crossFilter = Boolean(projection.interactions?.crossFilter);
8590
- const keyFilterField = dimension.keyFilterField?.trim();
8591
- if (crossFilter && !keyFilterField) {
8592
- throw new Error(`AnalyticsChartConfigAdapterService requires primaryDimension.keyFilterField when crossFilter is enabled for projection "${projection.id}".`);
8593
- }
8594
- return {
8595
- id: projection.id,
8596
- type: chartType,
8597
- orientation,
8598
- title: options?.title,
8599
- subtitle: options?.subtitle,
8600
- height: options?.height,
8601
- axes: this.buildAxes(projection, chartType),
8602
- series: this.buildSeries(projection, chartType),
8603
- dataSource: this.buildDataSource(projection),
8604
- interactions: {
8605
- pointClick: Boolean(projection.interactions?.pointSelection || projection.interactions?.drillDown),
8606
- drillDown: Boolean(projection.interactions?.drillDown),
8607
- selection: Boolean(projection.interactions?.pointSelection),
8608
- crossFilter,
8609
- ...(crossFilter
8610
- ? {
8611
- eventActions: {
8612
- crossFilter: {
8613
- action: 'emit',
8614
- mapping: { key: keyFilterField },
8615
- },
8616
- },
8617
- }
8618
- : {}),
8619
- },
8620
- };
8621
- }
8622
- buildAxes(projection, chartType) {
8623
- const dimension = projection.bindings.primaryDimension;
8624
- const metrics = this.getDisplayMetrics(projection);
8625
- const firstMetric = metrics[0];
8626
- if (chartType === 'pie' || chartType === 'donut') {
8627
- return {
8628
- x: {
8629
- field: dimension.field,
8630
- label: dimension.label ?? undefined,
8631
- },
8632
- };
8633
- }
8634
- return {
8635
- x: {
8636
- field: dimension.field,
8637
- label: dimension.label ?? undefined,
8638
- type: dimension.role === 'time' ? 'time' : 'category',
8639
- },
8640
- y: {
8641
- label: metrics.length === 1
8642
- ? firstMetric?.label ?? firstMetric?.field
8643
- : undefined,
8644
- type: 'value',
8645
- },
8646
- };
8647
- }
8648
- buildSeries(projection, chartType) {
8649
- const dimension = projection.bindings.primaryDimension;
8650
- const comparison = projection.source.operation === 'comparison';
8651
- return this.getDisplayMetrics(projection).flatMap((metric, index) => comparison
8652
- ? ['current', 'previous'].map((period) => ({
8653
- id: `${projection.id}.${metric.field}.${period}`,
8654
- name: `${metric.label ?? metric.field} (${this.comparisonPeriodLabel(period)})`,
8655
- type: chartType,
8656
- categoryField: dimension.field,
8657
- metric: { field: this.comparisonMetricField(metric.field, period), aggregation: this.mapAggregation(metric.aggregation) },
8658
- }))
8659
- : [{
8660
- id: `${projection.id}.${metric.field}.${index + 1}`,
8661
- name: metric.label ?? metric.field,
8662
- type: chartType,
8663
- categoryField: chartType === 'pie' || chartType === 'donut' ? dimension.field : undefined,
8664
- metric: {
8665
- field: metric.field,
8666
- aggregation: this.mapAggregation(metric.aggregation),
8667
- label: metric.label ?? undefined,
8668
- },
8669
- smooth: chartType === 'line' || chartType === 'area',
8670
- }]);
8671
- }
8672
- buildDataSource(projection) {
8673
- const executionPlan = this.statsBuilder.buildExecutionPlan(projection);
8674
- return {
8675
- kind: 'remote',
8676
- resourcePath: executionPlan.resourcePath,
8677
- schemaId: executionPlan.resourcePath,
8678
- query: {
8679
- sourceKind: 'praxis.stats',
8680
- statsOperation: executionPlan.operation,
8681
- statsPath: executionPlan.statsPath,
8682
- statsRequest: executionPlan.statsRequest,
8683
- dimensions: executionPlan.dimensions,
8684
- metrics: projection.source.operation === 'comparison'
8685
- ? this.getDisplayMetrics(projection).flatMap((metric) => ['current', 'previous'].map((period) => ({
8686
- field: this.comparisonMetricField(metric.field, period),
8687
- aggregation: this.mapAggregation(metric.aggregation),
8688
- alias: this.comparisonMetricField(metric.field, period),
8689
- })))
8690
- : this.mapExecutionMetrics(executionPlan),
8691
- sort: executionPlan.sort,
8692
- limit: executionPlan.limit,
8693
- },
8694
- };
8695
- }
8696
- resolveChartType(projection) {
8697
- if (projection.intent === 'trend' && projection.source.operation === 'timeseries') {
8698
- return 'line';
8699
- }
8700
- if (projection.intent === 'ranking' && projection.source.operation === 'group-by') {
8701
- return 'horizontal-bar';
8702
- }
8703
- if (projection.intent === 'composition') {
8704
- return 'pie';
8705
- }
8706
- if (projection.intent === 'distribution') {
8707
- return 'bar';
8708
- }
8709
- return 'bar';
8710
- }
8711
- mapAggregation(aggregation) {
8712
- const normalizedAggregation = (aggregation ?? '').toLowerCase();
8713
- switch (normalizedAggregation) {
8714
- case 'avg':
8715
- case 'min':
8716
- case 'max':
8717
- case 'count':
8718
- case 'distinct-count':
8719
- case 'sum':
8720
- return normalizedAggregation;
8721
- case '':
8722
- return undefined;
8723
- default:
8724
- throw new Error(`Analytics aggregation "${aggregation}" is not supported in @praxisui/charts.`);
8725
- }
8726
- }
8727
- mapExecutionMetrics(executionPlan) {
8728
- return executionPlan.metrics.map((metric) => ({
8729
- field: metric.field,
8730
- aggregation: this.mapAggregation(metric.aggregation),
8731
- alias: metric.alias,
8732
- }));
8733
- }
8734
- getDisplayMetrics(projection) {
8735
- return [
8736
- ...(projection.bindings.primaryMetrics ?? []),
8737
- ...(projection.bindings.secondaryMetrics ?? []),
8738
- ];
8739
- }
8740
- comparisonMetricField(metricField, period) {
8741
- return `__praxisComparison_${metricField}_${period}`;
8742
- }
8743
- comparisonPeriodLabel(period) {
8744
- const key = period === 'current'
8745
- ? 'praxis.charts.runtime.comparisonCurrent'
8746
- : 'praxis.charts.runtime.comparisonPrevious';
8747
- const fallback = period === 'current' ? 'Current' : 'Previous';
8748
- return this.i18n?.t(key, undefined, fallback, 'charts') ?? fallback;
8749
- }
8750
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, deps: [{ token: i1.PraxisI18nService, optional: true }], target: i0.ɵɵFactoryTarget.Injectable });
8751
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, providedIn: 'root' });
8752
- }
8753
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.14", ngImport: i0, type: AnalyticsChartConfigAdapterService, decorators: [{
8754
- type: Injectable,
8755
- args: [{ providedIn: 'root' }]
8756
- }], ctorParameters: () => [{ type: i1.PraxisI18nService, decorators: [{
8757
- type: Optional
8758
- }] }] });
8759
-
8760
9101
  class AnalyticsChartContractService {
8761
9102
  analyticsSchema;
8762
9103
  resolver;
@@ -10106,7 +10447,7 @@ const chartDocumentSchema = {
10106
10447
  required: ['version', 'kind', 'source'],
10107
10448
  properties: {
10108
10449
  version: { const: PRAXIS_X_UI_CHART_AUTHORABLE_VERSION },
10109
- kind: { enum: ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter'] },
10450
+ kind: { enum: ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter', 'funnel', 'pyramid'] },
10110
10451
  chartId: { type: 'string' },
10111
10452
  title: { oneOf: [{ type: 'string' }, { type: 'object' }] },
10112
10453
  subtitle: { oneOf: [{ type: 'string' }, { type: 'object' }] },
@@ -10135,7 +10476,7 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
10135
10476
  componentId: 'praxis-chart',
10136
10477
  ownerPackage: '@praxisui/charts',
10137
10478
  configSchemaId: 'PraxisXUiChartContract',
10138
- manifestVersion: '1.1.0',
10479
+ manifestVersion: '1.2.0',
10139
10480
  runtimeInputs: [
10140
10481
  { name: 'chartDocument', type: 'PraxisXUiChartContract | null', description: 'Canonical x-ui.chart document used as the authoring source of truth.' },
10141
10482
  { name: 'config', type: 'PraxisChartConfig', description: 'Runtime chart configuration mapped from or supplied beside the canonical chart document.' },
@@ -10180,11 +10521,11 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
10180
10521
  scope: 'global',
10181
10522
  targetKind: 'chartType',
10182
10523
  target: { kind: 'chartType', resolver: 'x-ui-chart-kind', ambiguityPolicy: 'fail', required: false },
10183
- inputSchema: { type: 'object', required: ['kind'], properties: { kind: { enum: ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter'] }, orientation: { enum: ['vertical', 'horizontal'] } } },
10524
+ inputSchema: { type: 'object', required: ['kind'], properties: { kind: { enum: ['bar', 'combo', 'horizontal-bar', 'line', 'pie', 'donut', 'area', 'stacked-bar', 'stacked-area', 'scatter', 'funnel', 'pyramid'] }, orientation: { enum: ['vertical', 'horizontal'] } } },
10184
10525
  effects: [{ kind: 'merge-object', path: 'chartDocument' }],
10185
10526
  destructive: false,
10186
10527
  requiresConfirmation: false,
10187
- validators: ['chart-type-supported', 'chart-type-series-axis-compatible', 'pie-single-metric', 'combo-minimum-series', 'editor-runtime-round-trip'],
10528
+ validators: ['chart-type-supported', 'chart-type-series-axis-compatible', 'category-slice-single-metric', 'combo-minimum-series', 'editor-runtime-round-trip'],
10188
10529
  affectedPaths: ['chartDocument.kind', 'chartDocument.orientation', 'chartDocument.metrics[]', 'chartDocument.dimensions[]'],
10189
10530
  submissionImpact: 'config-only',
10190
10531
  preconditions: ['config-initialized'],
@@ -10455,7 +10796,7 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
10455
10796
  { validatorId: 'chart-version-supported', level: 'error', code: 'CHART_VERSION_SUPPORTED', description: `Only x-ui.chart version ${PRAXIS_X_UI_CHART_AUTHORABLE_VERSION} is authorable.` },
10456
10797
  { validatorId: 'chart-type-supported', level: 'error', code: 'CHART_TYPE_SUPPORTED', description: 'Chart kind must be supported by @praxisui/charts.' },
10457
10798
  { validatorId: 'chart-type-series-axis-compatible', level: 'error', code: 'CHART_TYPE_SERIES_AXIS_COMPATIBLE', description: 'Chart kind, dimensions, metrics and axes must be compatible.' },
10458
- { validatorId: 'pie-single-metric', level: 'error', code: 'PIE_SINGLE_METRIC', description: 'Pie and donut charts support a single metric.' },
10799
+ { validatorId: 'category-slice-single-metric', level: 'error', code: 'CATEGORY_SLICE_SINGLE_METRIC', description: 'Pie, donut, funnel and pyramid charts require exactly one metric.' },
10459
10800
  { validatorId: 'combo-minimum-series', level: 'error', code: 'COMBO_MINIMUM_SERIES', description: 'Combo charts require at least two metrics.' },
10460
10801
  { validatorId: 'series-field-exists', level: 'error', code: 'SERIES_FIELD_EXISTS', description: 'Series field must exist in availableFields or schema context.' },
10461
10802
  { validatorId: 'series-field-aggregable', level: 'error', code: 'SERIES_FIELD_AGGREGABLE', description: 'Metric fields must support the requested aggregation.' },
@@ -10516,6 +10857,99 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
10516
10857
  ],
10517
10858
  };
10518
10859
 
10860
+ const PRAXIS_ANALYTICS_PRESENTATION_AUTHORING_MANIFEST = {
10861
+ schemaVersion: '1.0.0',
10862
+ componentId: 'praxis-analytics-presentation',
10863
+ ownerPackage: '@praxisui/charts',
10864
+ configSchemaId: 'PraxisAnalyticsPresentationInputs',
10865
+ manifestVersion: '1.0.0',
10866
+ runtimeInputs: [
10867
+ { name: 'analytics', type: 'PraxisXUiAnalytics', description: 'Canonical x-ui.analytics projections.' },
10868
+ { name: 'projectionId', type: 'string', description: 'Stable projection identity; labels never select projections.' },
10869
+ { name: 'availableFamilies', type: 'AnalyticsPresentationFamily[]', description: 'Capability- and host-authorized renderer families.' },
10870
+ { name: 'preferredFamily', type: 'AnalyticsPresentationFamily | null', allowedValues: ['chart', 'analytic-table'], description: 'Persisted host preference. Temporary user choice remains runtime-only.' },
10871
+ { name: 'queryContext', type: 'PraxisDataQueryContext | null', description: 'Shared governed query context forwarded to the selected renderer.' },
10872
+ ],
10873
+ editableTargets: [
10874
+ {
10875
+ kind: 'analyticsPresentationPreference',
10876
+ resolver: 'component-config',
10877
+ description: 'Persisted preferredFamily for the projection selected by exact projectionId.',
10878
+ },
10879
+ ],
10880
+ operations: [
10881
+ {
10882
+ operationId: 'analytics.presentation.prefer-family',
10883
+ title: 'Prefer analytics presentation family',
10884
+ description: 'Persists a renderer-family preference without changing the canonical projection, bindings or query context.',
10885
+ scope: 'global',
10886
+ targetKind: 'analyticsPresentationPreference',
10887
+ target: {
10888
+ kind: 'analyticsPresentationPreference',
10889
+ resolver: 'component-config',
10890
+ ambiguityPolicy: 'fail',
10891
+ required: false,
10892
+ },
10893
+ inputSchema: {
10894
+ type: 'object',
10895
+ additionalProperties: false,
10896
+ required: ['family'],
10897
+ properties: { family: { enum: ['chart', 'analytic-table'] } },
10898
+ },
10899
+ effects: [{ kind: 'set-value', path: 'preferredFamily', inputPath: 'family' }],
10900
+ destructive: false,
10901
+ requiresConfirmation: false,
10902
+ validators: ['diagnostics-before-patch', 'renderer-supported', 'runtime-component-resolves'],
10903
+ affectedPaths: ['preferredFamily'],
10904
+ submissionImpact: 'visual-only',
10905
+ preconditions: ['analytics-projection-resolved'],
10906
+ },
10907
+ ],
10908
+ validators: [
10909
+ { validatorId: 'diagnostics-before-patch', level: 'error', code: 'ANALYTICS_DIAGNOSTICS_BEFORE_PATCH', description: 'Projection resolution and family eligibility diagnostics must be clear before changing the preference.' },
10910
+ { validatorId: 'renderer-supported', level: 'error', code: 'ANALYTICS_RENDERER_SUPPORTED', description: 'The requested family must be a renderer supported by the component manifest.' },
10911
+ { validatorId: 'runtime-component-resolves', level: 'error', code: 'ANALYTICS_RUNTIME_COMPONENT_RESOLVES', description: 'The analytics presentation runtime must be registered before persisting its preference.' },
10912
+ ],
10913
+ roundTripRequirements: [
10914
+ 'Save and reopen must preserve preferredFamily without copying remote analytics results into the page document.',
10915
+ 'Changing preferredFamily must preserve analytics, projectionId and queryContext byte-for-byte.',
10916
+ 'Temporary user choice emitted by presentationChange must not mutate preferredFamily unless the host explicitly persists it.',
10917
+ ],
10918
+ presentationAffordances: {
10919
+ version: '1.0.0',
10920
+ componentId: 'praxis-analytics-presentation',
10921
+ defaultTargetKind: 'analyticsPresentationPreference',
10922
+ sourceRef: 'AnalyticsPresentationResolver',
10923
+ affordances: [
10924
+ {
10925
+ id: 'analytics-presentation-family',
10926
+ targetKind: 'analyticsPresentationPreference',
10927
+ category: 'analytics-renderer',
10928
+ description: 'Compatible presentation families resolved from the canonical projection and installed renderers.',
10929
+ options: ['chart', 'analytic-table'],
10930
+ appliesToTypes: ['PraxisXUiAnalytics'],
10931
+ unknownCompatible: false,
10932
+ },
10933
+ ],
10934
+ },
10935
+ examples: [
10936
+ {
10937
+ id: 'prefer-analytic-table',
10938
+ request: 'Use the table presentation for projection supplier-procurement-funnel.',
10939
+ operationId: 'analytics.presentation.prefer-family',
10940
+ params: { family: 'analytic-table' },
10941
+ isPositive: true,
10942
+ },
10943
+ {
10944
+ id: 'reject-kpi-without-renderer',
10945
+ request: 'Use KPI even though no KPI renderer is installed.',
10946
+ operationId: 'analytics.presentation.prefer-family',
10947
+ params: { family: 'kpi' },
10948
+ isPositive: false,
10949
+ },
10950
+ ],
10951
+ };
10952
+
10519
10953
  /*
10520
10954
  * Public API Surface of praxis-charts
10521
10955
  */
@@ -10524,4 +10958,4 @@ const PRAXIS_CHARTS_AUTHORING_MANIFEST = {
10524
10958
  * Generated bundle index. Do not edit.
10525
10959
  */
10526
10960
 
10527
- export { AnalyticsChartConfigAdapterService, AnalyticsChartContractService, ChartContractNormalizerService, ChartContractValidationService, ChartEditorDefaultsService, ChartEditorPreviewMapperService, ChartResourceCapabilityCatalogAdapter, 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_X_UI_CHART_AUTHORABLE_VERSION, PraxisChartBackendPayloadAdapterService, PraxisChartCanonicalContractMapperService, PraxisChartComponent, PraxisChartCompositionShowcaseComponent, PraxisChartConfigEditor, PraxisChartDataTransformerService, PraxisChartDrilldownPanelComponent, PraxisChartSchemaMapperService, PraxisChartStateProbeComponent, PraxisChartStatsApiService, PraxisChartWidgetConfigEditor, PraxisMicroVisualizationComponent, buildPraxisChartInteractiveCanvasPage, buildPraxisChartInteractiveWidgetPage, buildPraxisChartMockCanvasPage, buildPraxisChartMockWidgetPage, createPraxisChartsI18nConfig, isPraxisChartPaletteToken, providePraxisChartDrilldownPanelMetadata, providePraxisChartStateProbeMetadata, providePraxisCharts, providePraxisChartsI18n, providePraxisChartsMetadata, resolvePraxisChartPaletteToken, resolvePraxisChartsText };
10961
+ 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_X_UI_CHART_AUTHORABLE_VERSION, PraxisAnalyticsPresentationComponent, PraxisChartBackendPayloadAdapterService, PraxisChartCanonicalContractMapperService, PraxisChartComponent, PraxisChartCompositionShowcaseComponent, PraxisChartConfigEditor, PraxisChartDataTransformerService, PraxisChartDrilldownPanelComponent, PraxisChartSchemaMapperService, PraxisChartStateProbeComponent, PraxisChartStatsApiService, PraxisChartWidgetConfigEditor, PraxisMicroVisualizationComponent, buildPraxisChartInteractiveCanvasPage, buildPraxisChartInteractiveWidgetPage, buildPraxisChartMockCanvasPage, buildPraxisChartMockWidgetPage, createPraxisChartsI18nConfig, isPraxisChartPaletteToken, providePraxisAnalyticsPresentationMetadata, providePraxisChartDrilldownPanelMetadata, providePraxisChartStateProbeMetadata, providePraxisCharts, providePraxisChartsI18n, providePraxisChartsMetadata, resolvePraxisChartPaletteToken, resolvePraxisChartsText };