@foxeltech/angular-ui 0.7.104 → 0.7.108

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.
package/components.css CHANGED
@@ -849,3 +849,186 @@ input[type='password'] {
849
849
  .fx-rs-busy > *:not(fx-ui-route-skeleton) {
850
850
  display: none !important;
851
851
  }
852
+
853
+ /* ============================================================================
854
+ Grid tables, badges and status dots to the reference HTML (Binary
855
+ Intelligence Dashboard, porcelain) — anh Tú 15/09, moved into the lib in
856
+ 0.7.107 from three identical copies in analyze/knowledge/host styles.scss.
857
+
858
+ The reference draws its lists as CSS-grid rows, not <table>: a sunken 10px
859
+ header in 10.5px/600 tracked faint, 11px rows split by hairlines, hover to
860
+ surface-sunken, a trailing caret that turns accent and slides 2px. Columns
861
+ come from the consumer as `--fx-cols` on `.fx-grid-table`.
862
+
863
+ Inside layer(components) like the rest of this file, so a Tailwind utility
864
+ on the same element still wins.
865
+ ============================================================================ */
866
+ .fx-grid-table {
867
+ border: 1px solid rgb(var(--og-border));
868
+ border-radius: var(--og-radius-card);
869
+ background: rgb(var(--og-surface));
870
+ overflow: hidden;
871
+ }
872
+ .fx-grid-table__head,
873
+ .fx-grid-table__row {
874
+ display: grid;
875
+ grid-template-columns: var(--fx-cols);
876
+ gap: 12px;
877
+ align-items: center;
878
+ padding: 10px 12px;
879
+ }
880
+ .fx-grid-table__head {
881
+ border-bottom: 1px solid rgb(var(--og-border));
882
+ background: rgb(var(--og-surface-sunken));
883
+ font-size: var(--og-fs-2xs);
884
+ font-weight: 600;
885
+ letter-spacing: var(--og-tracking-label);
886
+ color: rgb(var(--og-text-faint));
887
+ }
888
+ .fx-grid-table__row {
889
+ appearance: none;
890
+ width: 100%;
891
+ padding: 11px 12px;
892
+ border: 0;
893
+ border-bottom: 1px solid rgb(var(--og-border-subtle));
894
+ background: transparent;
895
+ text-align: left;
896
+ cursor: pointer;
897
+ font: inherit;
898
+ color: inherit;
899
+ transition: background-color 130ms ease;
900
+ }
901
+ .fx-grid-table__row:last-child {
902
+ border-bottom: 0;
903
+ }
904
+ .fx-grid-table__row:hover {
905
+ background: rgb(var(--og-surface-sunken));
906
+ }
907
+ .fx-grid-table__row--on {
908
+ background: rgb(var(--og-accent-soft-bg));
909
+ }
910
+ .fx-grid-table__row:focus-visible {
911
+ outline: 2px solid rgb(var(--og-accent));
912
+ outline-offset: -2px;
913
+ }
914
+ .fx-grid-caret {
915
+ font-size: 15px;
916
+ line-height: 1;
917
+ color: rgb(var(--og-text-faint));
918
+ justify-self: end;
919
+ transition:
920
+ color 130ms ease,
921
+ transform 130ms ease;
922
+ }
923
+ .fx-grid-table__row:hover .fx-grid-caret {
924
+ color: rgb(var(--og-accent));
925
+ transform: translateX(2px);
926
+ }
927
+ .fx-grid-table__num {
928
+ font-family: var(--og-font-mono);
929
+ font-size: var(--og-fs-sm);
930
+ color: rgb(var(--og-text-body));
931
+ text-align: right;
932
+ font-variant-numeric: tabular-nums;
933
+ }
934
+ .fx-grid-table__head .fx-grid-table__num {
935
+ font-family: inherit;
936
+ font-size: inherit;
937
+ color: inherit;
938
+ }
939
+ .fx-grid-table__strong {
940
+ font-size: var(--og-fs-sm);
941
+ font-weight: 600;
942
+ color: rgb(var(--og-text));
943
+ }
944
+ .fx-grid-table__muted {
945
+ font-size: var(--og-fs-xs);
946
+ color: rgb(var(--og-text-muted));
947
+ }
948
+ .fx-grid-table__mono {
949
+ font-family: var(--og-font-mono);
950
+ font-size: var(--og-fs-xs);
951
+ color: rgb(var(--og-text-faint));
952
+ }
953
+ .fx-grid-empty {
954
+ padding: 15px;
955
+ text-align: center;
956
+ color: rgb(var(--og-text-muted));
957
+ font-size: var(--og-fs-sm);
958
+ border: 1px dashed rgb(var(--og-border));
959
+ border-radius: var(--og-radius-card);
960
+ }
961
+
962
+ /* `.og-badge`: 3px 10px, pill, 11.5px, soft tint by tone; outline = the
963
+ neutral "Low" band; solid = Critical. Tone names are the SEMANTIC tokens
964
+ (`--og-negative`, not the chart ramp) — see the note above `.tone-tint-*`. */
965
+ .fx-badge {
966
+ display: inline-flex;
967
+ align-items: center;
968
+ gap: 5px;
969
+ padding: 3px 10px;
970
+ border-radius: var(--og-radius-pill);
971
+ font-size: var(--og-fs-xs);
972
+ line-height: 1.5;
973
+ white-space: nowrap;
974
+ font-weight: 500;
975
+ }
976
+ .fx-badge--negative {
977
+ background: rgb(var(--og-negative-soft-bg));
978
+ color: rgb(var(--og-negative));
979
+ }
980
+ .fx-badge--warning {
981
+ background: rgb(var(--og-warning-soft-bg));
982
+ color: rgb(var(--og-warning));
983
+ }
984
+ .fx-badge--positive {
985
+ background: rgb(var(--og-positive-soft-bg));
986
+ color: rgb(var(--og-positive));
987
+ }
988
+ .fx-badge--neutral {
989
+ background: rgb(var(--og-neutral-soft-bg));
990
+ color: rgb(var(--og-neutral-soft-fg));
991
+ }
992
+ .fx-badge--accent {
993
+ background: rgb(var(--og-accent-soft-bg));
994
+ color: rgb(var(--og-accent-soft-fg));
995
+ }
996
+ .fx-badge--outline {
997
+ background: none;
998
+ border: 1px solid rgb(var(--og-border));
999
+ color: rgb(var(--og-text-muted));
1000
+ }
1001
+ .fx-badge--solid {
1002
+ background: rgb(var(--og-text));
1003
+ color: rgb(var(--og-surface));
1004
+ }
1005
+
1006
+ /* `.og-statusdot`: a 7px mark and the state's name. */
1007
+ .fx-statusdot {
1008
+ display: inline-flex;
1009
+ align-items: center;
1010
+ gap: 6px;
1011
+ font-size: var(--og-fs-sm);
1012
+ color: rgb(var(--og-text-body));
1013
+ white-space: nowrap;
1014
+ }
1015
+ .fx-statusdot::before {
1016
+ content: '';
1017
+ width: 7px;
1018
+ height: 7px;
1019
+ border-radius: 50%;
1020
+ background: rgb(var(--og-text-faint));
1021
+ flex: none;
1022
+ }
1023
+ .fx-statusdot--positive::before {
1024
+ background: rgb(var(--og-positive));
1025
+ }
1026
+ .fx-statusdot--negative::before {
1027
+ background: rgb(var(--og-negative));
1028
+ }
1029
+ .fx-statusdot--warning::before {
1030
+ background: rgb(var(--og-warning));
1031
+ }
1032
+ .fx-statusdot--accent::before {
1033
+ background: rgb(var(--og-accent));
1034
+ }
@@ -22,10 +22,34 @@ class ChartComponent {
22
22
  showPieLegend = input(true, ...(ngDevMode ? [{ debugName: "showPieLegend" }] : /* istanbul ignore next */ []));
23
23
  /** Caption under the donut's centre total (design: "42 / confirmed"). */
24
24
  pieCenterLabel = input('', ...(ngDevMode ? [{ debugName: "pieCenterLabel" }] : /* istanbul ignore next */ []));
25
+ /**
26
+ * Type size of the donut's centre TOTAL, in px. The default matches what the
27
+ * figure has always been; raise it where the total is the panel's headline
28
+ * rather than an annotation.
29
+ */
30
+ pieCenterValueSize = input(24, ...(ngDevMode ? [{ debugName: "pieCenterValueSize" }] : /* istanbul ignore next */ []));
31
+ /**
32
+ * The donut's [inner, outer] radius — ECharts' own vocabulary, so either a
33
+ * percentage of the shorter side or a pixel count. The default is what the
34
+ * ring has always been. The reference dashboard draws a 140px ring with a
35
+ * 22px stroke, i.e. `[48, 70]` at `height="140px"` — the panel that wants it
36
+ * says so; the other pies keep their proportions.
37
+ */
38
+ pieRadius = input(['40%', '70%'], ...(ngDevMode ? [{ debugName: "pieRadius" }] : /* istanbul ignore next */ []));
25
39
  mobileWidth = input(350, ...(ngDevMode ? [{ debugName: "mobileWidth" }] : /* istanbul ignore next */ []));
26
40
  /** Override font for canvas text; empty = follow the theme's --og-font-figure. */
27
41
  labelFontFamily = input('', ...(ngDevMode ? [{ debugName: "labelFontFamily" }] : /* istanbul ignore next */ []));
28
42
  emptyText = input('No data available', ...(ngDevMode ? [{ debugName: "emptyText" }] : /* istanbul ignore next */ []));
43
+ // line / area-trend controls
44
+ /**
45
+ * Print each point's value beside a marker on the line.
46
+ *
47
+ * Off by default: on a dense series the labels collide and the line becomes
48
+ * unreadable. Turn it on for the sparse, weekly kind of series where the
49
+ * number at each step IS the content — a reader should not have to hover to
50
+ * learn what a step was.
51
+ */
52
+ showPointLabels = input(false, ...(ngDevMode ? [{ debugName: "showPointLabels" }] : /* istanbul ignore next */ []));
29
53
  /* ================= OUTPUTS ================= */
30
54
  chartClick = output();
31
55
  chartInit = output();
@@ -78,6 +102,8 @@ class ChartComponent {
78
102
  */
79
103
  appearUntil = 0;
80
104
  static APPEAR_MS = 1100;
105
+ /** Quiet time a host size has to hold before a resize is acted on. */
106
+ static RESIZE_SETTLE_MS = 120;
81
107
  deferTimer;
82
108
  /**
83
109
  * Thay đổi kích thước ghi nhận TRONG cửa sổ appear, chờ áp sau. `measure()`
@@ -107,10 +133,15 @@ class ChartComponent {
107
133
  // resize() mù sau throttle 100ms, kể cả về 0×H khi phần tử display:none
108
134
  // (skeleton route ẩn nội dung cũ) rồi resize lại khi hiện → animation
109
135
  // xuất hiện bị giết (đo 05/09 khi đổi project trên chip breadcrumb).
136
+ // Coalesced: a host that is being ANIMATED (the drawer's 300ms width
137
+ // tween, a panel sliding open) fires one RO event per frame, and acting
138
+ // on each one resized + rebuilt the pie ~18 times and replayed its appear
139
+ // animation twice (anh Tú 15/09, donut on sidebar toggle). Wait until the
140
+ // size has been still for RESIZE_SETTLE_MS, then act once.
110
141
  this.resizeObserver = new ResizeObserver(() => {
111
142
  if (!this.canInit || this.destroyed)
112
143
  return;
113
- this.forceMediaRecalc();
144
+ this.deferResize(ChartComponent.RESIZE_SETTLE_MS);
114
145
  });
115
146
  this.resizeObserver.observe(this.ref.nativeElement);
116
147
  }
@@ -247,7 +278,14 @@ class ChartComponent {
247
278
  * lại cùng cỡ thì coi như không đổi (không resize, không cắt animation).
248
279
  */
249
280
  measure() {
250
- const { width, height } = this.ref.nativeElement.getBoundingClientRect();
281
+ const rect = this.ref.nativeElement.getBoundingClientRect();
282
+ // Whole pixels only. getBoundingClientRect() follows transforms, so a host
283
+ // riding an fxReveal translateY reports 200.00003 then 199.99997 — the
284
+ // old strict compare took that jitter for a resize, and after the appear
285
+ // window a pie was resized + rebuilt = drawn twice (đo 07/09, Overview
286
+ // "Open findings by severity" 2 × 60 frames 1.1 s apart).
287
+ const width = Math.round(rect.width);
288
+ const height = Math.round(rect.height);
251
289
  if (width <= 0 || height <= 0)
252
290
  return { size: false, mobile: false };
253
291
  const isMobile = width < this.mobileWidth();
@@ -262,10 +300,18 @@ class ChartComponent {
262
300
  markAppear() {
263
301
  this.appearUntil = performance.now() + ChartComponent.APPEAR_MS;
264
302
  }
265
- /** Pie/sunburst đặt legend + số giữa theo px của containerWidth → phải dựng lại. */
303
+ /**
304
+ * Sunburst, and a pie WITH its built-in legend, place things in px of
305
+ * containerWidth → must rebuild on resize. A legend-less pie centres its
306
+ * ring at 50%/50% and its total in a `left: 'center' / top: 'middle'`
307
+ * graphic group, so `resize()` alone re-centres it — no rebuild, no
308
+ * second appear animation (15/09, donut on sidebar toggle).
309
+ */
266
310
  needsLayoutRebuild() {
267
311
  const type = this.detectType();
268
- return type === 'pie' || type === 'sunburst';
312
+ if (type === 'sunburst')
313
+ return true;
314
+ return type === 'pie' && this.showPieLegend();
269
315
  }
270
316
  deferResize(ms) {
271
317
  if (this.deferTimer !== undefined)
@@ -318,10 +364,19 @@ class ChartComponent {
318
364
  if (!chart)
319
365
  return;
320
366
  chart.resize();
321
- if (mobile || this.needsLayoutRebuild())
367
+ // A rebuild forced by GEOMETRY draws the same data at a new size: no
368
+ // appear animation, or the panel visibly redraws (that was the "renders
369
+ // twice" of 15/09). Only a preset change (mobile) keeps the animation.
370
+ if (mobile)
322
371
  this.rebuildChart();
372
+ else if (this.needsLayoutRebuild())
373
+ this.rebuildChart(true);
323
374
  }
324
- rebuildChart() {
375
+ /**
376
+ * @param silent redraw for a new size only — same data, no appear animation,
377
+ * and the appear window is not re-armed.
378
+ */
379
+ rebuildChart(silent = false) {
325
380
  if (this.destroyed)
326
381
  return;
327
382
  this.isEmpty = this.checkEmpty();
@@ -329,7 +384,7 @@ class ChartComponent {
329
384
  this.liveChart?.clear();
330
385
  return;
331
386
  }
332
- const options = this.buildOptions();
387
+ const options = silent ? { ...this.buildOptions(), animation: false } : this.buildOptions();
333
388
  const chart = this.liveChart;
334
389
  if (!chart) {
335
390
  if (this.awaitingFirstInit) {
@@ -347,7 +402,8 @@ class ChartComponent {
347
402
  this.hasOptions = true;
348
403
  chart.clear();
349
404
  chart.setOption(options, true);
350
- this.markAppear();
405
+ if (!silent)
406
+ this.markAppear();
351
407
  }
352
408
  checkEmpty() {
353
409
  const type = this.detectType();
@@ -466,7 +522,7 @@ class ChartComponent {
466
522
  series: [
467
523
  {
468
524
  type: 'pie',
469
- radius: ['40%', '70%'],
525
+ radius: this.pieRadius(),
470
526
  center: layout.pieCenter,
471
527
  data,
472
528
  label: {
@@ -486,46 +542,62 @@ class ChartComponent {
486
542
  },
487
543
  },
488
544
  ],
489
- // Centre figure: positioned by x/y in pixels graphic left/top
490
- // percentages place the element's bounding-box EDGE, which shifted the
491
- // total right/down by half its own size (mis-centred, caption clipped).
545
+ // Centre figure. With the built-in legend the ring sits off-centre
546
+ // (layout.pieCenter in px of containerWidth), so the texts are placed
547
+ // by x/y in pixels graphic left/top PERCENTAGES place the element's
548
+ // bounding-box EDGE and mis-centred the total. Without the legend the
549
+ // ring is at 50%/50% and the pair goes in a group with the KEYWORDS
550
+ // left: 'center' / top: 'middle', which ECharts re-lays out on
551
+ // `resize()` — so a legend-less donut never needs a rebuild to stay
552
+ // centred (15/09).
492
553
  graphic: (() => {
493
- const cx = (this.containerWidth || 400) * (parseFloat(layout.pieCenter[0]) / 100);
494
- const ch = this.ref.nativeElement.clientHeight || 300;
495
- const cy = ch * (parseFloat(layout.pieCenter[1]) / 100);
496
- return [
497
- {
554
+ const centre = this.pieCentreAnchors();
555
+ const font = this.resolvedFontFamily();
556
+ const totalText = {
557
+ type: 'text',
558
+ style: {
559
+ text: total.toString(),
560
+ fontSize: this.pieCenterValueSize(),
561
+ fontWeight: 600,
562
+ fontFamily: font,
563
+ fill: FxUtils.convertColorFromVariable('--text-primary'),
564
+ align: 'center',
565
+ verticalAlign: 'middle',
566
+ },
567
+ };
568
+ const captionText = this.pieCenterLabel()
569
+ ? {
498
570
  type: 'text',
499
- // Shift the total up when a caption sits under it.
500
- x: cx,
501
- y: cy + (this.pieCenterLabel() ? -9 : 0),
502
571
  style: {
503
- text: total.toString(),
504
- fontSize: 24,
505
- fontWeight: 600,
506
- fontFamily: this.resolvedFontFamily(),
507
- fill: FxUtils.convertColorFromVariable('--text-primary'),
572
+ text: this.pieCenterLabel(),
573
+ fontSize: 11,
574
+ fontFamily: font,
575
+ fill: FxUtils.convertColorFromVariable('--text-secondary'),
508
576
  align: 'center',
509
577
  verticalAlign: 'middle',
510
578
  },
511
- },
512
- ...(this.pieCenterLabel()
513
- ? [
514
- {
515
- type: 'text',
516
- x: cx,
517
- y: cy + 12,
518
- style: {
519
- text: this.pieCenterLabel(),
520
- fontSize: 11,
521
- fontFamily: this.resolvedFontFamily(),
522
- fill: FxUtils.convertColorFromVariable('--text-secondary'),
523
- align: 'center',
524
- verticalAlign: 'middle',
525
- },
526
- },
527
- ]
528
- : []),
579
+ }
580
+ : undefined;
581
+ if (!this.showPieLegend()) {
582
+ return [
583
+ {
584
+ type: 'group',
585
+ left: 'center',
586
+ top: 'middle',
587
+ silent: true,
588
+ children: [
589
+ { ...totalText, x: 0, y: centre.total },
590
+ ...(captionText ? [{ ...captionText, x: 0, y: centre.caption }] : []),
591
+ ],
592
+ },
593
+ ];
594
+ }
595
+ const cx = (this.containerWidth || 400) * (parseFloat(layout.pieCenter[0]) / 100);
596
+ const ch = this.ref.nativeElement.clientHeight || 300;
597
+ const cy = ch * (parseFloat(layout.pieCenter[1]) / 100);
598
+ return [
599
+ { ...totalText, x: cx, y: cy + centre.total },
600
+ ...(captionText ? [{ ...captionText, x: cx, y: cy + centre.caption }] : []),
529
601
  ];
530
602
  })(),
531
603
  };
@@ -568,7 +640,7 @@ class ChartComponent {
568
640
  name: data?.currentLabel || 'Current',
569
641
  data: data?.current || [],
570
642
  smooth: true,
571
- symbol: 'none',
643
+ ...this.pointMarks('top', accent),
572
644
  lineStyle: { width: 2, color: accent },
573
645
  itemStyle: { color: accent },
574
646
  areaStyle: {
@@ -589,7 +661,9 @@ class ChartComponent {
589
661
  name: data?.previousLabel || 'Previous',
590
662
  data: data.previous,
591
663
  smooth: true,
592
- symbol: 'none',
664
+ // Labels below this line, above the other one: two series sharing an
665
+ // x tick would otherwise print their numbers on top of each other.
666
+ ...this.pointMarks('bottom', FxUtils.convertColorFromVariable('--text-placeholder', 1, { legacyCommaSyntax: true })),
593
667
  lineStyle: {
594
668
  width: 1.5,
595
669
  type: 'dashed',
@@ -696,14 +770,77 @@ class ChartComponent {
696
770
  return 'pie';
697
771
  return 'bar';
698
772
  }
773
+ /**
774
+ * Marker and value label for one line series, per `showPointLabels`.
775
+ *
776
+ * Returns the OFF state explicitly (`symbol: 'none'`) rather than nothing, so
777
+ * the caller spreads one object either way and the default stays exactly what
778
+ * it was before this input existed.
779
+ */
780
+ pointMarks(position, color) {
781
+ if (!this.showPointLabels())
782
+ return { symbol: 'none' };
783
+ return {
784
+ symbol: 'circle',
785
+ symbolSize: 6,
786
+ showSymbol: true,
787
+ label: {
788
+ show: true,
789
+ position,
790
+ distance: 7,
791
+ fontSize: this.labelFontSize,
792
+ fontFamily: this.resolvedFontFamily(),
793
+ color,
794
+ },
795
+ };
796
+ }
797
+ /**
798
+ * Where the donut's centre total and its caption sit, relative to the ring's
799
+ * centre.
800
+ *
801
+ * MEASURED, not guessed. Both numbers below were read off a render at two
802
+ * sizes (glyph ink bounding boxes against the ring's own bounding box):
803
+ * neutral-figure ink is ~0.73 of the font size tall, and an ECharts text with
804
+ * `verticalAlign: 'middle'` places its ANCHOR ~0.095 of the font size BELOW
805
+ * the ink's optical centre — digits have no descender, so the ink rides high
806
+ * in the line box.
807
+ *
808
+ * The line gap is a CONSTANT. That is the whole point of this function: the
809
+ * gap between two lines is typographic and does not scale with the size of
810
+ * the number above it. Deriving it from the font size instead — which the
811
+ * first cut of `pieCenterValueSize` did — inflated a 9.6px gap to 18.2px at
812
+ * 38px type and pushed the pair 4.8px above the ring's centre. The hand-tuned
813
+ * constants it replaced were already 2.5px high at the default size; this
814
+ * lands the block dead centre at every size (verified 24/30/38/48).
815
+ */
816
+ pieCentreAnchors() {
817
+ const FIGURE_INK = 0.73;
818
+ const MIDDLE_ANCHOR_DROP = 0.095;
819
+ const LINE_GAP = 10;
820
+ const CAPTION_SIZE = 11;
821
+ const value = this.pieCenterValueSize();
822
+ // No caption: one line, its ink centred on the ring.
823
+ if (!this.pieCenterLabel()) {
824
+ return { total: MIDDLE_ANCHOR_DROP * value, caption: 0 };
825
+ }
826
+ const valueInk = FIGURE_INK * value;
827
+ const captionInk = FIGURE_INK * CAPTION_SIZE;
828
+ const blockHeight = valueInk + LINE_GAP + captionInk;
829
+ return {
830
+ total: -blockHeight / 2 + valueInk / 2 + MIDDLE_ANCHOR_DROP * value,
831
+ caption: blockHeight / 2 - captionInk / 2 + MIDDLE_ANCHOR_DROP * CAPTION_SIZE,
832
+ };
833
+ }
699
834
  buildOptions() {
700
835
  const type = this.detectType();
701
836
  const palette = this.resolvePalette();
702
837
  const base = this.registry[type]?.(this.data(), palette) || {};
703
- return {
704
- ...base,
705
- ...this.options(),
706
- };
838
+ // DEEP merge, not a spread. A shallow one replaced whole branches, so
839
+ // `[options]="{series:[{label:…}]}"` threw away the recipe's own series —
840
+ // data included — and there was no way to adjust one nested detail from
841
+ // outside. Arrays merge BY INDEX for the same reason: a caller tweaking
842
+ // series 0 must not have to restate series 1.
843
+ return deepMergeOptions(base, this.options());
707
844
  }
708
845
  categoryAxis(categories) {
709
846
  return {
@@ -770,15 +907,49 @@ class ChartComponent {
770
907
  });
771
908
  }
772
909
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: ChartComponent, deps: [{ token: i0.ElementRef }, { token: i0.ChangeDetectorRef }], target: i0.ɵɵFactoryTarget.Component });
773
- static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.20", type: ChartComponent, isStandalone: true, selector: "fx-ui-chart", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, palette: { classPropertyName: "palette", publicName: "palette", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, pieLegendAlign: { classPropertyName: "pieLegendAlign", publicName: "pieLegendAlign", isSignal: true, isRequired: false, transformFunction: null }, pieLegendPosition: { classPropertyName: "pieLegendPosition", publicName: "pieLegendPosition", isSignal: true, isRequired: false, transformFunction: null }, showPieLabel: { classPropertyName: "showPieLabel", publicName: "showPieLabel", isSignal: true, isRequired: false, transformFunction: null }, showPieLegend: { classPropertyName: "showPieLegend", publicName: "showPieLegend", isSignal: true, isRequired: false, transformFunction: null }, pieCenterLabel: { classPropertyName: "pieCenterLabel", publicName: "pieCenterLabel", isSignal: true, isRequired: false, transformFunction: null }, mobileWidth: { classPropertyName: "mobileWidth", publicName: "mobileWidth", isSignal: true, isRequired: false, transformFunction: null }, labelFontFamily: { classPropertyName: "labelFontFamily", publicName: "labelFontFamily", isSignal: true, isRequired: false, transformFunction: null }, emptyText: { classPropertyName: "emptyText", publicName: "emptyText", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chartClick: "chartClick", chartInit: "chartInit" }, host: { listeners: { "window:theme-changed": "onThemeChange()" } }, providers: [provideEchartsCore({ echarts: () => import('echarts') })], usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-wrapper\" [style.height]=\"height()\">\n @if (isEmpty) {\n <div class=\"chart-empty\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"chart-empty-icon\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z\" />\n </svg>\n <span class=\"chart-empty-text\">{{ emptyText() }}</span>\n </div>\n } @else if (hasOptions) {\n <div\n echarts\n [options]=\"chartOptions\"\n [autoResize]=\"false\"\n [loading]=\"loading()\"\n (chartInit)=\"onChartInit($event)\"\n (chartClick)=\"onChartClick($event)\"\n class=\"fx-chart\"\n [style.height]=\"height()\"\n ></div>\n } @else {\n <!-- Until echarts is loaded + initialised (or the element scrolls into view)\n the panel keeps the chart's footprint with a quiet pulsing sketch\n instead of an empty box (anh T\u00FA 04/09: \"the page is too blank\"). -->\n <div class=\"chart-placeholder\" aria-hidden=\"true\">\n @for (h of placeholderBars; track $index) {\n <span class=\"chart-placeholder__bar\" [style.height.%]=\"h\"></span>\n }\n </div>\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.chart-wrapper{position:relative;width:100%;height:100%}.chart-container{width:100%;height:100%}.chart-loader{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(var(--bg-primary-hover)/.6);font-weight:500;font-size:1.1rem;z-index:2;border-radius:4px}.chart-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;color:rgba(var(--text-secondary)/1)}.chart-empty-icon{width:48px;height:48px;opacity:.4}.chart-empty-text{font-size:14px;font-weight:500}.chart-placeholder{position:absolute;inset:0;display:flex;align-items:flex-end;gap:6px;padding:12px 8px 22px;animation:chart-placeholder-pulse 1.6s ease-in-out infinite}.chart-placeholder__bar{flex:1;border-radius:3px 3px 0 0;background:rgb(var(--og-track))}@keyframes chart-placeholder-pulse{0%,to{opacity:1}50%{opacity:.45}}@media(prefers-reduced-motion:reduce){.chart-placeholder{animation:none}}\n"], dependencies: [{ kind: "directive", type: NgxEchartsDirective, selector: "echarts, [echarts]", inputs: ["options", "theme", "initOpts", "merge", "autoResize", "loading", "loadingType", "loadingOpts"], outputs: ["chartInit", "optionsError", "chartClick", "chartDblClick", "chartMouseDown", "chartMouseMove", "chartMouseUp", "chartMouseOver", "chartMouseOut", "chartGlobalOut", "chartContextMenu", "chartHighlight", "chartDownplay", "chartSelectChanged", "chartLegendSelectChanged", "chartLegendSelected", "chartLegendUnselected", "chartLegendLegendSelectAll", "chartLegendLegendInverseSelect", "chartLegendScroll", "chartDataZoom", "chartDataRangeSelected", "chartGraphRoam", "chartGeoRoam", "chartTreeRoam", "chartTimelineChanged", "chartTimelinePlayChanged", "chartRestore", "chartDataViewChanged", "chartMagicTypeChanged", "chartGeoSelectChanged", "chartGeoSelected", "chartGeoUnselected", "chartAxisAreaSelected", "chartBrush", "chartBrushEnd", "chartBrushSelected", "chartGlobalCursorTaken", "chartRendered", "chartFinished"], exportAs: ["echarts"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
910
+ static ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "21.2.20", type: ChartComponent, isStandalone: true, selector: "fx-ui-chart", inputs: { type: { classPropertyName: "type", publicName: "type", isSignal: true, isRequired: false, transformFunction: null }, data: { classPropertyName: "data", publicName: "data", isSignal: true, isRequired: false, transformFunction: null }, options: { classPropertyName: "options", publicName: "options", isSignal: true, isRequired: false, transformFunction: null }, palette: { classPropertyName: "palette", publicName: "palette", isSignal: true, isRequired: false, transformFunction: null }, height: { classPropertyName: "height", publicName: "height", isSignal: true, isRequired: false, transformFunction: null }, loading: { classPropertyName: "loading", publicName: "loading", isSignal: true, isRequired: false, transformFunction: null }, pieLegendAlign: { classPropertyName: "pieLegendAlign", publicName: "pieLegendAlign", isSignal: true, isRequired: false, transformFunction: null }, pieLegendPosition: { classPropertyName: "pieLegendPosition", publicName: "pieLegendPosition", isSignal: true, isRequired: false, transformFunction: null }, showPieLabel: { classPropertyName: "showPieLabel", publicName: "showPieLabel", isSignal: true, isRequired: false, transformFunction: null }, showPieLegend: { classPropertyName: "showPieLegend", publicName: "showPieLegend", isSignal: true, isRequired: false, transformFunction: null }, pieCenterLabel: { classPropertyName: "pieCenterLabel", publicName: "pieCenterLabel", isSignal: true, isRequired: false, transformFunction: null }, pieCenterValueSize: { classPropertyName: "pieCenterValueSize", publicName: "pieCenterValueSize", isSignal: true, isRequired: false, transformFunction: null }, pieRadius: { classPropertyName: "pieRadius", publicName: "pieRadius", isSignal: true, isRequired: false, transformFunction: null }, mobileWidth: { classPropertyName: "mobileWidth", publicName: "mobileWidth", isSignal: true, isRequired: false, transformFunction: null }, labelFontFamily: { classPropertyName: "labelFontFamily", publicName: "labelFontFamily", isSignal: true, isRequired: false, transformFunction: null }, emptyText: { classPropertyName: "emptyText", publicName: "emptyText", isSignal: true, isRequired: false, transformFunction: null }, showPointLabels: { classPropertyName: "showPointLabels", publicName: "showPointLabels", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { chartClick: "chartClick", chartInit: "chartInit" }, host: { listeners: { "window:theme-changed": "onThemeChange()" } }, providers: [provideEchartsCore({ echarts: () => import('echarts') })], usesOnChanges: true, ngImport: i0, template: "<div class=\"chart-wrapper\" [style.height]=\"height()\">\n @if (isEmpty) {\n <div class=\"chart-empty\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"chart-empty-icon\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z\" />\n </svg>\n <span class=\"chart-empty-text\">{{ emptyText() }}</span>\n </div>\n } @else if (hasOptions) {\n <div\n echarts\n [options]=\"chartOptions\"\n [autoResize]=\"false\"\n [loading]=\"loading()\"\n (chartInit)=\"onChartInit($event)\"\n (chartClick)=\"onChartClick($event)\"\n class=\"fx-chart\"\n [style.height]=\"height()\"\n ></div>\n } @else {\n <!-- Until echarts is loaded + initialised (or the element scrolls into view)\n the panel keeps the chart's footprint with a quiet pulsing sketch\n instead of an empty box (anh T\u00FA 04/09: \"the page is too blank\"). -->\n <div class=\"chart-placeholder\" aria-hidden=\"true\">\n @for (h of placeholderBars; track $index) {\n <span class=\"chart-placeholder__bar\" [style.height.%]=\"h\"></span>\n }\n </div>\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.chart-wrapper{position:relative;width:100%;height:100%}.chart-container{width:100%;height:100%}.chart-loader{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(var(--bg-primary-hover)/.6);font-weight:500;font-size:1.1rem;z-index:2;border-radius:4px}.chart-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;color:rgba(var(--text-secondary)/1)}.chart-empty-icon{width:48px;height:48px;opacity:.4}.chart-empty-text{font-size:14px;font-weight:500}.chart-placeholder{position:absolute;inset:0;display:flex;align-items:flex-end;gap:6px;padding:12px 8px 22px;animation:chart-placeholder-pulse 1.6s ease-in-out infinite}.chart-placeholder__bar{flex:1;border-radius:3px 3px 0 0;background:rgb(var(--og-track))}@keyframes chart-placeholder-pulse{0%,to{opacity:1}50%{opacity:.45}}@media(prefers-reduced-motion:reduce){.chart-placeholder{animation:none}}\n"], dependencies: [{ kind: "directive", type: NgxEchartsDirective, selector: "echarts, [echarts]", inputs: ["options", "theme", "initOpts", "merge", "autoResize", "loading", "loadingType", "loadingOpts"], outputs: ["chartInit", "optionsError", "chartClick", "chartDblClick", "chartMouseDown", "chartMouseMove", "chartMouseUp", "chartMouseOver", "chartMouseOut", "chartGlobalOut", "chartContextMenu", "chartHighlight", "chartDownplay", "chartSelectChanged", "chartLegendSelectChanged", "chartLegendSelected", "chartLegendUnselected", "chartLegendLegendSelectAll", "chartLegendLegendInverseSelect", "chartLegendScroll", "chartDataZoom", "chartDataRangeSelected", "chartGraphRoam", "chartGeoRoam", "chartTreeRoam", "chartTimelineChanged", "chartTimelinePlayChanged", "chartRestore", "chartDataViewChanged", "chartMagicTypeChanged", "chartGeoSelectChanged", "chartGeoSelected", "chartGeoUnselected", "chartAxisAreaSelected", "chartBrush", "chartBrushEnd", "chartBrushSelected", "chartGlobalCursorTaken", "chartRendered", "chartFinished"], exportAs: ["echarts"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush });
774
911
  }
775
912
  i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.20", ngImport: i0, type: ChartComponent, decorators: [{
776
913
  type: Component,
777
914
  args: [{ selector: 'fx-ui-chart', changeDetection: ChangeDetectionStrategy.OnPush, imports: [NgxEchartsDirective, InputComponent], providers: [provideEchartsCore({ echarts: () => import('echarts') })], template: "<div class=\"chart-wrapper\" [style.height]=\"height()\">\n @if (isEmpty) {\n <div class=\"chart-empty\">\n <svg xmlns=\"http://www.w3.org/2000/svg\" fill=\"none\" viewBox=\"0 0 24 24\" stroke-width=\"1.5\" stroke=\"currentColor\" class=\"chart-empty-icon\">\n <path stroke-linecap=\"round\" stroke-linejoin=\"round\" d=\"M3 13.125C3 12.504 3.504 12 4.125 12h2.25c.621 0 1.125.504 1.125 1.125v6.75C7.5 20.496 6.996 21 6.375 21h-2.25A1.125 1.125 0 0 1 3 19.875v-6.75ZM9.75 8.625c0-.621.504-1.125 1.125-1.125h2.25c.621 0 1.125.504 1.125 1.125v11.25c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V8.625ZM16.5 4.125c0-.621.504-1.125 1.125-1.125h2.25C20.496 3 21 3.504 21 4.125v15.75c0 .621-.504 1.125-1.125 1.125h-2.25a1.125 1.125 0 0 1-1.125-1.125V4.125Z\" />\n </svg>\n <span class=\"chart-empty-text\">{{ emptyText() }}</span>\n </div>\n } @else if (hasOptions) {\n <div\n echarts\n [options]=\"chartOptions\"\n [autoResize]=\"false\"\n [loading]=\"loading()\"\n (chartInit)=\"onChartInit($event)\"\n (chartClick)=\"onChartClick($event)\"\n class=\"fx-chart\"\n [style.height]=\"height()\"\n ></div>\n } @else {\n <!-- Until echarts is loaded + initialised (or the element scrolls into view)\n the panel keeps the chart's footprint with a quiet pulsing sketch\n instead of an empty box (anh T\u00FA 04/09: \"the page is too blank\"). -->\n <div class=\"chart-placeholder\" aria-hidden=\"true\">\n @for (h of placeholderBars; track $index) {\n <span class=\"chart-placeholder__bar\" [style.height.%]=\"h\"></span>\n }\n </div>\n }\n</div>\n", styles: ["@charset \"UTF-8\";:host{display:block;width:100%}.chart-wrapper{position:relative;width:100%;height:100%}.chart-container{width:100%;height:100%}.chart-loader{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:rgba(var(--bg-primary-hover)/.6);font-weight:500;font-size:1.1rem;z-index:2;border-radius:4px}.chart-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;gap:12px;color:rgba(var(--text-secondary)/1)}.chart-empty-icon{width:48px;height:48px;opacity:.4}.chart-empty-text{font-size:14px;font-weight:500}.chart-placeholder{position:absolute;inset:0;display:flex;align-items:flex-end;gap:6px;padding:12px 8px 22px;animation:chart-placeholder-pulse 1.6s ease-in-out infinite}.chart-placeholder__bar{flex:1;border-radius:3px 3px 0 0;background:rgb(var(--og-track))}@keyframes chart-placeholder-pulse{0%,to{opacity:1}50%{opacity:.45}}@media(prefers-reduced-motion:reduce){.chart-placeholder{animation:none}}\n"] }]
778
- }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.ChangeDetectorRef }], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], palette: [{ type: i0.Input, args: [{ isSignal: true, alias: "palette", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], pieLegendAlign: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieLegendAlign", required: false }] }], pieLegendPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieLegendPosition", required: false }] }], showPieLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPieLabel", required: false }] }], showPieLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPieLegend", required: false }] }], pieCenterLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieCenterLabel", required: false }] }], mobileWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileWidth", required: false }] }], labelFontFamily: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelFontFamily", required: false }] }], emptyText: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyText", required: false }] }], chartClick: [{ type: i0.Output, args: ["chartClick"] }], chartInit: [{ type: i0.Output, args: ["chartInit"] }], onThemeChange: [{
915
+ }], ctorParameters: () => [{ type: i0.ElementRef }, { type: i0.ChangeDetectorRef }], propDecorators: { type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: false }] }], data: [{ type: i0.Input, args: [{ isSignal: true, alias: "data", required: false }] }], options: [{ type: i0.Input, args: [{ isSignal: true, alias: "options", required: false }] }], palette: [{ type: i0.Input, args: [{ isSignal: true, alias: "palette", required: false }] }], height: [{ type: i0.Input, args: [{ isSignal: true, alias: "height", required: false }] }], loading: [{ type: i0.Input, args: [{ isSignal: true, alias: "loading", required: false }] }], pieLegendAlign: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieLegendAlign", required: false }] }], pieLegendPosition: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieLegendPosition", required: false }] }], showPieLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPieLabel", required: false }] }], showPieLegend: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPieLegend", required: false }] }], pieCenterLabel: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieCenterLabel", required: false }] }], pieCenterValueSize: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieCenterValueSize", required: false }] }], pieRadius: [{ type: i0.Input, args: [{ isSignal: true, alias: "pieRadius", required: false }] }], mobileWidth: [{ type: i0.Input, args: [{ isSignal: true, alias: "mobileWidth", required: false }] }], labelFontFamily: [{ type: i0.Input, args: [{ isSignal: true, alias: "labelFontFamily", required: false }] }], emptyText: [{ type: i0.Input, args: [{ isSignal: true, alias: "emptyText", required: false }] }], showPointLabels: [{ type: i0.Input, args: [{ isSignal: true, alias: "showPointLabels", required: false }] }], chartClick: [{ type: i0.Output, args: ["chartClick"] }], chartInit: [{ type: i0.Output, args: ["chartInit"] }], onThemeChange: [{
779
916
  type: HostListener,
780
917
  args: ['window:theme-changed']
781
918
  }] } });
