@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
@@ -117,6 +117,23 @@ const SUBJECTS: Subject[] = [
117
117
  .primaryTickmarkInterval=${25}
118
118
  ></obc-gauge-trend>`,
119
119
  },
120
+ {
121
+ label: 'gauge-trend + range labels',
122
+ contract: SizingContract.fills,
123
+ feedsSize: true,
124
+ render: () =>
125
+ html`<obc-gauge-trend
126
+ style=${FILL_STYLE}
127
+ .data=${DATA}
128
+ .minValue=${0}
129
+ .maxValue=${100}
130
+ .value=${55}
131
+ .chartFill=${true}
132
+ .hasBar=${true}
133
+ .hasScale=${true}
134
+ .rangeLabels=${'xy'}
135
+ ></obc-gauge-trend>`,
136
+ },
120
137
  {
121
138
  label: 'gauge-trend + advice + setpoint',
122
139
  contract: SizingContract.fills,
@@ -0,0 +1,154 @@
1
+ import {describe, it, expect, vi, afterEach} from 'vitest';
2
+ import {render, svg, type SVGTemplateResult} from 'lit';
3
+ import {
4
+ renderExternalScale,
5
+ ExternalScaleOrientation,
6
+ ExternalScaleSide,
7
+ ScaleType,
8
+ FillMode,
9
+ AdvicePosition,
10
+ EXTERNAL_SCALE_MAX_TICKS,
11
+ type ExternalScaleConfig,
12
+ } from './external-scale.js';
13
+ import {
14
+ FrameStyle,
15
+ InstrumentState,
16
+ Priority,
17
+ } from '../../navigation-instruments/types.js';
18
+
19
+ const bottomScale = (
20
+ overrides: Partial<ExternalScaleConfig>
21
+ ): ExternalScaleConfig => ({
22
+ orientation: ExternalScaleOrientation.horizontal,
23
+ side: ExternalScaleSide.bottom,
24
+ length: 600,
25
+ paddingStart: 32,
26
+ paddingEnd: 32,
27
+ minValue: 0,
28
+ maxValue: 100,
29
+ hasBar: false,
30
+ hasScale: true,
31
+ labels: true,
32
+ scaleBackground: false,
33
+ barThickness: 24,
34
+ tickThickness: 24,
35
+ labelThickness: 60,
36
+ mainTickmarks: [],
37
+ scaleType: ScaleType.regular,
38
+ frameStyle: FrameStyle.regular,
39
+ priority: Priority.regular,
40
+ fillMode: FillMode.fill,
41
+ atSetpoint: false,
42
+ autoAtSetpoint: true,
43
+ autoAtSetpointDeadband: 1,
44
+ setpointAtZeroDeadband: 0.5,
45
+ state: InstrumentState.active,
46
+ advicePosition: AdvicePosition.inner,
47
+ ...overrides,
48
+ });
49
+
50
+ // A twelve-minute window in epoch milliseconds — what a slotted scale
51
+ // receives from a chart on a time axis.
52
+ const TWELVE_MINUTES_MS = 12 * 60_000;
53
+
54
+ describe('tick ladder density guard (#1219)', () => {
55
+ afterEach(() => {
56
+ vi.restoreAllMocks();
57
+ });
58
+
59
+ it('drops a ladder that would exceed the tick cap instead of building it', () => {
60
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
61
+ const parts = renderExternalScale(
62
+ bottomScale({
63
+ minValue: 0,
64
+ maxValue: TWELVE_MINUTES_MS,
65
+ primaryTickmarkInterval: 2,
66
+ secondaryTickmarkInterval: 1,
67
+ })
68
+ );
69
+ expect(parts.tickmarks.length).toBeLessThan(EXTERNAL_SCALE_MAX_TICKS);
70
+ expect(parts.labels).toEqual([]);
71
+ expect(warn).toHaveBeenCalled();
72
+ });
73
+
74
+ it('counts both endpoints: a range of exactly the cap in steps is one over', () => {
75
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
76
+ const parts = renderExternalScale(
77
+ bottomScale({
78
+ minValue: 0,
79
+ maxValue: EXTERNAL_SCALE_MAX_TICKS,
80
+ primaryTickmarkInterval: 1,
81
+ })
82
+ );
83
+ expect(parts.labels).toEqual([]);
84
+ expect(warn).toHaveBeenCalledTimes(1);
85
+ });
86
+
87
+ it('keeps a ladder whose count is under the cap', () => {
88
+ const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
89
+ const parts = renderExternalScale(
90
+ bottomScale({
91
+ minValue: 0,
92
+ maxValue: TWELVE_MINUTES_MS,
93
+ primaryTickmarkInterval: 2 * 60_000,
94
+ })
95
+ );
96
+ // 0, 2 … 12 minutes: seven labels.
97
+ expect(parts.labels).toHaveLength(7);
98
+ expect(parts.tickmarks.length).toBeGreaterThan(0);
99
+ expect(warn).not.toHaveBeenCalled();
100
+ });
101
+ });
102
+
103
+ /** Renders one label fragment into a detached <svg> and returns its <text>. */
104
+ function labelElement(label: SVGTemplateResult): SVGTextElement {
105
+ const host = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
106
+ render(svg`${label}`, host);
107
+ return host.querySelector('text')!;
108
+ }
109
+
110
+ const labelText = (label: SVGTemplateResult) =>
111
+ labelElement(label).textContent?.trim();
112
+ const labelAttr = (label: SVGTemplateResult, name: string) =>
113
+ labelElement(label).getAttribute(name);
114
+
115
+ describe('main tickmark labels (#1191)', () => {
116
+ it('labels [min, 0, max] instead of the interval ladder', () => {
117
+ const parts = renderExternalScale(
118
+ bottomScale({
119
+ minValue: -50,
120
+ maxValue: 100,
121
+ primaryTickmarkInterval: 10,
122
+ mainTickmarkLabels: true,
123
+ })
124
+ );
125
+ expect(parts.labels.map(labelText)).toEqual(['-50', '0', '100']);
126
+ });
127
+
128
+ it('clamps the end labels inward on a horizontal scale', () => {
129
+ const parts = renderExternalScale(
130
+ bottomScale({minValue: 0, maxValue: 100, mainTickmarkLabels: true})
131
+ );
132
+ expect(parts.labels.map((l) => labelAttr(l, 'text-anchor'))).toEqual([
133
+ 'start',
134
+ 'end',
135
+ ]);
136
+ });
137
+
138
+ it('clamps the end labels inward on a vertical scale', () => {
139
+ const parts = renderExternalScale(
140
+ bottomScale({
141
+ orientation: ExternalScaleOrientation.vertical,
142
+ side: ExternalScaleSide.left,
143
+ minValue: 0,
144
+ maxValue: 100,
145
+ mainTickmarkLabels: true,
146
+ })
147
+ );
148
+ // min sits at the bottom and hangs upward, max at the top hangs downward.
149
+ expect(parts.labels.map((l) => labelAttr(l, 'dominant-baseline'))).toEqual([
150
+ 'auto',
151
+ 'hanging',
152
+ ]);
153
+ });
154
+ });
@@ -424,6 +424,11 @@ export interface ExternalScaleConfig {
424
424
  hasScale: boolean;
425
425
  /** Show labels at primary tickmark intervals. */
426
426
  labels?: boolean;
427
+ /**
428
+ * Label the main tickmarks (`mainTickmarks`, or min / 0 / max) instead of
429
+ * the primary interval ladder. For scales too short for a ladder.
430
+ */
431
+ mainTickmarkLabels?: boolean;
427
432
  /** Show bar. */
428
433
  hasBar: boolean;
429
434
  /** Show background behind the scale tickmarks. */
@@ -932,6 +937,50 @@ function rangeIncludesZero(minValue: number, maxValue: number): boolean {
932
937
  return minValue <= 0 && maxValue >= 0;
933
938
  }
934
939
 
940
+ /** The main tickmark values inside the range, ascending, without repeats. */
941
+ function resolveMainTickmarkValues(
942
+ config: Pick<ExternalScaleConfig, 'mainTickmarks' | 'minValue' | 'maxValue'>
943
+ ): number[] {
944
+ const source = config.mainTickmarks?.length
945
+ ? config.mainTickmarks
946
+ : [config.minValue, 0, config.maxValue];
947
+ return [...new Set(source)]
948
+ .filter((v) => v >= config.minValue && v <= config.maxValue)
949
+ .sort((a, b) => a - b);
950
+ }
951
+
952
+ /**
953
+ * Ticks one ladder (primary, secondary, tertiary or labels) may hold. A
954
+ * denser ladder cannot be read on any scale length, and building it would
955
+ * exhaust the call stack.
956
+ */
957
+ export const EXTERNAL_SCALE_MAX_TICKS = 1000;
958
+
959
+ // Bounded: a scale re-renders on every property change, and a live range
960
+ // that stays dense would otherwise grow this without limit.
961
+ const DENSE_LADDER_WARNINGS_KEPT = 64;
962
+ const warnedDenseLadders = new Set<string>();
963
+
964
+ /** True when `interval` would put more than the cap on the range; warns once per pair. */
965
+ function isLadderTooDense(
966
+ config: Pick<ExternalScaleConfig, 'minValue' | 'maxValue'>,
967
+ interval: number
968
+ ): boolean {
969
+ const count = Math.floor((config.maxValue - config.minValue) / interval) + 1;
970
+ if (!(count > EXTERNAL_SCALE_MAX_TICKS)) return false;
971
+ const key = `${config.minValue}/${config.maxValue}/${interval}`;
972
+ if (!warnedDenseLadders.has(key)) {
973
+ if (warnedDenseLadders.size >= DENSE_LADDER_WARNINGS_KEPT) {
974
+ warnedDenseLadders.clear();
975
+ }
976
+ warnedDenseLadders.add(key);
977
+ console.warn(
978
+ `[external-scale] tick interval ${interval} over the range ${config.minValue}…${config.maxValue} is ${count} ticks; the ladder is not drawn (limit ${EXTERNAL_SCALE_MAX_TICKS}). A range in epoch milliseconds needs an interval in milliseconds.`
979
+ );
980
+ }
981
+ return true;
982
+ }
983
+
935
984
  function calculateAtSetpoint(config: ExternalScaleConfig): boolean {
936
985
  const isTouching = config.touching ?? false;
937
986
  return computeAtSetpoint({
@@ -1207,6 +1256,7 @@ function generateTickmarksAtInterval(
1207
1256
  const values: number[] = [];
1208
1257
 
1209
1258
  if (interval <= 0 || !Number.isFinite(interval)) return {svgs, values};
1259
+ if (isLadderTooDense(config, interval)) return {svgs, values};
1210
1260
 
1211
1261
  const includesZero = rangeIncludesZero(config.minValue, config.maxValue);
1212
1262
 
@@ -1271,16 +1321,7 @@ function generateTickmarks(config: ExternalScaleConfig): SVGTemplateResult[] {
1271
1321
  const mainLen = config.frameStyle === 'flat' ? main + 4 : main;
1272
1322
  const dirLen = isOutwardPositive(config) ? mainLen : -mainLen;
1273
1323
 
1274
- // Use provided array or default to [minValue, 0, maxValue]
1275
- const mainTickValues =
1276
- config.mainTickmarks.length > 0
1277
- ? config.mainTickmarks
1278
- : [config.minValue, 0, config.maxValue];
1279
-
1280
- for (const value of mainTickValues) {
1281
- // Skip if outside range
1282
- if (value < config.minValue || value > config.maxValue) continue;
1283
-
1324
+ for (const value of resolveMainTickmarkValues(config)) {
1284
1325
  // Skip min/max tickmarks when scaleBackground is enabled (they align with the background edges)
1285
1326
  if (
1286
1327
  config.scaleBackground &&
@@ -1347,10 +1388,7 @@ function generateTickmarks(config: ExternalScaleConfig): SVGTemplateResult[] {
1347
1388
  }
1348
1389
 
1349
1390
  function generateLabels(config: ExternalScaleConfig): SVGTemplateResult[] {
1350
- if (!config.labels || config.primaryTickmarkInterval === undefined) return [];
1351
-
1352
- const interval = config.primaryTickmarkInterval;
1353
- if (interval <= 0 || !Number.isFinite(interval)) return [];
1391
+ if (!config.labels) return [];
1354
1392
 
1355
1393
  const fontFamily = 'var(--font-family-main)';
1356
1394
  const fontColor = 'var(--instrument-tick-mark-label-secondary-color)';
@@ -1365,18 +1403,17 @@ function generateLabels(config: ExternalScaleConfig): SVGTemplateResult[] {
1365
1403
  ? base + (config.hasScale ? effectiveTickThickness : 0) + labelGap()
1366
1404
  : base - (config.hasScale ? effectiveTickThickness : 0) - labelGap();
1367
1405
 
1368
- const includesZero = rangeIncludesZero(config.minValue, config.maxValue);
1369
-
1370
1406
  const labels: SVGTemplateResult[] = [];
1371
1407
 
1372
- const push = (v: number) => {
1408
+ const push = (v: number, edge?: {anchor: string; baseline: string}) => {
1373
1409
  const main = valueToMainAxis(config, v);
1374
1410
  if (isVertical(config)) {
1375
1411
  const x = labelPos;
1376
1412
  const y = main;
1377
1413
  const anchor = isOutwardPositive(config) ? 'start' : 'end';
1414
+ const baseline = edge?.baseline ?? 'middle';
1378
1415
  labels.push(
1379
- svg`<text x=${x} y=${y} text-anchor=${anchor} dominant-baseline="middle" font-family=${fontFamily} style="font-size: ${fontSize}" fill=${fontColor}>${v}</text>`
1416
+ svg`<text x=${x} y=${y} text-anchor=${anchor} dominant-baseline=${baseline} font-family=${fontFamily} style="font-size: ${fontSize}" fill=${fontColor}>${v}</text>`
1380
1417
  );
1381
1418
  return;
1382
1419
  }
@@ -1384,12 +1421,33 @@ function generateLabels(config: ExternalScaleConfig): SVGTemplateResult[] {
1384
1421
  const y = labelPos;
1385
1422
  const x = main;
1386
1423
  const baseline = isOutwardPositive(config) ? 'hanging' : 'auto';
1424
+ const anchor = edge?.anchor ?? 'middle';
1387
1425
  labels.push(
1388
- svg`<text x=${x} y=${y} text-anchor="middle" dominant-baseline=${baseline} font-family=${fontFamily} style="font-size: ${fontSize}" fill=${fontColor}>${v}</text>`
1426
+ svg`<text x=${x} y=${y} text-anchor=${anchor} dominant-baseline=${baseline} font-family=${fontFamily} style="font-size: ${fontSize}" fill=${fontColor}>${v}</text>`
1389
1427
  );
1390
1428
  };
1391
1429
 
1392
- if (includesZero) {
1430
+ if (config.mainTickmarkLabels) {
1431
+ // The end labels turn inward so they stay inside the drawing length.
1432
+ for (const v of resolveMainTickmarkValues(config)) {
1433
+ const edge =
1434
+ v === config.minValue
1435
+ ? {anchor: 'start', baseline: 'auto'}
1436
+ : v === config.maxValue
1437
+ ? {anchor: 'end', baseline: 'hanging'}
1438
+ : undefined;
1439
+ push(v, edge);
1440
+ }
1441
+ return labels;
1442
+ }
1443
+
1444
+ const interval = config.primaryTickmarkInterval;
1445
+ if (interval === undefined || interval <= 0 || !Number.isFinite(interval)) {
1446
+ return [];
1447
+ }
1448
+ if (isLadderTooDense(config, interval)) return [];
1449
+
1450
+ if (rangeIncludesZero(config.minValue, config.maxValue)) {
1393
1451
  for (let v = 0; v <= config.maxValue; v += interval) push(v);
1394
1452
  for (let v = -interval; v >= config.minValue; v -= interval) push(v);
1395
1453
  } else {
@@ -8,3 +8,4 @@ export * from './tooltip.js';
8
8
  export * from './legend.js';
9
9
  export * from './x-value.js';
10
10
  export * from './label-threshold.js';
11
+ export * from './range-labels.js';
@@ -0,0 +1,46 @@
1
+ import {describe, it, expect} from 'vitest';
2
+ import {
3
+ formatRangeValue,
4
+ yRangeLabelValues,
5
+ measureRangeLabelGutters,
6
+ rangeLabelLineHeight,
7
+ } from './range-labels.js';
8
+
9
+ const font = {family: 'sans-serif', sizePx: 12, weight: '400'};
10
+
11
+ describe('range label values', () => {
12
+ it('keeps integers integer and non-integers at one decimal', () => {
13
+ expect(formatRangeValue(7, 3, 7)).toBe('7');
14
+ expect(formatRangeValue(3.5, 3.5, 7)).toBe('3.5');
15
+ expect(formatRangeValue(7, 3.5, 7)).toBe('7.0');
16
+ });
17
+
18
+ it('adds 0 only when strictly inside the range', () => {
19
+ expect(yRangeLabelValues(-100, 100)).toEqual([-100, 0, 100]);
20
+ expect(yRangeLabelValues(0, 100)).toEqual([0, 100]);
21
+ expect(yRangeLabelValues(3, 7)).toEqual([3, 7]);
22
+ });
23
+ });
24
+
25
+ describe('range label gutters', () => {
26
+ const ctx = document.createElement('canvas').getContext('2d')!;
27
+
28
+ it('reserves the widest y label plus the gap, and one line for x', () => {
29
+ const g = measureRangeLabelGutters(
30
+ ctx,
31
+ font,
32
+ ['3', '1000'],
33
+ ['Jan', 'Dec']
34
+ );
35
+ ctx.font = '400 12px sans-serif';
36
+ expect(g.side).toBe(Math.ceil(ctx.measureText('1000').width) + 8);
37
+ expect(g.bottom).toBe(rangeLabelLineHeight(font) + 4);
38
+ });
39
+
40
+ it('reserves nothing for an axis without labels', () => {
41
+ expect(measureRangeLabelGutters(ctx, font, [], [])).toEqual({
42
+ side: 0,
43
+ bottom: 0,
44
+ });
45
+ });
46
+ });
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Min / max labels for charts below the label threshold: which values to
3
+ * show and how much gutter their text needs. Pure; the chart measures on
4
+ * its own canvas and draws with its own plugin.
5
+ */
6
+ export interface RangeLabelFont {
7
+ family: string;
8
+ sizePx: number;
9
+ weight: string;
10
+ }
11
+
12
+ /**
13
+ * @param font - Family, size in px and weight of the label text
14
+ * @returns The canvas `font` shorthand for that text
15
+ */
16
+ export function rangeLabelFontString(font: RangeLabelFont): string {
17
+ return `${font.weight} ${font.sizePx}px ${font.family}`;
18
+ }
19
+
20
+ /**
21
+ * Chart.js' own line-height ratio, so the gutter matches its tick labels.
22
+ *
23
+ * @param font - The label font
24
+ * @returns One text line in px
25
+ */
26
+ export function rangeLabelLineHeight(font: RangeLabelFont): number {
27
+ return Math.round(font.sizePx * 1.2);
28
+ }
29
+
30
+ /**
31
+ * One decimal only when an end of the range needs it; both ends share it.
32
+ *
33
+ * @param value - The value to print
34
+ * @param min - Lower end of the range the value belongs to
35
+ * @param max - Upper end of the range
36
+ * @returns The label text
37
+ */
38
+ export function formatRangeValue(
39
+ value: number,
40
+ min: number,
41
+ max: number
42
+ ): string {
43
+ const decimals = Number.isInteger(min) && Number.isInteger(max) ? 0 : 1;
44
+ return value.toFixed(decimals);
45
+ }
46
+
47
+ /**
48
+ * @param min - Lower end of the y range
49
+ * @param max - Upper end of the y range
50
+ * @returns `[min, 0, max]`, with 0 only strictly inside the range
51
+ */
52
+ export function yRangeLabelValues(min: number, max: number): number[] {
53
+ return min < 0 && max > 0 ? [min, 0, max] : [min, max];
54
+ }
55
+
56
+ export interface RangeLabelGutters {
57
+ /** Room on the y-axis side: the widest label plus the gap. */
58
+ side: number;
59
+ /** Room below the plot: one line plus the gap. */
60
+ bottom: number;
61
+ }
62
+
63
+ const SIDE_GAP = 8;
64
+ const BOTTOM_GAP = 4;
65
+
66
+ /**
67
+ * The x labels sit in a band one line tall, so only their count matters;
68
+ * their width is not measured.
69
+ *
70
+ * @param ctx - A 2d context to measure on; its font is restored afterwards
71
+ * @param font - The label font
72
+ * @param yTexts - Labels for the y side, empty when that side has none
73
+ * @param xTexts - Labels for the bottom, empty when the x side has none
74
+ * @returns Gutter sizes in px; 0 for a side without labels
75
+ */
76
+ export function measureRangeLabelGutters(
77
+ ctx: CanvasRenderingContext2D,
78
+ font: RangeLabelFont,
79
+ yTexts: string[],
80
+ xTexts: string[]
81
+ ): RangeLabelGutters {
82
+ ctx.save();
83
+ ctx.font = rangeLabelFontString(font);
84
+ const widest = yTexts.reduce(
85
+ (width, text) => Math.max(width, ctx.measureText(text).width),
86
+ 0
87
+ );
88
+ ctx.restore();
89
+ return {
90
+ side: yTexts.length ? Math.ceil(widest) + SIDE_GAP : 0,
91
+ bottom: xTexts.length ? rangeLabelLineHeight(font) + BOTTOM_GAP : 0,
92
+ };
93
+ }
@@ -167,53 +167,40 @@ button {
167
167
  }
168
168
  }
169
169
 
170
- .blinking.alert-type-warning .alert-button,
171
- .blinking.alert-type-level-medium .alert-button {
170
+ .flash-fast .alert-button {
172
171
  .visible-wrapper {
173
- opacity: var(--warning-blink-on);
172
+ opacity: var(--flash-fast-on);
174
173
  }
175
174
 
176
175
  .blink {
177
- opacity: var(--warning-blink-off);
176
+ opacity: var(--flash-fast-off);
178
177
  }
179
178
  }
180
179
 
181
- .blinking.alert-type-level-critical .alert-button {
180
+ .flash-slow .alert-button {
182
181
  .visible-wrapper {
183
- opacity: var(--critical-blink-on);
182
+ opacity: var(--flash-slow-on);
184
183
  }
185
184
 
186
185
  .blink {
187
- opacity: var(--critical-blink-off);
186
+ opacity: var(--flash-slow-off);
188
187
  }
189
188
  }
190
189
 
191
- .blinking.alert-type-alarm .alert-button,
192
- .blinking.alert-type-level-high .alert-button {
190
+ .flash-very-slow .alert-button {
193
191
  .visible-wrapper {
194
- opacity: var(--alarm-blink-on);
192
+ opacity: var(--flash-very-slow-on);
195
193
  }
196
194
 
197
195
  .blink {
198
- opacity: var(--alarm-blink-off);
199
- }
200
- }
201
-
202
- .blinking.alert-type-caution .alert-button,
203
- .blinking.alert-type-level-low .alert-button {
204
- .visible-wrapper {
205
- opacity: var(--low-blink-on);
206
- }
207
-
208
- .blink {
209
- opacity: var(--low-blink-off);
196
+ opacity: var(--flash-very-slow-off);
210
197
  }
211
198
  }
212
199
 
213
200
  .counter .alert-button .visible-wrapper,
214
201
  .counter .alert-button .blink {
215
202
  padding: 0px var(--ui-components-button-padding-horizontal);
216
- gap: var(--app-components-global-alert-item-counter-spacing);
203
+ gap: var(--app-components-alert-button-item-counter-spacing);
217
204
  }
218
205
 
219
206
  .alert-type-alarm {
@@ -3,24 +3,67 @@ import './alert-button.js';
3
3
  import {ObcAlertButton} from './alert-button.js';
4
4
  import {render} from 'vitest-browser-lit';
5
5
  import {html} from 'lit';
6
+ import {AlertType, FlashingSpeed} from '../../types.js';
6
7
 
7
- describe('obc-alert-button blinking lifecycle', () => {
8
- async function setup() {
9
- const screen = render(html`<obc-alert-button></obc-alert-button>`);
10
- const el = screen.baseElement.querySelector(
8
+ function durations(el: HTMLElement): number[] {
9
+ return el
10
+ .getAnimations()
11
+ .map((a) => (a.effect as KeyframeEffect).getTiming().duration as number);
12
+ }
13
+
14
+ describe('obc-alert-button flashing lifecycle', () => {
15
+ async function setup(
16
+ props: Partial<
17
+ Pick<
18
+ ObcAlertButton,
19
+ 'nAlerts' | 'alertType' | 'blinking' | 'flashingSpeed'
20
+ >
21
+ > = {}
22
+ ) {
23
+ const screen = render(
24
+ html`<obc-alert-button
25
+ .nAlerts=${props.nAlerts ?? 1}
26
+ .alertType=${props.alertType ?? AlertType.Alarm}
27
+ .blinking=${props.blinking ?? true}
28
+ .flashingSpeed=${props.flashingSpeed ?? FlashingSpeed.Default}
29
+ ></obc-alert-button>`
30
+ );
31
+ const el = screen.container.querySelector(
11
32
  'obc-alert-button'
12
33
  ) as ObcAlertButton;
13
34
  await el.updateComplete;
14
35
  return el;
15
36
  }
16
37
 
17
- it('installs the blink animations on first render', async () => {
38
+ it('flashes fast for a blinking alarm button with alerts', async () => {
18
39
  const el = await setup();
19
40
 
20
- expect(el.getAnimations().length).toBeGreaterThan(0);
41
+ expect(durations(el)).toEqual([800]);
42
+ expect(el.shadowRoot!.querySelector('.wrapper.flash-fast')).not.toBeNull();
43
+ });
44
+
45
+ it('flashes slow for a warning and never for caution', async () => {
46
+ expect(durations(await setup({alertType: AlertType.Warning}))).toEqual([
47
+ 1600,
48
+ ]);
49
+ expect(durations(await setup({alertType: AlertType.Caution}))).toEqual([]);
50
+ });
51
+
52
+ it('keeps blinking as the gate', async () => {
53
+ expect(durations(await setup({blinking: false}))).toEqual([]);
54
+ expect(durations(await setup({nAlerts: 0}))).toEqual([]);
55
+ });
56
+
57
+ it('honours an explicit flashingSpeed', async () => {
58
+ expect(
59
+ durations(await setup({flashingSpeed: FlashingSpeed.VerySlow}))
60
+ ).toEqual([3200]);
61
+ expect(
62
+ durations(await setup({flashingSpeed: FlashingSpeed.Fixed}))
63
+ ).toEqual([]);
21
64
  });
22
65
 
23
- it('cancels the blink animations on disconnect', async () => {
66
+ it('cancels the animation on disconnect', async () => {
24
67
  const el = await setup();
25
68
 
26
69
  el.parentElement!.removeChild(el);
@@ -28,31 +71,29 @@ describe('obc-alert-button blinking lifecycle', () => {
28
71
  expect(el.getAnimations()).toHaveLength(0);
29
72
  });
30
73
 
31
- it('resumes blinking after disconnect and reconnect', async () => {
74
+ it('resumes flashing after disconnect and reconnect', async () => {
32
75
  const el = await setup();
33
76
  const parent = el.parentElement!;
34
- const initial = el.getAnimations().length;
35
77
 
36
78
  parent.removeChild(el);
37
79
  expect(el.getAnimations()).toHaveLength(0);
38
80
 
39
81
  // Reconnect without touching any property. firstUpdated() will not run
40
- // again, so this only passes if blinking is reinstalled on update.
82
+ // again, so this only passes if the controller re-syncs on connect.
41
83
  parent.appendChild(el);
42
84
  await el.updateComplete;
43
85
 
44
- expect(el.getAnimations()).toHaveLength(initial);
86
+ expect(durations(el)).toEqual([800]);
45
87
  });
46
88
 
47
89
  it('does not accumulate animations across repeated updates', async () => {
48
90
  const el = await setup();
49
- const initial = el.getAnimations().length;
50
91
 
51
- el.nAlerts = 3;
92
+ el.large = true;
52
93
  await el.updateComplete;
53
- el.nAlerts = 5;
94
+ el.counter = true;
54
95
  await el.updateComplete;
55
96
 
56
- expect(el.getAnimations()).toHaveLength(initial);
97
+ expect(el.getAnimations()).toHaveLength(1);
57
98
  });
58
99
  });
@@ -1,6 +1,6 @@
1
1
  import type {Meta, StoryObj} from '@storybook/web-components-vite';
2
2
  import {ObcAlertButton, ObcAlertButtonType} from './alert-button.js';
3
- import {AlertType} from '../../types.js';
3
+ import {AlertType, FlashingSpeed} from '../../types.js';
4
4
  import './alert-button.js';
5
5
  import {widthDecorator} from '../../storybook-util.js';
6
6
 
@@ -27,6 +27,10 @@ const meta: Meta<typeof ObcAlertButton> = {
27
27
  options: Object.values(ObcAlertButtonType),
28
28
  control: {type: 'select'},
29
29
  },
30
+ flashingSpeed: {
31
+ options: Object.values(FlashingSpeed),
32
+ control: {type: 'select'},
33
+ },
30
34
  width: {control: {type: 'range', min: 64, max: 1028, step: 1}},
31
35
  },
32
36
  decorators: [widthDecorator],