@pushwoosh/kit-charts 0.0.11 → 0.0.13

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.
@@ -92,6 +92,24 @@ own `formatter` or `format` wins over it.
92
92
  the step is chosen from the candidates that stay integral at its magnitude, and
93
93
  never falls below 1.
94
94
 
95
+ `tickAmount` is Highcharts' demand and not a hint: the y axis stretches the end
96
+ the caller left open until exactly that many ticks fit, so a count of 2 on a
97
+ `tickAmount: 5` axis still draws 0..4 and reads against a stable grid rather than
98
+ filling the plot. An end pinned with `max` cannot be stretched, so a tick amount
99
+ alongside one is ignored.
100
+
101
+ An x axis measures `labels.y` from the axis line itself, as Highcharts does: a
102
+ config that sets it replaces the default drop rather than adding to it. Left
103
+ unset, the label falls the tick's length plus a fixed drop below the line.
104
+
105
+ `minPadding`/`maxPadding` add a share of the data range beyond an end the caller
106
+ left open — Highcharts' axis air, off unless asked for. `labels.padding` is the
107
+ gap Highcharts leaves between the y label column and the plot; it defaults to 5
108
+ and widens the gutter, so a migrated config keeps its left edge.
109
+
110
+ The x axis line is drawn after the series, so a bar or an area sitting on zero
111
+ cannot paint over the line it stands on.
112
+
95
113
  `labels.allowOverlap` defaults to false on both axes and on data labels. A label
96
114
  whose box would run into one already placed loses its text — Highcharts hides
97
115
  the label and keeps the tick, and so do we.
@@ -11,6 +11,8 @@ interface XAxisProps {
11
11
  axis: AxisModel;
12
12
  }
13
13
  export declare function XAxis({ plot, axis }: XAxisProps): ReactElement | null;
14
+ /** Drawn after the series: a body sitting on zero would paint over its own axis. */
15
+ export declare function XAxisLine({ plot, axis }: XAxisProps): ReactElement | null;
14
16
  interface YAxisProps {
15
17
  plot: Rect;
16
18
  axis: AxisModel;
@@ -1,7 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Hinted } from './Hinted';
3
3
  import { DEFAULT_FONT_SIZE, measureText } from '../layout/measure-text';
4
- import { AXIS_LABEL_GAP, AXIS_TITLE_GAP, crisp, TEXT_BASELINE_SHIFT, X_AXIS_LABEL_DROP, X_AXIS_TITLE_GAP } from '../layout/metrics';
4
+ import { AXIS_LABEL_GAP, AXIS_TITLE_GAP, crisp, TEXT_BASELINE_SHIFT, X_AXIS_TITLE_GAP } from '../layout/metrics';
5
+ import { xLabelOffset } from '../model/build-model';
5
6
  import { DEFAULT_AXIS_COLOR, DEFAULT_GRID_COLOR, DEFAULT_MUTED_TEXT_COLOR } from '../model/palette';
