@pushwoosh/kit-charts 0.0.1 → 0.0.2

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.
@@ -103,6 +103,25 @@ takes `value`; on a category axis both address a category by name and a band
103
103
  covers the whole slot. Both accept a `label`, and a `zIndex` of 5 or more draws
104
104
  them over the series instead of under it — the value Highcharts users reach for.
105
105
 
106
+ A label's `align` is horizontal and its `verticalAlign` vertical whichever way
107
+ the decoration runs, as in Highcharts: a line on a y axis takes its label to the
108
+ left edge of the plot (`align: 'left'`, the default there) and sits it on the
109
+ rule (`verticalAlign: 'middle'`). `x`/`y` shift it from there — a negative `x`
110
+ hangs it in the axis gutter.
111
+
112
+ `label.backgroundColor` draws the label on a pill instead of leaving it as loose
113
+ text, with `borderRadius` and `padding` to tune it. The text takes whichever of
114
+ white and `BRAND_BLACK` reads better on that fill — the greys a threshold is
115
+ painted in carry white at under 3:1 — and `style.color` overrides the choice.
116
+ That is what a threshold needs: a number sitting on a rule has no
117
+ ground of its own to read against. Highcharts has no such option — it takes
118
+ `useHTML` and a styled span, which this library has no equivalent of — so this is
119
+ an addition rather than a divergence: a Highcharts config still renders.
120
+
121
+ Whether a threshold is breached is the application's decision, not the chart's:
122
+ it passes the colour. Golden cases `area/threshold` and `area/threshold-breached`
123
+ are the same options with one colour changed.
124
+
106
125
  ## Interaction
107
126
 
108
127
  `onPointClick(point, event)` reports the point nearest a click inside the plot,
@@ -1,7 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { DEFAULT_FONT_SIZE, textHeight } from '../layout/measure-text';
3
- import { crisp, PLOT_LABEL_GAP } from '../layout/metrics';
4
- import { DEFAULT_AXIS_COLOR, DEFAULT_MUTED_TEXT_COLOR, DEFAULT_SURFACE_COLOR } from '../model/palette';
2
+ import { DEFAULT_FONT_SIZE, measureText, textAscent, textHeight } from '../layout/measure-text';
3
+ import { crisp, PLOT_BADGE_PADDING_X, PLOT_BADGE_PADDING_Y, PLOT_BADGE_RADIUS, PLOT_LABEL_GAP } from '../layout/metrics';
4
+ import { DEFAULT_AXIS_COLOR, DEFAULT_MUTED_TEXT_COLOR, DEFAULT_SURFACE_COLOR, textColorOn } from '../model/palette';
5
5
  import { ABOVE_SERIES_Z_INDEX } from '../types/options';