919
+ /**
920
+ * Recursively merges an override onto a base ECharts option tree.
921
+ *
922
+ * Plain objects merge key by key; arrays merge BY INDEX, so overriding one
923
+ * series does not delete its siblings; anything else — a scalar, a Date, a
924
+ * colour-stop function — is replaced whole, because a half-merged value of
925
+ * those types has no meaning. `undefined` in the override is skipped so an
926
+ * unset field cannot erase a built-in.
927
+ */
928
+ function deepMergeOptions(base, override) {
929
+ if (override === undefined)
930
+ return base;
931
+ if (Array.isArray(base) && Array.isArray(override)) {
932
+ const out = [...base];
933
+ override.forEach((value, i) => {
934
+ out[i] = i < base.length ? deepMergeOptions(base[i], value) : value;
935
+ });
936
+ return out;
937
+ }
938
+ if (isPlainObject(base) && isPlainObject(override)) {
939
+ const out = { ...base };
940
+ for (const [key, value] of Object.entries(override)) {
941
+ out[key] = key in base ? deepMergeOptions(base[key], value) : value;
942
+ }
943
+ return out;
944
+ }
945
+ return override;
946
+ }
947
+ function isPlainObject(value) {
948
+ return (typeof value === 'object' &&
949
+ value !== null &&
950
+ !Array.isArray(value) &&
951
+ (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null));
952
+ }
782
953
 
783
954
  let pending;
784
955
  /**