@oicl/openbridge-webcomponents-full-bundle 2.0.0-next.151 → 2.0.0-next.152

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.
Files changed (136) hide show
  1. package/.storybook/vitest.setup.ts +12 -1
  2. package/bundle/openbridge-webcomponents.bundle.js +989 -319
  3. package/bundle/openbridge-webcomponents.bundle.js.map +1 -1
  4. package/custom-elements.json +1514 -77
  5. package/dist/alert-severity.d.ts +12 -9
  6. package/dist/alert-severity.d.ts.map +1 -1
  7. package/dist/alert-severity.js +30 -38
  8. package/dist/alert-severity.js.map +1 -1
  9. package/dist/building-blocks/bar-horizontal/bar-horizontal.d.ts +6 -0
  10. package/dist/building-blocks/bar-horizontal/bar-horizontal.d.ts.map +1 -1
  11. package/dist/building-blocks/bar-horizontal/bar-horizontal.js +6 -1
  12. package/dist/building-blocks/bar-horizontal/bar-horizontal.js.map +1 -1
  13. package/dist/building-blocks/bar-vertical/bar-vertical.d.ts +6 -0
  14. package/dist/building-blocks/bar-vertical/bar-vertical.d.ts.map +1 -1
  15. package/dist/building-blocks/bar-vertical/bar-vertical.js +6 -1
  16. package/dist/building-blocks/bar-vertical/bar-vertical.js.map +1 -1
  17. package/dist/building-blocks/chart-line/chart-line-base.d.ts +129 -0
  18. package/dist/building-blocks/chart-line/chart-line-base.d.ts.map +1 -1
  19. package/dist/building-blocks/chart-line/chart-line-base.js +464 -79
  20. package/dist/building-blocks/chart-line/chart-line-base.js.map +1 -1
  21. package/dist/building-blocks/external-scale/external-scale.d.ts +11 -0
  22. package/dist/building-blocks/external-scale/external-scale.d.ts.map +1 -1
  23. package/dist/building-blocks/external-scale/external-scale.js +45 -12
  24. package/dist/building-blocks/external-scale/external-scale.js.map +1 -1
  25. package/dist/charthelpers/index.d.ts +1 -0
  26. package/dist/charthelpers/index.d.ts.map +1 -1
  27. package/dist/charthelpers/index.js +7 -1
  28. package/dist/charthelpers/index.js.map +1 -1
  29. package/dist/charthelpers/range-labels.d.ts +55 -0
  30. package/dist/charthelpers/range-labels.d.ts.map +1 -0
  31. package/dist/charthelpers/range-labels.js +36 -0
  32. package/dist/charthelpers/range-labels.js.map +1 -0
  33. package/dist/components/alert-button/alert-button.css.js +13 -21
  34. package/dist/components/alert-button/alert-button.css.js.map +1 -1
  35. package/dist/components/alert-button/alert-button.d.ts +9 -4
  36. package/dist/components/alert-button/alert-button.d.ts.map +1 -1
  37. package/dist/components/alert-button/alert-button.js +24 -15
  38. package/dist/components/alert-button/alert-button.js.map +1 -1
  39. package/dist/components/alert-frame/alert-frame.css.js +109 -33
  40. package/dist/components/alert-frame/alert-frame.css.js.map +1 -1
  41. package/dist/components/alert-frame/alert-frame.d.ts +34 -7
  42. package/dist/components/alert-frame/alert-frame.d.ts.map +1 -1
  43. package/dist/components/alert-frame/alert-frame.js +134 -30
  44. package/dist/components/alert-frame/alert-frame.js.map +1 -1
  45. package/dist/components/alert-icon/alert-icon.d.ts +10 -7
  46. package/dist/components/alert-icon/alert-icon.d.ts.map +1 -1
  47. package/dist/components/alert-icon/alert-icon.js +37 -60
  48. package/dist/components/alert-icon/alert-icon.js.map +1 -1
  49. package/dist/components/alert-menu-item/alert-menu-item.css.js +1 -4
  50. package/dist/components/alert-menu-item/alert-menu-item.css.js.map +1 -1
  51. package/dist/components/date-item/date-item.css.js +3 -3
  52. package/dist/components/keyboard-full/keyboard-full.css.js +4 -4
  53. package/dist/components/message-menu-item/message-menu-item.css.js +2 -6
  54. package/dist/components/message-menu-item/message-menu-item.css.js.map +1 -1
  55. package/dist/components/sequence-toolbar/sequence-toolbar.css.js +1 -2
  56. package/dist/components/sequence-toolbar/sequence-toolbar.css.js.map +1 -1
  57. package/dist/index.js +2 -1
  58. package/dist/navigation-instruments/gauge-trend/gauge-trend.d.ts +6 -0
  59. package/dist/navigation-instruments/gauge-trend/gauge-trend.d.ts.map +1 -1
  60. package/dist/navigation-instruments/gauge-trend/gauge-trend.js +8 -0
  61. package/dist/navigation-instruments/gauge-trend/gauge-trend.js.map +1 -1
  62. package/dist/navigation-instruments/instrument-field/instrument-field.css.js +2 -2
  63. package/dist/navigation-instruments/readout/readout.d.ts.map +1 -1
  64. package/dist/navigation-instruments/readout/readout.js +2 -1
  65. package/dist/navigation-instruments/readout/readout.js.map +1 -1
  66. package/dist/navigation-instruments/readout-list-item/readout-list-item.d.ts.map +1 -1
  67. package/dist/navigation-instruments/readout-list-item/readout-list-item.js +2 -1
  68. package/dist/navigation-instruments/readout-list-item/readout-list-item.js.map +1 -1
  69. package/dist/openbridge.css +19 -4
  70. package/dist/palettes/blinking.d.ts +14 -4
  71. package/dist/palettes/blinking.d.ts.map +1 -1
  72. package/dist/palettes/blinking.js +41 -35
  73. package/dist/palettes/blinking.js.map +1 -1
  74. package/dist/palettes/flashing-controller.d.ts +21 -0
  75. package/dist/palettes/flashing-controller.d.ts.map +1 -0
  76. package/dist/palettes/flashing-controller.js +45 -0
  77. package/dist/palettes/flashing-controller.js.map +1 -0
  78. package/dist/svghelpers/index.d.ts +1 -0
  79. package/dist/svghelpers/index.d.ts.map +1 -1
  80. package/dist/svghelpers/index.js +2 -0
  81. package/dist/svghelpers/index.js.map +1 -1
  82. package/dist/svghelpers/rounded-rect.d.ts +15 -0
  83. package/dist/svghelpers/rounded-rect.d.ts.map +1 -0
  84. package/dist/svghelpers/rounded-rect.js +29 -0
  85. package/dist/svghelpers/rounded-rect.js.map +1 -0
  86. package/dist/types.d.ts +14 -0
  87. package/dist/types.d.ts.map +1 -1
  88. package/dist/types.js +9 -0
  89. package/dist/types.js.map +1 -1
  90. package/package.json +1 -1
  91. package/script/check-css-variables.ts +2 -15
  92. package/src/alert-severity.spec.ts +56 -0
  93. package/src/alert-severity.ts +45 -43
  94. package/src/bars-graphs/area-graph/area-graph.stories.ts +235 -1
  95. package/src/bars-graphs/line-graph/line-graph.stories.ts +24 -0
  96. package/src/building-blocks/bar-horizontal/bar-horizontal.ts +7 -0
  97. package/src/building-blocks/bar-vertical/bar-vertical.ts +7 -0
  98. package/src/building-blocks/chart-line/chart-line-base.spec.ts +767 -0
  99. package/src/building-blocks/chart-line/chart-line-base.stories.ts +124 -0
  100. package/src/building-blocks/chart-line/chart-line-base.ts +671 -97
  101. package/src/building-blocks/chart-line/chart-sizing-battleground.stories.ts +17 -0
  102. package/src/building-blocks/external-scale/external-scale.spec.ts +154 -0
  103. package/src/building-blocks/external-scale/external-scale.ts +78 -20
  104. package/src/charthelpers/index.ts +1 -0
  105. package/src/charthelpers/range-labels.spec.ts +46 -0
  106. package/src/charthelpers/range-labels.ts +93 -0
  107. package/src/components/alert-button/alert-button.css +10 -23
  108. package/src/components/alert-button/alert-button.spec.ts +56 -15
  109. package/src/components/alert-button/alert-button.stories.ts +5 -1
  110. package/src/components/alert-button/alert-button.ts +33 -24
  111. package/src/components/alert-frame/alert-flash-scenario.stories.ts +669 -0
  112. package/src/components/alert-frame/alert-frame.css +106 -33
  113. package/src/components/alert-frame/alert-frame.spec.ts +176 -17
  114. package/src/components/alert-frame/alert-frame.stories.ts +61 -0
  115. package/src/components/alert-frame/alert-frame.ts +179 -41
  116. package/src/components/alert-icon/alert-icon.spec.ts +82 -27
  117. package/src/components/alert-icon/alert-icon.stories.ts +7 -1
  118. package/src/components/alert-icon/alert-icon.ts +48 -74
  119. package/src/components/alert-menu-item/alert-menu-item.css +1 -4
  120. package/src/components/date-item/date-item.css +3 -3
  121. package/src/components/keyboard-full/keyboard-full.css +4 -4
  122. package/src/components/message-menu-item/message-menu-item.css +2 -6
  123. package/src/components/sequence-toolbar/sequence-toolbar.css +1 -2
  124. package/src/navigation-instruments/gauge-trend/gauge-trend.stories.ts +29 -0
  125. package/src/navigation-instruments/gauge-trend/gauge-trend.ts +11 -0
  126. package/src/navigation-instruments/instrument-field/instrument-field.css +2 -2
  127. package/src/navigation-instruments/readout/readout.ts +2 -1
  128. package/src/navigation-instruments/readout-list-item/readout-list-item.ts +2 -1
  129. package/src/palettes/blinking.spec.ts +83 -0
  130. package/src/palettes/blinking.ts +47 -35
  131. package/src/palettes/flashing-controller.ts +62 -0
  132. package/src/palettes/manual.css +18 -4
  133. package/src/svghelpers/index.ts +1 -0
  134. package/src/svghelpers/rounded-rect.spec.ts +47 -0
  135. package/src/svghelpers/rounded-rect.ts +42 -0
  136. package/src/types.ts +20 -0
