@rowsncolumns/charts 28.1.2 → 28.1.4

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 (35) hide show
  1. package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
  2. package/dist/cjs/basic-chart/basic-chart.js +138 -76
  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 +218 -128
  8. package/dist/cjs/tsconfig.cjs.tsbuildinfo +1 -1
  9. package/dist/cjs/utils/axis-number-format.d.ts +14 -0
  10. package/dist/cjs/utils/axis-number-format.d.ts.map +1 -1
  11. package/dist/cjs/utils/axis-number-format.js +41 -1
  12. package/dist/cjs/utils/empty-cells.d.ts +9 -0
  13. package/dist/cjs/utils/empty-cells.d.ts.map +1 -1
  14. package/dist/cjs/utils/empty-cells.js +26 -1
  15. package/dist/cjs/utils/legend-layout.d.ts +168 -0
  16. package/dist/cjs/utils/legend-layout.d.ts.map +1 -0
  17. package/dist/cjs/utils/legend-layout.js +338 -0
  18. package/dist/esm/basic-chart/basic-chart.d.ts.map +1 -1
  19. package/dist/esm/basic-chart/basic-chart.js +96 -34
  20. package/dist/esm/basic-chart/helpers.d.ts +9 -0
  21. package/dist/esm/basic-chart/helpers.d.ts.map +1 -1
  22. package/dist/esm/basic-chart/helpers.js +30 -0
  23. package/dist/esm/plotly/basic-chart/basic-chart.d.ts.map +1 -1
  24. package/dist/esm/plotly/basic-chart/basic-chart.js +136 -6
  25. package/dist/esm/tsconfig.esm.tsbuildinfo +1 -1
  26. package/dist/esm/utils/axis-number-format.d.ts +14 -0
  27. package/dist/esm/utils/axis-number-format.d.ts.map +1 -1
  28. package/dist/esm/utils/axis-number-format.js +36 -0
  29. package/dist/esm/utils/empty-cells.d.ts +9 -0
  30. package/dist/esm/utils/empty-cells.d.ts.map +1 -1
  31. package/dist/esm/utils/empty-cells.js +21 -0
  32. package/dist/esm/utils/legend-layout.d.ts +168 -0
  33. package/dist/esm/utils/legend-layout.d.ts.map +1 -0
  34. package/dist/esm/utils/legend-layout.js +299 -0
  35. package/package.json +7 -7
