@rowsncolumns/charts 28.1.2 → 28.1.5
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/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/cjs/basic-chart/basic-chart.js +152 -88
- package/dist/cjs/basic-chart/helpers.d.ts +9 -0
- package/dist/cjs/basic-chart/helpers.d.ts.map +1 -1
- package/dist/cjs/basic-chart/helpers.js +55 -1
- package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/cjs/plotly/basic-chart/basic-chart.js +225 -140
- package/dist/cjs/plotly/plot.d.ts +12 -0
- package/dist/cjs/plotly/plot.d.ts.map +1 -0
- package/dist/cjs/plotly/plot.js +8 -0
- package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
- package/dist/cjs/utils/axis-number-format.d.ts +14 -0
- package/dist/cjs/utils/axis-number-format.d.ts.map +1 -1
- package/dist/cjs/utils/axis-number-format.js +41 -1
- package/dist/cjs/utils/empty-cells.d.ts +9 -0
- package/dist/cjs/utils/empty-cells.d.ts.map +1 -1
- package/dist/cjs/utils/empty-cells.js +26 -1
- package/dist/cjs/utils/legend-layout.d.ts +168 -0
- package/dist/cjs/utils/legend-layout.d.ts.map +1 -0
- package/dist/cjs/utils/legend-layout.js +338 -0
- package/dist/cjs/utils/trendline.d.ts +18 -5
- package/dist/cjs/utils/trendline.d.ts.map +1 -1
- package/dist/cjs/utils/trendline.js +38 -16
- package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/esm/basic-chart/basic-chart.js +118 -47
- package/dist/esm/basic-chart/helpers.d.ts +9 -0
- package/dist/esm/basic-chart/helpers.d.ts.map +1 -1
- package/dist/esm/basic-chart/helpers.js +30 -0
- package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/esm/plotly/basic-chart/basic-chart.js +140 -13
- package/dist/esm/plotly/plot.d.ts +12 -0
- package/dist/esm/plotly/plot.d.ts.map +1 -0
- package/dist/esm/plotly/plot.js +12 -0
- package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
- package/dist/esm/utils/axis-number-format.d.ts +14 -0
- package/dist/esm/utils/axis-number-format.d.ts.map +1 -1
- package/dist/esm/utils/axis-number-format.js +36 -0
- package/dist/esm/utils/empty-cells.d.ts +9 -0
- package/dist/esm/utils/empty-cells.d.ts.map +1 -1
- package/dist/esm/utils/empty-cells.js +21 -0
- package/dist/esm/utils/legend-layout.d.ts +168 -0
- package/dist/esm/utils/legend-layout.d.ts.map +1 -0
- package/dist/esm/utils/legend-layout.js +299 -0
- package/dist/esm/utils/trendline.d.ts +18 -5
- package/dist/esm/utils/trendline.d.ts.map +1 -1
- package/dist/esm/utils/trendline.js +36 -16
- package/package.json +9 -9
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
// Legend band reservation (rowsncolumns/spreadsheet#986).
|
|
2
|
+
//
|
|
3
|
+
// Excel and Google Sheets never let the legend paint over the plot: for
|
|
4
|
+
// every legend position the chart reserves a band of its own — legend
|
|
5
|
+
// outermost, then the axis title, then the tick labels, then the plot
|
|
6
|
+
// area shrinks to whatever is left. Both renderers here used to pin the
|
|
7
|
+
// legend a fixed distance from the frame edge and leave the plot margin
|
|
8
|
+
// constant, so a bottom legend sat on the category labels and hid the
|
|
9
|
+
// horizontal axis title (and a top legend crowded the chart title).
|
|
10
|
+
//
|
|
11
|
+
// This module estimates the legend's rendered size from its actual items
|
|
12
|
+
// (count, label widths, font size, wrapping into rows / columns) and turns
|
|
13
|
+
// that into renderer geometry: the echarts `grid` + legend offsets +
|
|
14
|
+
// category-axis `nameGap`, and the plotly `margin` + paper-relative legend
|
|
15
|
+
// anchor. The estimate is deliberately generous — a few px of slack beats
|
|
16
|
+
// a legend on top of the labels.
|
|
17
|
+
/** Space kept between neighbouring bands (legend ↔ axis title ↔ labels). */
|
|
18
|
+
export const BAND_GAP = 8;
|
|
19
|
+
/** Inset of the outermost band from the chart frame. */
|
|
20
|
+
export const EDGE_PAD = 10;
|
|
21
|
+
/** echarts `legend.padding` default used by both adapters' legend boxes. */
|
|
22
|
+
export const LEGEND_PADDING = 10;
|
|
23
|
+
/** echarts `legend.itemGap` default. */
|
|
24
|
+
export const LEGEND_ITEM_GAP = 10;
|
|
25
|
+
/** Gap echarts leaves between a legend icon and its text. */
|
|
26
|
+
const LEGEND_ICON_TEXT_GAP = 5;
|
|
27
|
+
/** Approximate rendered width of `text` at `fontSize` (canvas when available). */
|
|
28
|
+
export const measureTextWidth = (text, fontSize) => {
|
|
29
|
+
if (typeof document !== "undefined") {
|
|
30
|
+
const ctx = document.createElement("canvas").getContext("2d");
|
|
31
|
+
if (ctx) {
|
|
32
|
+
ctx.font = `${fontSize}px sans-serif`;
|
|
33
|
+
const width = ctx.measureText(text).width;
|
|
34
|
+
if (Number.isFinite(width) && width > 0)
|
|
35
|
+
return width;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
// Headless fallback: ~0.6em per character.
|
|
39
|
+
return text.length * fontSize * 0.6;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Estimate the legend's rendered box from its items. Horizontal legends
|
|
43
|
+
* pack items greedily into rows no wider than the frame; vertical legends
|
|
44
|
+
* stack items and spill into a new column when taller than the frame.
|
|
45
|
+
*/
|
|
46
|
+
export const estimateLegendBand = ({ labels, fontSize = 12, itemWidth = 10, itemGap = LEGEND_ITEM_GAP, padding = LEGEND_PADDING, orientation, availableWidth, availableHeight, }) => {
|
|
47
|
+
if (!labels.length)
|
|
48
|
+
return { rows: 0, columns: 0, width: 0, height: 0 };
|
|
49
|
+
const lineHeight = Math.ceil(fontSize * 1.3);
|
|
50
|
+
const itemWidths = labels.map((label) => itemWidth + LEGEND_ICON_TEXT_GAP + Math.ceil(measureTextWidth(label, fontSize)));
|
|
51
|
+
if (orientation === "horizontal") {
|
|
52
|
+
const maxRowWidth = Math.max(1, availableWidth - padding * 2);
|
|
53
|
+
let rows = 1;
|
|
54
|
+
let rowWidth = 0;
|
|
55
|
+
let widestRow = 0;
|
|
56
|
+
for (const w of itemWidths) {
|
|
57
|
+
const next = rowWidth === 0 ? w : rowWidth + itemGap + w;
|
|
58
|
+
if (next > maxRowWidth && rowWidth > 0) {
|
|
59
|
+
widestRow = Math.max(widestRow, rowWidth);
|
|
60
|
+
rows += 1;
|
|
61
|
+
rowWidth = w;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
rowWidth = next;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
widestRow = Math.max(widestRow, rowWidth);
|
|
68
|
+
return {
|
|
69
|
+
rows,
|
|
70
|
+
columns: 1,
|
|
71
|
+
width: padding * 2 + widestRow,
|
|
72
|
+
height: padding * 2 + rows * lineHeight + (rows - 1) * itemGap,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const maxColumnHeight = Math.max(1, availableHeight - padding * 2);
|
|
76
|
+
const perColumn = Math.max(1, Math.floor((maxColumnHeight + itemGap) / (lineHeight + itemGap)));
|
|
77
|
+
const columns = Math.ceil(labels.length / perColumn);
|
|
78
|
+
const rows = Math.min(labels.length, perColumn);
|
|
79
|
+
const widestItem = Math.max(...itemWidths);
|
|
80
|
+
return {
|
|
81
|
+
rows,
|
|
82
|
+
columns,
|
|
83
|
+
width: padding * 2 + columns * widestItem + (columns - 1) * itemGap,
|
|
84
|
+
height: padding * 2 + rows * lineHeight + (rows - 1) * itemGap,
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Pull the legend entry labels out of an echarts `legend.data` (strings or
|
|
89
|
+
* `{ name }` items); falls back to the series names, which is what echarts
|
|
90
|
+
* itself lists when `data` is empty.
|
|
91
|
+
*/
|
|
92
|
+
export const legendLabelsFrom = (legendData, seriesNames) => {
|
|
93
|
+
const fromData = (legendData ?? [])
|
|
94
|
+
.map((d) => typeof d === "string"
|
|
95
|
+
? d
|
|
96
|
+
: d && typeof d === "object" && typeof d.name === "string"
|
|
97
|
+
? d.name
|
|
98
|
+
: "")
|
|
99
|
+
.filter((s) => s.length > 0);
|
|
100
|
+
if (fromData.length)
|
|
101
|
+
return fromData;
|
|
102
|
+
return seriesNames
|
|
103
|
+
.map((n) => (typeof n === "string" ? n : n === undefined || n === null ? "" : String(n)))
|
|
104
|
+
.filter((s) => s.length > 0);
|
|
105
|
+
};
|
|
106
|
+
/**
|
|
107
|
+
* Depth (px) of one row of echarts axis tick labels measured from the axis
|
|
108
|
+
* line: 5px tick + 8px label margin + one 12px line, rounded up. With
|
|
109
|
+
* `grid.containLabel` this is the part the grid already accounts for; the
|
|
110
|
+
* axis title sits beyond it.
|
|
111
|
+
*/
|
|
112
|
+
export const echartsAxisLabelDepth = (fontSize = 12) => 5 + 8 + Math.ceil(fontSize * 1.1);
|
|
113
|
+
/**
|
|
114
|
+
* echarts `nameGap` for a category axis whose title sits in the middle
|
|
115
|
+
* below the tick labels: clear one row of labels plus a small gap, measured
|
|
116
|
+
* to the title's centre line.
|
|
117
|
+
*/
|
|
118
|
+
export const categoryAxisNameGap = (fontSize = 12) => echartsAxisLabelDepth(fontSize) + BAND_GAP / 2 + Math.ceil(fontSize / 2);
|
|
119
|
+
/** Height of the echarts chart-title band (padding 10 each side + one line). */
|
|
120
|
+
export const echartsTitleBand = (titleFontSize = 16) => LEGEND_PADDING * 2 + Math.ceil(titleFontSize * 1.25);
|
|
121
|
+
/**
|
|
122
|
+
* Echarts cartesian geometry with a band reserved for the legend in its
|
|
123
|
+
* position, outside the axis-title band, outside the tick labels.
|
|
124
|
+
*/
|
|
125
|
+
export const reserveEchartsLegendBand = ({ position, visible, labels, legendFontSize = 12, legendItemWidth = 10, width, height, hasTitle, titleFontSize = 16, hasBottomAxisTitle, bottomAxisNameGap, hasLeftAxisTitle, axisFontSize = 12, }) => {
|
|
126
|
+
const pos = visible ? (position ?? "top") : "none";
|
|
127
|
+
const horizontal = pos === "top" || pos === "bottom";
|
|
128
|
+
const band = pos === "none"
|
|
129
|
+
? { rows: 0, columns: 0, width: 0, height: 0 }
|
|
130
|
+
: estimateLegendBand({
|
|
131
|
+
labels,
|
|
132
|
+
fontSize: legendFontSize,
|
|
133
|
+
itemWidth: legendItemWidth,
|
|
134
|
+
orientation: horizontal ? "horizontal" : "vertical",
|
|
135
|
+
availableWidth: width,
|
|
136
|
+
availableHeight: height,
|
|
137
|
+
});
|
|
138
|
+
const titleBand = hasTitle ? echartsTitleBand(titleFontSize) : 0;
|
|
139
|
+
const labelDepth = echartsAxisLabelDepth(axisFontSize);
|
|
140
|
+
// The bottom axis title's centre sits `nameGap` below the axis line and
|
|
141
|
+
// its line box extends half a font below that; the contained labels
|
|
142
|
+
// already cover `labelDepth` of it.
|
|
143
|
+
const bottomTitleOverhang = hasBottomAxisTitle
|
|
144
|
+
? Math.max(0, bottomAxisNameGap + Math.ceil(axisFontSize / 2) - labelDepth) + BAND_GAP
|
|
145
|
+
: 0;
|
|
146
|
+
// The rotated left axis title clears its labels by `fontSize + 6` (see
|
|
147
|
+
// valueAxisNameGap) and its own line box extends half a font further.
|
|
148
|
+
const leftTitleOverhang = hasLeftAxisTitle
|
|
149
|
+
? Math.ceil(axisFontSize * 1.5) + 6 + BAND_GAP / 2
|
|
150
|
+
: 0;
|
|
151
|
+
// Half a tick label pokes past the top / right end of an axis.
|
|
152
|
+
const topHeadroom = Math.ceil(axisFontSize / 2) + 4;
|
|
153
|
+
const rightHeadroom = Math.ceil(axisFontSize / 2) + 10;
|
|
154
|
+
const legend = {};
|
|
155
|
+
let top = (hasTitle ? titleBand : EDGE_PAD) + topHeadroom;
|
|
156
|
+
let bottom = EDGE_PAD + bottomTitleOverhang;
|
|
157
|
+
let left = EDGE_PAD + leftTitleOverhang;
|
|
158
|
+
let right = rightHeadroom;
|
|
159
|
+
switch (pos) {
|
|
160
|
+
case "top": {
|
|
161
|
+
legend.top = hasTitle ? titleBand : EDGE_PAD;
|
|
162
|
+
legend.bottom = undefined;
|
|
163
|
+
top = legend.top + band.height + BAND_GAP + topHeadroom;
|
|
164
|
+
break;
|
|
165
|
+
}
|
|
166
|
+
case "bottom": {
|
|
167
|
+
legend.bottom = EDGE_PAD;
|
|
168
|
+
legend.top = undefined;
|
|
169
|
+
bottom = EDGE_PAD + band.height + BAND_GAP + bottomTitleOverhang;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "left": {
|
|
173
|
+
legend.left = EDGE_PAD;
|
|
174
|
+
legend.right = undefined;
|
|
175
|
+
left = EDGE_PAD + band.width + BAND_GAP + leftTitleOverhang;
|
|
176
|
+
break;
|
|
177
|
+
}
|
|
178
|
+
case "right": {
|
|
179
|
+
legend.right = EDGE_PAD;
|
|
180
|
+
legend.left = undefined;
|
|
181
|
+
right = EDGE_PAD + band.width + BAND_GAP;
|
|
182
|
+
break;
|
|
183
|
+
}
|
|
184
|
+
case "topRight": {
|
|
185
|
+
legend.right = EDGE_PAD;
|
|
186
|
+
legend.left = undefined;
|
|
187
|
+
legend.top = hasTitle ? titleBand : EDGE_PAD;
|
|
188
|
+
legend.bottom = undefined;
|
|
189
|
+
right = EDGE_PAD + band.width + BAND_GAP;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
default:
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
return {
|
|
196
|
+
band,
|
|
197
|
+
grid: { left, right, top, bottom, containLabel: true },
|
|
198
|
+
legend,
|
|
199
|
+
};
|
|
200
|
+
};
|
|
201
|
+
/** Plotly text line height (its LINE_SPACING is 1.3em). */
|
|
202
|
+
const plotlyLineHeight = (fontSize) => Math.ceil(fontSize * 1.3);
|
|
203
|
+
/** Plotly default `legend.itemwidth`. */
|
|
204
|
+
const PLOTLY_LEGEND_ITEM_WIDTH = 30;
|
|
205
|
+
/**
|
|
206
|
+
* Plotly geometry with a band reserved for the legend in its position.
|
|
207
|
+
* Legend coordinates are paper-relative (fractions of the plot area), so
|
|
208
|
+
* they are derived from the margins this returns; `automargin` on the
|
|
209
|
+
* axes may still grow a margin further for unusually deep tick labels.
|
|
210
|
+
*/
|
|
211
|
+
export const reservePlotlyLegendBand = ({ position, visible, labels, legendFontSize = 12, width, height, hasTitle, titleFontSize = 16, hasBottomAxisTitle, hasLeftAxisTitle, leftAxisDepth, rightAxisDepth = 0, bottomAxisTitleStandoff = BAND_GAP, axisFontSize = 12, }) => {
|
|
212
|
+
const pos = visible ? (position ?? "right") : "none";
|
|
213
|
+
const horizontal = pos === "top" || pos === "bottom";
|
|
214
|
+
const band = pos === "none"
|
|
215
|
+
? { rows: 0, columns: 0, width: 0, height: 0 }
|
|
216
|
+
: estimateLegendBand({
|
|
217
|
+
labels,
|
|
218
|
+
fontSize: legendFontSize,
|
|
219
|
+
itemWidth: PLOTLY_LEGEND_ITEM_WIDTH,
|
|
220
|
+
itemGap: 5,
|
|
221
|
+
padding: 5,
|
|
222
|
+
orientation: horizontal ? "horizontal" : "vertical",
|
|
223
|
+
availableWidth: width,
|
|
224
|
+
availableHeight: height,
|
|
225
|
+
});
|
|
226
|
+
const titleBand = hasTitle
|
|
227
|
+
? EDGE_PAD + plotlyLineHeight(titleFontSize) + BAND_GAP
|
|
228
|
+
: 0;
|
|
229
|
+
// One row of bottom tick labels: plotly pads them ~3px off the axis.
|
|
230
|
+
const bottomLabelDepth = 3 + plotlyLineHeight(axisFontSize);
|
|
231
|
+
const bottomTitleBand = hasBottomAxisTitle
|
|
232
|
+
? bottomAxisTitleStandoff + plotlyLineHeight(axisFontSize)
|
|
233
|
+
: 0;
|
|
234
|
+
const bottomAxisDepth = bottomLabelDepth + bottomTitleBand;
|
|
235
|
+
const topHeadroom = Math.ceil(axisFontSize / 2) + 4;
|
|
236
|
+
let t = Math.max(EDGE_PAD + topHeadroom, titleBand + topHeadroom);
|
|
237
|
+
let b = bottomAxisDepth + EDGE_PAD;
|
|
238
|
+
let l = leftAxisDepth + (hasLeftAxisTitle ? BAND_GAP : 0) + EDGE_PAD;
|
|
239
|
+
let r = Math.max(EDGE_PAD + Math.ceil(axisFontSize / 2), rightAxisDepth + EDGE_PAD);
|
|
240
|
+
let legend = null;
|
|
241
|
+
switch (pos) {
|
|
242
|
+
case "top": {
|
|
243
|
+
t = (hasTitle ? titleBand : EDGE_PAD) + band.height + BAND_GAP + topHeadroom;
|
|
244
|
+
legend = { orientation: "h", x: 0.5, xanchor: "center", y: 1, yanchor: "bottom" };
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
case "bottom": {
|
|
248
|
+
b = bottomAxisDepth + BAND_GAP + band.height + EDGE_PAD;
|
|
249
|
+
legend = { orientation: "h", x: 0.5, xanchor: "center", y: 0, yanchor: "top" };
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
case "left": {
|
|
253
|
+
l = leftAxisDepth + BAND_GAP + band.width + EDGE_PAD;
|
|
254
|
+
legend = { orientation: "v", x: 0, xanchor: "right", y: 0.5, yanchor: "middle" };
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
case "right": {
|
|
258
|
+
r = rightAxisDepth + BAND_GAP + band.width + EDGE_PAD;
|
|
259
|
+
legend = { orientation: "v", x: 1, xanchor: "left", y: 0.5, yanchor: "middle" };
|
|
260
|
+
break;
|
|
261
|
+
}
|
|
262
|
+
case "topRight": {
|
|
263
|
+
r = rightAxisDepth + BAND_GAP + band.width + EDGE_PAD;
|
|
264
|
+
legend = { orientation: "v", x: 1, xanchor: "left", y: 1, yanchor: "top" };
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
default:
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
// Paper coordinates: offsets outside the plot area are fractions of it.
|
|
271
|
+
const plotWidth = Math.max(1, width - l - r);
|
|
272
|
+
const plotHeight = Math.max(1, height - t - b);
|
|
273
|
+
if (legend) {
|
|
274
|
+
switch (pos) {
|
|
275
|
+
case "top":
|
|
276
|
+
legend.y = 1 + (BAND_GAP + topHeadroom) / plotHeight;
|
|
277
|
+
break;
|
|
278
|
+
case "bottom":
|
|
279
|
+
legend.y = -(bottomAxisDepth + BAND_GAP) / plotHeight;
|
|
280
|
+
break;
|
|
281
|
+
case "left":
|
|
282
|
+
legend.x = -(leftAxisDepth + BAND_GAP) / plotWidth;
|
|
283
|
+
break;
|
|
284
|
+
case "right":
|
|
285
|
+
case "topRight":
|
|
286
|
+
legend.x = 1 + (rightAxisDepth + BAND_GAP) / plotWidth;
|
|
287
|
+
break;
|
|
288
|
+
default:
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
band,
|
|
294
|
+
margin: { l, r, t, b },
|
|
295
|
+
legend,
|
|
296
|
+
bottomAxisTitleStandoff,
|
|
297
|
+
title: { yref: "container", y: 1, yanchor: "top", pad: { t: EDGE_PAD } },
|
|
298
|
+
};
|
|
299
|
+
};
|
|
@@ -7,12 +7,24 @@
|
|
|
7
7
|
* so the regressions are hand-rolled here as pure least-squares math and
|
|
8
8
|
* the renderers draw the returned points as an overlay line series.
|
|
9
9
|
*
|
|
10
|
-
* All fits operate on
|
|
11
|
-
* category)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* All fits operate on Excel's category domain x = 1, 2, …, n (one step per
|
|
11
|
+
* category, first category = 1) — the domain Excel fits and prints for a
|
|
12
|
+
* category-axis chart (line, column, bar, area). Linear, polynomial and
|
|
13
|
+
* exponential curves are invariant to where the domain starts (only the
|
|
14
|
+
* equation text moves), but logarithmic and power fits are not: fitting
|
|
15
|
+
* them on x = 0, 1, … dropped the first category from the fit and evaluated
|
|
16
|
+
* ln(x) at x → 0 for it, which drew the Jan point of a 12-month log trend
|
|
17
|
+
* at about −524 instead of Excel's ≈6.4 (rowsncolumns/spreadsheet#1028).
|
|
18
|
+
*
|
|
19
|
+
* The emitted points keep the renderer-facing 0-based CATEGORY INDEX in `x`
|
|
20
|
+
* (echarts plots `[x, y]` on the category axis, plotly maps `x` onto the
|
|
21
|
+
* category labels), so a forecast of k periods still extends to index
|
|
22
|
+
* n−1+k forward and −k backward. The caller passes the raw y-values; gaps
|
|
23
|
+
* (null/NaN) are skipped during fitting but the fitted curve is still
|
|
24
|
+
* emitted for every x in range.
|
|
14
25
|
*/
|
|
15
26
|
export type TrendlineType = "linear" | "exp" | "log" | "poly" | "power" | "movingAvg";
|
|
27
|
+
/** `x` is the 0-based category index (−k … n−1+k with forecasting), `y` the fitted value. */
|
|
16
28
|
export type TrendlinePoint = {
|
|
17
29
|
x: number;
|
|
18
30
|
y: number;
|
|
@@ -28,7 +40,8 @@ export type TrendlineResult = {
|
|
|
28
40
|
/**
|
|
29
41
|
* Compute a trendline for a series of y-values.
|
|
30
42
|
*
|
|
31
|
-
* @param values Raw y-values; the category
|
|
43
|
+
* @param values Raw y-values; the 1-based category position is used as x
|
|
44
|
+
* for the fit, the 0-based index is reported in the points.
|
|
32
45
|
* @param spec Trendline parameters (type/order/period/forecast).
|
|
33
46
|
*/
|
|
34
47
|
export declare const computeTrendline: (values: (number | null | undefined)[], spec?: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trendline.d.ts","sourceRoot":"","sources":["../../../utils/trendline.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"trendline.d.ts","sourceRoot":"","sources":["../../../utils/trendline.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAEH,MAAM,MAAM,aAAa,GACrB,QAAQ,GACR,KAAK,GACL,KAAK,GACL,MAAM,GACN,OAAO,GACP,WAAW,CAAC;AAEhB,6FAA6F;AAC7F,MAAM,MAAM,cAAc,GAAG;IAAE,CAAC,EAAE,MAAM,CAAC;IAAC,CAAC,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD,MAAM,MAAM,eAAe,GAAG;IAC5B,sEAAsE;IACtE,MAAM,EAAE,cAAc,EAAE,CAAC;IACzB,uEAAuE;IACvE,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB,2DAA2D;IAC3D,QAAQ,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB,CAAC;AAsJF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,GAC3B,QAAQ,CAAC,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC,EAAE,EACrC,OAAM;IACJ,IAAI,CAAC,EAAE,aAAa,GAAG,IAAI,CAAC;IAC5B,KAAK,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACtB,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACvB,eAAe,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAChC,gBAAgB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;CAC7B,KACL,eAAe,GAAG,IAiGpB,CAAC"}
|
|
@@ -7,14 +7,26 @@
|
|
|
7
7
|
* so the regressions are hand-rolled here as pure least-squares math and
|
|
8
8
|
* the renderers draw the returned points as an overlay line series.
|
|
9
9
|
*
|
|
10
|
-
* All fits operate on
|
|
11
|
-
* category)
|
|
12
|
-
*
|
|
13
|
-
*
|
|
10
|
+
* All fits operate on Excel's category domain x = 1, 2, …, n (one step per
|
|
11
|
+
* category, first category = 1) — the domain Excel fits and prints for a
|
|
12
|
+
* category-axis chart (line, column, bar, area). Linear, polynomial and
|
|
13
|
+
* exponential curves are invariant to where the domain starts (only the
|
|
14
|
+
* equation text moves), but logarithmic and power fits are not: fitting
|
|
15
|
+
* them on x = 0, 1, … dropped the first category from the fit and evaluated
|
|
16
|
+
* ln(x) at x → 0 for it, which drew the Jan point of a 12-month log trend
|
|
17
|
+
* at about −524 instead of Excel's ≈6.4 (rowsncolumns/spreadsheet#1028).
|
|
18
|
+
*
|
|
19
|
+
* The emitted points keep the renderer-facing 0-based CATEGORY INDEX in `x`
|
|
20
|
+
* (echarts plots `[x, y]` on the category axis, plotly maps `x` onto the
|
|
21
|
+
* category labels), so a forecast of k periods still extends to index
|
|
22
|
+
* n−1+k forward and −k backward. The caller passes the raw y-values; gaps
|
|
23
|
+
* (null/NaN) are skipped during fitting but the fitted curve is still
|
|
24
|
+
* emitted for every x in range.
|
|
14
25
|
*/
|
|
15
26
|
const isFiniteNum = (v) => typeof v === "number" && Number.isFinite(v);
|
|
16
27
|
/**
|
|
17
|
-
* Build (x, y) pairs from raw y-values using
|
|
28
|
+
* Build (x, y) pairs from raw y-values using Excel's 1-based category
|
|
29
|
+
* position as x (the first category is x = 1, see the module comment).
|
|
18
30
|
* Non-finite y-values are dropped (they cannot be fitted).
|
|
19
31
|
*/
|
|
20
32
|
const toPairs = (values) => {
|
|
@@ -22,7 +34,7 @@ const toPairs = (values) => {
|
|
|
22
34
|
for (let i = 0; i < values.length; i++) {
|
|
23
35
|
const y = values[i];
|
|
24
36
|
if (isFiniteNum(y))
|
|
25
|
-
pairs.push({ x: i, y });
|
|
37
|
+
pairs.push({ x: i + 1, y });
|
|
26
38
|
}
|
|
27
39
|
return pairs;
|
|
28
40
|
};
|
|
@@ -48,6 +60,11 @@ const round = (n, digits = 4) => {
|
|
|
48
60
|
const f = 10 ** digits;
|
|
49
61
|
return Math.round(n * f) / f;
|
|
50
62
|
};
|
|
63
|
+
/** `+ 2` / `- 2` for an additive term, the way Excel prints an equation. */
|
|
64
|
+
const signed = (n) => {
|
|
65
|
+
const r = round(n);
|
|
66
|
+
return r < 0 ? `- ${Math.abs(r)}` : `+ ${r}`;
|
|
67
|
+
};
|
|
51
68
|
/** Solve a small linear system A·x = b via Gaussian elimination. */
|
|
52
69
|
const solveLinearSystem = (A, b) => {
|
|
53
70
|
const n = b.length;
|
|
@@ -157,7 +174,8 @@ const fitPower = (pairs) => {
|
|
|
157
174
|
/**
|
|
158
175
|
* Compute a trendline for a series of y-values.
|
|
159
176
|
*
|
|
160
|
-
* @param values Raw y-values; the category
|
|
177
|
+
* @param values Raw y-values; the 1-based category position is used as x
|
|
178
|
+
* for the fit, the 0-based index is reported in the points.
|
|
161
179
|
* @param spec Trendline parameters (type/order/period/forecast).
|
|
162
180
|
*/
|
|
163
181
|
export const computeTrendline = (values, spec = {}) => {
|
|
@@ -187,9 +205,9 @@ export const computeTrendline = (values, spec = {}) => {
|
|
|
187
205
|
}
|
|
188
206
|
return { points, rSquared: null, equation: null };
|
|
189
207
|
}
|
|
190
|
-
|
|
191
|
-
const xStart = -backward;
|
|
192
|
-
const xEnd =
|
|
208
|
+
// Fit domain is 1..n; a forecast of k periods extends it to 1−k / n+k.
|
|
209
|
+
const xStart = 1 - backward;
|
|
210
|
+
const xEnd = values.length + forward;
|
|
193
211
|
const xs = [];
|
|
194
212
|
for (let x = xStart; x <= xEnd; x++)
|
|
195
213
|
xs.push(x);
|
|
@@ -201,7 +219,7 @@ export const computeTrendline = (values, spec = {}) => {
|
|
|
201
219
|
if (!fit)
|
|
202
220
|
return null;
|
|
203
221
|
predict = (x) => fit.m * x + fit.b;
|
|
204
|
-
equation = `y = ${round(fit.m)}x
|
|
222
|
+
equation = `y = ${round(fit.m)}x ${signed(fit.b)}`;
|
|
205
223
|
break;
|
|
206
224
|
}
|
|
207
225
|
case "poly": {
|
|
@@ -212,8 +230,8 @@ export const computeTrendline = (values, spec = {}) => {
|
|
|
212
230
|
equation =
|
|
213
231
|
"y = " +
|
|
214
232
|
coeffs
|
|
215
|
-
.map((c, i) =>
|
|
216
|
-
.join("
|
|
233
|
+
.map((c, i) => i === 0 ? `${round(c)}` : `${signed(c)}x${i === 1 ? "" : `^${i}`}`)
|
|
234
|
+
.join(" ");
|
|
217
235
|
break;
|
|
218
236
|
}
|
|
219
237
|
case "exp": {
|
|
@@ -228,9 +246,10 @@ export const computeTrendline = (values, spec = {}) => {
|
|
|
228
246
|
const fit = fitLog(pairs);
|
|
229
247
|
if (!fit)
|
|
230
248
|
return null;
|
|
231
|
-
//
|
|
249
|
+
// Only a backward forecast reaches x ≤ 0 (the fit domain starts at 1);
|
|
250
|
+
// ln(0) and ln(negative) are undefined, so clamp x to >0 for the curve.
|
|
232
251
|
predict = (x) => fit.a + fit.b * Math.log(x <= 0 ? 1e-9 : x);
|
|
233
|
-
equation = `y = ${round(fit.
|
|
252
|
+
equation = `y = ${round(fit.b)}ln(x) ${signed(fit.a)}`;
|
|
234
253
|
break;
|
|
235
254
|
}
|
|
236
255
|
case "power": {
|
|
@@ -244,8 +263,9 @@ export const computeTrendline = (values, spec = {}) => {
|
|
|
244
263
|
default:
|
|
245
264
|
return null;
|
|
246
265
|
}
|
|
266
|
+
// Report the renderer-facing 0-based category index (see module comment).
|
|
247
267
|
const points = xs
|
|
248
|
-
.map((x) => ({ x, y: predict(x) }))
|
|
268
|
+
.map((x) => ({ x: x - 1, y: predict(x) }))
|
|
249
269
|
.filter((p) => Number.isFinite(p.y));
|
|
250
270
|
if (points.length < 2)
|
|
251
271
|
return null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rowsncolumns/charts",
|
|
3
|
-
"version": "28.1.
|
|
3
|
+
"version": "28.1.5",
|
|
4
4
|
"author": "Vinay M",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -67,19 +67,19 @@
|
|
|
67
67
|
},
|
|
68
68
|
"devDependencies": {
|
|
69
69
|
"@rowsncolumns/calculator": ">=7.3.1",
|
|
70
|
-
"@rowsncolumns/common-types": ">=5.3.
|
|
70
|
+
"@rowsncolumns/common-types": ">=5.3.2",
|
|
71
71
|
"@rowsncolumns/grid": ">=13.2.35",
|
|
72
72
|
"@rowsncolumns/icons": ">=7.2.33",
|
|
73
|
-
"@rowsncolumns/spreadsheet": ">=10.4.
|
|
74
|
-
"@rowsncolumns/spreadsheet-state": ">=29.1.
|
|
75
|
-
"@rowsncolumns/toolkit": ">=24.5.
|
|
76
|
-
"@rowsncolumns/ui": ">=16.0.
|
|
77
|
-
"@rowsncolumns/utils": ">=9.5.
|
|
73
|
+
"@rowsncolumns/spreadsheet": ">=10.4.5",
|
|
74
|
+
"@rowsncolumns/spreadsheet-state": ">=29.1.7",
|
|
75
|
+
"@rowsncolumns/toolkit": ">=24.5.6",
|
|
76
|
+
"@rowsncolumns/ui": ">=16.0.4",
|
|
77
|
+
"@rowsncolumns/utils": ">=9.5.4",
|
|
78
78
|
"@types/react-plotly.js": "^2.6.3",
|
|
79
79
|
"echarts": "^5.6.0",
|
|
80
80
|
"formik": "^2.4.6",
|
|
81
81
|
"immer": "^10.1.1",
|
|
82
|
-
"plotly.js": "^3.1.1",
|
|
82
|
+
"plotly.js-dist-min": "^3.1.1",
|
|
83
83
|
"react-plotly.js": "^2.6.0"
|
|
84
84
|
},
|
|
85
85
|
"peerDependencies": {
|
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"@rowsncolumns/utils": ">=9.3.0",
|
|
95
95
|
"immer": ">10.0.0",
|
|
96
96
|
"jotai": ">=2.10.3",
|
|
97
|
-
"plotly.js": ">=3",
|
|
97
|
+
"plotly.js-dist-min": ">=3",
|
|
98
98
|
"react": ">=18.0.0",
|
|
99
99
|
"react-dom": ">=18.0.0",
|
|
100
100
|
"react-plotly.js": ">=2"
|