@@ -42,7 +42,13 @@ import {
42
42
  formatXValue,
43
43
  XValueMode,
44
44
  observeLabelThreshold,
45
+ measureRangeLabelGutters,
46
+ rangeLabelFontString,
47
+ rangeLabelLineHeight,
48
+ formatRangeValue,
49
+ yRangeLabelValues,
45
50
  } from '../../charthelpers/index.js';
51
+ import type {RangeLabelFont} from '../../charthelpers/index.js';
46
52
  import type {ChartXValue} from '../../charthelpers/x-value.js';
47
53
  import {
48
54
  EXTERNAL_SCALE_BORDER_RADIUS_CSS_VAR,
@@ -91,6 +97,8 @@ interface ExternalScaleElement extends HTMLElement {
91
97
  paddingEnd?: number;
92
98
  primaryTickmarkInterval?: number;
93
99
  showLabels?: boolean;
100
+ showMainTickmarkLabels?: boolean;
101
+ labelThickness?: number;
94
102
  fixedAspectRatio?: boolean;
95
103
  scaleReferenceSize?: number;
96
104
  state?: InstrumentState;
@@ -123,6 +131,14 @@ export enum TimeDisplay {
123
131
  date = 'date',
124
132
  }
125
133
 
134
+ /** Which axes keep min / max labels below the label threshold. */
135
+ export enum RangeLabels {
136
+ none = 'none',
137
+ y = 'y',
138
+ x = 'x',
139
+ xy = 'xy',
140
+ }
141
+
126
142
  export type ChartLinePoint = number | {x: ChartXValue; y: number};
127
143
 
128
144
  export type ChartLineDataItem = {
@@ -145,6 +161,11 @@ export type ChartLineYAxisConfig = {
145
161
  grid?: boolean;
146
162
  };
147
163
 
164
+ export type ChartLineXAxisConfig = {
165
+ min?: number;
166
+ max?: number;
167
+ };
168
+
148
169
  const LINE_GRAPH_WATCHED_PROP_NAMES = [
149
170
  'data',
150
171
  'datasets',
@@ -153,6 +174,7 @@ const LINE_GRAPH_WATCHED_PROP_NAMES = [
153
174
  'xAxisType',
154
175
  'yAxisPosition',
155
176
  'yAxes',
177
+ 'xAxis',
156
178
  'showGrid',
157
179
  'showGridX',
158
180
  'showGridY',
@@ -173,6 +195,7 @@ const LINE_GRAPH_WATCHED_PROP_NAMES = [
173
195
  'height',
174
196
  'fixedAspectRatioScaling', // Triggers responsive mode change
175
197
  'hasLabelPadding', // Toggles edge-to-edge rendering and label visibility
198
+ 'rangeLabels',
176
199
  ] as const;
177
200
 
178
201
  const LINE_GRAPH_RECREATE_PROP_NAMES = [
@@ -180,6 +203,7 @@ const LINE_GRAPH_RECREATE_PROP_NAMES = [
180
203
  'width',
181
204
  'height',
182
205
  'fixedAspectRatioScaling',
206
+ 'rangeLabels', // Adds or removes the range-labels plugin
183
207
  ] as const;
184
208
 
185
209
  /**
@@ -347,12 +371,21 @@ const LINE_GRAPH_DIMENSION_PROP_NAMES = [
347
371
  * numeric x-values.
348
372
  * @property yAxisPosition - Single y-axis position ('left' or 'right'). For multiple y-axes, use yAxes instead.
349
373
  * @property yAxes - Multiple y-axis definitions for complex multi-axis charts.
374
+ * @property xAxis - Pinned x range (`min`/`max`) for time and number axes. Without it the
375
+ * axis spans exactly the data, so a window that is still filling stretches across the
376
+ * full width. In `minutes` display `max` is the `0min` reference.
377
+ * @availableWhen xAxis xAxisType!=category
350
378
  * @property showGrid - Show grid lines.
351
379
  * @property showGridX - Show vertical grid lines (x-axis). Default: false.
352
380
  * @availableWhen showGridX showGrid==true
353
381
  * @property showGridY - Show horizontal grid lines (y-axis). Default: false.
354
382
  * @availableWhen showGridY showGrid==true
355
383
  * @property showTickMarks - Show axis tick marks and labels.
384
+ * @property rangeLabels - Labels kept below the 192px threshold, where the axis labels are
385
+ * otherwise hidden: 'y' draws min, max and (inside the range) 0 in a gutter on the
386
+ * y-axis side; 'x' draws the first and last x value in a bottom gutter; 'xy' both.
387
+ * Ignored when `hasLabelPadding` is false.
388
+ * @availableWhen rangeLabels hasLabelPadding==true
356
389
  * @property showPoints - Show point markers on data points. Default: false.
357
390
  * @property lineMode - Line drawing style: 'smooth' (curved), 'straight', or 'stepped'.
358
391
  * @property unit - Unit label displayed in tooltips (e.g., 'kW', 'kg', '%').
@@ -417,6 +450,9 @@ export class ObcChartLineBase extends LitElement {
417
450
  @property({type: Array, attribute: false})
418
451
  yAxes?: ChartLineYAxisConfig[] = undefined;
419
452
 
453
+ @property({type: Object, attribute: false})
454
+ xAxis?: ChartLineXAxisConfig = undefined;
455
+
420
456
  @property({type: Boolean})
421
457
  showGrid = false;
422
458
 
@@ -449,6 +485,27 @@ export class ObcChartLineBase extends LitElement {
449
485
  @property({type: Boolean, attribute: false})
450
486
  hasLabelPadding = true;
451
487
 
488
+ @property({type: String})
489
+ rangeLabels: RangeLabels = RangeLabels.none;
490
+
491
+ /** @internal - The y side wants range labels; an edge-to-edge chart never does. */
492
+ protected get rangeLabelsY(): boolean {
493
+ return (
494
+ this.hasLabelPadding &&
495
+ (this.rangeLabels === RangeLabels.y ||
496
+ this.rangeLabels === RangeLabels.xy)
497
+ );
498
+ }
499
+
500
+ /** @internal - The x side wants range labels; an edge-to-edge chart never does. */
501
+ protected get rangeLabelsX(): boolean {
502
+ return (
503
+ this.hasLabelPadding &&
504
+ (this.rangeLabels === RangeLabels.x ||
505
+ this.rangeLabels === RangeLabels.xy)
506
+ );
507
+ }
508
+
452
509
  /** @internal - True when the x-axis positions points by numeric value. */
453
510
  protected get isNumericXAxis(): boolean {
454
511
  return (
@@ -456,6 +513,14 @@ export class ObcChartLineBase extends LitElement {
456
513
  );
457
514
  }
458
515
 
516
+ /**
517
+ * Id of the y scale a dataset lands on when it names none. Chart.js would
518
+ * otherwise add a default `y` scale next to the configured ones and draw it.
519
+ */
520
+ private get primaryYAxisId(): string {
521
+ return this.yAxes?.length ? (this.yAxes[0].id ?? 'y0') : 'y';
522
+ }
523
+
459
524
  /** @internal - Normalization mode for the current x-axis type. */
460
525
  protected get xValueMode(): XValueMode {
461
526
  return this.xAxisType === XAxisType.number
@@ -466,6 +531,9 @@ export class ObcChartLineBase extends LitElement {
466
531
  /** @internal - Last data/datasets reference already warned about. */
467
532
  private lastWarnedXSource?: unknown;
468
533
 
534
+ /** @internal - Whether the `date`-display scale warning has been issued. */
535
+ private warnedDateDisplayScale = false;
536
+
469
537
  /** @internal - Warn once per data assignment about unparseable x-values. */
470
538
  private warnOnInvalidX(xValues: number[], sourceRef: unknown) {
471
539
  const invalid = xValues.filter((x) => !Number.isFinite(x)).length;
@@ -621,18 +689,303 @@ export class ObcChartLineBase extends LitElement {
621
689
  */
622
690
 
623
691
  /**
624
- * Check if a slotted scale element is actually visible (renders content).
625
- * For bar-vertical/bar-horizontal elements, checks hasBar and hasScale properties.
692
+ * @internal - Label band each slotted scale had before the chart narrowed
693
+ * it for compact labels, so it can be given back on the way out.
626
694
  */
627
- private hasVisibleScale(side: 'left' | 'right' | 'top' | 'bottom'): boolean {
628
- const slotMap = {
695
+ private savedLabelThickness = new WeakMap<ExternalScaleElement, number>();
696
+ private savedMainTickmarkLabels = new WeakMap<
697
+ ExternalScaleElement,
698
+ boolean
699
+ >();
700
+ /** Last known threshold side, to notice a crossing in `updated()`. */
701
+ private wasBelowThreshold?: boolean;
702
+
703
+ /**
704
+ * Label band a slotted scale needs for its compact labels, in the scale's
705
+ * own viewBox units. The scale prints the raw values, so those are what is
706
+ * measured; the x band is one text line.
707
+ */
708
+ private compactLabelThickness(axis: 'x' | 'y'): number | undefined {
709
+ const context = this.canvasEl?.getContext('2d');
710
+ if (!context) return undefined;
711
+ const font = this.rangeLabelFont();
712
+ let visual: number;
713
+ if (axis === 'y') {
714
+ const bounds = this.rangeLabelCache.bounds;
715
+ if (!bounds) return undefined;
716
+ const texts = yRangeLabelValues(bounds.min, bounds.max).map(String);
717
+ visual = measureRangeLabelGutters(context, font, texts, []).side + 4;
718
+ } else {
719
+ visual = measureRangeLabelGutters(context, font, [], ['x']).bottom + 8;
720
+ }
721
+ if (!this.fixedAspectRatioScaling) return Math.ceil(visual);
722
+ const effectiveLength =
723
+ axis === 'y' ? this.getEffectiveHeight() : this.getEffectiveWidth();
724
+ return Math.ceil((visual * this.scaleReferenceSize) / effectiveLength);
725
+ }
726
+
727
+ /** @internal - Labels the range-labels plugin drew last, for tests. */
728
+ lastRangeLabels: {axis: 'x' | 'y'; text: string; x: number; y: number}[] = [];
729
+
730
+ /**
731
+ * Range labels for the current data and size, refreshed with every
732
+ * `getChartOptions()` so the plugin never rebuilds the datasets on a draw.
733
+ */
734
+ private rangeLabelCache: {
735
+ y: string[];
736
+ x: string[];
737
+ bounds?: {min: number; max: number};
738
+ } = {y: [], x: []};
739
+
740
+ private slotFor(
741
+ side: 'left' | 'right' | 'top' | 'bottom'
742
+ ): HTMLSlotElement | undefined {
743
+ return {
629
744
  left: this.leftScaleSlot,
630
745
  right: this.rightScaleSlot,
631
746
  top: this.topScaleSlot,
632
747
  bottom: this.bottomScaleSlot,
748
+ }[side];
749
+ }
750
+
751
+ private hasSlottedScale(side: 'left' | 'right' | 'top' | 'bottom'): boolean {
752
+ return (this.slotFor(side)?.assignedElements().length ?? 0) > 0;
753
+ }
754
+
755
+ /** @internal - Below the label threshold on either dimension. */
756
+ private isBelowThreshold(): boolean {
757
+ return (
758
+ this.getEffectiveWidth() <
759
+ RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS ||
760
+ this.getEffectiveHeight() <
761
+ RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS
762
+ );
763
+ }
764
+
765
+ private get ySide(): 'left' | 'right' {
766
+ const first = this.yAxes?.[0]?.position;
767
+ return (first ?? this.yAxisPosition) === 'right' ? 'right' : 'left';
768
+ }
769
+
770
+ private rangeLabelFont(): RangeLabelFont {
771
+ const sizePx = Number.parseFloat(
772
+ getCssVariableValue(this, LINE_GRAPH_LABEL_CONFIG.fontSizeVar)
773
+ );
774
+ return {
775
+ family: getCssVariableValue(this, LINE_GRAPH_LABEL_CONFIG.fontFamily),
776
+ sizePx: Number.isFinite(sizePx) ? sizePx : 12,
777
+ weight: getCssVariableValue(this, LINE_GRAPH_LABEL_CONFIG.fontWeightVar),
778
+ };
779
+ }
780
+
781
+ /**
782
+ * Extent of the prepared data: one for x, one per y scale id. Each y axis
783
+ * ranges from its own datasets, so a merged extent would label one axis
784
+ * with another's values.
785
+ */
786
+ private dataExtents(
787
+ prepared: ReturnType<ObcChartLineBase['prepareChartDataAndLabels']>
788
+ ): {
789
+ x?: {min: number; max: number};
790
+ y: Map<string, {min: number; max: number}>;
791
+ } {
792
+ const x = {min: Infinity, max: -Infinity};
793
+ const y = new Map<string, {min: number; max: number}>();
794
+ const take = (extent: {min: number; max: number}, v: number) => {
795
+ if (!Number.isFinite(v)) return;
796
+ extent.min = Math.min(extent.min, v);
797
+ extent.max = Math.max(extent.max, v);
798
+ };
799
+ prepared.datasets.forEach((dataset) => {
800
+ const id = dataset.yAxisID ?? this.primaryYAxisId;
801
+ const extent = y.get(id) ?? {min: Infinity, max: -Infinity};
802
+ y.set(id, extent);
803
+ (dataset.data as ChartLinePoint[]).forEach((point) => {
804
+ if (typeof point === 'number') {
805
+ take(extent, point);
806
+ } else if (point) {
807
+ take(x, point.x as number);
808
+ take(extent, point.y);
809
+ }
810
+ });
811
+ });
812
+ y.forEach((extent, id) => {
813
+ if (!(extent.min <= extent.max)) y.delete(id);
814
+ });
815
+ return {x: x.min <= x.max ? x : undefined, y};
816
+ }
817
+
818
+ /**
819
+ * Range the y labels describe. Below the threshold the axis runs on data
820
+ * bounds, so its datasets' extent is exact and needs no chart to read from.
821
+ */
822
+ private yRangeBounds(
823
+ extents: Map<string, {min: number; max: number}>
824
+ ): {min: number; max: number} | undefined {
825
+ let axis: ChartLineYAxisConfig | undefined;
826
+ let id = 'y';
827
+ if (this.yAxes?.length) {
828
+ const match = this.yAxes.findIndex(
829
+ (a) => (a.position ?? 'left') === this.ySide
830
+ );
831
+ const index = match === -1 ? 0 : match;
832
+ axis = this.yAxes[index];
833
+ id = axis.id ?? `y${index}`;
834
+ }
835
+ const extent = extents.get(id);
836
+ // Stacking makes the axis span the accumulated series, which a per-dataset
837
+ // extent cannot describe, so the laid-out scale is the only source for it.
838
+ const stackedScale = this.shouldStack()
839
+ ? this.chart?.scales[id]
840
+ : undefined;
841
+ const min = axis?.min ?? stackedScale?.min ?? extent?.min;
842
+ const max = axis?.max ?? stackedScale?.max ?? extent?.max;
843
+ return min !== undefined && max !== undefined ? {min, max} : undefined;
844
+ }
845
+
846
+ /** First and last x label, or nothing when there is no span to label. */
847
+ private xEdgeLabelTexts(
848
+ prepared: ReturnType<ObcChartLineBase['prepareChartDataAndLabels']>,
849
+ extent: {min: number; max: number} | undefined
850
+ ): string[] {
851
+ if (!this.isNumericXAxis) {
852
+ const labels = prepared.labels.map(String);
853
+ const first = labels[0];
854
+ const last = labels[labels.length - 1];
855
+ return labels.length > 1 && first !== last ? [first, last] : [];
856
+ }
857
+ const min = this.xAxis?.min ?? extent?.min;
858
+ const max = this.xAxis?.max ?? extent?.max;
859
+ if (min === undefined || max === undefined || min === max) return [];
860
+ const reference =
861
+ this.timeDisplay === TimeDisplay.minutes
862
+ ? this.computeTimeReference()
863
+ : undefined;
864
+ return [
865
+ formatXValue(min, this.xValueMode, reference),
866
+ formatXValue(max, this.xValueMode, reference),
867
+ ];
868
+ }
869
+
870
+ /**
871
+ * Recompute the range labels. A side with a slotted scale is labelled by
872
+ * the scale, so it gets no text here; the y bounds are kept regardless,
873
+ * since the scale's compact band is measured from them.
874
+ */
875
+ private refreshRangeLabels(): typeof this.rangeLabelCache {
876
+ const cache: typeof this.rangeLabelCache = {y: [], x: []};
877
+ if (this.isBelowThreshold() && (this.rangeLabelsY || this.rangeLabelsX)) {
878
+ // Prepared once: it restyles every dataset and reads CSS variables.
879
+ const prepared = this.prepareChartDataAndLabels();
880
+ const extents = this.dataExtents(prepared);
881
+ if (this.rangeLabelsY) {
882
+ cache.bounds = this.yRangeBounds(extents.y);
883
+ if (
884
+ cache.bounds &&
885
+ !this.hasSlottedScale('left') &&
886
+ !this.hasSlottedScale('right')
887
+ ) {
888
+ const {min, max} = cache.bounds;
889
+ cache.y = yRangeLabelValues(min, max).map((v) =>
890
+ formatRangeValue(v, min, max)
891
+ );
892
+ }
893
+ }
894
+ if (
895
+ this.rangeLabelsX &&
896
+ !this.hasSlottedScale('top') &&
897
+ !this.hasSlottedScale('bottom')
898
+ ) {
899
+ cache.x = this.xEdgeLabelTexts(prepared, extents.x);
900
+ }
901
+ }
902
+ this.rangeLabelCache = cache;
903
+ return cache;
904
+ }
905
+
906
+ /**
907
+ * Paints min / 0 / max and first / last once Chart.js has drawn, inside the
908
+ * gutters `getChartOptions()` reserved. The end labels sit flush with the
909
+ * plot edges so no vertical padding is needed for them.
910
+ */
911
+ private createRangeLabelsPlugin() {
912
+ return {
913
+ id: 'rangeLabels',
914
+ afterDraw: (chart: Chart) => {
915
+ this.lastRangeLabels = [];
916
+ const {y: yTexts, x: xTexts, bounds} = this.rangeLabelCache;
917
+ if (!yTexts.length && !xTexts.length) return;
918
+ const area = chart.chartArea;
919
+ const ctx = chart.ctx;
920
+ const font = this.rangeLabelFont();
921
+ const lineHeight = rangeLabelLineHeight(font);
922
+ ctx.save();
923
+ ctx.font = rangeLabelFontString(font);
924
+ ctx.fillStyle = getCssVariableValue(
925
+ this,
926
+ LINE_GRAPH_LABEL_CONFIG.fontColorVar
927
+ );
928
+
929
+ const scale = chart.scales[this.primaryYAxisId];
930
+ if (yTexts.length && bounds && scale) {
931
+ const onRight = this.ySide === 'right';
932
+ const x = onRight ? area.right + 8 : area.left - 8;
933
+ ctx.textAlign = onRight ? 'left' : 'right';
934
+ const values = yRangeLabelValues(bounds.min, bounds.max);
935
+ // Top-down, so the recorded order reads max, 0, min.
936
+ for (let i = values.length - 1; i >= 0; i--) {
937
+ const isMax = i === values.length - 1;
938
+ const isMin = i === 0;
939
+ const y = isMax
940
+ ? area.top
941
+ : isMin
942
+ ? area.bottom
943
+ : scale.getPixelForValue(values[i]);
944
+ // A 0 within a line of either end would collide with it.
945
+ if (
946
+ !isMax &&
947
+ !isMin &&
948
+ (y - area.top < lineHeight || area.bottom - y < lineHeight)
949
+ ) {
950
+ continue;
951
+ }
952
+ ctx.textBaseline = isMax ? 'top' : isMin ? 'bottom' : 'middle';
953
+ ctx.fillText(yTexts[i], x, y);
954
+ this.lastRangeLabels.push({axis: 'y', text: yTexts[i], x, y});
955
+ }
956
+ }
957
+
958
+ if (xTexts.length === 2) {
959
+ const y = area.bottom + 4;
960
+ ctx.textBaseline = 'top';
961
+ ctx.textAlign = 'left';
962
+ ctx.fillText(xTexts[0], area.left, y);
963
+ this.lastRangeLabels.push({
964
+ axis: 'x',
965
+ text: xTexts[0],
966
+ x: area.left,
967
+ y,
968
+ });
969
+ ctx.textAlign = 'right';
970
+ ctx.fillText(xTexts[1], area.right, y);
971
+ this.lastRangeLabels.push({
972
+ axis: 'x',
973
+ text: xTexts[1],
974
+ x: area.right,
975
+ y,
976
+ });
977
+ }
978
+ ctx.restore();
979
+ },
633
980
  };
981
+ }
634
982
 
635
- const slot = slotMap[side];
983
+ /**
984
+ * Check if a slotted scale element is actually visible (renders content).
985
+ * For bar-vertical/bar-horizontal elements, checks hasBar and hasScale properties.
986
+ */
987
+ private hasVisibleScale(side: 'left' | 'right' | 'top' | 'bottom'): boolean {
988
+ const slot = this.slotFor(side);
636
989
  const elements = slot?.assignedElements() ?? [];
637
990
 
638
991
  return elements.some((el: Element) => {
@@ -1231,18 +1584,23 @@ export class ObcChartLineBase extends LitElement {
1231
1584
  this.isUpdatingScales = true;
1232
1585
 
1233
1586
  try {
1234
- // Step 1: Calculate padding from scale dimensions
1235
- const padding = this.calculatePaddingFromScales();
1236
-
1237
- // Step 2: Calculate effective chart area
1238
- const effectiveWidth = this.width - padding.left - padding.right;
1239
- const effectiveHeight = this.height - padding.top - padding.bottom;
1587
+ // Step 1: The padding the chart itself will lay out with, so a slotted
1588
+ // scale's drawing area lines up with the chart area on every side.
1589
+ const padding = this.computeChartPadding();
1590
+
1591
+ // Step 2: The chart area left once the (visual) padding is taken from
1592
+ // the visual size — the reference `width` / `height` would go negative
1593
+ // in a container larger than the reference.
1594
+ const effectiveWidth =
1595
+ this.getEffectiveWidth() - padding.left - padding.right;
1596
+ const effectiveHeight =
1597
+ this.getEffectiveHeight() - padding.top - padding.bottom;
1240
1598
 
1241
1599
  // Guard against invalid dimensions
1242
1600
  if (effectiveWidth <= 0 || effectiveHeight <= 0) {
1243
1601
  console.warn('[chart-line-base] Invalid effective dimensions', {
1244
- width: this.width,
1245
- height: this.height,
1602
+ width: this.getEffectiveWidth(),
1603
+ height: this.getEffectiveHeight(),
1246
1604
  padding,
1247
1605
  effectiveWidth,
1248
1606
  effectiveHeight,
@@ -1289,6 +1647,75 @@ export class ObcChartLineBase extends LitElement {
1289
1647
  return padding;
1290
1648
  }
1291
1649
 
1650
+ /**
1651
+ * Range a slotted scale on the given side has to cover.
1652
+ *
1653
+ * A range pinned in the axis configuration is used as is: it is the source
1654
+ * of truth, and on the first sync there is no chart to read yet. Otherwise
1655
+ * the live Chart.js scale is read under the id `buildScalesConfig()` gives
1656
+ * it. A side with no axis of its own follows the first configured one.
1657
+ */
1658
+ private resolveAxisRange(side: 'left' | 'right' | 'x'): {
1659
+ min: number;
1660
+ max: number;
1661
+ } {
1662
+ let id = side === 'x' ? 'x' : 'y';
1663
+ let configured: {min?: number; max?: number} | undefined;
1664
+
1665
+ if (side === 'x') {
1666
+ configured = this.isNumericXAxis ? this.xAxis : undefined;
1667
+ } else if (this.yAxes?.length) {
1668
+ const match = this.yAxes.findIndex(
1669
+ (axis) => (axis.position ?? 'left') === side
1670
+ );
1671
+ const index = match === -1 ? 0 : match;
1672
+ const axis = this.yAxes[index];
1673
+ id = axis.id ?? `y${index}`;
1674
+ configured = axis;
1675
+ }
1676
+
1677
+ if (configured?.min !== undefined && configured.max !== undefined) {
1678
+ return {min: configured.min, max: configured.max};
1679
+ }
1680
+ const scale = this.chart?.scales[id];
1681
+ return {
1682
+ min: scale?.min ?? configured?.min ?? 0,
1683
+ max: scale?.max ?? configured?.max ?? 100,
1684
+ };
1685
+ }
1686
+
1687
+ /**
1688
+ * Range pushed to the top and bottom scales, or `undefined` to leave them
1689
+ * alone. A scale prints its values verbatim, and on a `time` axis the raw
1690
+ * range is epoch milliseconds. In `minutes` display the range is converted
1691
+ * to the minutes the chart's own labels show; `date` display has no numeric
1692
+ * equivalent, so the scale keeps its own range.
1693
+ */
1694
+ private resolveSlottedXRange(): {min: number; max: number} | undefined {
1695
+ const range = this.resolveAxisRange('x');
1696
+ if (this.xAxisType !== XAxisType.time) return range;
1697
+
1698
+ if (this.timeDisplay !== TimeDisplay.minutes) {
1699
+ if (!this.warnedDateDisplayScale) {
1700
+ this.warnedDateDisplayScale = true;
1701
+ console.warn(
1702
+ "[obc-chart] a slotted horizontal scale cannot label a time axis in 'date' display and keeps its own range; use timeDisplay='minutes' or a number axis."
1703
+ );
1704
+ }
1705
+ return undefined;
1706
+ }
1707
+
1708
+ const pinned =
1709
+ this.xAxis?.min !== undefined && this.xAxis?.max !== undefined;
1710
+ const reference = this.computeTimeReference();
1711
+ if (reference === undefined || (!this.chart && !pinned)) return undefined;
1712
+ const minute = 60_000;
1713
+ return {
1714
+ min: (range.min - reference) / minute,
1715
+ max: (range.max - reference) / minute,
1716
+ };
1717
+ }
1718
+
1292
1719
  /**
1293
1720
  * Update properties on slotted scale elements
1294
1721
  */
@@ -1298,11 +1725,9 @@ export class ObcChartLineBase extends LitElement {
1298
1725
  bottom: number;
1299
1726
  left: number;
1300
1727
  }) {
1301
- // Get chart scales for min/max values
1302
- const yMin = this.chart?.scales['y']?.min ?? 0;
1303
- const yMax = this.chart?.scales['y']?.max ?? 100;
1304
- const xMin = this.chart?.scales['x']?.min ?? 0;
1305
- const xMax = this.chart?.scales['x']?.max ?? 100;
1728
+ const leftRange = this.resolveAxisRange('left');
1729
+ const rightRange = this.resolveAxisRange('right');
1730
+ const xRange = this.resolveSlottedXRange();
1306
1731
 
1307
1732
  // Use effective dimensions for threshold checks
1308
1733
  const effectiveWidth = this.getEffectiveWidth();
@@ -1314,23 +1739,75 @@ export class ObcChartLineBase extends LitElement {
1314
1739
  // `labelThickness` band stays in the reported thickness and the chart
1315
1740
  // gets re-padded inward (leaving whitespace on the scale's side), and any
1316
1741
  // visible labels would be clipped against the canvas edge.
1317
- const showLabels =
1318
- this.hasLabelPadding &&
1742
+ const aboveThreshold =
1319
1743
  effectiveWidth >= RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS &&
1320
1744
  effectiveHeight >= RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS;
1745
+ const showLabels = this.hasLabelPadding && aboveThreshold;
1746
+ // Below the threshold a ladder has no room, but min / 0 / max still has.
1747
+ const compactY = !aboveThreshold && this.rangeLabelsY;
1748
+ const compactX = !aboveThreshold && this.rangeLabelsX;
1749
+ const compactYThickness = compactY
1750
+ ? this.compactLabelThickness('y')
1751
+ : undefined;
1752
+ const compactXThickness = compactX
1753
+ ? this.compactLabelThickness('x')
1754
+ : undefined;
1755
+
1756
+ // Compact mode labels a slotted scale's main tickmarks; a scale the chart
1757
+ // never compacted keeps whatever the consumer set.
1758
+ const mainLabelsFor = (
1759
+ scale: ExternalScaleElement,
1760
+ compact: boolean
1761
+ ): boolean | undefined => {
1762
+ if (compact) {
1763
+ if (!this.savedMainTickmarkLabels.has(scale)) {
1764
+ this.savedMainTickmarkLabels.set(
1765
+ scale,
1766
+ scale.showMainTickmarkLabels ?? false
1767
+ );
1768
+ }
1769
+ return true;
1770
+ }
1771
+ if (!this.savedMainTickmarkLabels.has(scale)) return undefined;
1772
+ const saved = this.savedMainTickmarkLabels.get(scale);
1773
+ this.savedMainTickmarkLabels.delete(scale);
1774
+ return saved;
1775
+ };
1776
+
1777
+ // Narrow a scale's label band to its compact labels, and give the scale
1778
+ // its own band back once the chart leaves compact mode.
1779
+ const bandFor = (
1780
+ scale: ExternalScaleElement,
1781
+ compact: boolean,
1782
+ thickness: number | undefined
1783
+ ): number | undefined => {
1784
+ if (compact) {
1785
+ if (
1786
+ thickness === undefined ||
1787
+ typeof scale.labelThickness !== 'number'
1788
+ ) {
1789
+ return undefined;
1790
+ }
1791
+ if (!this.savedLabelThickness.has(scale)) {
1792
+ this.savedLabelThickness.set(scale, scale.labelThickness);
1793
+ }
1794
+ return thickness;
1795
+ }
1796
+ const saved = this.savedLabelThickness.get(scale);
1797
+ this.savedLabelThickness.delete(scale);
1798
+ return saved;
1799
+ };
1321
1800
 
1322
- // Calculate viewBox padding for external scales.
1323
- // When fixedAspectRatioScaling is true, the chart's Canvas padding is scaled by
1324
- // scaleFactor = computedWidth / this.width. For external scales to match, their
1325
- // viewBox padding needs to be: basePadding * scaleReferenceSize / referenceSize
1326
- // This ensures the visual padding matches when the SVG scales to fill the container.
1801
+ // Visual pixels into a viewBox stretched to the *effective* length; the
1802
+ // reference `width` / `height` only matches in a reference-sized
1803
+ // container, and every other one drifts.
1327
1804
  const verticalViewBoxPadding = this.fixedAspectRatioScaling
1328
1805
  ? {
1329
1806
  top: Math.round(
1330
- (padding.top * this.scaleReferenceSize) / this.height
1807
+ (padding.top * this.scaleReferenceSize) / effectiveHeight
1331
1808
  ),
1332
1809
  bottom: Math.round(
1333
- (padding.bottom * this.scaleReferenceSize) / this.height
1810
+ (padding.bottom * this.scaleReferenceSize) / effectiveHeight
1334
1811
  ),
1335
1812
  }
1336
1813
  : {top: padding.top, bottom: padding.bottom};
@@ -1338,10 +1815,10 @@ export class ObcChartLineBase extends LitElement {
1338
1815
  const horizontalViewBoxPadding = this.fixedAspectRatioScaling
1339
1816
  ? {
1340
1817
  left: Math.round(
1341
- (padding.left * this.scaleReferenceSize) / this.width
1818
+ (padding.left * this.scaleReferenceSize) / effectiveWidth
1342
1819
  ),
1343
1820
  right: Math.round(
1344
- (padding.right * this.scaleReferenceSize) / this.width
1821
+ (padding.right * this.scaleReferenceSize) / effectiveWidth
1345
1822
  ),
1346
1823
  }
1347
1824
  : {left: padding.left, right: padding.right};
@@ -1355,20 +1832,28 @@ export class ObcChartLineBase extends LitElement {
1355
1832
  ]
1356
1833
  > = [];
1357
1834
 
1835
+ /** Empty on a side whose slotted scale carries a range of its own. */
1836
+ const rangeProps = (
1837
+ side: 'left' | 'right' | 'top' | 'bottom',
1838
+ range: {min: number; max: number}
1839
+ ): Partial<ExternalScaleElement> =>
1840
+ this.ownsSlottedScaleRange(side)
1841
+ ? {minValue: range.min, maxValue: range.max}
1842
+ : {};
1843
+
1358
1844
  // Left scale
1359
1845
  if (this.leftScaleSlot) {
1360
1846
  const scales =
1361
1847
  this.leftScaleSlot.assignedElements() as ExternalScaleElement[];
1362
1848
  scales.forEach((scale) => {
1363
1849
  const props: Partial<ExternalScaleElement> = {
1364
- minValue: yMin,
1365
- maxValue: yMax,
1850
+ ...rangeProps('left', leftRange),
1366
1851
  height: effectiveHeight, // Use effective height for proper sizing
1367
1852
  paddingTop: verticalViewBoxPadding.top,
1368
1853
  paddingBottom: verticalViewBoxPadding.bottom,
1369
1854
  paddingStart: verticalViewBoxPadding.top,
1370
1855
  paddingEnd: verticalViewBoxPadding.bottom,
1371
- showLabels,
1856
+ showLabels: showLabels || compactY,
1372
1857
  fixedAspectRatio: this.fixedAspectRatioScaling,
1373
1858
  // Use chart's scaleReferenceSize property for proportional scaling
1374
1859
  scaleReferenceSize: this.scaleReferenceSize,
@@ -1381,6 +1866,10 @@ export class ObcChartLineBase extends LitElement {
1381
1866
  if (this.yStepSize !== undefined) {
1382
1867
  props.primaryTickmarkInterval = this.yStepSize;
1383
1868
  }
1869
+ const band = bandFor(scale, compactY, compactYThickness);
1870
+ const mainLabels = mainLabelsFor(scale, compactY);
1871
+ if (mainLabels !== undefined) props.showMainTickmarkLabels = mainLabels;
1872
+ if (band !== undefined) props.labelThickness = band;
1384
1873
  updates.push([this.leftScaleSlot, scale, props]);
1385
1874
  });
1386
1875
  }
@@ -1391,14 +1880,13 @@ export class ObcChartLineBase extends LitElement {
1391
1880
  this.rightScaleSlot.assignedElements() as ExternalScaleElement[];
1392
1881
  scales.forEach((scale) => {
1393
1882
  const props: Partial<ExternalScaleElement> = {
1394
- minValue: yMin,
1395
- maxValue: yMax,
1883
+ ...rangeProps('right', rightRange),
1396
1884
  height: effectiveHeight, // Use effective height for proper sizing
1397
1885
  paddingTop: verticalViewBoxPadding.top,
1398
1886
  paddingBottom: verticalViewBoxPadding.bottom,
1399
1887
  paddingStart: verticalViewBoxPadding.top,
1400
1888
  paddingEnd: verticalViewBoxPadding.bottom,
1401
- showLabels,
1889
+ showLabels: showLabels || compactY,
1402
1890
  fixedAspectRatio: this.fixedAspectRatioScaling,
1403
1891
  // Use chart's scaleReferenceSize property for proportional scaling
1404
1892
  scaleReferenceSize: this.scaleReferenceSize,
@@ -1411,6 +1899,10 @@ export class ObcChartLineBase extends LitElement {
1411
1899
  if (this.yStepSize !== undefined) {
1412
1900
  props.primaryTickmarkInterval = this.yStepSize;
1413
1901
  }
1902
+ const band = bandFor(scale, compactY, compactYThickness);
1903
+ const mainLabels = mainLabelsFor(scale, compactY);
1904
+ if (mainLabels !== undefined) props.showMainTickmarkLabels = mainLabels;
1905
+ if (band !== undefined) props.labelThickness = band;
1414
1906
  updates.push([this.rightScaleSlot, scale, props]);
1415
1907
  });
1416
1908
  }
@@ -1421,14 +1913,13 @@ export class ObcChartLineBase extends LitElement {
1421
1913
  this.topScaleSlot.assignedElements() as ExternalScaleElement[];
1422
1914
  scales.forEach((scale) => {
1423
1915
  const props: Partial<ExternalScaleElement> = {
1424
- minValue: xMin,
1425
- maxValue: xMax,
1916
+ ...xRange,
1426
1917
  width: effectiveWidth, // Use effective width for proper sizing
1427
1918
  paddingLeft: horizontalViewBoxPadding.left,
1428
1919
  paddingRight: horizontalViewBoxPadding.right,
1429
1920
  paddingStart: horizontalViewBoxPadding.left,
1430
1921
  paddingEnd: horizontalViewBoxPadding.right,
1431
- showLabels,
1922
+ showLabels: showLabels || compactX,
1432
1923
  fixedAspectRatio: this.fixedAspectRatioScaling,
1433
1924
  // Use chart's scaleReferenceSize property for proportional scaling
1434
1925
  scaleReferenceSize: this.scaleReferenceSize,
@@ -1441,6 +1932,10 @@ export class ObcChartLineBase extends LitElement {
1441
1932
  if (this.xStepSize !== undefined) {
1442
1933
  props.primaryTickmarkInterval = this.xStepSize;
1443
1934
  }
1935
+ const band = bandFor(scale, compactX, compactXThickness);
1936
+ const mainLabels = mainLabelsFor(scale, compactX);
1937
+ if (mainLabels !== undefined) props.showMainTickmarkLabels = mainLabels;
1938
+ if (band !== undefined) props.labelThickness = band;
1444
1939
  updates.push([this.topScaleSlot, scale, props]);
1445
1940
  });
1446
1941
  }
@@ -1451,14 +1946,13 @@ export class ObcChartLineBase extends LitElement {
1451
1946
  this.bottomScaleSlot.assignedElements() as ExternalScaleElement[];
1452
1947
  scales.forEach((scale) => {
1453
1948
  const props: Partial<ExternalScaleElement> = {
1454
- minValue: xMin,
1455
- maxValue: xMax,
1949
+ ...xRange,
1456
1950
  width: effectiveWidth, // Use effective width for proper sizing
1457
1951
  paddingLeft: horizontalViewBoxPadding.left,
1458
1952
  paddingRight: horizontalViewBoxPadding.right,
1459
1953
  paddingStart: horizontalViewBoxPadding.left,
1460
1954
  paddingEnd: horizontalViewBoxPadding.right,
1461
- showLabels,
1955
+ showLabels: showLabels || compactX,
1462
1956
  fixedAspectRatio: this.fixedAspectRatioScaling,
1463
1957
  // Use chart's scaleReferenceSize property for proportional scaling
1464
1958
  scaleReferenceSize: this.scaleReferenceSize,
@@ -1471,6 +1965,10 @@ export class ObcChartLineBase extends LitElement {
1471
1965
  if (this.xStepSize !== undefined) {
1472
1966
  props.primaryTickmarkInterval = this.xStepSize;
1473
1967
  }
1968
+ const band = bandFor(scale, compactX, compactXThickness);
1969
+ const mainLabels = mainLabelsFor(scale, compactX);
1970
+ if (mainLabels !== undefined) props.showMainTickmarkLabels = mainLabels;
1971
+ if (band !== undefined) props.labelThickness = band;
1474
1972
  updates.push([this.bottomScaleSlot, scale, props]);
1475
1973
  });
1476
1974
  }
@@ -1481,6 +1979,42 @@ export class ObcChartLineBase extends LitElement {
1481
1979
  });
1482
1980
  }
1483
1981
 
1982
+ /**
1983
+ * Whether the chart's axis range is what a slotted scale on this side should
1984
+ * describe. A subclass that slots a scale of its own and gives it a separate
1985
+ * range returns false for that side, otherwise the sync below overwrites it.
1986
+ */
1987
+ protected ownsSlottedScaleRange(
1988
+ _side: 'left' | 'right' | 'top' | 'bottom'
1989
+ ): boolean {
1990
+ return true;
1991
+ }
1992
+
1993
+ /**
1994
+ * Push the current axis ranges to the slotted scales. The full
1995
+ * `updateScaleProperties()` cascade runs before the chart is (re)built and
1996
+ * so reads the previous chart's ranges; this closes that gap once the new
1997
+ * chart exists, and keeps auto-ranged scales in step with the data.
1998
+ */
1999
+ private syncSlottedScaleRanges() {
2000
+ const apply = (
2001
+ side: 'left' | 'right' | 'top' | 'bottom',
2002
+ slot: HTMLSlotElement | undefined,
2003
+ range: {min: number; max: number} | undefined
2004
+ ) => {
2005
+ if (!slot || !range || !this.ownsSlottedScaleRange(side)) return;
2006
+ (slot.assignedElements() as ExternalScaleElement[]).forEach((scale) => {
2007
+ scale.minValue = range.min;
2008
+ scale.maxValue = range.max;
2009
+ });
2010
+ };
2011
+ apply('left', this.leftScaleSlot, this.resolveAxisRange('left'));
2012
+ apply('right', this.rightScaleSlot, this.resolveAxisRange('right'));
2013
+ const xRange = this.resolveSlottedXRange();
2014
+ apply('top', this.topScaleSlot, xRange);
2015
+ apply('bottom', this.bottomScaleSlot, xRange);
2016
+ }
2017
+
1484
2018
  private hasAnyChanged(
1485
2019
  changed: PropertyValues,
1486
2020
  props: readonly (keyof ObcChartLineBase)[]
@@ -1611,13 +2145,21 @@ export class ObcChartLineBase extends LitElement {
1611
2145
  if (this.updateComputedDimensions()) return;
1612
2146
  }
1613
2147
 
1614
- // `hasLabelPadding` cascades into slotted scales via `updateScaleProperties()`
1615
- // (toggles `showLabels`, which collapses/expands the bar's label band and
1616
- // changes its reported thickness). That path only runs through
1617
- // `syncScalesAndChart()`, so when only `hasLabelPadding` changes we must
1618
- // route through there — otherwise slotted scales stay stale until the
1619
- // next slot/resize event and the chart re-pads on stale thickness.
1620
- if (changed.has('hasLabelPadding') && this.hasExternalScales()) {
2148
+ // Only `syncScalesAndChart()` re-cascades to slotted scales, and these are
2149
+ // the changes that need it: the two flags decide what a scale renders, and
2150
+ // a threshold crossing reaches a plain rebuild in pixel mode.
2151
+ const belowThreshold = this.isBelowThreshold();
2152
+ const crossedThreshold =
2153
+ this.wasBelowThreshold !== undefined &&
2154
+ this.wasBelowThreshold !== belowThreshold;
2155
+ this.wasBelowThreshold = belowThreshold;
2156
+
2157
+ if (
2158
+ (changed.has('hasLabelPadding') ||
2159
+ changed.has('rangeLabels') ||
2160
+ crossedThreshold) &&
2161
+ this.hasExternalScales()
2162
+ ) {
1621
2163
  this.syncScalesAndChart();
1622
2164
  return;
1623
2165
  }
@@ -1689,7 +2231,9 @@ export class ObcChartLineBase extends LitElement {
1689
2231
  this.canvasEl,
1690
2232
  () =>
1691
2233
  (this.canvasEl?.clientHeight ?? 0) >=
1692
- RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS,
2234
+ RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS &&
2235
+ (this.canvasEl?.clientWidth ?? 0) >=
2236
+ RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS,
1693
2237
  {
1694
2238
  rebuild: () => {
1695
2239
  this.chart?.destroy();
@@ -1865,6 +2409,7 @@ export class ObcChartLineBase extends LitElement {
1865
2409
  const result = {
1866
2410
  ...(existingDataset || {}),
1867
2411
  data: existingDataset?.data ?? values!,
2412
+ yAxisID: existingDataset?.yAxisID ?? this.primaryYAxisId,
1868
2413
  borderColor,
1869
2414
  backgroundColor,
1870
2415
  borderWidth: existingDataset?.borderWidth ?? 2,
@@ -1922,6 +2467,7 @@ export class ObcChartLineBase extends LitElement {
1922
2467
  const baselineDataset: ChartDataset<'line', ChartLinePoint[]> = {
1923
2468
  label: 'threshold-baseline',
1924
2469
  data: baselineData,
2470
+ yAxisID: this.primaryYAxisId,
1925
2471
  borderColor: 'transparent',
1926
2472
  backgroundColor: 'transparent',
1927
2473
  borderWidth: 0,
@@ -2040,6 +2586,62 @@ export class ObcChartLineBase extends LitElement {
2040
2586
  return {datasets, labels};
2041
2587
  }
2042
2588
 
2589
+ /**
2590
+ * One padding source for the Chart.js layout and the slotted scales: two
2591
+ * sources painted the canvas over a scale and inset a scale inside a chart.
2592
+ */
2593
+ private computeChartPadding(): {
2594
+ top: number;
2595
+ right: number;
2596
+ bottom: number;
2597
+ left: number;
2598
+ } {
2599
+ const isTooSmall = this.isBelowThreshold();
2600
+ // Get scale factor for proportional scaling in fixed aspect ratio mode
2601
+ const scaleFactor = this.getScaleFactor();
2602
+
2603
+ const defaultPaddingScaled = !this.hasLabelPadding
2604
+ ? 0
2605
+ : this.fixedAspectRatioScaling
2606
+ ? Math.round(CHART_DIMENSIONS.CANVAS_PADDING * scaleFactor)
2607
+ : CHART_DIMENSIONS.CANVAS_PADDING;
2608
+
2609
+ const {y: yTexts, x: xTexts} = this.refreshRangeLabels();
2610
+ const context = this.canvasEl?.getContext('2d');
2611
+ const gutters =
2612
+ (yTexts.length || xTexts.length) && context
2613
+ ? measureRangeLabelGutters(
2614
+ context,
2615
+ this.rangeLabelFont(),
2616
+ yTexts,
2617
+ xTexts
2618
+ )
2619
+ : {side: 0, bottom: 0};
2620
+
2621
+ const scalePadding = this.calculatePaddingFromScales();
2622
+ const freeSide = (side: 'top' | 'right' | 'bottom' | 'left'): number => {
2623
+ if (!isTooSmall) return defaultPaddingScaled;
2624
+ if (side === 'bottom') return gutters.bottom;
2625
+ return side === this.ySide ? gutters.side : 0;
2626
+ };
2627
+ const padding = {
2628
+ top: this.externalScaleDimensions.has('top')
2629
+ ? scalePadding.top
2630
+ : freeSide('top'),
2631
+ right: this.externalScaleDimensions.has('right')
2632
+ ? scalePadding.right
2633
+ : freeSide('right'),
2634
+ bottom: this.externalScaleDimensions.has('bottom')
2635
+ ? scalePadding.bottom
2636
+ : freeSide('bottom'),
2637
+ left: this.externalScaleDimensions.has('left')
2638
+ ? scalePadding.left
2639
+ : freeSide('left'),
2640
+ };
2641
+
2642
+ return padding;
2643
+ }
2644
+
2043
2645
  /**
2044
2646
  * Get Chart.js options with dynamic sizing and padding
2045
2647
  */
@@ -2053,53 +2655,7 @@ export class ObcChartLineBase extends LitElement {
2053
2655
  effectiveWidth < RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS ||
2054
2656
  effectiveHeight < RECTANGULAR_CHART_DIMENSIONS.MIN_HEIGHT_WITH_LABELS;
2055
2657
 
2056
- // Get scale factor for proportional scaling in fixed aspect ratio mode
2057
- const scaleFactor = this.getScaleFactor();
2058
-
2059
- // Calculate padding for the chart.
2060
- // External scales report their actual visual (scaled) thickness when in fixedAspectRatio mode,
2061
- // so we use those values directly without additional scaling.
2062
- // For sides without external scales, we apply the chart's scaleFactor to default padding.
2063
- let padding: {top: number; right: number; bottom: number; left: number};
2064
-
2065
- // When hasLabelPadding=false the chart never reserves space for axis tick
2066
- // labels on sides without an external scale (renders edge-to-edge there).
2067
- // External-scale sides still receive their reported visual thickness so
2068
- // slotted scales/bars remain fully visible.
2069
- const defaultPaddingScaled = !this.hasLabelPadding
2070
- ? 0
2071
- : this.fixedAspectRatioScaling
2072
- ? Math.round(CHART_DIMENSIONS.CANVAS_PADDING * scaleFactor)
2073
- : CHART_DIMENSIONS.CANVAS_PADDING;
2074
-
2075
- if (isTooSmall && this.hasLabelPadding) {
2076
- padding = {top: 0, right: 0, bottom: 0, left: 0};
2077
- } else if (this.hasExternalScales()) {
2078
- // External scales report their visual dimensions (already scaled when fixedAspectRatio=true)
2079
- const scalePadding = this.calculatePaddingFromScales();
2080
- padding = {
2081
- top: this.externalScaleDimensions.has('top')
2082
- ? scalePadding.top
2083
- : defaultPaddingScaled,
2084
- right: this.externalScaleDimensions.has('right')
2085
- ? scalePadding.right
2086
- : defaultPaddingScaled,
2087
- bottom: this.externalScaleDimensions.has('bottom')
2088
- ? scalePadding.bottom
2089
- : defaultPaddingScaled,
2090
- left: this.externalScaleDimensions.has('left')
2091
- ? scalePadding.left
2092
- : defaultPaddingScaled,
2093
- };
2094
- } else {
2095
- // No external scales - apply scaleFactor to all default padding
2096
- padding = {
2097
- top: defaultPaddingScaled,
2098
- right: defaultPaddingScaled,
2099
- bottom: defaultPaddingScaled,
2100
- left: defaultPaddingScaled,
2101
- };
2102
- }
2658
+ const padding = this.computeChartPadding();
2103
2659
 
2104
2660
  // Set CSS variables for wrapper and canvas sizing
2105
2661
  if (this.fixedAspectRatioScaling) {
@@ -2169,10 +2725,18 @@ export class ObcChartLineBase extends LitElement {
2169
2725
  /**
2170
2726
  * Compute reference timestamp for time axis formatting.
2171
2727
  * Returns earliest timestamp for 'date' mode, latest for 'minutes' mode.
2728
+ * A pinned x range supplies the reference instead, so the axis edge stays
2729
+ * the reference while the data has not reached it yet.
2172
2730
  */
2173
2731
  private computeTimeReference(): number | undefined {
2174
2732
  if (this.xAxisType !== XAxisType.time) return undefined;
2175
2733
 
2734
+ const pinned =
2735
+ this.timeDisplay === TimeDisplay.minutes
2736
+ ? this.xAxis?.max
2737
+ : this.xAxis?.min;
2738
+ if (pinned !== undefined && Number.isFinite(pinned)) return pinned;
2739
+
2176
2740
  const timestamps: number[] = [];
2177
2741
 
2178
2742
  // Collect timestamps from datasets
@@ -2267,6 +2831,9 @@ export class ObcChartLineBase extends LitElement {
2267
2831
  offset: false, // Always edge-to-edge (no padding on x-axis)
2268
2832
  grace: 0, // No extra margin
2269
2833
  bounds: 'data', // Use data bounds for edge-to-edge rendering
2834
+ // A category axis has no numeric range to pin.
2835
+ min: this.isNumericXAxis ? this.xAxis?.min : undefined,
2836
+ max: this.isNumericXAxis ? this.xAxis?.max : undefined,
2270
2837
  grid: {
2271
2838
  display: this.showGrid && this.showGridX,
2272
2839
  color: gridColor,
@@ -2381,12 +2948,18 @@ export class ObcChartLineBase extends LitElement {
2381
2948
  type: 'line',
2382
2949
  data: {labels, datasets},
2383
2950
  options: this.getChartOptions(),
2384
- plugins: this.borderRadiusPosition ? [this.createBorderPlugin()] : [],
2951
+ plugins: [
2952
+ ...(this.borderRadiusPosition ? [this.createBorderPlugin()] : []),
2953
+ ...(this.rangeLabels !== RangeLabels.none
2954
+ ? [this.createRangeLabelsPlugin()]
2955
+ : []),
2956
+ ],
2385
2957
  } as ChartConfiguration<'line'>);
2386
2958
 
2387
2959
  // Defer legend update to next tick to ensure Chart.js metadata is initialized
2388
2960
  requestAnimationFrame(() => this.updateLegend());
2389
2961
  this.applyFillModes();
2962
+ this.syncSlottedScaleRanges();
2390
2963
  }
2391
2964
 
2392
2965
  private updateChart() {
@@ -2403,6 +2976,7 @@ export class ObcChartLineBase extends LitElement {
2403
2976
 
2404
2977
  this.applyFillModes();
2405
2978
  this.chart.update();
2979
+ this.syncSlottedScaleRanges();
2406
2980
 
2407
2981
  // Update legend after chart update completes to ensure metadata is ready
2408
2982
  requestAnimationFrame(() => this.updateLegend());