@@ -0,0 +1,338 @@
1
+ "use strict";
2
+ // Legend band reservation (rowsncolumns/spreadsheet#986).
3
+ //
4
+ // Excel and Google Sheets never let the legend paint over the plot: for
5
+ // every legend position the chart reserves a band of its own — legend
6
+ // outermost, then the axis title, then the tick labels, then the plot
7
+ // area shrinks to whatever is left. Both renderers here used to pin the
8
+ // legend a fixed distance from the frame edge and leave the plot margin
9
+ // constant, so a bottom legend sat on the category labels and hid the
10
+ // horizontal axis title (and a top legend crowded the chart title).
11
+ //
12
+ // This module estimates the legend's rendered size from its actual items
13
+ // (count, label widths, font size, wrapping into rows / columns) and turns
14
+ // that into renderer geometry: the echarts `grid` + legend offsets +
15
+ // category-axis `nameGap`, and the plotly `margin` + paper-relative legend
16
+ // anchor. The estimate is deliberately generous — a few px of slack beats
17
+ // a legend on top of the labels.
18
+ Object.defineProperty(exports, "__esModule", { value: true });
19
+ exports.reservePlotlyLegendBand = exports.reserveEchartsLegendBand = exports.echartsTitleBand = exports.categoryAxisNameGap = exports.echartsAxisLabelDepth = exports.legendLabelsFrom = exports.estimateLegendBand = exports.measureTextWidth = exports.LEGEND_ITEM_GAP = exports.LEGEND_PADDING = exports.EDGE_PAD = exports.BAND_GAP = void 0;
20
+ var tslib_1 = require("tslib");
21
+ /** Space kept between neighbouring bands (legend ↔ axis title ↔ labels). */
22
+ exports.BAND_GAP = 8;
23
+ /** Inset of the outermost band from the chart frame. */
24
+ exports.EDGE_PAD = 10;
25
+ /** echarts `legend.padding` default used by both adapters' legend boxes. */
26
+ exports.LEGEND_PADDING = 10;
27
+ /** echarts `legend.itemGap` default. */
28
+ exports.LEGEND_ITEM_GAP = 10;
29
+ /** Gap echarts leaves between a legend icon and its text. */
30
+ var LEGEND_ICON_TEXT_GAP = 5;
31
+ /** Approximate rendered width of `text` at `fontSize` (canvas when available). */
32
+ var measureTextWidth = function (text, fontSize) {
33
+ if (typeof document !== "undefined") {
34
+ var ctx = document.createElement("canvas").getContext("2d");
35
+ if (ctx) {
36
+ ctx.font = "".concat(fontSize, "px sans-serif");
37
+ var width = ctx.measureText(text).width;
38
+ if (Number.isFinite(width) && width > 0)
39
+ return width;
40
+ }
41
+ }
42
+ // Headless fallback: ~0.6em per character.
43
+ return text.length * fontSize * 0.6;
44
+ };
45
+ exports.measureTextWidth = measureTextWidth;
46
+ /**
47
+ * Estimate the legend's rendered box from its items. Horizontal legends
48
+ * pack items greedily into rows no wider than the frame; vertical legends
49
+ * stack items and spill into a new column when taller than the frame.
50
+ */
51
+ var estimateLegendBand = function (_a) {
52
+ var e_1, _b;
53
+ var labels = _a.labels, _c = _a.fontSize, fontSize = _c === void 0 ? 12 : _c, _d = _a.itemWidth, itemWidth = _d === void 0 ? 10 : _d, _e = _a.itemGap, itemGap = _e === void 0 ? exports.LEGEND_ITEM_GAP : _e, _f = _a.padding, padding = _f === void 0 ? exports.LEGEND_PADDING : _f, orientation = _a.orientation, availableWidth = _a.availableWidth, availableHeight = _a.availableHeight;
54
+ if (!labels.length)
55
+ return { rows: 0, columns: 0, width: 0, height: 0 };
56
+ var lineHeight = Math.ceil(fontSize * 1.3);
57
+ var itemWidths = labels.map(function (label) {
58
+ return itemWidth + LEGEND_ICON_TEXT_GAP + Math.ceil((0, exports.measureTextWidth)(label, fontSize));
59
+ });
60
+ if (orientation === "horizontal") {
61
+ var maxRowWidth = Math.max(1, availableWidth - padding * 2);
62
+ var rows_1 = 1;
63
+ var rowWidth = 0;
64
+ var widestRow = 0;
65
+ try {
66
+ for (var itemWidths_1 = tslib_1.__values(itemWidths), itemWidths_1_1 = itemWidths_1.next(); !itemWidths_1_1.done; itemWidths_1_1 = itemWidths_1.next()) {
67
+ var w = itemWidths_1_1.value;
68
+ var next = rowWidth === 0 ? w : rowWidth + itemGap + w;
69
+ if (next > maxRowWidth && rowWidth > 0) {
70
+ widestRow = Math.max(widestRow, rowWidth);
71
+ rows_1 += 1;
72
+ rowWidth = w;
73
+ }
74
+ else {
75
+ rowWidth = next;
76
+ }
77
+ }
78
+ }
79
+ catch (e_1_1) { e_1 = { error: e_1_1 }; }
80
+ finally {
81
+ try {
82
+ if (itemWidths_1_1 && !itemWidths_1_1.done && (_b = itemWidths_1.return)) _b.call(itemWidths_1);
83
+ }
84
+ finally { if (e_1) throw e_1.error; }
85
+ }
86
+ widestRow = Math.max(widestRow, rowWidth);
87
+ return {
88
+ rows: rows_1,
89
+ columns: 1,
90
+ width: padding * 2 + widestRow,
91
+ height: padding * 2 + rows_1 * lineHeight + (rows_1 - 1) * itemGap,
92
+ };
93
+ }
94
+ var maxColumnHeight = Math.max(1, availableHeight - padding * 2);
95
+ var perColumn = Math.max(1, Math.floor((maxColumnHeight + itemGap) / (lineHeight + itemGap)));
96
+ var columns = Math.ceil(labels.length / perColumn);
97
+ var rows = Math.min(labels.length, perColumn);
98
+ var widestItem = Math.max.apply(Math, tslib_1.__spreadArray([], tslib_1.__read(itemWidths), false));
99
+ return {
100
+ rows: rows,
101
+ columns: columns,
102
+ width: padding * 2 + columns * widestItem + (columns - 1) * itemGap,
103
+ height: padding * 2 + rows * lineHeight + (rows - 1) * itemGap,
104
+ };
105
+ };
106
+ exports.estimateLegendBand = estimateLegendBand;
107
+ /**
108
+ * Pull the legend entry labels out of an echarts `legend.data` (strings or
109
+ * `{ name }` items); falls back to the series names, which is what echarts
110
+ * itself lists when `data` is empty.
111
+ */
112
+ var legendLabelsFrom = function (legendData, seriesNames) {
113
+ var fromData = (legendData !== null && legendData !== void 0 ? legendData : [])
114
+ .map(function (d) {
115
+ return typeof d === "string"
116
+ ? d
117
+ : d && typeof d === "object" && typeof d.name === "string"
118
+ ? d.name
119
+ : "";
120
+ })
121
+ .filter(function (s) { return s.length > 0; });
122
+ if (fromData.length)
123
+ return fromData;
124
+ return seriesNames
125
+ .map(function (n) { return (typeof n === "string" ? n : n === undefined || n === null ? "" : String(n)); })
126
+ .filter(function (s) { return s.length > 0; });
127
+ };
128
+ exports.legendLabelsFrom = legendLabelsFrom;
129
+ /**
130
+ * Depth (px) of one row of echarts axis tick labels measured from the axis
131
+ * line: 5px tick + 8px label margin + one 12px line, rounded up. With
132
+ * `grid.containLabel` this is the part the grid already accounts for; the
133
+ * axis title sits beyond it.
134
+ */
135
+ var echartsAxisLabelDepth = function (fontSize) {
136
+ if (fontSize === void 0) { fontSize = 12; }
137
+ return 5 + 8 + Math.ceil(fontSize * 1.1);
138
+ };
139
+ exports.echartsAxisLabelDepth = echartsAxisLabelDepth;
140
+ /**
141
+ * echarts `nameGap` for a category axis whose title sits in the middle
142
+ * below the tick labels: clear one row of labels plus a small gap, measured
143
+ * to the title's centre line.
144
+ */
145
+ var categoryAxisNameGap = function (fontSize) {
146
+ if (fontSize === void 0) { fontSize = 12; }
147
+ return (0, exports.echartsAxisLabelDepth)(fontSize) + exports.BAND_GAP / 2 + Math.ceil(fontSize / 2);
148
+ };
149
+ exports.categoryAxisNameGap = categoryAxisNameGap;
150
+ /** Height of the echarts chart-title band (padding 10 each side + one line). */
151
+ var echartsTitleBand = function (titleFontSize) {
152
+ if (titleFontSize === void 0) { titleFontSize = 16; }
153
+ return exports.LEGEND_PADDING * 2 + Math.ceil(titleFontSize * 1.25);
154
+ };
155
+ exports.echartsTitleBand = echartsTitleBand;
156
+ /**
157
+ * Echarts cartesian geometry with a band reserved for the legend in its
158
+ * position, outside the axis-title band, outside the tick labels.
159
+ */
160
+ var reserveEchartsLegendBand = function (_a) {
161
+ var position = _a.position, visible = _a.visible, labels = _a.labels, _b = _a.legendFontSize, legendFontSize = _b === void 0 ? 12 : _b, _c = _a.legendItemWidth, legendItemWidth = _c === void 0 ? 10 : _c, width = _a.width, height = _a.height, hasTitle = _a.hasTitle, _d = _a.titleFontSize, titleFontSize = _d === void 0 ? 16 : _d, hasBottomAxisTitle = _a.hasBottomAxisTitle, bottomAxisNameGap = _a.bottomAxisNameGap, hasLeftAxisTitle = _a.hasLeftAxisTitle, _e = _a.axisFontSize, axisFontSize = _e === void 0 ? 12 : _e;
162
+ var pos = visible ? (position !== null && position !== void 0 ? position : "top") : "none";
163
+ var horizontal = pos === "top" || pos === "bottom";
164
+ var band = pos === "none"
165
+ ? { rows: 0, columns: 0, width: 0, height: 0 }
166
+ : (0, exports.estimateLegendBand)({
167
+ labels: labels,
168
+ fontSize: legendFontSize,
169
+ itemWidth: legendItemWidth,
170
+ orientation: horizontal ? "horizontal" : "vertical",
171
+ availableWidth: width,
172
+ availableHeight: height,
173
+ });
174
+ var titleBand = hasTitle ? (0, exports.echartsTitleBand)(titleFontSize) : 0;
175
+ var labelDepth = (0, exports.echartsAxisLabelDepth)(axisFontSize);
176
+ // The bottom axis title's centre sits `nameGap` below the axis line and
177
+ // its line box extends half a font below that; the contained labels
178
+ // already cover `labelDepth` of it.
179
+ var bottomTitleOverhang = hasBottomAxisTitle
180
+ ? Math.max(0, bottomAxisNameGap + Math.ceil(axisFontSize / 2) - labelDepth) + exports.BAND_GAP
181
+ : 0;
182
+ // The rotated left axis title clears its labels by `fontSize + 6` (see
183
+ // valueAxisNameGap) and its own line box extends half a font further.
184
+ var leftTitleOverhang = hasLeftAxisTitle
185
+ ? Math.ceil(axisFontSize * 1.5) + 6 + exports.BAND_GAP / 2
186
+ : 0;
187
+ // Half a tick label pokes past the top / right end of an axis.
188
+ var topHeadroom = Math.ceil(axisFontSize / 2) + 4;
189
+ var rightHeadroom = Math.ceil(axisFontSize / 2) + 10;
190
+ var legend = {};
191
+ var top = (hasTitle ? titleBand : exports.EDGE_PAD) + topHeadroom;
192
+ var bottom = exports.EDGE_PAD + bottomTitleOverhang;
193
+ var left = exports.EDGE_PAD + leftTitleOverhang;
194
+ var right = rightHeadroom;
195
+ switch (pos) {
196
+ case "top": {
197
+ legend.top = hasTitle ? titleBand : exports.EDGE_PAD;
198
+ legend.bottom = undefined;
199
+ top = legend.top + band.height + exports.BAND_GAP + topHeadroom;
200
+ break;
201
+ }
202
+ case "bottom": {
203
+ legend.bottom = exports.EDGE_PAD;
204
+ legend.top = undefined;
205
+ bottom = exports.EDGE_PAD + band.height + exports.BAND_GAP + bottomTitleOverhang;
206
+ break;
207
+ }
208
+ case "left": {
209
+ legend.left = exports.EDGE_PAD;
210
+ legend.right = undefined;
211
+ left = exports.EDGE_PAD + band.width + exports.BAND_GAP + leftTitleOverhang;
212
+ break;
213
+ }
214
+ case "right": {
215
+ legend.right = exports.EDGE_PAD;
216
+ legend.left = undefined;
217
+ right = exports.EDGE_PAD + band.width + exports.BAND_GAP;
218
+ break;
219
+ }
220
+ case "topRight": {
221
+ legend.right = exports.EDGE_PAD;
222
+ legend.left = undefined;
223
+ legend.top = hasTitle ? titleBand : exports.EDGE_PAD;
224
+ legend.bottom = undefined;
225
+ right = exports.EDGE_PAD + band.width + exports.BAND_GAP;
226
+ break;
227
+ }
228
+ default:
229
+ break;
230
+ }
231
+ return {
232
+ band: band,
233
+ grid: { left: left, right: right, top: top, bottom: bottom, containLabel: true },
234
+ legend: legend,
235
+ };
236
+ };
237
+ exports.reserveEchartsLegendBand = reserveEchartsLegendBand;
238
+ /** Plotly text line height (its LINE_SPACING is 1.3em). */
239
+ var plotlyLineHeight = function (fontSize) { return Math.ceil(fontSize * 1.3); };
240
+ /** Plotly default `legend.itemwidth`. */
241
+ var PLOTLY_LEGEND_ITEM_WIDTH = 30;
242
+ /**
243
+ * Plotly geometry with a band reserved for the legend in its position.
244
+ * Legend coordinates are paper-relative (fractions of the plot area), so
245
+ * they are derived from the margins this returns; `automargin` on the
246
+ * axes may still grow a margin further for unusually deep tick labels.
247
+ */
248
+ var reservePlotlyLegendBand = function (_a) {
249
+ var position = _a.position, visible = _a.visible, labels = _a.labels, _b = _a.legendFontSize, legendFontSize = _b === void 0 ? 12 : _b, width = _a.width, height = _a.height, hasTitle = _a.hasTitle, _c = _a.titleFontSize, titleFontSize = _c === void 0 ? 16 : _c, hasBottomAxisTitle = _a.hasBottomAxisTitle, hasLeftAxisTitle = _a.hasLeftAxisTitle, leftAxisDepth = _a.leftAxisDepth, _d = _a.rightAxisDepth, rightAxisDepth = _d === void 0 ? 0 : _d, _e = _a.bottomAxisTitleStandoff, bottomAxisTitleStandoff = _e === void 0 ? exports.BAND_GAP : _e, _f = _a.axisFontSize, axisFontSize = _f === void 0 ? 12 : _f;
250
+ var pos = visible ? (position !== null && position !== void 0 ? position : "right") : "none";
251
+ var horizontal = pos === "top" || pos === "bottom";
252
+ var band = pos === "none"
253
+ ? { rows: 0, columns: 0, width: 0, height: 0 }
254
+ : (0, exports.estimateLegendBand)({
255
+ labels: labels,
256
+ fontSize: legendFontSize,
257
+ itemWidth: PLOTLY_LEGEND_ITEM_WIDTH,
258
+ itemGap: 5,
259
+ padding: 5,
260
+ orientation: horizontal ? "horizontal" : "vertical",
261
+ availableWidth: width,
262
+ availableHeight: height,
263
+ });
264
+ var titleBand = hasTitle
265
+ ? exports.EDGE_PAD + plotlyLineHeight(titleFontSize) + exports.BAND_GAP
266
+ : 0;
267
+ // One row of bottom tick labels: plotly pads them ~3px off the axis.
268
+ var bottomLabelDepth = 3 + plotlyLineHeight(axisFontSize);
269
+ var bottomTitleBand = hasBottomAxisTitle
270
+ ? bottomAxisTitleStandoff + plotlyLineHeight(axisFontSize)
271
+ : 0;
272
+ var bottomAxisDepth = bottomLabelDepth + bottomTitleBand;
273
+ var topHeadroom = Math.ceil(axisFontSize / 2) + 4;
274
+ var t = Math.max(exports.EDGE_PAD + topHeadroom, titleBand + topHeadroom);
275
+ var b = bottomAxisDepth + exports.EDGE_PAD;
276
+ var l = leftAxisDepth + (hasLeftAxisTitle ? exports.BAND_GAP : 0) + exports.EDGE_PAD;
277
+ var r = Math.max(exports.EDGE_PAD + Math.ceil(axisFontSize / 2), rightAxisDepth + exports.EDGE_PAD);
278
+ var legend = null;
279
+ switch (pos) {
280
+ case "top": {
281
+ t = (hasTitle ? titleBand : exports.EDGE_PAD) + band.height + exports.BAND_GAP + topHeadroom;
282
+ legend = { orientation: "h", x: 0.5, xanchor: "center", y: 1, yanchor: "bottom" };
283
+ break;
284
+ }
285
+ case "bottom": {
286
+ b = bottomAxisDepth + exports.BAND_GAP + band.height + exports.EDGE_PAD;
287
+ legend = { orientation: "h", x: 0.5, xanchor: "center", y: 0, yanchor: "top" };
288
+ break;
289
+ }
290
+ case "left": {
291
+ l = leftAxisDepth + exports.BAND_GAP + band.width + exports.EDGE_PAD;
292
+ legend = { orientation: "v", x: 0, xanchor: "right", y: 0.5, yanchor: "middle" };
293
+ break;
294
+ }
295
+ case "right": {
296
+ r = rightAxisDepth + exports.BAND_GAP + band.width + exports.EDGE_PAD;
297
+ legend = { orientation: "v", x: 1, xanchor: "left", y: 0.5, yanchor: "middle" };
298
+ break;
299
+ }
300
+ case "topRight": {
301
+ r = rightAxisDepth + exports.BAND_GAP + band.width + exports.EDGE_PAD;
302
+ legend = { orientation: "v", x: 1, xanchor: "left", y: 1, yanchor: "top" };
303
+ break;
304
+ }
305
+ default:
306
+ break;
307
+ }
308
+ // Paper coordinates: offsets outside the plot area are fractions of it.
309
+ var plotWidth = Math.max(1, width - l - r);
310
+ var plotHeight = Math.max(1, height - t - b);
311
+ if (legend) {
312
+ switch (pos) {
313
+ case "top":
314
+ legend.y = 1 + (exports.BAND_GAP + topHeadroom) / plotHeight;
315
+ break;
316
+ case "bottom":
317
+ legend.y = -(bottomAxisDepth + exports.BAND_GAP) / plotHeight;
318
+ break;
319
+ case "left":
320
+ legend.x = -(leftAxisDepth + exports.BAND_GAP) / plotWidth;
321
+ break;
322
+ case "right":
323
+ case "topRight":
324
+ legend.x = 1 + (rightAxisDepth + exports.BAND_GAP) / plotWidth;
325
+ break;
326
+ default:
327
+ break;
328
+ }
329
+ }
330
+ return {
331
+ band: band,
332
+ margin: { l: l, r: r, t: t, b: b },
333
+ legend: legend,
334
+ bottomAxisTitleStandoff: bottomAxisTitleStandoff,
335
+ title: { yref: "container", y: 1, yanchor: "top", pad: { t: exports.EDGE_PAD } },
336
+ };
337
+ };
338
+ exports.reservePlotlyLegendBand = reservePlotlyLegendBand;
@@ -1 +1 @@
1
- {"version":3,"file":"basic-chart.d.ts","sourceRoot":"","sources":["../../../basic-chart/basic-chart.tsx"],"names":[],"mappings":"AA4DA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAEtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AA0PrD,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC;AAkEnD,eAAO,MAAM,UAAU,GAAI,wOAiBxB,eAAe,sBAmuGjB,CAAC"}
1
+ {"version":3,"file":"basic-chart.d.ts","sourceRoot":"","sources":["../../../basic-chart/basic-chart.tsx"],"names":[],"mappings":"AA4DA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAEtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAmQrD,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC;AAqEnD,eAAO,MAAM,UAAU,GAAI,wOAiBxB,eAAe,sBAwyGjB,CAAC"}
@@ -10,7 +10,7 @@ import * as echarts from "echarts/core";
10
10
  import { CanvasRenderer } from "echarts/renderers";