6
7
  const textProps = style => ({
7
8
  fill: style?.color ?? DEFAULT_MUTED_TEXT_COLOR,
@@ -125,16 +126,9 @@ export function XAxis({
125
126
  const tickLength = options.tickLength ?? 0;
126
127
  const tickWidth = options.tickWidth ?? 1;
127
128
  const labelsEnabled = options.labels?.enabled !== false;
128
- const labelY = baseline + direction * (tickLength + (onTop ? AXIS_LABEL_GAP : X_AXIS_LABEL_DROP)) + (options.labels?.y ?? 0);
129
+ const labelY = baseline + direction * xLabelOffset(options, onTop);
129
130
  return _jsxs("g", {
130
- children: [options.lineWidth === 0 ? null : _jsx("line", {
131
- x1: plot.x,
132
- x2: plot.x + plot.width,
133
- y1: crisp(baseline, options.lineWidth ?? 1),
134
- y2: crisp(baseline, options.lineWidth ?? 1),
135
- stroke: lineColor,
136
- strokeWidth: options.lineWidth ?? 1
137
- }), axis.ticks.map(tick => {
131
+ children: [axis.ticks.map(tick => {
138
132
  const content = tickContent(tick);
139
133
  return _jsxs("g", {
140
134
  children: [tickLength > 0 && tickWidth > 0 ? _jsx("line", {
@@ -162,6 +156,28 @@ export function XAxis({
162
156
  })]
163
157
  });
164
158
  }
159
+ /** Drawn after the series: a body sitting on zero would paint over its own axis. */
160
+ export function XAxisLine({
161
+ plot,
162
+ axis
163
+ }) {
164
+ const {
165
+ options
166
+ } = axis;
167
+ const width = options.lineWidth ?? 1;
168
+ if (options.visible === false || width === 0) {
169
+ return null;
170
+ }
171
+ const baseline = options.opposite === true ? plot.y - (options.offset ?? 0) : plot.y + plot.height + (options.offset ?? 0);
172
+ return _jsx("line", {
173
+ x1: plot.x,
174
+ x2: plot.x + plot.width,
175
+ y1: crisp(baseline, width),
176
+ y2: crisp(baseline, width),
177
+ stroke: options.lineColor ?? DEFAULT_AXIS_COLOR,
178
+ strokeWidth: width
179
+ });
180
+ }
165
181
  export function YAxis({
166
182
  plot,
167
183
  axis
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
- import { Grid, XAxis, YAxis } from './Axes';
3
+ import { Grid, XAxis, XAxisLine, YAxis } from './Axes';
4
4
  import { Canvas, Centred, Container, PinnedAxes, ResetZoom, ScrollBox, SeriesLayer, TooltipCell, TooltipDivider, TooltipHeadCell, TooltipName, TooltipRow, TooltipSeriesCell, TooltipSeriesInner, TooltipSwatch, TooltipTable, TooltipTitle, TooltipValue, ZoomBand } from './Chart.styles';
5
5
  import { ChartErrorBoundary } from './ChartErrorBoundary';
6
6
  import { Legend } from './Legend';
@@ -575,6 +575,9 @@ function ChartBody({
575
575
  "aria-hidden": series.hidden,
576
576
  children: series.renderer.render(ctx)
577
577
  }, `series-${series.index}`) : null;
578
+ }), model.axisless ? null : _jsx(XAxisLine, {
579
+ plot: model.plot,
580
+ axis: model.xAxis
578
581
  }), decorations('over'), drag ? _jsx(ZoomBand, {
579
582
  x: Math.min(drag.from, drag.to),
580
583
  y: model.plot.y,
@@ -12,6 +12,8 @@ export declare const AXIS_LABEL_GAP = 8;
12
12
  export declare const X_AXIS_LABEL_DROP = 14;
13
13
  /** Air added to the tick label column when measuring the y axis gutter. */
14
14
  export declare const AXIS_GUTTER_PADDING = 10;
15
+ /** `labels.padding` when the axis does not set it — the Highcharts default. */
16
+ export declare const AXIS_LABEL_PADDING = 5;
15
17
  /** A rotated axis title to the column of tick labels it stands beside. */
16
18
  export declare const AXIS_TITLE_GAP = 6;
17
19
  /** An x axis title below its labels. */
@@ -12,6 +12,8 @@ export const AXIS_LABEL_GAP = 8;
12
12
  export const X_AXIS_LABEL_DROP = 14;
13
13
  /** Air added to the tick label column when measuring the y axis gutter. */
14
14
  export const AXIS_GUTTER_PADDING = 10;
15
+ /** `labels.padding` when the axis does not set it — the Highcharts default. */
16
+ export const AXIS_LABEL_PADDING = 5;
15
17
  /** A rotated axis title to the column of tick labels it stands beside. */
16
18
  export const AXIS_TITLE_GAP = 6;
17
19
  /** An x axis title below its labels. */
@@ -58,6 +58,8 @@ export interface ChartModel {
58
58
  export declare const hiddenKey: (seriesIndex: number, pointIndex?: number) => string;
59
59
  declare const isBand: (scale: AnyScale) => scale is BandScale;
60
60
  declare const isContinuous: (scale: AnyScale) => scale is ContinuousScale;
61
+ /** Axis line to the label baseline; `labels.y` replaces the drop, as in Highcharts. */
62
+ export declare function xLabelOffset(axis: AxisOptions, onTop: boolean): number;
61
63
  export interface BuildModelInput {
62
64
  options: ChartOptions;
63
65
  size: Size;
@@ -3,7 +3,7 @@ import { normalizePoints } from '../data/points';
3
3
  import { applyValueFormat, formatTime } from '../format/time';
4
4
  import { measureLegend } from '../layout/legend-layout';
5
5
  import { measureText, textHeight } from '../layout/measure-text';
6
- import { AXIS_GUTTER_PADDING, AXIS_LABEL_GAP, AXIS_TITLE_GAP, MARKER_REACH, VALUE_LABEL_HEADROOM, X_AXIS_LABEL_DROP } from '../layout/metrics';
6
+ import { AXIS_GUTTER_PADDING, AXIS_LABEL_GAP, AXIS_LABEL_PADDING, AXIS_TITLE_GAP, MARKER_REACH, VALUE_LABEL_HEADROOM, X_AXIS_LABEL_DROP } from '../layout/metrics';
7
7
  import { nodeText } from '../layout/node-text';
8
8
  import { computeSlots } from '../layout/point-width';
9
9
  import { layoutTitles } from '../layout/title-layout';
@@ -88,6 +88,27 @@ const grow = (extent, value) => {
88
88
  extent.min = Math.min(extent.min, value);
89
89
  extent.max = Math.max(extent.max, value);
90
90
  };
91
+ /** Axis line to the label baseline; `labels.y` replaces the drop, as in Highcharts. */
92
+ export function xLabelOffset(axis, onTop) {
93
+ const explicit = axis.labels?.y;
94
+ if (explicit !== undefined) {
95
+ return onTop ? -explicit : explicit;
96
+ }
97
+ return (axis.tickLength ?? 0) + (onTop ? AXIS_LABEL_GAP : X_AXIS_LABEL_DROP);
98
+ }
99
+ /** Highcharts' air around a series: a share of the range beyond each open end. */
100
+ function axisPadding(axis, domain) {
101
+ const span = domain.max - domain.min;
102
+ const low = axis.min === undefined ? axis.minPadding ?? 0 : 0;
103
+ const high = axis.max === undefined ? axis.maxPadding ?? 0 : 0;
104
+ if (!Number.isFinite(span) || span <= 0 || low === 0 && high === 0) {
105
+ return domain;
106
+ }
107
+ return {
108
+ min: domain.min - span * low,
109
+ max: domain.max + span * high
110
+ };
111
+ }
91
112
  const settled = (extent, fallback) => Number.isFinite(extent.min) && Number.isFinite(extent.max) ? [extent.min, extent.max] : fallback;
92
113
  function buildXScale(axis, kind, extent, categories, range, hint) {
93
114
  if (kind === 'band') {
@@ -128,7 +149,11 @@ function buildYScale(axis, extent, range, hint, linked) {
128
149
  if (linked) {
129
150
  return linked;
130
151
  }
131
- const [min, max] = settled(extent, [0, 1]);
152
+ const padded = axisPadding(axis, {
153
+ min: extent.min,
154
+ max: extent.max
155
+ });
156
+ const [min, max] = settled(padded, [0, 1]);
132
157
  return createLinearScale({
133
158
  domain: [axis.min ?? min, axis.max ?? max],
134
159
  range,
@@ -139,6 +164,7 @@ function buildYScale(axis, extent, range, hint, linked) {
139
164
  max: axis.max === undefined
140
165
  },
141
166
  tickHint: hint,
167
+ tickCount: axis.tickAmount,
142
168
  allowDecimals: axis.allowDecimals
143
169
  });
144
170
  }
@@ -409,10 +435,11 @@ export function buildChartModel({
409
435
  renderer
410
436
  }) => renderer.scales.x === 'band');
411
437
  const pad = bandOnContinuous ? halfStepPadding(resolved) : 0;
412
- const xDomain = pad > 0 ? {
438
+ const padded = pad > 0 ? {
413
439
  min: acc.x.min - pad,
414
440
  max: acc.x.max + pad
415
441
  } : acc.x;
442
+ const xDomain = xKind === 'band' ? padded : axisPadding(xAxisOptions, padded);
416
443
  const yDomains = acc.y;
417
444
  const spacing = resolveSpacing(options.chart);
418
445
  const [spaceTop, spaceRight, spaceBottom, spaceLeft] = spacing;
@@ -421,8 +448,8 @@ export function buildChartModel({
421
448
  // A tick label is drawn a tick's length plus a fixed drop away from the axis
422
449
  // line, and `labels.y` pushes it further still. Reserving only its text height
423
450
  // leaves it hanging over the chart edge, where the svg clips it.
424
- const xLabelPush = labelsOnTop ? Math.max(0, -(xAxisOptions.labels?.y ?? 0)) : Math.max(0, xAxisOptions.labels?.y ?? 0);
425
- const xLabelHeight = axisless || xAxisOptions.labels?.enabled === false ? 0 : textHeight(labelStyle) + (xAxisOptions.tickLength ?? 0) + (labelsOnTop ? AXIS_LABEL_GAP : X_AXIS_LABEL_DROP) + xLabelPush;
451
+ const xLabelDrop = Math.max(0, xLabelOffset(xAxisOptions, labelsOnTop));
452
+ const xLabelHeight = axisless || xAxisOptions.labels?.enabled === false ? 0 : textHeight(labelStyle) + xLabelDrop;
426
453
  const legendEnabled = options.legend?.enabled !== false;
427
454
  const legend = legendEnabled ? all.flatMap(({
428
455
  renderer,
@@ -486,7 +513,7 @@ export function buildChartModel({
486
513
  }), axis.tickAmount ?? 5, undefined, 'y', numberFormat).map(tick => tick.label);
487
514
  const widest = labels.reduce((widest_, label) => Math.max(widest_, measureText(label, axis.labels?.style)), 0);
488
515
  const title = axis.title?.text && (axis.title.rotation ?? -90) !== 0 ? textHeight(axis.title.style) + AXIS_TITLE_GAP : 0;
489
- return Math.ceil(widest) + AXIS_GUTTER_PADDING + title;
516
+ return Math.ceil(widest) + AXIS_GUTTER_PADDING + (axis.labels?.padding ?? AXIS_LABEL_PADDING) + title;
490
517
  };
491
518
  const gutter = axisless || yAxes[0]?.visible === false ? 0 : measureGutter(32);
492
519
  const legendAlign = options.legend?.align ?? 'left';
@@ -9,6 +9,8 @@ export interface LinearScaleOptions {
9
9
  range: readonly [number, number];
10
10
  nice?: boolean | NiceEnds;
11
11
  tickHint?: number;
12
+ /** Highcharts' `tickAmount`: stretch the open end so exactly this many ticks fit. */
13
+ tickCount?: number;
12
14
  /** False keeps every tick a whole number, as a count axis needs. */
13
15
  allowDecimals?: boolean;
14
16
  }
@@ -31,18 +31,32 @@ export function createLinearScale(options) {
31
31
  range,
32
32
  nice = false,
33
33
  tickHint = 5,
34
+ tickCount,
34
35
  allowDecimals = true
35
36
  } = options;
36
37
  let [min, max] = options.domain;
38
+ const niceMin = nice === true || typeof nice === 'object' && nice.min === true;
39
+ const niceMax = nice === true || typeof nice === 'object' && nice.max === true;
37
40
  if (!Number.isFinite(min) || !Number.isFinite(max)) {
38
41
  [min, max] = [0, 1];
39
42
  } else if (min === max) {
40
43
  const pad = Math.abs(min) || 1;
41
- [min, max] = [min - pad, max + pad];
44
+ // An end the caller pinned stays put: padding both would take a `min: 0`
45
+ // count axis below zero on a day where every value is zero.
46
+ [min, max] = [min - (niceMin || !niceMax ? pad : 0), max + (niceMax || !niceMin ? pad : 0)];
42
47
  }
43
- const niceMin = nice === true || typeof nice === 'object' && nice.min === true;
44
- const niceMax = nice === true || typeof nice === 'object' && nice.max === true;
45
- if (niceMin || niceMax) {
48
+ if (tickCount !== undefined && tickCount > 1 && niceMax) {
49
+ let step = niceStep((max - min) / (tickCount - 1), allowDecimals);
50
+ if (niceMin) {
51
+ min = Math.floor(min / step) * step;
52
+ }
53
+ // Rounding the low end down lengthens the run, which can leave the last tick
54
+ // short of the data; widen the step until the fixed number of them covers it.
55
+ while (min + (tickCount - 1) * step < max) {
56
+ step = niceStep(step * 1.5, allowDecimals);
57
+ }
58
+ max = min + (tickCount - 1) * step;
59
+ } else if (niceMin || niceMax) {
46
60
  const step = niceStep((max - min) / tickHint, allowDecimals);
47
61
  if (niceMin) {
48
62
  min = Math.floor(min / step) * step;
@@ -88,6 +88,8 @@ export interface AxisLabelsOptions {
88
88
  align?: 'left' | 'center' | 'right';
89
89
  x?: number;
90
90
  y?: number;
91
+ /** Air between the label column and the plot, as Highcharts reserves it. */
92
+ padding?: number;
91
93
  style?: TextStyle;
92
94
  }
93
95
  /** `zIndex` at or above this draws over the series instead of under it. */
@@ -132,6 +134,9 @@ export interface AxisOptions {
132
134
  categories?: readonly string[];
133
135
  min?: number;
134
136
  max?: number;
137
+ /** Share of the data range added beyond each end, Highcharts' axis padding. */
138
+ minPadding?: number;
139
+ maxPadding?: number;
135
140
  visible?: boolean;
136
141
  opposite?: boolean;
137
142
  reversed?: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pushwoosh/kit-charts",
3
- "version": "0.0.11",
3
+ "version": "0.0.13",
4
4
  "description": "Small React chart library on SVG, with a Highcharts-shaped configuration model",
5
5
  "license": "MIT",
6
6
  "repository": {