6
6
  const DASH = {
7
7
  solid: undefined,
@@ -28,8 +28,8 @@ function project(scale, value, edge) {
28
28
  const at = scale.map(numeric);
29
29
  return typeof at === 'number' ? at : null;
30
30
  }
31
- const textProps = style => ({
32
- fill: (style === null || style === void 0 ? void 0 : style.color) ?? DEFAULT_MUTED_TEXT_COLOR,
31
+ const textProps = (style, onFill) => ({
32
+ fill: (style === null || style === void 0 ? void 0 : style.color) ?? (onFill ? textColorOn(onFill) : DEFAULT_MUTED_TEXT_COLOR),
33
33
  fontFamily: style === null || style === void 0 ? void 0 : style.fontFamily,
34
34
  fontSize: (style === null || style === void 0 ? void 0 : style.fontSize) ?? DEFAULT_FONT_SIZE,
35
35
  fontWeight: style === null || style === void 0 ? void 0 : style.fontWeight
@@ -39,40 +39,59 @@ const ANCHOR = {
39
39
  center: 'middle',
40
40
  right: 'end'
41
41
  };
42
+ /**
43
+ * `align` is horizontal and `verticalAlign` vertical whichever way the
44
+ * decoration runs, as in Highcharts — a horizontal line takes its label to the
45
+ * left edge of the plot and sits it on the rule.
46
+ */
42
47
  function PlotLabel({
43
48
  label,
44
- plot,
45
- span,
49
+ box,
46
50
  along
47
51
  }) {
48
52
  if (!(label !== null && label !== void 0 && label.text)) {
49
53
  return null;
50
54
  }
51
- const [low, high] = span[0] <= span[1] ? span : [span[1], span[0]];
52
55
  const align = label.align ?? (along === 'x' ? 'center' : 'left');
53
56
  const verticalAlign = label.verticalAlign ?? (along === 'x' ? 'top' : 'middle');
54
- const acrossLow = along === 'x' ? plot.y : plot.x;
55
- const acrossHigh = along === 'x' ? plot.y + plot.height : plot.x + plot.width;
56
- const alongAt = {
57
- left: low,
58
- center: (low + high) / 2,
59
- right: high
60
- }[align];
61
- const acrossAt = {
62
- top: acrossLow + PLOT_LABEL_GAP + textHeight(label.style),
63
- middle: (acrossLow + acrossHigh) / 2,
64
- bottom: acrossHigh - PLOT_LABEL_GAP
65
- }[verticalAlign];
66
- const x = (along === 'x' ? alongAt : acrossAt) + (label.x ?? 0);
67
- const y = (along === 'x' ? acrossAt : alongAt) + (label.y ?? 0);
57
+ const line = textHeight(label.style);
58
+ const ascent = textAscent(label.style);
59
+ const x = {
60
+ left: box.x + PLOT_LABEL_GAP,
61
+ center: box.x + box.width / 2,
62
+ right: box.x + box.width - PLOT_LABEL_GAP
63
+ }[align] + (label.x ?? 0);
64
+ const y = {
65
+ top: box.y + PLOT_LABEL_GAP + ascent,
66
+ middle: box.y + box.height / 2 + ascent - line / 2,
67
+ bottom: box.y + box.height - PLOT_LABEL_GAP - (line - ascent)
68
+ }[verticalAlign] + (label.y ?? 0);
68
69
  const rotation = label.rotation ?? 0;
69
- return _jsx("text", {
70
- x: x,
71
- y: y,
72
- textAnchor: along === 'x' ? ANCHOR[align] : 'start',
70
+ const fill = label.backgroundColor;
71
+ const padX = label.padding ?? PLOT_BADGE_PADDING_X;
72
+ const padY = label.padding ?? PLOT_BADGE_PADDING_Y;
73
+ const width = measureText(label.text, label.style) + padX * 2;
74
+ const height = line + padY * 2;
75
+ return _jsxs("g", {
73
76
  transform: rotation === 0 ? undefined : `rotate(${rotation} ${x} ${y})`,
74
- ...textProps(label.style),
75
- children: label.text
77
+ children: [fill && _jsx("rect", {
78
+ x: {
79
+ left: x - padX,
80
+ center: x - width / 2,
81
+ right: x - width + padX
82
+ }[align],
83
+ y: y - ascent - padY,
84
+ width: width,
85
+ height: height,
86
+ rx: label.borderRadius ?? PLOT_BADGE_RADIUS,
87
+ fill: fill
88
+ }), _jsx("text", {
89
+ x: x,
90
+ y: y,
91
+ textAnchor: ANCHOR[align],
92
+ ...textProps(label.style, fill),
93
+ children: label.text
94
+ })]
76
95
  });
77
96
  }
78
97
  /**
@@ -124,8 +143,7 @@ export function PlotDecorations({
124
143
  strokeWidth: options_.borderWidth ?? 0
125
144
  }), _jsx(PlotLabel, {
126
145
  label: options_.label,
127
- plot: plot,
128
- span: [low, high],
146
+ box: box,
129
147
  along: along
130
148
  })]
131
149
  }, `band-${index}`);
@@ -159,8 +177,17 @@ export function PlotDecorations({
159
177
  strokeDasharray: DASH[options_.dashStyle ?? 'solid']
160
178
  }), _jsx(PlotLabel, {
161
179
  label: options_.label,
162
- plot: plot,
163
- span: [at, at],
180
+ box: along === 'x' ? {
181
+ x: on,
182
+ y: plot.y,
183
+ width: 0,
184
+ height: plot.height
185
+ } : {
186
+ x: plot.x,
187
+ y: on,
188
+ width: plot.width,
189
+ height: 0
190
+ },
164
191
  along: along
165
192
  })]
166
193
  }, `line-${index}`);
@@ -6,4 +6,9 @@ export declare const DEFAULT_SUBTITLE_FONT_SIZE: string;
6
6
  export declare const toCssFont: (style?: TextStyle) => string;
7
7
  export declare function measureText(text: string, style?: TextStyle): number;
8
8
  export declare const textHeight: (style?: TextStyle) => number;
9
+ /**
10
+ * How much of a line box stands above the baseline. Drawing a box around text
11
+ * needs the split, not just the height: SVG places text by its baseline.
12
+ */
13
+ export declare const textAscent: (style?: TextStyle) => number;
9
14
  export declare function clearTextMeasurementCache(): void;
@@ -51,6 +51,11 @@ export function measureText(text, style) {
51
51
  return width;
52
52
  }
53
53
  export const textHeight = style => Math.ceil(parseFontSize(toCssFont(style)) * 1.2);
54
+ /**
55
+ * How much of a line box stands above the baseline. Drawing a box around text
56
+ * needs the split, not just the height: SVG places text by its baseline.
57
+ */
58
+ export const textAscent = style => Math.round(textHeight(style) * 0.8);
54
59
  export function clearTextMeasurementCache() {
55
60
  cache.clear();
56
61
  context = undefined;
@@ -48,6 +48,13 @@ export declare const TOOLTIP_PADDING = 8;
48
48
  export declare const TOOLTIP_RADIUS = 6;
49
49
  /** Distance from a plot band or plot line to its own label. */
50
50
  export declare const PLOT_LABEL_GAP = 6;
51
+ /**
52
+ * A plot label drawn on a fill: its padding and corner radius. Wider than it is
53
+ * tall, because a pill reads as a pill only when the text has room either side.
54
+ */
55
+ export declare const PLOT_BADGE_PADDING_X = 6;
56
+ export declare const PLOT_BADGE_PADDING_Y = 2;
57
+ export declare const PLOT_BADGE_RADIUS = 4;
51
58
  /**
52
59
  * Puts a stroke where the device can draw it in one row of pixels: half a stroke
53
60
  * either side of the position has to land on a pixel boundary, so an odd stroke
@@ -48,6 +48,13 @@ export const TOOLTIP_PADDING = 8;
48
48
  export const TOOLTIP_RADIUS = 6;
49
49
  /** Distance from a plot band or plot line to its own label. */
50
50
  export const PLOT_LABEL_GAP = 6;
51
+ /**
52
+ * A plot label drawn on a fill: its padding and corner radius. Wider than it is
53
+ * tall, because a pill reads as a pill only when the text has room either side.
54
+ */
55
+ export const PLOT_BADGE_PADDING_X = 6;
56
+ export const PLOT_BADGE_PADDING_Y = 2;
57
+ export const PLOT_BADGE_RADIUS = 4;
51
58
  /**
52
59
  * Puts a stroke where the device can draw it in one row of pixels: half a stroke
53
60
  * either side of the position has to land on a pixel boundary, so an odd stroke
@@ -10,6 +10,14 @@ export declare const DEFAULT_AXIS_COLOR: string;
10
10
  export declare const DEFAULT_TEXT_COLOR: string;
11
11
  export declare const DEFAULT_MUTED_TEXT_COLOR: string;
12
12
  export declare const DEFAULT_SURFACE_COLOR: string;
13
+ /**
14
+ * Text drawn on a colour the caller chose — a plot label badge, and nothing else
15
+ * yet. Two neutrals, not one: white on the mid greys a threshold is painted in
16
+ * reads at 2.8:1, far under the 4.5:1 small text needs.
17
+ */
18
+ export declare const DEFAULT_ON_FILL_TEXT_COLOR: string;
19
+ /** Whichever of the two neutrals reads better on a fill. */
20
+ export declare function textColorOn(fill: string): string;
13
21
  export declare const DEFAULT_TOOLTIP_SURFACE_COLOR: string;
14
22
  export declare const DEFAULT_TOOLTIP_TEXT_COLOR: string;
15
23
  export declare const colorAt: (colors: readonly string[], index: number) => string;
@@ -1,4 +1,5 @@
1
1
  import { Color } from '@pushwoosh/kit-constants';
2
+ import { parseColor } from '../scale/color';
2
3
  /**
3
4
  * Series palette, in the order series are drawn. These are the design system's
4
5
  * service and brand colours rather than a set invented here, so a chart matches
@@ -11,6 +12,36 @@ export const DEFAULT_AXIS_COLOR = Color.FORM;
11
12
  export const DEFAULT_TEXT_COLOR = Color.MAIN;
12
13
  export const DEFAULT_MUTED_TEXT_COLOR = Color.LOCKED;
13
14
  export const DEFAULT_SURFACE_COLOR = Color.CLEAR;
15
+ /**
16
+ * Text drawn on a colour the caller chose — a plot label badge, and nothing else
17
+ * yet. Two neutrals, not one: white on the mid greys a threshold is painted in
18
+ * reads at 2.8:1, far under the 4.5:1 small text needs.
19
+ */
20
+ export const DEFAULT_ON_FILL_TEXT_COLOR = Color.CLEAR;
21
+ const ON_FILL_DARK_TEXT_COLOR = Color.BRAND_BLACK;
22
+ const linearChannel = value => {
23
+ const unit = value / 255;
24
+ return unit <= 0.03928 ? unit / 12.92 : ((unit + 0.055) / 1.055) ** 2.4;
25
+ };
26
+ /** WCAG relative luminance, or null for a colour that cannot be read. */
27
+ const luminance = color => {
28
+ const rgba = parseColor(color);
29
+ if (!rgba) {
30
+ return null;
31
+ }
32
+ return 0.2126 * linearChannel(rgba[0]) + 0.7152 * linearChannel(rgba[1]) + 0.0722 * linearChannel(rgba[2]);
33
+ };
34
+ const contrast = (one, other) => (Math.max(one, other) + 0.05) / (Math.min(one, other) + 0.05);
35
+ const LIGHT_TEXT_LUMINANCE = luminance(DEFAULT_ON_FILL_TEXT_COLOR) ?? 1;
36
+ const DARK_TEXT_LUMINANCE = luminance(ON_FILL_DARK_TEXT_COLOR) ?? 0;
37
+ /** Whichever of the two neutrals reads better on a fill. */
38
+ export function textColorOn(fill) {
39
+ const behind = luminance(fill);
40
+ if (behind === null) {
41
+ return DEFAULT_ON_FILL_TEXT_COLOR;
42
+ }
43
+ return contrast(behind, LIGHT_TEXT_LUMINANCE) >= contrast(behind, DARK_TEXT_LUMINANCE) ? DEFAULT_ON_FILL_TEXT_COLOR : ON_FILL_DARK_TEXT_COLOR;
44
+ }
14
45
  // Chart tooltips follow the product hint style: dark bubble, light text.
15
46
  export const DEFAULT_TOOLTIP_SURFACE_COLOR = Color.BRAND_BLACK;
16
47
  export const DEFAULT_TOOLTIP_TEXT_COLOR = Color.CLEAR;
@@ -113,6 +113,10 @@ export interface PlotLabelOptions {
113
113
  x?: number;
114
114
  y?: number;
115
115
  rotation?: number;
116
+ /** Draws the label on a filled pill, the way a threshold states its value. */
117
+ backgroundColor?: string;
118
+ borderRadius?: number;
119
+ padding?: number;
116
120
  style?: TextStyle;
117
121
  }
118
122
  export interface StackLabelsOptions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.1",
3
+ "version": "0.0.2",
4
4
  "description": "Small React chart library on SVG, with a Highcharts-shaped configuration model",
5
5
  "license": "MIT",
6
6
  "repository": {