@bro-code/charts 0.0.0-stage → 2026.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Bro Code Technologies
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,142 @@
1
- # Temporary Holding Version
1
+ # @bro-code/charts
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@bro-code/charts.svg)](https://www.npmjs.com/package/@bro-code/charts)
4
+ [![license](https://img.shields.io/npm/l/@bro-code/charts.svg)](./LICENSE)
5
+
6
+ Chart geometry for SVG: scales, area paths, pie slices and grouped bars, computed as plain data with no framework or DOM, so any app can draw its own charts.
7
+
8
+ The package computes numbers and SVG path data and nothing else. The app draws the `<svg>` in whatever it renders with, and owns the colours, tooltips and any animation, so a change to how a chart looks never needs a new version of this package. It has no runtime dependencies and does nothing when imported.
9
+
10
+ ## Installation
11
+
12
+ ```bash
13
+ npm install @bro-code/charts
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ An area chart of six monthly lows, as an `<svg>` string:
19
+
20
+ ```ts
21
+ import { areaPath, extent, linearScale, nearestIndex } from '@bro-code/charts';
22
+
23
+ const lows = [120, 80, -40, 15, 60, 95];
24
+ const width = 300;
25
+ const height = 120;
26
+
27
+ const x = linearScale([0, lows.length - 1], [0, width]);
28
+ const y = linearScale(extent(lows, { includeZero: true }), [height, 0]);
29
+ const points = lows.map((low, i) => ({ x: x.map(i), y: y.map(low) }));
30
+ const { line, area } = areaPath(points, { baseline: y.map(0) });
31
+
32
+ const svg = `<svg viewBox="0 0 ${width} ${height}" width="${width}" height="${height}">
33
+ <path d="${area}" fill="rgb(76 175 80 / 0.25)" />
34
+ <path d="${line}" fill="none" stroke="rgb(76 175 80)" stroke-width="2" />
35
+ </svg>`;
36
+
37
+ // The month under the pointer, for a tooltip:
38
+ const pointerX = 130; // from a pointer event, in the chart's coordinates
39
+ const month = nearestIndex(points.map((p) => p.x), pointerX); // 2
40
+ ```
41
+
42
+ ### CommonJS
43
+
44
+ ```js
45
+ const { areaPath, extent, linearScale } = require('@bro-code/charts');
46
+ ```
47
+
48
+ ## API
49
+
50
+ Every position is in drawing coordinates: x grows to the right and y grows downward, as in SVG. Path data is written with at most two decimals.
51
+
52
+ ### `linearScale(domain, range)`
53
+
54
+ Maps `domain` (data values) linearly onto `range` (drawing positions). Both are `[start, end]` pairs and either may run backwards; a value axis is usually `linearScale([min, max], [height, 0])`. Returns a `LinearScale`:
55
+
56
+ | Member | |
57
+ |---|---|
58
+ | `map(value)` | The position of a domain value. Values outside the domain extrapolate past the ends of the range. |
59
+ | `invert(position)` | The domain value at a position, extrapolating the same way. |
60
+
61
+ A domain whose ends are equal (every value the same) maps every value to the middle of the range, and a range whose ends are equal (a chart with no width yet) inverts every position to the middle of the domain, instead of dividing by zero.
62
+
63
+ ### `extent(values, options?)`
64
+
65
+ The smallest and largest of `values`, as `[min, max]`; `[0, 0]` when there are none. The values are not changed.
66
+
67
+ | Option | Default | |
68
+ |---|---|---|
69
+ | `includeZero` | `false` | Stretch the extent to take in zero, so bars and areas have a zero line to stand on. An extent that already spans zero is unchanged. |
70
+
71
+ ### `areaPath(points, options)`
72
+
73
+ Path data for an area chart through `points` (`{ x, y }`, in drawing order), joined with straight segments. Returns `{ line, area }`: `line` runs through every point, for the stroke, and `area` is that line closed to the baseline, for the fill.
74
+
75
+ | Option | |
76
+ |---|---|
77
+ | `baseline` | The y the area closes to: the zero line (`y.map(0)`) or the bottom of the chart. Above the points, the area closes upward. |
78
+
79
+ No points give empty strings. One point gives a zero-length line, which a round `stroke-linecap` shows as a dot, and an area with no width.
80
+
81
+ ### `pieSlices(values, options)`
82
+
83
+ One `PieSlice` per value, in the order given, going clockwise. Angles are in degrees, clockwise from 12 o'clock.
84
+
85
+ | Option | Default | |
86
+ |---|---|---|
87
+ | `cx`, `cy` | | The centre. |
88
+ | `radius` | | The outer radius. |
89
+ | `innerRadius` | `0` | The radius of the hole, for a donut. |
90
+ | `startAngle` | `0` | Where the first slice starts; `180` (or `-180`) starts at the bottom. |
91
+ | `padAngle` | `0` | Degrees of gap between neighbouring slices, taken half from each end of each slice. |
92
+
93
+ | Slice field | |
94
+ |---|---|
95
+ | `index`, `value` | The value's place in `values`, and the value. |
96
+ | `fraction` | Its share of the total, from 0 to 1; 0 for every slice when all the values are 0. |
97
+ | `startAngle`, `endAngle` | Its angles before padding, so the slices tile the full turn. |
98
+ | `path` | Path data for the slice; empty when it draws nothing. |
99
+ | `centroid` | `{ x, y }` at the middle of its angle, halfway between the inner and outer radius: a place for a tooltip or label. |
100
+
101
+ A value that is the whole total is drawn as a full circle (a ring for a donut), with no padding. A zero value, every value when all are zero, and a slice no wider than `padAngle` draw nothing. A negative value throws a `RangeError` naming its index.
102
+
103
+ ### `groupedBars(series, options)`
104
+
105
+ Grouped bars for `series`, one number array per series (charges and deposits, say), all the same length: group `i` holds value `i` of each series. Returns `{ bars, baseline }`: `baseline` is the y of the zero line, and `bars` run left to right, group by group, each group's bars in series order. Each `Bar` is `{ series, index, value, x, y, width, height }`, `index` being its group.
106
+
107
+ | Option | Default | |
108
+ |---|---|---|
109
+ | `width`, `height` | | The size of the chart. |
110
+ | `groupPadding` | `0.1` | Share of each group's band left empty, half on each side of the group. |
111
+ | `barPadding` | `0` | Share of each group's width left empty between its bars. |
112
+
113
+ The groups split `width` into equal bands with no outer padding, so equal grid columns under the chart (month labels, say) line up with them. The value domain spans zero and every value, so a negative value hangs below the zero line; with no values or only zeros, the zero line is the bottom. Series of different lengths throw a `RangeError`.
114
+
115
+ ### `nearestIndex(positions, x)`
116
+
117
+ The index of the position closest to `x`, the first of a tie; -1 when there are none. For an area chart's tooltip, pass the points' x positions and the pointer's x.
118
+
119
+ ### `bandIndex(x, count, width)`
120
+
121
+ Which of `count` equal bands across `width` holds `x`, clamped to the first and last band; -1 when `count` is 0. For a bar chart's tooltip, `bandIndex(x, groupCount, width)` gives the group under the pointer, and `bandIndex` again across that group's bars (from its first bar's `x` to its last bar's `x + width`) gives the bar.
122
+
123
+ ### Types
124
+
125
+ The package exports `LinearScale`, `ExtentOptions`, `Interval` (a `readonly [number, number]` pair), `Point`, `AreaOptions`, `AreaPath`, `PieOptions`, `PieSlice`, `GroupedBarsOptions`, `GroupedBars` and `Bar` as types.
126
+
127
+ ## License
128
+
129
+ MIT - see [LICENSE](./LICENSE).
130
+
131
+ ## Changelog
132
+
133
+ ### [2026.0.0] - 2026-10-29
134
+
135
+ #### Added
136
+ - Initial release.
137
+ - `linearScale(domain, range)`, with `map` and `invert`, reversed domains and ranges, and equal-ended domains and ranges mapped to their middle.
138
+ - `extent(values, { includeZero })`.
139
+ - `areaPath(points, { baseline })`: the line and the filled area, with straight segments.
140
+ - `pieSlices(values, options)`: pie and donut slices with a start angle and padding, full-circle and empty slices, and a centroid for each.
141
+ - `groupedBars(series, options)`: bars in equal bands with group and bar padding, and negative values below the zero line.
142
+ - `nearestIndex(positions, x)` and `bandIndex(x, count, width)`, for finding what is under the pointer.
@@ -0,0 +1,22 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.areaPath = areaPath;
4
+ const path_js_1 = require("./internal/path.js");
5
+ /**
6
+ * SVG path data for an area chart through `points`, which are in drawing
7
+ * coordinates and drawing order, joined with straight segments. No points give
8
+ * empty strings. One point gives a zero-length line, which a round
9
+ * `stroke-linecap` shows as a dot, and an area with no width.
10
+ */
11
+ function areaPath(points, options) {
12
+ if (points.length === 0)
13
+ return { line: '', area: '' };
14
+ const first = points[0];
15
+ const last = points[points.length - 1];
16
+ const lineTo = (rest) => rest.map((point) => `L${(0, path_js_1.pt)(point)}`).join('');
17
+ const baseline = (0, path_js_1.num)(options.baseline);
18
+ return {
19
+ line: `M${(0, path_js_1.pt)(first)}${lineTo(points.length === 1 ? points : points.slice(1))}`,
20
+ area: `M${(0, path_js_1.num)(first.x)},${baseline}${lineTo(points)}L${(0, path_js_1.num)(last.x)},${baseline}Z`,
21
+ };
22
+ }
@@ -0,0 +1,47 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.groupedBars = groupedBars;
4
+ const scale_js_1 = require("./scale.js");
5
+ /**
6
+ * Grouped bars for `series`, one number array per series, all the same length:
7
+ * group `i` holds value `i` of each series. The groups split `width` into equal
8
+ * bands with no outer padding, so equal grid columns under the chart line up
9
+ * with them. The value domain spans zero and every value, so a negative value
10
+ * hangs below the zero line; with no values or only zeros, the zero line is the
11
+ * bottom. Throws a `RangeError` when the series differ in length.
12
+ */
13
+ function groupedBars(series, options) {
14
+ const count = series.length > 0 ? series[0].length : 0;
15
+ series.forEach((values, index) => {
16
+ if (values.length !== count) {
17
+ throw new RangeError(`groupedBars: series ${index} has ${values.length} values, series 0 has ${count}`);
18
+ }
19
+ });
20
+ const { width, height, groupPadding = 0.1, barPadding = 0 } = options;
21
+ const [min, max] = (0, scale_js_1.extent)(series.flat(), { includeZero: true });
22
+ const y = (0, scale_js_1.linearScale)(min === max ? [0, 1] : [min, max], [height, 0]);
23
+ const baseline = y.map(0);
24
+ const band = count > 0 ? width / count : 0;
25
+ const groupWidth = band * (1 - groupPadding);
26
+ const gaps = series.length - 1;
27
+ const gap = gaps > 0 ? (groupWidth * barPadding) / gaps : 0;
28
+ const barWidth = series.length > 0 ? (groupWidth - gap * gaps) / series.length : 0;
29
+ const bars = [];
30
+ for (let index = 0; index < count; index++) {
31
+ const left = index * band + (band - groupWidth) / 2;
32
+ series.forEach((values, s) => {
33
+ const value = values[index];
34
+ const top = y.map(value);
35
+ bars.push({
36
+ series: s,
37
+ index,
38
+ value,
39
+ x: left + s * (barWidth + gap),
40
+ y: Math.min(top, baseline),
41
+ width: barWidth,
42
+ height: Math.abs(top - baseline),
43
+ });
44
+ });
45
+ }
46
+ return { bars, baseline };
47
+ }
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.linearScale = exports.extent = exports.nearestIndex = exports.bandIndex = exports.pieSlices = exports.groupedBars = exports.areaPath = void 0;
4
+ var area_js_1 = require("./area.js");
5
+ Object.defineProperty(exports, "areaPath", { enumerable: true, get: function () { return area_js_1.areaPath; } });
6
+ var bars_js_1 = require("./bars.js");
7
+ Object.defineProperty(exports, "groupedBars", { enumerable: true, get: function () { return bars_js_1.groupedBars; } });
8
+ var pie_js_1 = require("./pie.js");
9
+ Object.defineProperty(exports, "pieSlices", { enumerable: true, get: function () { return pie_js_1.pieSlices; } });
10
+ var pointer_js_1 = require("./pointer.js");
11
+ Object.defineProperty(exports, "bandIndex", { enumerable: true, get: function () { return pointer_js_1.bandIndex; } });
12
+ Object.defineProperty(exports, "nearestIndex", { enumerable: true, get: function () { return pointer_js_1.nearestIndex; } });
13
+ var scale_js_1 = require("./scale.js");
14
+ Object.defineProperty(exports, "extent", { enumerable: true, get: function () { return scale_js_1.extent; } });
15
+ Object.defineProperty(exports, "linearScale", { enumerable: true, get: function () { return scale_js_1.linearScale; } });
@@ -0,0 +1,12 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.num = num;
4
+ exports.pt = pt;
5
+ /** A coordinate for SVG path data: at most two decimals, no trailing zeros and no `-0`. */
6
+ function num(value) {
7
+ const rounded = Math.round(value * 100) / 100;
8
+ return String(rounded === 0 ? 0 : rounded);
9
+ }
10
+ function pt(point) {
11
+ return `${num(point.x)},${num(point.y)}`;
12
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "commonjs"
3
+ }
@@ -0,0 +1,70 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.pieSlices = pieSlices;
4
+ const path_js_1 = require("./internal/path.js");
5
+ function polar(ring, radius, degrees) {
6
+ const radians = (degrees * Math.PI) / 180;
7
+ return { x: ring.cx + radius * Math.sin(radians), y: ring.cy - radius * Math.cos(radians) };
8
+ }
9
+ function arc(radius, large, clockwise, to) {
10
+ return `A${(0, path_js_1.num)(radius)},${(0, path_js_1.num)(radius)} 0 ${large ? 1 : 0} ${clockwise ? 1 : 0} ${(0, path_js_1.pt)(to)}`;
11
+ }
12
+ // One SVG arc can't close a circle, so a whole one is two half circles. A
13
+ // donut's hole runs the other way round, so the nonzero fill rule leaves it empty.
14
+ function circlePath(ring, from) {
15
+ const { radius, innerRadius } = ring;
16
+ const start = polar(ring, radius, from);
17
+ let path = `M${(0, path_js_1.pt)(start)}${arc(radius, true, true, polar(ring, radius, from + 180))}${arc(radius, true, true, start)}Z`;
18
+ if (innerRadius > 0) {
19
+ const inner = polar(ring, innerRadius, from);
20
+ path += `M${(0, path_js_1.pt)(inner)}${arc(innerRadius, true, false, polar(ring, innerRadius, from + 180))}`;
21
+ path += `${arc(innerRadius, true, false, inner)}Z`;
22
+ }
23
+ return path;
24
+ }
25
+ function slicePath(ring, from, to) {
26
+ const { radius, innerRadius } = ring;
27
+ const large = to - from > 180;
28
+ const outer = `${(0, path_js_1.pt)(polar(ring, radius, from))}${arc(radius, large, true, polar(ring, radius, to))}`;
29
+ if (innerRadius <= 0)
30
+ return `M${(0, path_js_1.num)(ring.cx)},${(0, path_js_1.num)(ring.cy)}L${outer}Z`;
31
+ const inner = arc(innerRadius, large, false, polar(ring, innerRadius, from));
32
+ return `M${outer}L${(0, path_js_1.pt)(polar(ring, innerRadius, to))}${inner}Z`;
33
+ }
34
+ /**
35
+ * One slice per value, in the order given. A value that is the whole total is
36
+ * drawn as a full circle (or ring), with no padding. A zero value draws nothing.
37
+ * `padAngle` is taken from each slice's own angle, half at each end, and a slice
38
+ * no wider than it draws nothing. Throws a `RangeError` for a negative value.
39
+ */
40
+ function pieSlices(values, options) {
41
+ values.forEach((value, index) => {
42
+ if (value < 0)
43
+ throw new RangeError(`pieSlices: value ${index} is negative (${value})`);
44
+ });
45
+ const { cx, cy, radius, innerRadius = 0, startAngle = 0, padAngle = 0 } = options;
46
+ const ring = { cx, cy, radius, innerRadius };
47
+ const total = values.reduce((sum, value) => sum + value, 0);
48
+ const drawn = values.filter((value) => value > 0).length;
49
+ const pad = drawn > 1 ? padAngle : 0;
50
+ let sum = 0;
51
+ return values.map((value, index) => {
52
+ const from = total > 0 ? startAngle + (sum / total) * 360 : startAngle;
53
+ sum += value;
54
+ const to = total > 0 ? startAngle + (sum / total) * 360 : startAngle;
55
+ let path = '';
56
+ if (drawn === 1 && value > 0)
57
+ path = circlePath(ring, from);
58
+ else if (value > 0 && to - from > pad)
59
+ path = slicePath(ring, from + pad / 2, to - pad / 2);
60
+ return {
61
+ index,
62
+ value,
63
+ fraction: total > 0 ? value / total : 0,
64
+ startAngle: from,
65
+ endAngle: to,
66
+ path,
67
+ centroid: polar(ring, (radius + innerRadius) / 2, (from + to) / 2),
68
+ };
69
+ });
70
+ }
@@ -0,0 +1,26 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.nearestIndex = nearestIndex;
4
+ exports.bandIndex = bandIndex;
5
+ /** The index of the position closest to `x`, the first of a tie; -1 when there are none. */
6
+ function nearestIndex(positions, x) {
7
+ let nearest = -1;
8
+ let nearestDistance = Infinity;
9
+ positions.forEach((position, index) => {
10
+ const distance = Math.abs(position - x);
11
+ if (distance < nearestDistance) {
12
+ nearest = index;
13
+ nearestDistance = distance;
14
+ }
15
+ });
16
+ return nearest;
17
+ }
18
+ /**
19
+ * Which of `count` equal bands across `width` holds `x`, clamped to the first
20
+ * and last band; -1 when there are no bands.
21
+ */
22
+ function bandIndex(x, count, width) {
23
+ if (count <= 0)
24
+ return -1;
25
+ return Math.min(count - 1, Math.max(0, Math.floor((x / width) * count)));
26
+ }
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.linearScale = linearScale;
4
+ exports.extent = extent;
5
+ /**
6
+ * Maps a domain linearly onto a range. Either may run backwards: SVG's y grows
7
+ * downward, so a value axis is usually `linearScale([min, max], [height, 0])`.
8
+ * A domain whose ends are equal maps every value to the middle of the range,
9
+ * and a range whose ends are equal inverts every position to the middle of
10
+ * the domain, instead of dividing by zero.
11
+ */
12
+ function linearScale(domain, range) {
13
+ const [d0, d1] = domain;
14
+ const [r0, r1] = range;
15
+ const domainSpan = d1 - d0;
16
+ const rangeSpan = r1 - r0;
17
+ return {
18
+ map: (value) => (domainSpan === 0 ? (r0 + r1) / 2 : r0 + ((value - d0) / domainSpan) * rangeSpan),
19
+ invert: (position) => (rangeSpan === 0 ? (d0 + d1) / 2 : d0 + ((position - r0) / rangeSpan) * domainSpan),
20
+ };
21
+ }
22
+ /** The smallest and largest of `values`, or `[0, 0]` when there are none. */
23
+ function extent(values, options = {}) {
24
+ if (values.length === 0)
25
+ return [0, 0];
26
+ let min = values[0];
27
+ let max = values[0];
28
+ for (const value of values) {
29
+ if (value < min)
30
+ min = value;
31
+ if (value > max)
32
+ max = value;
33
+ }
34
+ if (options.includeZero) {
35
+ min = Math.min(min, 0);
36
+ max = Math.max(max, 0);
37
+ }
38
+ return [min, max];
39
+ }
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,19 @@
1
+ import { num, pt } from './internal/path.js';
2
+ /**
3
+ * SVG path data for an area chart through `points`, which are in drawing
4
+ * coordinates and drawing order, joined with straight segments. No points give
5
+ * empty strings. One point gives a zero-length line, which a round
6
+ * `stroke-linecap` shows as a dot, and an area with no width.
7
+ */
8
+ export function areaPath(points, options) {
9
+ if (points.length === 0)
10
+ return { line: '', area: '' };
11
+ const first = points[0];
12
+ const last = points[points.length - 1];
13
+ const lineTo = (rest) => rest.map((point) => `L${pt(point)}`).join('');
14
+ const baseline = num(options.baseline);
15
+ return {
16
+ line: `M${pt(first)}${lineTo(points.length === 1 ? points : points.slice(1))}`,
17
+ area: `M${num(first.x)},${baseline}${lineTo(points)}L${num(last.x)},${baseline}Z`,
18
+ };
19
+ }
@@ -0,0 +1,44 @@
1
+ import { extent, linearScale } from './scale.js';
2
+ /**
3
+ * Grouped bars for `series`, one number array per series, all the same length:
4
+ * group `i` holds value `i` of each series. The groups split `width` into equal
5
+ * bands with no outer padding, so equal grid columns under the chart line up
6
+ * with them. The value domain spans zero and every value, so a negative value
7
+ * hangs below the zero line; with no values or only zeros, the zero line is the
8
+ * bottom. Throws a `RangeError` when the series differ in length.
9
+ */
10
+ export function groupedBars(series, options) {
11
+ const count = series.length > 0 ? series[0].length : 0;
12
+ series.forEach((values, index) => {
13
+ if (values.length !== count) {
14
+ throw new RangeError(`groupedBars: series ${index} has ${values.length} values, series 0 has ${count}`);
15
+ }
16
+ });
17
+ const { width, height, groupPadding = 0.1, barPadding = 0 } = options;
18
+ const [min, max] = extent(series.flat(), { includeZero: true });
19
+ const y = linearScale(min === max ? [0, 1] : [min, max], [height, 0]);
20
+ const baseline = y.map(0);
21
+ const band = count > 0 ? width / count : 0;
22
+ const groupWidth = band * (1 - groupPadding);
23
+ const gaps = series.length - 1;
24
+ const gap = gaps > 0 ? (groupWidth * barPadding) / gaps : 0;
25
+ const barWidth = series.length > 0 ? (groupWidth - gap * gaps) / series.length : 0;
26
+ const bars = [];
27
+ for (let index = 0; index < count; index++) {
28
+ const left = index * band + (band - groupWidth) / 2;
29
+ series.forEach((values, s) => {
30
+ const value = values[index];
31
+ const top = y.map(value);
32
+ bars.push({
33
+ series: s,
34
+ index,
35
+ value,
36
+ x: left + s * (barWidth + gap),
37
+ y: Math.min(top, baseline),
38
+ width: barWidth,
39
+ height: Math.abs(top - baseline),
40
+ });
41
+ });
42
+ }
43
+ return { bars, baseline };
44
+ }
@@ -0,0 +1,5 @@
1
+ export { areaPath } from './area.js';
2
+ export { groupedBars } from './bars.js';
3
+ export { pieSlices } from './pie.js';
4
+ export { bandIndex, nearestIndex } from './pointer.js';
5
+ export { extent, linearScale } from './scale.js';
@@ -0,0 +1,8 @@
1
+ /** A coordinate for SVG path data: at most two decimals, no trailing zeros and no `-0`. */
2
+ export function num(value) {
3
+ const rounded = Math.round(value * 100) / 100;
4
+ return String(rounded === 0 ? 0 : rounded);
5
+ }
6
+ export function pt(point) {
7
+ return `${num(point.x)},${num(point.y)}`;
8
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
@@ -0,0 +1,67 @@
1
+ import { num, pt } from './internal/path.js';
2
+ function polar(ring, radius, degrees) {
3
+ const radians = (degrees * Math.PI) / 180;
4
+ return { x: ring.cx + radius * Math.sin(radians), y: ring.cy - radius * Math.cos(radians) };
5
+ }
6
+ function arc(radius, large, clockwise, to) {
7
+ return `A${num(radius)},${num(radius)} 0 ${large ? 1 : 0} ${clockwise ? 1 : 0} ${pt(to)}`;
8
+ }
9
+ // One SVG arc can't close a circle, so a whole one is two half circles. A
10
+ // donut's hole runs the other way round, so the nonzero fill rule leaves it empty.
11
+ function circlePath(ring, from) {
12
+ const { radius, innerRadius } = ring;
13
+ const start = polar(ring, radius, from);
14
+ let path = `M${pt(start)}${arc(radius, true, true, polar(ring, radius, from + 180))}${arc(radius, true, true, start)}Z`;
15
+ if (innerRadius > 0) {
16
+ const inner = polar(ring, innerRadius, from);
17
+ path += `M${pt(inner)}${arc(innerRadius, true, false, polar(ring, innerRadius, from + 180))}`;
18
+ path += `${arc(innerRadius, true, false, inner)}Z`;
19
+ }
20
+ return path;
21
+ }
22
+ function slicePath(ring, from, to) {
23
+ const { radius, innerRadius } = ring;
24
+ const large = to - from > 180;
25
+ const outer = `${pt(polar(ring, radius, from))}${arc(radius, large, true, polar(ring, radius, to))}`;
26
+ if (innerRadius <= 0)
27
+ return `M${num(ring.cx)},${num(ring.cy)}L${outer}Z`;
28
+ const inner = arc(innerRadius, large, false, polar(ring, innerRadius, from));
29
+ return `M${outer}L${pt(polar(ring, innerRadius, to))}${inner}Z`;
30
+ }
31
+ /**
32
+ * One slice per value, in the order given. A value that is the whole total is
33
+ * drawn as a full circle (or ring), with no padding. A zero value draws nothing.
34
+ * `padAngle` is taken from each slice's own angle, half at each end, and a slice
35
+ * no wider than it draws nothing. Throws a `RangeError` for a negative value.
36
+ */
37
+ export function pieSlices(values, options) {
38
+ values.forEach((value, index) => {
39
+ if (value < 0)
40
+ throw new RangeError(`pieSlices: value ${index} is negative (${value})`);
41
+ });
42
+ const { cx, cy, radius, innerRadius = 0, startAngle = 0, padAngle = 0 } = options;
43
+ const ring = { cx, cy, radius, innerRadius };
44
+ const total = values.reduce((sum, value) => sum + value, 0);
45
+ const drawn = values.filter((value) => value > 0).length;
46
+ const pad = drawn > 1 ? padAngle : 0;
47
+ let sum = 0;
48
+ return values.map((value, index) => {
49
+ const from = total > 0 ? startAngle + (sum / total) * 360 : startAngle;
50
+ sum += value;
51
+ const to = total > 0 ? startAngle + (sum / total) * 360 : startAngle;
52
+ let path = '';
53
+ if (drawn === 1 && value > 0)
54
+ path = circlePath(ring, from);
55
+ else if (value > 0 && to - from > pad)
56
+ path = slicePath(ring, from + pad / 2, to - pad / 2);
57
+ return {
58
+ index,
59
+ value,
60
+ fraction: total > 0 ? value / total : 0,
61
+ startAngle: from,
62
+ endAngle: to,
63
+ path,
64
+ centroid: polar(ring, (radius + innerRadius) / 2, (from + to) / 2),
65
+ };
66
+ });
67
+ }
@@ -0,0 +1,22 @@
1
+ /** The index of the position closest to `x`, the first of a tie; -1 when there are none. */
2
+ export function nearestIndex(positions, x) {
3
+ let nearest = -1;
4
+ let nearestDistance = Infinity;
5
+ positions.forEach((position, index) => {
6
+ const distance = Math.abs(position - x);
7
+ if (distance < nearestDistance) {
8
+ nearest = index;
9
+ nearestDistance = distance;
10
+ }
11
+ });
12
+ return nearest;
13
+ }
14
+ /**
15
+ * Which of `count` equal bands across `width` holds `x`, clamped to the first
16
+ * and last band; -1 when there are no bands.
17
+ */
18
+ export function bandIndex(x, count, width) {
19
+ if (count <= 0)
20
+ return -1;
21
+ return Math.min(count - 1, Math.max(0, Math.floor((x / width) * count)));
22
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Maps a domain linearly onto a range. Either may run backwards: SVG's y grows
3
+ * downward, so a value axis is usually `linearScale([min, max], [height, 0])`.
4
+ * A domain whose ends are equal maps every value to the middle of the range,
5
+ * and a range whose ends are equal inverts every position to the middle of
6
+ * the domain, instead of dividing by zero.
7
+ */
8
+ export function linearScale(domain, range) {
9
+ const [d0, d1] = domain;
10
+ const [r0, r1] = range;
11
+ const domainSpan = d1 - d0;
12
+ const rangeSpan = r1 - r0;
13
+ return {
14
+ map: (value) => (domainSpan === 0 ? (r0 + r1) / 2 : r0 + ((value - d0) / domainSpan) * rangeSpan),
15
+ invert: (position) => (rangeSpan === 0 ? (d0 + d1) / 2 : d0 + ((position - r0) / rangeSpan) * domainSpan),
16
+ };
17
+ }
18
+ /** The smallest and largest of `values`, or `[0, 0]` when there are none. */
19
+ export function extent(values, options = {}) {
20
+ if (values.length === 0)
21
+ return [0, 0];
22
+ let min = values[0];
23
+ let max = values[0];
24
+ for (const value of values) {
25
+ if (value < min)
26
+ min = value;
27
+ if (value > max)
28
+ max = value;
29
+ }
30
+ if (options.includeZero) {
31
+ min = Math.min(min, 0);
32
+ max = Math.max(max, 0);
33
+ }
34
+ return [min, max];
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import type { Point } from './types.js';
2
+ export interface AreaOptions {
3
+ /** The y the area closes to: usually the zero line or the bottom of the chart. */
4
+ baseline: number;
5
+ }
6
+ export interface AreaPath {
7
+ /** Path data through every point, for the stroke. */
8
+ line: string;
9
+ /** The line closed to the baseline, for the fill. */
10
+ area: string;
11
+ }
12
+ /**
13
+ * SVG path data for an area chart through `points`, which are in drawing
14
+ * coordinates and drawing order, joined with straight segments. No points give
15
+ * empty strings. One point gives a zero-length line, which a round
16
+ * `stroke-linecap` shows as a dot, and an area with no width.
17
+ */
18
+ export declare function areaPath(points: readonly Point[], options: AreaOptions): AreaPath;
@@ -0,0 +1,34 @@
1
+ export interface GroupedBarsOptions {
2
+ width: number;
3
+ height: number;
4
+ /** Share of each group's band left empty, half on each side of the group. Default 0.1. */
5
+ groupPadding?: number;
6
+ /** Share of each group's width left empty between its bars. Default 0. */
7
+ barPadding?: number;
8
+ }
9
+ export interface Bar {
10
+ /** Which series the bar belongs to. */
11
+ series: number;
12
+ /** Which group (month, say) the bar belongs to. */
13
+ index: number;
14
+ value: number;
15
+ x: number;
16
+ y: number;
17
+ width: number;
18
+ height: number;
19
+ }
20
+ export interface GroupedBars {
21
+ /** Left to right: group by group, and each group's bars in series order. */
22
+ bars: Bar[];
23
+ /** The y of the zero line. */
24
+ baseline: number;
25
+ }
26
+ /**
27
+ * Grouped bars for `series`, one number array per series, all the same length:
28
+ * group `i` holds value `i` of each series. The groups split `width` into equal
29
+ * bands with no outer padding, so equal grid columns under the chart line up
30
+ * with them. The value domain spans zero and every value, so a negative value
31
+ * hangs below the zero line; with no values or only zeros, the zero line is the
32
+ * bottom. Throws a `RangeError` when the series differ in length.
33
+ */
34
+ export declare function groupedBars(series: readonly (readonly number[])[], options: GroupedBarsOptions): GroupedBars;
@@ -0,0 +1,10 @@
1
+ export { areaPath } from './area.js';
2
+ export { groupedBars } from './bars.js';
3
+ export { pieSlices } from './pie.js';
4
+ export { bandIndex, nearestIndex } from './pointer.js';
5
+ export { extent, linearScale } from './scale.js';
6
+ export type { AreaOptions, AreaPath } from './area.js';
7
+ export type { Bar, GroupedBars, GroupedBarsOptions } from './bars.js';
8
+ export type { PieOptions, PieSlice } from './pie.js';
9
+ export type { ExtentOptions, Interval, LinearScale } from './scale.js';
10
+ export type { Point } from './types.js';
@@ -0,0 +1,4 @@
1
+ import type { Point } from '../types.js';
2
+ /** A coordinate for SVG path data: at most two decimals, no trailing zeros and no `-0`. */
3
+ export declare function num(value: number): string;
4
+ export declare function pt(point: Point): string;
@@ -0,0 +1,32 @@
1
+ import type { Point } from './types.js';
2
+ export interface PieOptions {
3
+ cx: number;
4
+ cy: number;
5
+ radius: number;
6
+ /** Radius of the hole, for a donut. Default 0. */
7
+ innerRadius?: number;
8
+ /** Where the first slice starts, in degrees clockwise from 12 o'clock. Default 0. */
9
+ startAngle?: number;
10
+ /** Degrees of gap between neighbouring slices. Default 0. */
11
+ padAngle?: number;
12
+ }
13
+ export interface PieSlice {
14
+ index: number;
15
+ value: number;
16
+ /** The slice's share of the total, from 0 to 1; 0 when every value is 0. */
17
+ fraction: number;
18
+ /** Degrees clockwise from 12 o'clock, before padding. */
19
+ startAngle: number;
20
+ endAngle: number;
21
+ /** SVG path data; empty when the slice draws nothing. */
22
+ path: string;
23
+ /** The middle of the slice's angle, halfway between the inner and outer radius. */
24
+ centroid: Point;
25
+ }
26
+ /**
27
+ * One slice per value, in the order given. A value that is the whole total is
28
+ * drawn as a full circle (or ring), with no padding. A zero value draws nothing.
29
+ * `padAngle` is taken from each slice's own angle, half at each end, and a slice
30
+ * no wider than it draws nothing. Throws a `RangeError` for a negative value.
31
+ */
32
+ export declare function pieSlices(values: readonly number[], options: PieOptions): PieSlice[];
@@ -0,0 +1,7 @@
1
+ /** The index of the position closest to `x`, the first of a tie; -1 when there are none. */
2
+ export declare function nearestIndex(positions: readonly number[], x: number): number;
3
+ /**
4
+ * Which of `count` equal bands across `width` holds `x`, clamped to the first
5
+ * and last band; -1 when there are no bands.
6
+ */
7
+ export declare function bandIndex(x: number, count: number, width: number): number;
@@ -0,0 +1,22 @@
1
+ /** Two ends of a domain or range, in either order. */
2
+ export type Interval = readonly [number, number];
3
+ export interface LinearScale {
4
+ /** The position of a domain value. Values outside the domain extrapolate. */
5
+ map(value: number): number;
6
+ /** The domain value at a position. */
7
+ invert(position: number): number;
8
+ }
9
+ export interface ExtentOptions {
10
+ /** Stretch the extent to take in zero. */
11
+ includeZero?: boolean;
12
+ }
13
+ /**
14
+ * Maps a domain linearly onto a range. Either may run backwards: SVG's y grows
15
+ * downward, so a value axis is usually `linearScale([min, max], [height, 0])`.
16
+ * A domain whose ends are equal maps every value to the middle of the range,
17
+ * and a range whose ends are equal inverts every position to the middle of
18
+ * the domain, instead of dividing by zero.
19
+ */
20
+ export declare function linearScale(domain: Interval, range: Interval): LinearScale;
21
+ /** The smallest and largest of `values`, or `[0, 0]` when there are none. */
22
+ export declare function extent(values: readonly number[], options?: ExtentOptions): [number, number];
@@ -0,0 +1,5 @@
1
+ /** A point in drawing coordinates. */
2
+ export interface Point {
3
+ x: number;
4
+ y: number;
5
+ }
package/package.json CHANGED
@@ -1,6 +1,63 @@
1
1
  {
2
2
  "name": "@bro-code/charts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "2026.0.0",
4
+ "type": "module",
5
+ "description": "Chart geometry for SVG: scales, area paths, pie slices and grouped bars, computed as plain data with no framework or DOM, so any app can draw its own charts.",
6
+ "main": "./dist/cjs/index.js",
7
+ "module": "./dist/esm/index.js",
8
+ "types": "./dist/types/index.d.ts",
9
+ "sideEffects": false,
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/types/index.d.ts",
13
+ "import": "./dist/esm/index.js",
14
+ "require": "./dist/cjs/index.js"
15
+ }
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "README.md",
20
+ "LICENSE"
21
+ ],
22
+ "scripts": {
23
+ "build": "npm run build:esm && npm run build:cjs && npm run build:types && npm run build:fixup",
24
+ "build:esm": "tsc --project tsconfig.esm.json",
25
+ "build:cjs": "tsc --project tsconfig.cjs.json",
26
+ "build:types": "tsc --project tsconfig.types.json",
27
+ "build:fixup": "node scripts/fixup.cjs",
28
+ "test": "vitest run",
29
+ "test:watch": "vitest",
30
+ "lint": "eslint src/",
31
+ "prepublishOnly": "npm run lint && npm run test && npm run build",
32
+ "preversion": "npm run lint && npm run test",
33
+ "version": "git add -A"
34
+ },
35
+ "keywords": [
36
+ "charts",
37
+ "svg",
38
+ "geometry",
39
+ "scale",
40
+ "pie",
41
+ "bar-chart",
42
+ "area-chart",
43
+ "bro-code"
44
+ ],
45
+ "author": "Bro Code Technologies",
46
+ "license": "MIT",
47
+ "repository": {
48
+ "type": "git",
49
+ "url": "https://github.com/Bro-Code-Technologies/bro-code-package-maker.git",
50
+ "directory": "packages/charts"
51
+ },
52
+ "engines": {
53
+ "node": ">=18"
54
+ },
55
+ "devDependencies": {
56
+ "@eslint/js": "~10.0.1",
57
+ "@types/node": "~25.9.9",
58
+ "eslint": "~10.13.0",
59
+ "typescript": "~6.0.3",
60
+ "typescript-eslint": "~8.72.1",
61
+ "vitest": "~4.1.12"
62
+ }
63
+ }