@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.
Files changed (47) hide show
  1. package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
  2. package/dist/cjs/basic-chart/basic-chart.js +152 -88
  3. package/dist/cjs/basic-chart/helpers.d.ts +9 -0
  4. package/dist/cjs/basic-chart/helpers.d.ts.map +1 -1
  5. package/dist/cjs/basic-chart/helpers.js +55 -1
  6. package/dist/cjs/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  7. package/dist/cjs/plotly/basic-chart/basic-chart.js +225 -140
  8. package/dist/cjs/plotly/plot.d.ts +12 -0
  9. package/dist/cjs/plotly/plot.d.ts.map +1 -0
  10. package/dist/cjs/plotly/plot.js +8 -0
  11. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  12. package/dist/cjs/utils/axis-number-format.d.ts +14 -0
  13. package/dist/cjs/utils/axis-number-format.d.ts.map +1 -1
  14. package/dist/cjs/utils/axis-number-format.js +41 -1
  15. package/dist/cjs/utils/empty-cells.d.ts +9 -0
  16. package/dist/cjs/utils/empty-cells.d.ts.map +1 -1
  17. package/dist/cjs/utils/empty-cells.js +26 -1
  18. package/dist/cjs/utils/legend-layout.d.ts +168 -0
  19. package/dist/cjs/utils/legend-layout.d.ts.map +1 -0
  20. package/dist/cjs/utils/legend-layout.js +338 -0
  21. package/dist/cjs/utils/trendline.d.ts +18 -5
  22. package/dist/cjs/utils/trendline.d.ts.map +1 -1
  23. package/dist/cjs/utils/trendline.js +38 -16
  24. package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
  25. package/dist/esm/basic-chart/basic-chart.js +118 -47
  26. package/dist/esm/basic-chart/helpers.d.ts +9 -0
  27. package/dist/esm/basic-chart/helpers.d.ts.map +1 -1
  28. package/dist/esm/basic-chart/helpers.js +30 -0
  29. package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  30. package/dist/esm/plotly/basic-chart/basic-chart.js +140 -13
  31. package/dist/esm/plotly/plot.d.ts +12 -0
  32. package/dist/esm/plotly/plot.d.ts.map +1 -0
  33. package/dist/esm/plotly/plot.js +12 -0
  34. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  35. package/dist/esm/utils/axis-number-format.d.ts +14 -0
  36. package/dist/esm/utils/axis-number-format.d.ts.map +1 -1
  37. package/dist/esm/utils/axis-number-format.js +36 -0
  38. package/dist/esm/utils/empty-cells.d.ts +9 -0
  39. package/dist/esm/utils/empty-cells.d.ts.map +1 -1
  40. package/dist/esm/utils/empty-cells.js +21 -0
  41. package/dist/esm/utils/legend-layout.d.ts +168 -0
  42. package/dist/esm/utils/legend-layout.d.ts.map +1 -0
  43. package/dist/esm/utils/legend-layout.js +299 -0
  44. package/dist/esm/utils/trendline.d.ts +18 -5
  45. package/dist/esm/utils/trendline.d.ts.map +1 -1
  46. package/dist/esm/utils/trendline.js +36 -16
  47. 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 the index domain x = 0, 1, 2, … (one step per
11
- * category) which matches how a category-axis chart positions points.
12
- * The caller passes the raw y-values; gaps (null/NaN) are skipped during
13
- * fitting but the fitted curve is still emitted for every x in range.
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 index is used as x.
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;;;;;;;;;;;;;GAaG;AAEH,MAAM,MAAM,aAAa,GACrB,QAAQ,GACR,KAAK,GACL,KAAK,GACL,MAAM,GACN,OAAO,GACP,WAAW,CAAC;AAEhB,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;AA+IF;;;;;GAKG;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,IA6FpB,CAAC"}
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 the index domain x = 0, 1, 2, … (one step per
11
- * category) which matches how a category-axis chart positions points.
12
- * The caller passes the raw y-values; gaps (null/NaN) are skipped during
13
- * fitting but the fitted curve is still emitted for every x in range.
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 the category index as x.
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 index is used as x.
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
- const lastX = values.length - 1;
191
- const xStart = -backward;
192
- const xEnd = lastX + forward;
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 + ${round(fit.b)}`;
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) => (i === 0 ? `${round(c)}` : `${round(c)}x^${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
- // ln(0) and ln(negative) are undefined; clamp x to >0 for the curve.
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.a)} + ${round(fit.b)}ln(x)`;
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.2",
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.1",
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.2",
74
- "@rowsncolumns/spreadsheet-state": ">=29.1.4",
75
- "@rowsncolumns/toolkit": ">=24.5.4",
76
- "@rowsncolumns/ui": ">=16.0.3",
77
- "@rowsncolumns/utils": ">=9.5.1",
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"