11
11
  import React, { useCallback, useMemo, useRef, useState } from "react";
12
12
  import { aggregateSeriesByDomain, getDomainValuesForAxis, sanitizeDomainValues, shouldAggregateSeries, } from "../utils/aggregation";
13
- import { circularTooltipFormatter, formatAxisValue, resolveSeriesNumberFormats, resolveSourceNumberFormat, resolveValueAxisNumberFormat, tooltipValueFormatter, valueAxisLabelFormatter, } from "../utils/axis-number-format";
13
+ import { circularTooltipFormatter, formatAxisValue, resolveSeriesNumberFormats, resolveSourceNumberFormat, resolveValueAxisNumberFormat, tooltipValueFormatter, valueAxisLabelFormatter, valueAxisNameGap, } from "../utils/axis-number-format";
14
14
  import { gapWidthToBarCategoryGap, overlapToBarGap, } from "../utils/bar-layout";
15
15
  import { computeBoxplotSummary } from "../utils/boxplot";
16
16
  import { createBubbleSizeScale, prepareBubbleSeriesData, } from "../utils/bubble";
@@ -18,13 +18,14 @@ import { coerceCalculatedLabel, getChartDataRanges, } from "../utils/chart-spec-
18
18
  import { canSwitchRowColumn, cellLabelGetterFromRangeReader, switchRowColumn, } from "../utils/chart-orientation";
