@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.
- package/dist/cjs/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/cjs/basic-chart/basic-chart.js +138 -76
- 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 +218 -128
- 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/esm/basic-chart/basic-chart.d.ts.map +1 -1
- package/dist/esm/basic-chart/basic-chart.js +96 -34
- 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 +136 -6
- 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/package.json +7 -7
|
@@ -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
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rowsncolumns/charts",
|
|
3
|
-
"version": "28.1.
|
|
3
|
+
"version": "28.1.4",
|
|
4
4
|
"author": "Vinay M",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE.md",
|
|
6
6
|
"main": "./dist/cjs/index.js",
|
|
@@ -67,14 +67,14 @@
|
|
|
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.4",
|
|
74
|
+
"@rowsncolumns/spreadsheet-state": ">=29.1.6",
|
|
75
|
+
"@rowsncolumns/toolkit": ">=24.5.5",
|
|
76
|
+
"@rowsncolumns/ui": ">=16.0.4",
|
|
77
|
+
"@rowsncolumns/utils": ">=9.5.3",
|
|
78
78
|
"@types/react-plotly.js": "^2.6.3",
|
|
79
79
|
"echarts": "^5.6.0",
|
|
80
80
|
"formik": "^2.4.6",
|