19
19
  import { dataUrlToBlob } from "../utils/chart-image";
20
20
  import { getChartPalette } from "../utils/chart-styles";
21
- import { applyEmptyCellsPolicy, connectsAcrossEmptyCells, isLineLikeChartType, } from "../utils/empty-cells";
21
+ import { applyEmptyCellsPolicy, connectsAcrossEmptyCells, isolatedPointIndexes, isLineLikeChartType, } from "../utils/empty-cells";
22
22
  import { getEchartsGlState, loadEchartsGl, } from "../utils/echarts-gl-loader";
23
23
  import { computeErrorBars, resolveCustomErrorBarValues, } from "../utils/error-bars";
24
24
  import { echartsGroupedCategoryAxes, resolveCategoryLevels, } from "../utils/category-levels";
25
25
  import { gradientToEcharts } from "../utils/gradient";
26
26
  import { buildHierarchyTree, extractHierarchyRows, getHierarchyHeaderTitle, } from "../utils/hierarchy";
27
27
  import { binHistogram, computePareto, isParetoSpec, } from "../utils/histogram";
28
+ import { categoryAxisNameGap, legendLabelsFrom, reserveEchartsLegendBand, } from "../utils/legend-layout";
28
29
  import { clampSecondPieSize, isOfPieChartSpec, otherSliceHalfAngle, resolveOfPieType, splitOfPie, } from "../utils/of-pie";
29
30
  import { applyAxisOverrides, applyLegendOverrides, buildSeriesLabelOption, } from "../utils/overrides";
30
31
  import { buildRadarIndicators, buildRadarSeriesData } from "../utils/radar";
@@ -37,7 +38,7 @@ import { buildBar3DData, buildContourData, buildLine3DSeries, buildSurfaceData,
37
38
  import { computeTrendline } from "../utils/trendline";
38
39
  import { computeWaterfall } from "../utils/waterfall";
39
40
  import { isWebGLAvailable } from "../utils/webgl";
40
- import { chartTypeToEchartsType, getEchartsSeriesStyle, mapMarkerSymbolToEcharts, showXAxis, } from "./helpers";
41
+ import { chartTypeToEchartsType, circularLegendEntries, getEchartsSeriesStyle, isCircularChartType, mapMarkerSymbolToEcharts, showXAxis, } from "./helpers";
41
42
  // Excel error values that should be treated as null in charts
42
43
  const EXCEL_ERROR_VALUES = new Set([
43
44
  "#N/A",
@@ -180,6 +181,8 @@ const defaultLegendConfig = {
180
181
  itemHeight: 4,
181
182
  top: 20,
182
183
  };
184
+ /** Category-axis title gap: clears one row of tick labels (#986). */
185
+ const CATEGORY_AXIS_NAME_GAP = categoryAxisNameGap();
183
186
  export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, readonly, getDomainValuesFromRange, getSeriesValuesFromRange, getFormattingFromRange, onDeleteChart, onUpdateChart, onRequestEdit, onRequestCalculate, isSelected, fieldButtons, }) => {
184
187
  const spec = chart.spec;
185
188
  const explicitChartTitle = chart.spec.title ?? undefined;
@@ -306,7 +309,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
306
309
  rawDomainValues.length === seriesValues.length;
307
310
  if (canAggregate) {
308
311
  const aggregatedValues = aggregateSeriesByDomain(rawDomainValues, seriesValues, aggregateType, orderedDomainValues);
309
- if (spec.chartType === "pie") {
312
+ if (isCircularChartType(spec.chartType)) {
310
313
  return orderedDomainValues.map((name, idx) => ({
311
314
  value: aggregatedValues[idx] ?? 0,
312
315
  name,
@@ -315,7 +318,9 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
315
318
  return aggregatedValues;
316
319
  }
317
320
  const numericValues = seriesValues;
318
- if (spec.chartType === "pie") {
321
+ // Pie and doughnut points are slices keyed by their category name;
322
+ // a doughnut is a pie series with a hole, so it labels the same way.
323
+ if (isCircularChartType(spec.chartType)) {
319
324
  if (options.pieLabels) {
320
325
  const { domainLabels, offset } = options.pieLabels;
321
326
  const labels = offset === undefined
@@ -1458,7 +1463,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1458
1463
  ? spec.verticalAxisTitle ?? ""
1459
1464
  : spec.horizontalAxisTitle ?? "",
1460
1465
  nameLocation: "middle",
1461
- nameGap: 30 + 24 * categoryLevels.outer.length,
1466
+ nameGap: CATEGORY_AXIS_NAME_GAP + 24 * categoryLevels.outer.length,
1462
1467
  }));
1463
1468
  }
1464
1469
  // One category axis per domain (Excel): a domain drawn from several
@@ -1485,7 +1490,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1485
1490
  splitLine: { show: !isCircularChart },
1486
1491
  name: axisTitle,
1487
1492
  nameLocation: "middle",
1488
- nameGap: 30,
1493
+ nameGap: CATEGORY_AXIS_NAME_GAP,
1489
1494
  });
1490
1495
  });
1491
1496
  if (chartDomains.length === 0 && !isCircularChart) {
@@ -1500,7 +1505,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1500
1505
  ? spec.verticalAxisTitle ?? ""
1501
1506
  : spec.horizontalAxisTitle ?? "",
1502
1507
  nameLocation: "middle",
1503
- nameGap: 30,
1508
+ nameGap: CATEGORY_AXIS_NAME_GAP,
1504
1509
  });
1505
1510
  }
1506
1511
  // Trendline + error-bar overlays are extra series appended after the
@@ -1689,7 +1694,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1689
1694
  // domain range's labels in sequence, each series range taking its own
1690
1695
  // window — not the primary domain range alone (C-78).
1691
1696
  const domainSources = spec.domains?.[0]?.sources ?? [];
1692
- const pieDomainLabels = spec.chartType === "pie" &&
1697
+ const pieDomainLabels = isCircularChart &&
1693
1698
  (domainSources.length > 1 || series.sources.length > 1)
1694
1699
  ? domainSources.flatMap((sheetRange) => getDomainValuesFromRange(sheetRange, spec.hiddenDimensionStrategy) ?? [])
1695
1700
  : undefined;
@@ -1766,6 +1771,22 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1766
1771
  mapMarkerSymbolToEcharts(markerSymbol) ?? "circle";
1767
1772
  seriesEntry.symbolSize = seriesAny.marker?.size ?? 6;
1768
1773
  }
1774
+ else if (Array.isArray(data)) {
1775
+ // A marker-less line draws nothing for a point with a gap on both
1776
+ // sides (a data row followed by a blank row), so that row vanishes
1777
+ // from the chart. Excel plots an isolated marker there: keep the
1778
+ // series symbol-less and mark only those points.
1779
+ const isolated = isolatedPointIndexes(data, connectsAcrossEmptyCells(spec, lineLike));
1780
+ if (isolated.length) {
1781
+ seriesEntry.showSymbol = true;
1782
+ seriesEntry.symbol = "none";
1783
+ const marked = [...data];
1784
+ for (const i of isolated) {
1785
+ marked[i] = { value: marked[i], symbol: "circle", symbolSize: 6 };
1786
+ }
1787
+ seriesEntry.data = marked;
1788
+ }
1789
+ }
1769
1790
  }
1770
1791
  // Gradient fill — overrides solid color when present. echarts
1771
1792
  // paints it natively via a LinearGradient on itemStyle.color
@@ -1876,6 +1897,12 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
1876
1897
  chartSeries.push(seriesEntry);
1877
1898
  pushSeriesOverlays(series, data, dataLabel);
1878
1899
  }
1900
+ if (isCircularChart && !isOfPie) {
1901
+ // Excel's pie / doughnut legend lists the categories, one entry per
1902
+ // slice — not the series name (the loop above collected those). The
1903
+ // of-pie branch below builds its own category legend.
1904
+ legend.data = circularLegendEntries(chartSeries);
1905
+ }
1879
1906
  if (isOfPie) {
1880
1907
  // Pie-of-pie / bar-of-pie — Excel splits the minor slices into a
1881
1908
  // secondary pie or stacked bar beside the main pie, replaces them
@@ -2298,27 +2325,6 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2298
2325
  previousSeriesCount.current !== renderedSeriesCount
2299
2326
  ? ["series", "xAxis", "yAxis", "legend", "grid", "tooltip"]
2300
2327
  : [];
2301
- // Of-pie uses a full-container grid (for the connector overlay's
2302
- // hidden 0-1 axes) plus, for bar-of-pie, a second grid hosting the
2303
- // stacked secondary bar.
2304
- const gridConfig = isOfPie
2305
- ? [
2306
- { left: 0, right: 0, top: 0, bottom: 0 },
2307
- ...(ofPieLayout?.barGrid ? [ofPieLayout.barGrid] : []),
2308
- ]
2309
- : isCircularChart
2310
- ? {
2311
- left: 0,
2312
- right: 0,
2313
- top: 0,
2314
- bottom: 0,
2315
- }
2316
- : {
2317
- left: "3%",
2318
- right: "4%",
2319
- bottom: "3%",
2320
- containLabel: true,
2321
- };
2322
2328
  const valueAxisBase = {
2323
2329
  type: "value",
2324
2330
  show: !isCircularChart,
@@ -2380,6 +2386,49 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2380
2386
  };
2381
2387
  // Phase 5.1: apply legend / axis / dataLabels / doughnut overrides.
2382
2388
  const finalLegend = applyLegendOverrides(legend, spec.legend);
2389
+ // Reserve a band for the legend outside the axis-title band, outside
2390
+ // the tick labels — Excel shrinks the plot instead of painting the
2391
+ // legend over the category labels / axis title (#986). The bottom
2392
+ // axis is the value axis on a horizontal bar chart, else the (first)
2393
+ // category axis with whatever nameGap its levels needed.
2394
+ const bottomAxisNameGap = isHorizontalBarChart
2395
+ ? valueAxisBase.nameGap
2396
+ : Number(chartDomains[0]?.nameGap ?? CATEGORY_AXIS_NAME_GAP);
2397
+ const legendBand = reserveEchartsLegendBand({
2398
+ position: spec.legend?.position,
2399
+ visible: finalLegend.show !== false,
2400
+ labels: legendLabelsFrom(finalLegend.data, chartSeries.map((s) => s?.name)),
2401
+ legendFontSize: finalLegend.textStyle?.fontSize ??
2402
+ spec.legend?.textFormat?.fontSize ??
2403
+ 12,
2404
+ legendItemWidth: defaultLegendConfig.itemWidth,
2405
+ width: width ?? 0,
2406
+ height: height ?? 0,
2407
+ hasTitle: Boolean(title),
2408
+ titleFontSize: spec.titleTextFormat?.fontSize ?? 16,
2409
+ hasBottomAxisTitle: !isCircularChart && Boolean(spec.horizontalAxisTitle),
2410
+ bottomAxisNameGap,
2411
+ hasLeftAxisTitle: !isCircularChart && Boolean(spec.verticalAxisTitle),
2412
+ });
2413
+ const positionedLegend = isCircularChart
2414
+ ? finalLegend
2415
+ : { ...finalLegend, ...legendBand.legend };
2416
+ // Of-pie uses a full-container grid (for the connector overlay's
2417
+ // hidden 0-1 axes) plus, for bar-of-pie, a second grid hosting the
2418
+ // stacked secondary bar.
2419
+ const gridConfig = isOfPie
2420
+ ? [
2421
+ { left: 0, right: 0, top: 0, bottom: 0 },
2422
+ ...(ofPieLayout?.barGrid ? [ofPieLayout.barGrid] : []),
2423
+ ]
2424
+ : isCircularChart
2425
+ ? {
2426
+ left: 0,
2427
+ right: 0,
2428
+ top: 0,
2429
+ bottom: 0,
2430
+ }
2431
+ : legendBand.grid;
2383
2432
  // Data labels fall back to the series' axis format when the spec has
2384
2433
  // no explicit `<c:dLbls><c:numFmt>` — Excel's "Linked to source".
2385
2434
  const dataLabelOptions = spec.dataLabels;
@@ -2540,7 +2589,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2540
2589
  splitLine: { show: true },
2541
2590
  name: spec.horizontalAxisTitle ?? "",
2542
2591
  nameLocation: "middle",
2543
- nameGap: 30,
2592
+ nameGap: CATEGORY_AXIS_NAME_GAP,
2544
2593
  },
2545
2594
  ];
2546
2595
  return base.map((axis) => applyAxisOverrides(axis, spec.categoryAxisOptions));
@@ -2595,13 +2644,26 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2595
2644
  splitLine: { show: true },
2596
2645
  name: spec.verticalAxisTitle ?? "",
2597
2646
  nameLocation: "middle",
2598
- nameGap: 30,
2647
+ nameGap: CATEGORY_AXIS_NAME_GAP,
2599
2648
  },
2600
2649
  ];
2601
2650
  return base.map((axis) => applyAxisOverrides(axis, spec.categoryAxisOptions));
2602
2651
  }
2603
2652
  const axes = [
2604
- applyAxisOverrides({ ...valueAxisBase, name: spec.verticalAxisTitle ?? "" }, spec.valueAxisOptions, primaryAxisNumberFormat),
2653
+ applyAxisOverrides({
2654
+ ...valueAxisBase,
2655
+ name: spec.verticalAxisTitle ?? "",
2656
+ // Clear the tick labels: "$2,000.00" is wider than the 50px
2657
+ // default gap, and the title painted over its own labels.
2658
+ nameGap: valueAxisNameGap({
2659
+ values: chartSeries
2660
+ .filter((s) => s?.yAxisIndex !== 1)
2661
+ .flatMap((s) => Array.isArray(s?.data)
2662
+ ? s.data.map((d) => d !== null && typeof d === "object" ? d.value : d)
2663
+ : []),
2664
+ pattern: primaryAxisNumberFormat,
2665
+ }),
2666
+ }, spec.valueAxisOptions, primaryAxisNumberFormat),
2605
2667
  ];
2606
2668
  if (hasSecondaryAxis) {
2607
2669
  axes.push(applyAxisOverrides({
@@ -2621,7 +2683,7 @@ export const BasicChart = ({ width, height, chart, theme, isDarkMode, sheetId, r
2621
2683
  return axes;
2622
2684
  })(),
2623
2685
  series: seriesWithLabels,
2624
- legend: finalLegend,
2686
+ legend: positionedLegend,
2625
2687
  grid: gridConfig,
2626
2688
  tooltip: tooltipConfig,
2627
2689
  }, { replaceMerge, lazyUpdate: true });
@@ -5,6 +5,15 @@ export declare const chartTypeToEchartsType: (type: EmbeddedChart["spec"]["chart
5
5
  * Returns undefined for "auto" / unknown — caller defaults to "circle".
6
6
  */
7
7
  export declare const mapMarkerSymbolToEcharts: (symbol: string | undefined | null) => string | undefined;
8
+ /** Pie and doughnut — the pie-family types whose points are slices. */
9
+ export declare const isCircularChartType: (type: EmbeddedChart["spec"]["chartType"] | undefined) => boolean;
10
+ /**
11
+ * Legend entries of a pie / doughnut: the slice names (the categories), in
12
+ * order, each once across every pie series — Excel's pie legend lists the
13
+ * categories, not the series name. echarts keys pie legend items by data
14
+ * item name, so an entry toggles that slice in every series.
15
+ */
16
+ export declare const circularLegendEntries: (series: ReadonlyArray<unknown>) => string[];
8
17
  export declare const getEchartsSeriesStyle: (type: EmbeddedChart["spec"]["chartType"]) => {
9
18
  areaStyle: {};
10
19
  } | undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../basic-chart/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D,eAAO,MAAM,sBAAsB,GACjC,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,yGA6DzC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,GACnC,QAAQ,MAAM,GAAG,SAAS,GAAG,IAAI,KAChC,MAAM,GAAG,SAuBX,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC;;aASzC,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,YAejE,CAAC"}
1
+ {"version":3,"file":"helpers.d.ts","sourceRoot":"","sources":["../../../basic-chart/helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D,eAAO,MAAM,sBAAsB,GACjC,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,yGA6DzC,CAAC;AAEF;;;GAGG;AACH,eAAO,MAAM,wBAAwB,GACnC,QAAQ,MAAM,GAAG,SAAS,GAAG,IAAI,KAChC,MAAM,GAAG,SAuBX,CAAC;AAEF,uEAAuE;AACvE,eAAO,MAAM,mBAAmB,GAC9B,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,GAAG,SAAS,KACnD,OAAgD,CAAC;AAEpD;;;;;GAKG;AACH,eAAO,MAAM,qBAAqB,GAChC,QAAQ,aAAa,CAAC,OAAO,CAAC,KAC7B,MAAM,EAmBR,CAAC;AAEF,eAAO,MAAM,qBAAqB,GAChC,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC;;aASzC,CAAC;AAEF,eAAO,MAAM,SAAS,GAAI,MAAM,aAAa,CAAC,MAAM,CAAC,CAAC,WAAW,CAAC,YAejE,CAAC"}
@@ -74,6 +74,36 @@ export const mapMarkerSymbolToEcharts = (symbol) => {
74
74
  return undefined;
75
75
  }
76
76
  };
77
+ /** Pie and doughnut — the pie-family types whose points are slices. */
78
+ export const isCircularChartType = (type) => type === "pie" || type === "doughnut";
79
+ /**
80
+ * Legend entries of a pie / doughnut: the slice names (the categories), in
81
+ * order, each once across every pie series — Excel's pie legend lists the
82
+ * categories, not the series name. echarts keys pie legend items by data
83
+ * item name, so an entry toggles that slice in every series.
84
+ */
85
+ export const circularLegendEntries = (series) => {
86
+ const seen = new Set();
87
+ const entries = [];
88
+ for (const raw of series) {
89
+ const entry = raw;
90
+ if (entry?.type !== "pie" || !Array.isArray(entry.data))
91
+ continue;
92
+ for (const item of entry.data) {
93
+ const name = item !== null && typeof item === "object" && "name" in item
94
+ ? item.name
95
+ : undefined;
96
+ if (name === undefined || name === null)
97
+ continue;
98
+ const label = String(name);
99
+ if (label === "" || seen.has(label))
100
+ continue;
101
+ seen.add(label);
102
+ entries.push(label);
103
+ }
104
+ }
105
+ return entries;
106
+ };
77
107
  export const getEchartsSeriesStyle = (type) => {
78
108
  switch (type) {
79
109
  case "area":
@@ -1 +1 @@
1
- {"version":3,"file":"basic-chart.d.ts","sourceRoot":"","sources":["../../../../plotly/basic-chart/basic-chart.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAGtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA4VxD,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC;AA0FnD,eAAO,MAAM,UAAU,GAAI,wOAiBxB,eAAe,sBAmrEjB,CAAC"}
1
+ {"version":3,"file":"basic-chart.d.ts","sourceRoot":"","sources":["../../../../plotly/basic-chart/basic-chart.tsx"],"names":[],"mappings":"AA6BA,OAAO,KAAiD,MAAM,OAAO,CAAC;AAGtE,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AA+WxD,MAAM,MAAM,eAAe,GAAG,oBAAoB,CAAC;AA0FnD,eAAO,MAAM,UAAU,GAAI,wOAiBxB,eAAe,sBAg0EjB,CAAC"}