svelte-plots-basic 2.5.7 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/methods.js ADDED
@@ -0,0 +1,1484 @@
1
+ import { cbind, vector, isvector, Vector, matrix } from 'mdatools/arrays';
2
+ import { min, max, diff } from 'mdatools/stat';
3
+ import { TICK_NUM, XTICK_NUM, YTICK_NUM, TICK_SIZE, COLMAP_WIDTH_RATIO, COLMAP_EL_HEIGHTS_PX, COLMAP_TOP_MARGIN } from './constants';
4
+
5
+
6
+ /**
7
+ * Creates array with sequence of values from 1 to n
8
+ * @param {number} n - length of array.
9
+ * @returns {Array}
10
+ */
11
+ export function seq(n) {
12
+ let x = Array(n);
13
+ for (let i = 0; i < n; i++) {
14
+ x[i] = i + 1;
15
+ }
16
+ return x;
17
+ }
18
+
19
+
20
+ /**
21
+ * Check of array has correct type, does not contaim undefined or NaN values and has proper length.
22
+ * @param {Array} arr - array to check.
23
+ * @param {number} len - expected length.
24
+ * @returns {boolean} check result.
25
+ */
26
+ export function checkArray(arr, len) {
27
+ return Array.isArray(arr) &&
28
+ arr.length === len &&
29
+ !arr.some(v => v === undefined) &&
30
+ !arr.some(v => isNaN(v));
31
+ }
32
+
33
+
34
+ /**
35
+ * Generic function to transform x or y-values from plot coordinates to screen (SVG) coordinates.
36
+ *
37
+ * @param {Array|Vector} v - vector with coordinates (or objects size) in original plot coordinates.
38
+ * @param {Object} tA - array with scaling and translation factors.
39
+ *
40
+ * @returns {Vector} vector with rescaled values
41
+ *
42
+ */
43
+ export function transform(v, tA) {
44
+ if (v === undefined || v === null) return undefined;
45
+ if (Array.isArray(v)) v = vector(v);
46
+ if (!isvector(v) || v.length < 1) return undefined;
47
+ return v.apply(a => roundCoords((a - tA[1]) * tA[0] + tA[2])).v;
48
+ }
49
+
50
+
51
+ // shortcut for transformation of coordinates
52
+ export function transformCoords(v, t) {
53
+ return transform(v, t.coords);
54
+ }
55
+
56
+ // shortcut for transformation of objects
57
+ export function transformObjects(v, t) {
58
+ return transform(v, t.objects);
59
+ }
60
+
61
+
62
+ /**
63
+ * Generic function to transform x or y-values from screen (SVG) coordinates to plot coordinates.
64
+ *
65
+ * @param {Array|Vector} v - vector with coordinates (or objects size) in screen (SVG) coordinates.
66
+ * @param {Object} tA - array with scaling and translation factors.
67
+ *
68
+ * @returns {Vector} vector plot coordinates.
69
+ *
70
+ */
71
+ export function invTransform(v, tA) {
72
+ if (v === undefined || v === null) return undefined;
73
+ if (Array.isArray(v)) v = vector(v);
74
+ if (!isvector(v) || v.length < 1) return undefined;
75
+ return v.apply(a => (a - tA[2]) / tA[0] + tA[1]).v;
76
+ }
77
+
78
+ // shortcut for inverse transformation of coordinates
79
+ export function invTransformCoords(v, t) {
80
+ return invTransform(v, t.coords);
81
+ }
82
+
83
+ // shortcut for inverse transformation of objects
84
+ export function invTransformObjects(v, t) {
85
+ return invTransform(v, t.objects);
86
+ }
87
+
88
+
89
+ /**
90
+ * Creates label for a tick factor.
91
+ *
92
+ * @param {Number} tf - tick factor value (negative or positive)
93
+ * @returns
94
+ */
95
+ export function getTickFactorLabel(tf) {
96
+ if (tf === 0) return '';
97
+ return `<tspan>&times;10</tspan><tspan font-size="0.75em" dy="-0.5em">${(tf).toString()}</tspan>`;
98
+ }
99
+
100
+
101
+ /**
102
+ * Trim number leaving only significant decimals.
103
+ * @param {number} num
104
+ * @returns trimmed number
105
+ */
106
+ export function trimNum(num) {
107
+
108
+ // check if number is whole — return as is
109
+ if (num === Math.round(num)) return num;
110
+
111
+ // if it is very large remove the decimals
112
+ if (Math.abs(num) > 10**6) return Math.round(num);
113
+
114
+ // if it is very small return as is
115
+ if (Math.abs(num) < 10**(-6)) return num;
116
+
117
+ // Convert to string with precision to maintain significant digits
118
+ let str = num.toPrecision(10);
119
+
120
+ // Remove trailing zeros
121
+ str = str.replace(/\.?0+$/, '');
122
+
123
+ // Convert back to number
124
+ return parseFloat(str);
125
+ }
126
+
127
+
128
+ /**
129
+ * Generate vector with axis tick labels and tick factor based on numeric tick values.
130
+ *
131
+ * @param {Vector} ticks - vector with numeric tick values.
132
+ *
133
+ * @returns {Array} an array with tick factor and array with tick labels.
134
+ */
135
+ export function getTickLabels(ticks) {
136
+
137
+ if (ticks === undefined) return [0, undefined];
138
+ if (ticks.length < 1) return [0, undefined];
139
+ if (ticks.length === 1) return [0, [ticks[0].toString()]];
140
+
141
+ // compute and correct step between ticks
142
+ let step = trimNum(Math.abs(min(diff(ticks))));
143
+ let tickFactor = 0;
144
+
145
+ // step is large (over 1)
146
+ if (step >= 1) {
147
+ if (step < 100) return [0, Array.from(ticks).map(v => v.toFixed(0))];
148
+ let digNum = Math.ceil(trimNum(Math.log10(step)));
149
+ tickFactor = digNum - 2;
150
+ return [tickFactor, Array.from(ticks).map(v => (v / Math.pow(10, tickFactor)).toFixed(0))];
151
+ }
152
+
153
+ let decNum = Math.ceil(-trimNum(Math.log10(step)));
154
+ if (decNum <= 2) {
155
+ return [0, Array.from(ticks).map(v => v.toFixed(decNum))];
156
+ }
157
+ tickFactor = decNum - 2;
158
+ decNum = 2;
159
+
160
+ if (trimNum(Math.abs(max(ticks))) <= 10) {
161
+ return [-tickFactor, Array.from(ticks).map(v => (v * Math.pow(10, tickFactor)).toFixed(decNum))];
162
+ } else {
163
+ return [-tickFactor, Array.from(ticks).map(v => (v / Math.pow(10, tickFactor)).toString())];
164
+ }
165
+ }
166
+
167
+
168
+ /**
169
+ * Check coordinates and convert them to vector if necessary.
170
+ *
171
+ * @param {Array|Vector} x - vector or array with coordinates.
172
+ * @param {string} source - name of component which called the method (needed for error message).
173
+ *
174
+ * @returns {Vector}
175
+ *
176
+ */
177
+ export function checkCoords(x, source, len) {
178
+
179
+ if (Array.isArray(x)) {
180
+ x = vector(x);
181
+ }
182
+
183
+ if (!isvector(x)) {
184
+ console.error(source + ': coordinates must be provided as array or instance of Vector class.');
185
+ return null;
186
+ }
187
+
188
+ if (x.length < 1) {
189
+ console.error(source + ': vector with coordinates is empty.');
190
+ return null;
191
+ }
192
+
193
+ if (len && x.length !== len) {
194
+ console.error(source + ': vector does not have the expected length of ' + len + '.');
195
+ return null;
196
+ }
197
+
198
+ return x;
199
+ }
200
+
201
+
202
+ /**
203
+ * Create string with coordinates of SVG polygon.
204
+ *
205
+ * @param {Vector} x - vector with x-coordinates of polygon points.
206
+ * @param {Vector} y - vector with y-coordinates of polygon points.
207
+ * @param {Array} tX - transformation parameters for x-coordinates (from 'Axes').
208
+ * @param {Array} tY - transformation parameters for y-coordinates (from 'Axes').
209
+ * @param {Object} axes - JSON with Axes context.
210
+ *
211
+ * @returns {string} string with coordinates.
212
+ *
213
+ */
214
+ export function val2p(x, y, tX, tY) {
215
+
216
+ if (!x || !y || x.length !== y.length) {
217
+ console.error('val2p: provided coordinate values are not correct.');
218
+ return null;
219
+ }
220
+
221
+ const tXc = tX.coords;
222
+ const tYc = tY.coords;
223
+ let p = '';
224
+ for (let i = 0; i < x.length; i++) {
225
+ const px = roundCoords((x.v[i] - tXc[1]) * tXc[0] + tXc[2]);
226
+ const py = roundCoords((y.v[i] - tYc[1]) * tYc[0] + tYc[2]);
227
+ p += px + ',' + py + ' ';
228
+ }
229
+ return p;
230
+ }
231
+
232
+
233
+ /**
234
+ * Validate tick labels entered by user and process them to SVG markup.
235
+ *
236
+ * @param {Array} tickLabels - array with tick labels.
237
+ * @param {Vector} ticks - vector with ticks (already validated).
238
+ * @returns {Array} - first element are processed tick labels, second - error message if any.
239
+ */
240
+ export function validateTickLabels(tickLabels, ticks) {
241
+
242
+ if (!tickLabels) {
243
+ return [null, ''];
244
+ }
245
+
246
+ if (!Array.isArray(tickLabels)) {
247
+ return [null, 'tick labels must be provided as array.'];
248
+ }
249
+
250
+ if (!ticks || ticks.length < 1) {
251
+ return [null, 'tick labels must be accompanied with tick values.']
252
+ }
253
+
254
+ if (ticks.v.length !== tickLabels.length) {
255
+ return [null, 'number of tick labels and values should be the same.'];
256
+ }
257
+
258
+ return [tickLabels.map(v => text2svg(v)), ''];
259
+ }
260
+
261
+ /**
262
+ * Validate tick values entered by user.
263
+ *
264
+ * @param {Array|Vector} ticks - array of vector with tick values.
265
+ * @param {Array} lim - array with axis limits.
266
+ * @returns {Array} - first element are processed ticks, second - error message if any.
267
+ */
268
+ export function validateTicks(ticks, lim) {
269
+
270
+ if (Array.isArray(ticks)) {
271
+ ticks = vector(ticks);
272
+ }
273
+
274
+ if (!isvector(ticks)) {
275
+ return [null, 'tick values must be provided as an array or as a vector.'];
276
+ }
277
+
278
+ if (ticks.v.some(v => isNaN(v))) {
279
+ return [null, 'tick values must be numeric'];
280
+ }
281
+
282
+ const un = new Set(ticks.v);
283
+ if (ticks.length !== un.size) {
284
+ return [null, 'ticks values must be unique.'];
285
+ }
286
+
287
+ // check if provided ticks values are outside the axis limit range continue with automatic ticks
288
+ const newTicks = ticks.filter(x => x >= lim[0] & x <= lim[1]);
289
+ if (newTicks.length !== ticks.length) {
290
+ return [null, 'some of ticks values are outside axis limits.'];
291
+ }
292
+
293
+ return [newTicks, ''];
294
+ }
295
+
296
+
297
+ /**
298
+ * Computes nice tick values for axis.
299
+ *
300
+ * @param {Array} ticks - vector with ticks if alredy available (if not, new will be computed).
301
+ * @param {Array} lim - vector with axis limits tickets must be computed for.
302
+ * @param {number} maxTickNum - maximum number of ticks to compute.
303
+ * @param {boolean} round - round or not the fractions when computing nice numbers for the ticks.
304
+ * @param {boolean} whole - should the ticks be a whole number.
305
+ * @param {Number} deltaFactor - percent of range to use as internal margins
306
+ *
307
+ * @returns {Array} an array with computed tick positions.
308
+ *
309
+ */
310
+ export function getAxisTicks(ticks, lim, maxTickNum, round, whole, deltaFactor) {
311
+
312
+ if (round === undefined || round === null) {
313
+ round = true;
314
+ }
315
+
316
+ if (whole === undefined || whole === null) {
317
+ whole = false;
318
+ }
319
+
320
+ if (deltaFactor === undefined) {
321
+ deltaFactor = 0.0001;
322
+ }
323
+
324
+ // if ticks are already provided do not recompute them
325
+ // the validation is done in corresponding component, so no need to do it again
326
+ if (ticks) return ticks;
327
+
328
+ // check if limits are ok
329
+ if (typeof(lim) !== "object" || lim[0] === undefined || lim[1] === undefined) {
330
+ console.error('getAxisTicks: provided axis limits are not valid.');
331
+ return undefined;
332
+ }
333
+
334
+ // get range as a nice number and compute min, max and steps for the tick sequence
335
+ const delta = Math.abs((lim[1] - lim[0])) * deltaFactor;
336
+ const localRange = lim[1] - lim[0] - 2 * delta;
337
+ const exponent = Math.floor(Math.log10(localRange));
338
+ const fraction = 1 / Math.pow(10, exponent - 1);
339
+ const range = Math.round(localRange * fraction) / fraction;
340
+
341
+ let tickSpacing = niceNum(range / maxTickNum, false);
342
+ if (whole) {
343
+ if (Math.abs(lim[1] - lim[0]) <= 1) {
344
+ console.error('getAxisTicks: parameter "whole" will be ignored as axis limits are too narrow to fit whole ticks.');
345
+ } else {
346
+ if (tickSpacing < 1) tickSpacing = 1;
347
+ tickSpacing = Math.round(tickSpacing);
348
+ }
349
+ }
350
+
351
+ // compute smallest and largest tick rounded to tickSpacing
352
+ let tickMin = Math.ceil((lim[0] + delta ) / tickSpacing) * tickSpacing;
353
+ let tickMax = Math.floor((lim[1] - delta) / tickSpacing) * tickSpacing;
354
+
355
+ // adjust largest tick if it is too close to axis limit and tick spacing
356
+ if (Math.abs((lim[0] - tickMin)) < (tickSpacing / 10)) tickMin = tickMin + tickSpacing;
357
+ if (Math.abs((lim[1] - tickMax)) < (tickSpacing / 10)) tickMax = tickMax - tickSpacing;
358
+
359
+
360
+ // recompute maxTickNum
361
+ maxTickNum = (Math.ceil(trimNum((tickMax - tickMin) / tickSpacing)) + 1);
362
+
363
+ // create a sequence of ticks
364
+ ticks = Vector.zeros(maxTickNum);
365
+ for (let i = 0; i < maxTickNum; i++) {
366
+ ticks.v[i] = tickMin + tickSpacing * i;
367
+ }
368
+
369
+ // if step is smaller than 1 round values to remove small decimals accidentially added by JS
370
+ if (Math.abs(tickSpacing) < 1) {
371
+ const decNum = Math.round(-Math.log10(tickSpacing));
372
+ const r = Math.pow(10, 1 + Math.round(-Math.log10(tickSpacing)));
373
+ ticks = ticks.apply(v => (Math.round((v + Number.EPSILON) * r) / r));
374
+ }
375
+
376
+ // make sure the ticks are not aligned with axes limits
377
+ return ticks.filter(x => x >= lim[0] & x <= lim[1]);
378
+ }
379
+
380
+
381
+ /**
382
+ * Computes a nice spacing value for a given range.
383
+ *
384
+ * @param {Number} localRange - a range (max - min).
385
+ * @param {boolean} round - round or not the fractions when computing the number.
386
+ *
387
+ * @returns {Number} the computed spacing value.
388
+ *
389
+ */
390
+ export function niceNum(localRange, round) {
391
+
392
+ const exponent = Math.floor(Math.log10(localRange));
393
+ const fraction = localRange / Math.pow(10, exponent);
394
+ let niceFraction;
395
+
396
+ if (round) {
397
+ if (fraction < 1.5)
398
+ niceFraction = 1;
399
+ else if (fraction < 3)
400
+ niceFraction = 2;
401
+ else if (fraction < 7)
402
+ niceFraction = 5;
403
+ else
404
+ niceFraction = 10;
405
+ } else {
406
+ if (fraction <= 1)
407
+ niceFraction = 1;
408
+ else if (fraction <= 2)
409
+ niceFraction = 2;
410
+ else if (fraction <= 5)
411
+ niceFraction = 5;
412
+ else
413
+ niceFraction = 10;
414
+ }
415
+
416
+ return niceFraction * Math.pow(10, exponent);
417
+ }
418
+
419
+
420
+ /**
421
+ * Rounds coordinate values to single decimal
422
+ */
423
+ export function roundCoords(x) {
424
+ return Math.round(x * 10) / 10
425
+ }
426
+
427
+
428
+ /**
429
+ * Computes a scale level.
430
+ *
431
+ * @param {numeric} width - width of plotting area in pixels.
432
+ * @param {numeric} height - height of plotting area in pixels.
433
+ *
434
+ * @returns {text} the scale level ("small", "medium" or "large").
435
+ *
436
+ */
437
+ export function getScale(width, height) {
438
+ if (height < 300.2 || width < 400.2) return "small";
439
+ if (height < 400.2 || width < 700.2) return "medium";
440
+ if (height < 650.2 || width < 850.2) return "large";
441
+ return "xlarge";
442
+ }
443
+
444
+
445
+ /**
446
+ * Computes a scale level for single axis.
447
+ *
448
+ * @param {numeric} size - width or height of plotting area in pixels.
449
+ * @returns {text} the scale level ("small", "medium" or "large").
450
+ *
451
+ */
452
+ export function getAxisScale(width, size) {
453
+ if (width < 400.2) return "small";
454
+ if (width < 700.2) return "medium";
455
+ if (width < 900.2) return "large";
456
+ return "xlarge";
457
+ }
458
+
459
+
460
+ /*************************************************************/
461
+ /* Functions needed to adjust DPI of resulting PNG image */
462
+ /* taken from: https://github.com/shutterstock/changeDPI */
463
+ /* and modified */
464
+ /*************************************************************/
465
+
466
+ /**
467
+ * Create table for PNG CRC calculation
468
+ */
469
+ function createPngDataTable() {
470
+ const crcTable = new Int32Array(256);
471
+ for (let n = 0; n < 256; n++) {
472
+ let c = n;
473
+ for (let k = 0; k < 8; k++) {
474
+ c = (c & 1) ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
475
+ }
476
+ crcTable[n] = c;
477
+ }
478
+ return crcTable;
479
+ }
480
+
481
+
482
+ /**
483
+ * Calculate CRC for PNG image
484
+ * @param buf image data
485
+ */
486
+ function calcCrc(buf) {
487
+ let c = -1;
488
+ let pngDataTable = createPngDataTable();
489
+ for (let n = 0; n < buf.length; n++) {
490
+ c = pngDataTable[(c ^ buf[n]) & 0xFF] ^ (c >>> 8);
491
+ }
492
+ return c ^ -1;
493
+ }
494
+
495
+
496
+ /**
497
+ * Change DPI of PNG image
498
+ * @param dataArray image data
499
+ * @param dpi new DPI
500
+ */
501
+ function changeDpiOnArray(dataArray, dpi) {
502
+
503
+ const physChunk = new Uint8Array(13);
504
+ // chunk header pHYs
505
+ // 9 bytes of data
506
+ // 4 bytes of crc
507
+ // this multiplication is because the standard is dpi per meter.
508
+ dpi *= 39.3701;
509
+ physChunk[0] = 'p'.charCodeAt(0);
510
+ physChunk[1] = 'H'.charCodeAt(0);
511
+ physChunk[2] = 'Y'.charCodeAt(0);
512
+ physChunk[3] = 's'.charCodeAt(0);
513
+ physChunk[4] = dpi >>> 24; // dpiX byte 3
514
+ physChunk[5] = dpi >>> 16; // dpiX byte 2
515
+ physChunk[6] = dpi >>> 8; // dpiX byte 1
516
+ physChunk[7] = dpi & 0xff; // dpiX byte 0
517
+ physChunk[8] = physChunk[4]; // dpiY byte 3
518
+ physChunk[9] = physChunk[5]; // dpiY byte 2
519
+ physChunk[10] = physChunk[6]; // dpiY byte 1
520
+ physChunk[11] = physChunk[7]; // dpiY byte 0
521
+ physChunk[12] = 1; // dot per meter....
522
+
523
+ // compute and add CRC for physChunk
524
+ const crc = calcCrc(physChunk);
525
+ const crcChunk = new Uint8Array(4);
526
+ crcChunk[0] = crc >>> 24;
527
+ crcChunk[1] = crc >>> 16;
528
+ crcChunk[2] = crc >>> 8;
529
+ crcChunk[3] = crc & 0xff;
530
+
531
+ // chunk structur 4 bytes for length is 9
532
+ const chunkLength = new Uint8Array(4);
533
+ chunkLength[0] = 0;
534
+ chunkLength[1] = 0;
535
+ chunkLength[2] = 0;
536
+ chunkLength[3] = 9;
537
+
538
+ const finalHeader = new Uint8Array(54);
539
+ finalHeader.set(dataArray, 0);
540
+ finalHeader.set(chunkLength, 33);
541
+ finalHeader.set(physChunk, 37);
542
+ finalHeader.set(crcChunk, 50);
543
+ return finalHeader;
544
+ }
545
+
546
+
547
+ /**
548
+ * Change DPI of PNG image
549
+ * @param base64Image image data
550
+ * @param dpi new DPI
551
+ */
552
+ function changeDPI(base64Image, dpi) {
553
+
554
+ const dataSplitted = base64Image.split(',');
555
+ const format = dataSplitted[0];
556
+ const body = dataSplitted[1];
557
+
558
+ // here we assume there is pHYS chunk in the header
559
+ const headerLength = 33 / 3 * 4;
560
+
561
+ const stringHeader = body.substring(0, headerLength);
562
+ const restOfData = body.substring(headerLength);
563
+ const headerBytes = atob(stringHeader);
564
+ const dataArray = new Uint8Array(headerBytes.length);
565
+ for (let i = 0; i < dataArray.length; i++) {
566
+ dataArray[i] = headerBytes.charCodeAt(i);
567
+ }
568
+ const finalArray = changeDpiOnArray(dataArray, dpi, 'image/png');
569
+ const base64Header = btoa(String.fromCharCode(...finalArray));
570
+ return [format, ',', base64Header, restOfData].join('');
571
+ }
572
+
573
+
574
+ /**
575
+ * Downloads plot (or any other SVG image) as a PNG file.
576
+ *
577
+ * @param {SVGElement} svg - root SVG element of a plot
578
+ * @param {string} fileName - filename to save with (without extension)
579
+ * @param {number} width - desired image width in cm
580
+ * @param {number} height - desired image height in cm
581
+ * @param {number} res - resolution (pixels per inch)
582
+ *
583
+ */
584
+ export function downloadPNG (svg, fileName, width, height, res) {
585
+
586
+ if (!width) width = 10;
587
+ if (!height) height = 10;
588
+ if (!res) res = 300;
589
+
590
+ if (width < 1 || width > 30) {
591
+ console.error('Parameter "width" must be between 1 and 30 (cm).');
592
+ return null;
593
+ }
594
+
595
+ if (height < 1 || height > 30) {
596
+ console.error('Parameter "height" must be between 1 and 30 (cm).');
597
+ return null;
598
+ }
599
+
600
+ if (res < 50 || res > 1200) {
601
+ console.error('Parameter "res" must be between 50 and 1200 (ppi).');
602
+ return null;
603
+ }
604
+
605
+ if (!svg || !(svg instanceof SVGElement)) {
606
+ console.error('Parameter "svg" is not an instance of SVGElement.');
607
+ return null;
608
+ }
609
+
610
+ if (!fileName || fileName.trim() === '') {
611
+ fileName = 'plot';
612
+ }
613
+
614
+
615
+ // recalculate width and height to pixels
616
+ const pxWidth = width / 2.54 * res;
617
+ const pxHeight = height / 2.54 * res;
618
+
619
+ setTimeout(() => {
620
+
621
+ // compute canvas size based on SVG size and desired PNG WIDTH in pixels
622
+ const svgHeight = svg.clientHeight;
623
+ const svgWidth = svg.clientWidth;
624
+ let scaleFactor = Math.max(pxWidth / svgWidth, pxHeight / svgHeight);
625
+
626
+ // this is needed if SVG image is larger than the future PNG image
627
+ let specialScale = 1;
628
+ if (scaleFactor < 1) {
629
+ specialScale = scaleFactor;
630
+ scaleFactor = 1;
631
+ }
632
+
633
+ // create a new canvas element
634
+ const canvas = document.createElement('canvas');
635
+ const context = canvas.getContext('2d');
636
+
637
+ // set the canvas size to match the SVG element
638
+ canvas.width = svgWidth * (specialScale < 1 ? specialScale : scaleFactor);
639
+ canvas.height = svgHeight * (specialScale < 1 ? specialScale : scaleFactor);
640
+
641
+ // set corresponding attributes for SVG element
642
+ svg.setAttribute('width', svgWidth * scaleFactor);
643
+ svg.setAttribute('height', svgHeight * scaleFactor);
644
+
645
+ // draw a white background
646
+ context.fillStyle = 'white';
647
+ context.fillRect(0, 0, canvas.width, canvas.height);
648
+ context.scale(scaleFactor, scaleFactor)
649
+
650
+ // draw the SVG onto the canvas
651
+ const svgXml = new XMLSerializer().serializeToString(svg);
652
+ const blob = new Blob([svgXml], { type: 'image/svg+xml;charset=utf-8' });
653
+ const url = URL.createObjectURL(blob);
654
+
655
+ const img = new Image();
656
+
657
+ img.onload = function () {
658
+
659
+ // downscale image if it must be smaller than the current SVG element
660
+ if (specialScale < 0) {
661
+ img.width = img.width * specialScale;
662
+ img.height = img.height * specialScale;
663
+ }
664
+
665
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
666
+
667
+ // create a data URL for the canvas and adjust its DPI
668
+ const dataURL = canvas.toDataURL('image/png');
669
+ const dataURL2 = changeDPI(dataURL, res);
670
+
671
+ // create a temporary link and trigger the download
672
+ const downloadLink = document.createElement('a');
673
+ downloadLink.href = dataURL2;
674
+ downloadLink.download = `${fileName}.png`;
675
+ downloadLink.click();
676
+ };
677
+
678
+ // set the image source
679
+ img.src = url;
680
+ }, 20);
681
+ }
682
+
683
+
684
+
685
+ /**
686
+ * Downloads plot (or any other SVG image) as an SVG file.
687
+ *
688
+ * @param {SVGElement} svg - root SVG element of a plot
689
+ * @param {string} fileName - filename to save with (without extension)
690
+ */
691
+ export function downloadSVG(svg, fileName) {
692
+
693
+ if (!svg || !(svg instanceof SVGElement)) {
694
+ console.error('Variable "svg" is not an instance of SVGElement.');
695
+ return null;
696
+ }
697
+
698
+ if (!fileName || fileName.trim() === "") {
699
+ fileName = "plot";
700
+ }
701
+
702
+ const svgHeight = svg.clientHeight;
703
+ const svgWidth = svg.clientWidth;
704
+ svg.setAttribute('width', svgWidth + 'px');
705
+ svg.setAttribute('height', svgHeight + 'px');
706
+ svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
707
+
708
+ const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
709
+ const url = window.URL.createObjectURL(blob)
710
+ const a = document.createElement('a')
711
+ a.setAttribute('href', url)
712
+ a.setAttribute('download', `${fileName}.svg`);
713
+ a.click()
714
+ }
715
+
716
+ /**
717
+ * Copies plot to clipboard as PNG image.
718
+ * @param {HTMLElement} btn - button element used to activate the copy event.
719
+ * @param {SVGAElement} svg - root SVG element of a plot.
720
+ * @param {number} width - width of the plot image to copy.
721
+ * @param {number} height - height of the plot image to copy.
722
+ */
723
+ export function copyToClipboard(btn, svg, width, height) {
724
+
725
+ // save button conent
726
+ const content = btn.textContent;
727
+
728
+ navigator.clipboard.write([
729
+ new ClipboardItem({ 'image/png': createPngBlob(svg, width, height) })
730
+ ])
731
+ .then(
732
+ () => {
733
+ // add class "copied" for visual confirmation
734
+ btn.classList.add('copied');
735
+ btn.textContent = '✓ done';
736
+
737
+ // Remove the class after 0.5 seconds and revert the text
738
+ setTimeout(() => {
739
+ btn.classList.remove('copied');
740
+ btn.textContent = content;
741
+ }, 500);
742
+ },
743
+ (e) => {
744
+ console.error(e)
745
+
746
+ // add class "error" for visual confirmation
747
+ btn.classList.add('error');
748
+ btn.textContent = 'x copy';
749
+
750
+ // Remove the class after 0.5 seconds and revert the text
751
+ setTimeout(() => {
752
+ btn.classList.remove('error');
753
+ btn.textContent = content;
754
+ }, 500);
755
+ }
756
+ )
757
+ .catch((error) => console.error(error));
758
+
759
+ }
760
+
761
+
762
+ /**
763
+ * Returns plot BLOB to copy it to clipboard.
764
+ *
765
+ * @param {SVGElement} svg - root SVG element of a plot
766
+ * @param {number} width - desired image width in pixels
767
+ * @param {number} height - desired image height in pixels
768
+ *
769
+ */
770
+ export async function createPngBlob(svg, width, height) {
771
+
772
+ // compute canvas size based on SVG size and scaling factor
773
+ // based on desired size in pixels
774
+ const svgHeight = svg.clientHeight;
775
+ const svgWidth = svg.clientWidth;
776
+ const scaleFactor = Math.max(width / svgWidth, height / svgHeight);
777
+
778
+ // create a new canvas element and 2D context for it
779
+ const canvas = document.createElement('canvas');
780
+ const context = canvas.getContext('2d');
781
+
782
+ // set the canvas size to match the size of SVG element
783
+ canvas.width = svgWidth * scaleFactor;
784
+ canvas.height = svgHeight * scaleFactor;
785
+
786
+ // scale the context
787
+ context.scale(scaleFactor, scaleFactor)
788
+
789
+ // set corresponding attributes for SVG element
790
+ svg.setAttribute('width', canvas.width);
791
+ svg.setAttribute('height', canvas.height);
792
+
793
+ // create promise
794
+ const promise = new Promise((resolve, reject) => {
795
+
796
+ // serialzie SVG, create blob and URL for it
797
+ const svgData = new XMLSerializer().serializeToString(svg);
798
+ const svgBlob = new Blob([svgData], { type: 'image/svg+xml;charset=utf-8' });
799
+ const DOMURL = window.URL || window.webkitURL || window;
800
+ const url = DOMURL.createObjectURL(svgBlob);
801
+
802
+ // create image an image and add callback
803
+ const img = new Image();
804
+ img.onload = function () {
805
+
806
+ // draw the image
807
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
808
+ DOMURL.revokeObjectURL(url);
809
+
810
+ // convert canvas to blob
811
+ canvas.toBlob(function (blob) {
812
+ if (blob) {
813
+ resolve(blob);
814
+ } else {
815
+ reject(new Error('Failed to create blob'));
816
+ }
817
+ }, 'image/png');
818
+ };
819
+
820
+ // add error callback
821
+ img.onerror = function (err) {
822
+ reject(err);
823
+ };
824
+
825
+ // assign value to run the onload callback
826
+ img.src = url;
827
+ });
828
+
829
+ // resolve promise and return the result
830
+ return (await promise);
831
+ }
832
+
833
+
834
+ /**
835
+ * Returns an array with 'n' colors.
836
+ *
837
+ * @param {number} n - number of colors to return.
838
+ * @param {string} alpha - string with hex number from 00 to FF defining alpha parameter for color.
839
+ *
840
+ * @returns {Array} array with colors as strings (hexadecimal).
841
+ *
842
+ */
843
+ export function getcolmap(n, alpha) {
844
+ if (n > 16) n = 16;
845
+ const colmap = [
846
+ ["#2679B2"],
847
+ ["#2679B2", "#D22C2F"],
848
+ ["#2679B2", "#92B42A", "#D22C2F"],
849
+ ["#2679B2", "#2E9658", "#F2B825", "#D22C2F"],
850
+ ["#2679B2", "#22988A", "#92B42A", "#F79426", "#D22C2F"],
851
+ ["#2679B2", "#1C9AA8", "#379531", "#EED524", "#FB7F28", "#D22C2F"],
852
+ ["#2679B2", "#1D94A9", "#2E9658", "#92B42A", "#F2B825", "#F47129", "#D22C2F"],
853
+ ["#2679B2", "#1E90AA", "#279775", "#519E2F", "#D3CB25", "#F5A326", "#EF672A", "#D22C2F"],
854
+ ["#2679B2", "#1F8DAB", "#22988A", "#33953F", "#92B42A", "#EFCA24", "#F79426", "#EB5F2A", "#D22C2F"],
855
+ ["#2679B2", "#208BAC", "#1E999A", "#2E9658", "#5FA32E", "#C5C626", "#F2B825", "#F98827", "#E85A2B", "#D22C2F"],
856
+ ["#2679B2", "#2089AD", "#1C9AA8", "#29976C", "#379531", "#92B42A", "#EED524", "#F4AA26", "#FB7F28", "#E6552B", "#D22C2F"],
857
+ ["#2679B2", "#2188AD", "#1C97A8", "#25987C", "#329546", "#68A62D", "#BCC327", "#F0C524", "#F69E26", "#F77728", "#E4512B", "#D22C2F"],
858
+ ["#2679B2", "#2186AD", "#1D94A9", "#22988A", "#2E9658", "#469A2F", "#92B42A", "#DECF25", "#F2B825", "#F79426", "#F47129", "#E34E2C", "#D22C2F"],
859
+ ["#2679B2", "#2285AE", "#1E92AA", "#209995", "#2A9767", "#34953A", "#6FA82D", "#B5C128", "#EFCE24", "#F3AD25", "#F98C27", "#F16B29", "#E14B2C", "#D22C2F"],
860
+ ["#2679B2", "#2284AE", "#1E90AA", "#1D999F", "#279775", "#31964A", "#519E2F", "#92B42A", "#D3CB25", "#F0C224", "#F5A326", "#FA8527", "#EF672A", "#E0492C", "#D22C2F"],
861
+ ["#2679B2", "#2283AE", "#1F8FAB", "#1C9AA8", "#249880", "#2E9658", "#379531", "#73AA2C", "#B0BF28", "#EDD424", "#F2B825", "#F69B26", "#FB7F28", "#ED632A", "#DF472C", "#D22C2F"]
862
+ ]
863
+
864
+ return alpha ? colmap[n - 1].map(v => v + alpha) : colmap[n - 1];
865
+ }
866
+
867
+
868
+ /**
869
+ * Computes coordinates and other parameters of main elements of x-axis
870
+ *
871
+ * @param {Array} limX - limits for x-axis.
872
+ * @param {Array} limY - limits for y-axis.
873
+ * @param {Array} scales - array of scale names (plot, x, y).
874
+ * @param {Array} tY - transposition matrix for y-coordinates.
875
+ * @param {Object} axis - JSON with x-axis parameters provided by user.
876
+ *
877
+ * @returns {Object} - JSON with coordinate of axis line, ticks, tick labels and factor.
878
+ */
879
+ export function getXAxisParams(limX, limY, scales, tY, axis) {
880
+
881
+ let ticks = axis.ticks;
882
+ let tickLabels = axis.tickLabels;
883
+ let tickFactor = 0;
884
+
885
+ // compute x-coordinates of the ticks or take the ones manually specified by user
886
+ const ticksX = getAxisTicks(ticks, limX, XTICK_NUM[scales.x], true, axis.whole);
887
+ const tickNum = ticksX.length;
888
+
889
+ // compute tick y-coordinates (up and bottom)
890
+ const dY = invTransformObjects([TICK_SIZE[scales.plot]], tY)[0];
891
+ const ticksY1 = Vector.fill(limY[0], tickNum);
892
+ const ticksY2 = ticksY1.add(dY);
893
+
894
+ // compute coordinates for the ends of grid
895
+ const gridYEnd = Vector.fill(limY[1], tickNum);
896
+
897
+ // tick labels and tick factor
898
+ if (!tickLabels) {
899
+ if (ticks) {
900
+ tickFactor = 0;
901
+ tickLabels = ticks.v.map(v => trimNum(v).toString());
902
+ } else {
903
+ [tickFactor, tickLabels] = getTickLabels(ticksX.v);
904
+ }
905
+ }
906
+
907
+
908
+ if (!tickLabels || tickLabels.length !== ticksX.length) {
909
+ console.error('XAxis: "tickLabels" must be a array of the same size as ticks.');
910
+ return null;
911
+ }
912
+
913
+ // combine all coordinates together
914
+ const grid = [
915
+ [ticksX, ticksY1],
916
+ [ticksX, gridYEnd]
917
+ ];
918
+
919
+ const axisLine = [
920
+ [vector([limX[0]]), vector([limY[0]])],
921
+ [vector([limX[1]]), vector([limY[0]])]
922
+ ]
923
+
924
+ const tickCoords = [
925
+ [ticksX, ticksY2],
926
+ [ticksX, ticksY1]
927
+ ];
928
+
929
+ const dX = (limX[1] - limX[0]) * 0.01;
930
+ const tfCoords = [
931
+ [null, null],
932
+ [[limX[1] - (tickFactor > 9 ? 1 : 0) * dX], [ticksY1.v[tickNum - 1]]],
933
+ ];
934
+
935
+ const tfLabel = getTickFactorLabel(tickFactor);
936
+
937
+ // hide last label if it is too close to tick factor
938
+ const dtl = Math.abs(limX[1] - ticksX.v[ticksX.length - 1]);
939
+ const dl = limX[1] - limX[0];
940
+ const dt = Math.abs(ticksX.v[1] - ticksX.v[0]);
941
+ if (tfLabel.length > 0 && dtl < dl * 0.10 && dtl < dt * 0.5) {
942
+ tickLabels[tickLabels.length - 1] = ' ';
943
+ }
944
+
945
+ return {grid, axisLine, tickCoords, tfCoords, ticks, tickLabels, tickFactor, tfLabel};
946
+ }
947
+
948
+
949
+ /**
950
+ * Computes coordinates and other parameters of main elements of y-axis
951
+ *
952
+ * @param {Array} limX - limits for x-axis.
953
+ * @param {Array} limY - limits for y-axis.
954
+ * @param {Array} scales - array of scale names (plot, x, y).
955
+ * @param {Array} tX - transposition matrix for x-coordinates.
956
+ * @param {Object} axis - JSON with x-axis parameters provided by user.
957
+ *
958
+ * @returns {Object} - JSON with coordinate of axis line, ticks, tick labels and factor.
959
+ */
960
+ export function getYAxisParams(limX, limY, scales, tX, axis) {
961
+
962
+ let ticks = axis.ticks;
963
+ let tickLabels = axis.tickLabels;
964
+ let tickFactor = 0;
965
+
966
+ // compute x-coordinates of the ticks or take the ones manually specified by user
967
+ const ticksY = getAxisTicks(axis.ticks, limY, YTICK_NUM[scales.y], true, axis.whole);
968
+ const tickNum = ticksY.length;
969
+
970
+ // compute tick y-coordinates (up and bottom)
971
+ const dX = invTransformObjects([TICK_SIZE[scales.plot]], tX)[0];
972
+ const ticksX1 = Vector.fill(limX[0], tickNum)
973
+ const ticksX2 = ticksX1.add(dX)
974
+
975
+ // coordinates for the ends of grid
976
+ const gridXEnd = Vector.fill(limX[1], tickNum);
977
+
978
+ // tick labels and tick factor
979
+ if (!tickLabels) {
980
+ if (ticks) {
981
+ tickFactor = 0;
982
+ tickLabels = ticks.v.map(v => trimNum(v).toString());
983
+ } else {
984
+ [tickFactor, tickLabels] = getTickLabels(ticksY.v);
985
+ }
986
+ }
987
+
988
+ if (tickLabels.length !== ticksY.length) {
989
+ console.error('YAxis: "tickLabels" must be a array of the same size as ticks.');
990
+ return null;
991
+ }
992
+
993
+ // combine all coordinates together
994
+ const grid = [
995
+ [ticksX1, ticksY],
996
+ [gridXEnd, ticksY]
997
+ ];
998
+
999
+ const axisLine = [
1000
+ [vector([limX[0]]), vector([limY[0]])],
1001
+ [vector([limX[0]]), vector([limY[1]])]
1002
+ ]
1003
+
1004
+ const tickCoords = [
1005
+ [ticksX1, ticksY],
1006
+ [ticksX2, ticksY]
1007
+ ];
1008
+
1009
+ const tfCoords = [
1010
+ [[], []],
1011
+ [[ticksX2.v[tickNum - 1] + (Math.abs(tickFactor) > 9 ? 3 : 2) * dX], [limY[1]]],
1012
+ ];
1013
+
1014
+ const tfLabel = getTickFactorLabel(tickFactor);
1015
+
1016
+ // hide last label if it is too close to tick factor
1017
+ if (tfLabel.length > 0 && (limY[1] - ticksY.v[ticksY.length - 1]) < ((limY[1] - limY[0]) * 0.05)) {
1018
+ tickLabels[tickLabels.length - 1] = ' ';
1019
+ }
1020
+
1021
+ return {grid, axisLine, tickCoords, tfCoords, ticks, tickLabels, tickFactor, tfLabel};
1022
+ }
1023
+
1024
+
1025
+ /**
1026
+ * Performs sanity check of user defined parameters for colormap legend defines a couple of extra parameters.
1027
+ *
1028
+ * @param {Object} cml - JSON with user defined parameters from `ColormapLegend` component
1029
+ *
1030
+ * @returns {Object} objects with full set of parameters.
1031
+ */
1032
+ export function getColormapLegendParams(cml) {
1033
+
1034
+ if (!cml.breaks) {
1035
+ console.error('ColormapLegend: breaks values must be provided.')
1036
+ return null;
1037
+ }
1038
+
1039
+ const breaks = isvector(cml.breaks) ? cml.breaks.v : cml.breaks;
1040
+
1041
+ if (breaks.length < 2) {
1042
+ console.error('ColormapLegend: incorrect breaks values.')
1043
+ return null;
1044
+ };
1045
+
1046
+ if (!cml.colmap) {
1047
+ console.error('ColormapLegend: values for "colmap" parameter must be provided.');
1048
+ return null;
1049
+ }
1050
+
1051
+ if (!Array.isArray(cml.colmap)) {
1052
+ console.error('ColormapLegend: parameter "colmap" must be array with colors.');
1053
+ return null;
1054
+ }
1055
+
1056
+ if (cml.colmap.length !== breaks.length - 1) {
1057
+ console.error('ColormapLegend: number of color values in "colmap" does not match number of intervals defined by breaks.');
1058
+ return null;
1059
+ }
1060
+
1061
+ const colmap = cml.colmap;
1062
+
1063
+ let labels, labelsFactor;
1064
+ if (!cml.labels) {
1065
+ [labelsFactor, labels] = getTickLabels(breaks);
1066
+ } else {
1067
+ labels = cml.labels;
1068
+ labelsFactor = 0;
1069
+ }
1070
+
1071
+ if (!(labels.length === (breaks.length - 1) || labels.length === breaks.length)) {
1072
+ console.error('ColormapLegend: number of values in "labels" should be equal to or by one less than number of values in "breaks".');
1073
+ return null;
1074
+ }
1075
+
1076
+ const isCentered = labels.length === colmap.length;
1077
+ return {colmap, breaks, labels, isCentered, labelsFactor, fontSize: cml.fontSize, labelColor: cml.labelColor};
1078
+ }
1079
+
1080
+
1081
+ /**
1082
+ * Computes screen coordinates of colormap legend elements.
1083
+ *
1084
+ * @param {Object} cmlParams - JSON with colormap legend parameters from `getgColormapLegendParams`.
1085
+ * @param {number} axesWidth - width of axes in pixels.
1086
+ * @param {Array} pxMargins - array with axes margins in pixels.
1087
+ * @param {Object} scales - JSON with scales.
1088
+ *
1089
+ * @returns {Object} JSOn with coordinates.
1090
+ */
1091
+ export function getColormapLegendCoords(cmlParams, axesWidth, pxMargins, scales) {
1092
+
1093
+ const n = cmlParams.colmap.length;
1094
+ const colmapWidth = axesWidth * COLMAP_WIDTH_RATIO;
1095
+
1096
+ // compute elements size and position
1097
+ const width = colmapWidth / n;
1098
+ const start = pxMargins[1] + (axesWidth - colmapWidth) * 0.5;
1099
+ const height = COLMAP_EL_HEIGHTS_PX[scales.y]
1100
+ const top = pxMargins[2] + height * COLMAP_TOP_MARGIN;
1101
+ const ltop = top + height * (1 + COLMAP_TOP_MARGIN)
1102
+ const dxl = cmlParams.isCentered ? width / 2 : 0
1103
+
1104
+ return {start, top, height, width, dxl, ltop};
1105
+ }
1106
+
1107
+
1108
+ /**
1109
+ * Mouse click handler for plot elements.
1110
+ * @param {Object} e - event object.
1111
+ * @param {string} tagName - name of the tag the click is expected from.
1112
+ * @param {function} onclickCallback - callback function to run when click event happened.
1113
+ */
1114
+ export function handleClick(e, tagName, onclickCallback) {
1115
+ if (!e || !tagName || !onclickCallback || typeof onclickCallback !== 'function') return;
1116
+ const el = e.target;
1117
+ if (el.tagName === tagName) {
1118
+ const id = Array.prototype.indexOf.call(el.parentNode.children, el);
1119
+ if (id < 0) return;
1120
+ onclickCallback(id);
1121
+ e.stopPropagation();
1122
+ }
1123
+ }
1124
+
1125
+
1126
+ /**
1127
+ * Computes width of text element for given font.
1128
+ *
1129
+ * @param {string} text - string with text
1130
+ * @param {string} font - font name and size, e.g. '12px Arial'
1131
+ *
1132
+ * @return {number} size of element in pixels
1133
+ */
1134
+ export function getTextWidth(text, font) {
1135
+ const element = document.createElement('canvas');
1136
+ const context = element.getContext('2d');
1137
+ context.font = font;
1138
+ return context.measureText(text.replace(/<(.|\n)*?>/g, '')).width;
1139
+ }
1140
+
1141
+
1142
+ /**
1143
+ * Compute size of main legend elements.
1144
+ *
1145
+ * @param {Array} items - array of legend items and their properties.
1146
+ * @param {number} fontSize - font size of a legend element in pixels.
1147
+ * @param {string} position - position of the legend.
1148
+ *
1149
+ * @return {Array} array with height and width of graphical part of legend item, padding size,
1150
+ * height and width of text labels (all in pixels).
1151
+ */
1152
+ function getLegendSize(items, fontSize) {
1153
+
1154
+ // TODO: implement dynamic font family
1155
+ const fontName = 'Arial';
1156
+
1157
+ // compute size of graphical elements
1158
+ const elp = fontSize / 8; // top and bottom padding
1159
+ const elw = fontSize * 1.5; // width of legend element without label and padding
1160
+
1161
+ // compute size of text label elements
1162
+ // TODO: implement one row legend if position is "top" or "bottom"
1163
+ const elh = Array(items.length);
1164
+ const ely = Array(items.length);
1165
+ let y = 0;
1166
+ let lgh = 0;
1167
+ let lbw = 0;
1168
+
1169
+ for (let i = 0; i < items.length; i++) {
1170
+ const w = getTextWidth(' ' + items[i].label + ' ', fontSize + 'px ' + fontName);
1171
+ lbw = w > lbw ? w : lbw;
1172
+ const h = fontSize * items[i].labelHeight + 2 * elp;
1173
+ elh[i] = h;
1174
+ ely[i] = y;
1175
+ y = y + h;
1176
+ lgh = lgh + h;
1177
+ }
1178
+
1179
+ const lgw = (elw + 2 * elp + lbw);
1180
+ return [ely, elh, elw, elp, lgh, lgw];
1181
+ }
1182
+
1183
+
1184
+ /**
1185
+ * Computes screen coordinates and sizes of legend and its elements.
1186
+ * @param {Object} params - object with legend params (items array and position name).
1187
+ * @param {number} fontSize - font size in puxels.
1188
+ * @param {Array} cpx - screen coordinates of axes corners along x.
1189
+ * @param {Array} cpy - screen coordinates of axes corners along y.
1190
+ * @param {number} ts - ticks size in pixels.
1191
+ */
1192
+ export function getGroupLegendCoords(params, cpx, cpy, fontSize, ts) {
1193
+
1194
+ // compute size of legend elements
1195
+ const [ely, elh, elw, elp, lgh, lgw] = getLegendSize(params.items, fontSize * params.fontSize);
1196
+
1197
+ // compute coordinates of top left corner of the legend box
1198
+ const lgl = params.position.includes("left") ? cpx[0] + ts :
1199
+ params.position.includes("right") ? cpx[1] - lgw - ts :
1200
+ (cpx[1] + cpx[0] - lgw) * 0.5;
1201
+
1202
+ const lgt = params.position.includes("top") ? cpy[1] + ts :
1203
+ params.position.includes("bottom") ? cpy[0] - lgh - ts :
1204
+ (cpy[1] + cpy[0] - lgh) * 0.5;
1205
+
1206
+
1207
+ return {lgl, lgt, lgw, lgh, ely, elh, elw, elp};
1208
+ }
1209
+
1210
+
1211
+ /**
1212
+ * Converts text with super and subscripts to SVG text.
1213
+ * @param {string} text
1214
+ * @returns {string} converted text.
1215
+ */
1216
+ export function text2svg(text) {
1217
+
1218
+ if (!text || text.length < 1) return text;
1219
+
1220
+ function toSuper(txt) {
1221
+ return '<tspan font-size="0.75em" dy="-0.5em">' + txt + '</tspan><tspan dy="0.4em"> </tspan>';
1222
+ }
1223
+
1224
+ function toSub(txt) {
1225
+ return '<tspan font-size="0.75em" dy="0.5em">' + txt + '</tspan><tspan dy="-0.4em"> </tspan>';
1226
+ }
1227
+
1228
+ let i = 0;
1229
+ const dy = 0.5;
1230
+ const fs = 0.8;
1231
+ let svgText = '';
1232
+ while (i < text.length) {
1233
+
1234
+ const char = text[i];
1235
+ if ((char === '^' || char === '_') && i < text.length - 1) {
1236
+
1237
+ const isSuperscript = char === '^';
1238
+ i++;
1239
+
1240
+ if (text[i] === '(') {
1241
+ // multiscript, everything betwee ( and ) should be taken
1242
+ let j = i + 1;
1243
+ while (j < text.length && text[j] !== ')') j++;
1244
+ const scriptContent = text.slice(i + 1, j);
1245
+ svgText += isSuperscript ? toSuper(scriptContent) : toSub(scriptContent);
1246
+ i = j + 1; // Move past the closing '}'
1247
+ } else if (text[i] === '-' || text[i] === '–') {
1248
+ // negative script, take the sign and next element
1249
+ const scriptContent = text.slice(i, i + 2);
1250
+ svgText += isSuperscript ? toSuper(scriptContent) : toSub(scriptContent);
1251
+ i = i + 2;
1252
+ } else {
1253
+ svgText += isSuperscript ? toSuper(text[i]) : toSub(text[i]);
1254
+ i++;
1255
+ }
1256
+ } else {
1257
+ svgText += char;
1258
+ i++;
1259
+ }
1260
+ }
1261
+
1262
+ return svgText;
1263
+ }
1264
+
1265
+
1266
+ /**
1267
+ * Transforms 3D world coordinates to 2D scene pixels by applying the transformation matrix 'tM'.
1268
+ *
1269
+ * @param {Matrix} coords - matrix with coordinates [X, Y, Z]
1270
+ * @param {Matrix} tM - transformation matrix
1271
+ *
1272
+ * @returns {Array} array with transformed coordinates as two vectors [x, y].
1273
+ *
1274
+ */
1275
+ export function transform3D(coords, tM) {
1276
+ const coords2D = cbind(coords, Vector.ones(coords.nrows)).dot(tM);
1277
+ return {x: coords2D.getcolref(1).map(v => roundCoords(v)), y: coords2D.getcolref(2).map(v => roundCoords(v))};
1278
+ }
1279
+
1280
+
1281
+ /**
1282
+ * Compute coordinates of "latent" ticks by replicating lim[0] value and adding shifts.
1283
+ * @param {Array} lim - array with axis limits.
1284
+ * @param {number} tickNum - number of ticks to generate.
1285
+ * @returns
1286
+ */
1287
+ function getLatentTicks(lim, tickNum) {
1288
+ const dl = (lim[1] - lim[0]) / 100; // 1% of axis size
1289
+ const ticks = Vector.fill(lim[0], tickNum);
1290
+ const ticks1 = ticks.subtract(dl * 1.5);
1291
+ const ticks2 = ticks.add(dl * 1.5);
1292
+ const ticks3 = ticks.subtract(dl * 5);
1293
+
1294
+ return [ticks, ticks1, ticks2, ticks3];
1295
+ }
1296
+
1297
+
1298
+ /**
1299
+ * Computes coordinates of all elements (lines, labels, etc) for x-axis ticks.
1300
+ * @param {Object} xaxis - JSON with x-axis paramaters.
1301
+ * @param {Array} limX - array with x-axis limits.
1302
+ * @param {Array} limY - array with y-axis limits.
1303
+ * @param {Array} limZ - array with z-axis limits.
1304
+ * @param {string} scale - current scale value.
1305
+ * @returns {Object} JSON with coordinates.
1306
+ */
1307
+ export function getXAxisCoords3D(xaxis, limX, limY, limZ, scale) {
1308
+
1309
+ const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1310
+ const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1311
+ const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1312
+ const ticksX = getAxisTicks(xaxis.ticks, limX, TICK_NUM[scale], true);
1313
+ const tickNum = ticksX.length;
1314
+
1315
+ const [ticksY, ticksY1, ticksY2, ticksY3] = getLatentTicks(limY, tickNum);
1316
+ const [ticksZ, ticksZ1, ticksZ2, ticksZ3] = getLatentTicks(limZ, tickNum);
1317
+
1318
+ // coordinates for the ends of grid
1319
+ const gridYEnd = Vector.fill(limY[1], tickNum);
1320
+ const gridZEnd = Vector.fill(limZ[1], tickNum);
1321
+
1322
+ // tick labels
1323
+ const tickLabels = (!xaxis.ticks || !xaxis.tickLabels) ? ticksX.v : xaxis.tickLabels;
1324
+ if (tickLabels.length !== ticksX.length) {
1325
+ console.error('XAxis (3D): "tickLabels" must be a array of the same size as ticks.');
1326
+ }
1327
+
1328
+ // combine all coordinates together
1329
+ const grid1 = [
1330
+ cbind(ticksX, ticksY, ticksZ),
1331
+ cbind(ticksX, gridYEnd, ticksZ)
1332
+ ];
1333
+
1334
+ const grid2 = [
1335
+ cbind(ticksX, ticksY, ticksZ),
1336
+ cbind(ticksX, ticksY, gridZEnd)
1337
+ ];
1338
+
1339
+ const axisLine = [
1340
+ matrix([limX[0], limY[0], limZ[0]], 1, 3),
1341
+ matrix([limX[1], limY[0], limZ[0]], 1, 3)
1342
+ ]
1343
+
1344
+ const tickCoords = [
1345
+ cbind(ticksX, ticksY, ticksZ), // middle point
1346
+ cbind(ticksX, ticksY2, ticksZ), // middle point with positive shift along Y
1347
+ cbind(ticksX, ticksY, ticksZ2), // middle point with positive shift along Z
1348
+ cbind(ticksX, ticksY1, ticksZ1), // middle point with negative shift along Y and Z
1349
+ cbind(ticksX, ticksY3, ticksZ3) // middle point with negative shift along Y and Z for ticks
1350
+ ];
1351
+
1352
+ // here we do not need to make a matrix as the three values will be used as vectors
1353
+ const titleCoords = [
1354
+ [limX[1] + 3 * dX], [limY[0] - 2 * dY], [limZ[0] - 2 * dZ]
1355
+ ];
1356
+
1357
+ return {grid1, grid2, axisLine, tickCoords, titleCoords, tickLabels};
1358
+ }
1359
+
1360
+
1361
+ /**
1362
+ * Computes coordinates of all elements (lines, labels, etc) for y-axis ticks.
1363
+ * @param {Object} yaxis - JSON with y-axis paramaters.
1364
+ * @param {Array} limX - array with x-axis limits.
1365
+ * @param {Array} limY - array with y-axis limits.
1366
+ * @param {Array} limZ - array with z-axis limits.
1367
+ * @param {string} scale - current scale value.
1368
+ * @returns {Object} JSON with coordinates.
1369
+ */
1370
+ export function getYAxisCoords3D(yaxis, limX, limY, limZ, scale) {
1371
+
1372
+ const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1373
+ const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1374
+ const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1375
+ const ticksY = getAxisTicks(yaxis.ticks, limY, TICK_NUM[scale], true);
1376
+ const tickNum = ticksY.length;
1377
+
1378
+ const [ticksX, ticksX1, ticksX2, ticksX3] = getLatentTicks(limX, tickNum);
1379
+ const [ticksZ, ticksZ1, ticksZ2, ticksZ3] = getLatentTicks(limZ, tickNum);
1380
+
1381
+ // coordinates for the ends of grid
1382
+ const gridXEnd = Vector.fill(limX[1], tickNum);
1383
+ const gridZEnd = Vector.fill(limZ[1], tickNum);
1384
+
1385
+ // tick labels
1386
+ const tickLabels = (!yaxis.ticks || !yaxis.tickLabels) ? ticksY.v : yaxis.tickLabels;
1387
+ if (tickLabels.length !== ticksY.length) {
1388
+ console.error('YAxis (3D): "tickLabels" must be a array of the same size as ticks.');
1389
+ }
1390
+
1391
+ // combine all coordinates together
1392
+ const grid1 = [
1393
+ cbind(ticksX, ticksY, ticksZ),
1394
+ cbind(gridXEnd, ticksY, ticksZ)
1395
+ ];
1396
+
1397
+ const grid2 = [
1398
+ cbind(ticksX, ticksY, ticksZ),
1399
+ cbind(ticksX, ticksY, gridZEnd)
1400
+ ];
1401
+
1402
+ const axisLine = [
1403
+ matrix([limX[0], limY[0], limZ[0]], 1, 3),
1404
+ matrix([limX[0], limY[1], limZ[0]], 1, 3)
1405
+ ]
1406
+
1407
+ const tickCoords = [
1408
+ cbind(ticksX, ticksY, ticksZ), // middle point
1409
+ cbind(ticksX2, ticksY, ticksZ), // middle point with positive shift along X
1410
+ cbind(ticksX, ticksY, ticksZ2), // middle point with positive shift along Z
1411
+ cbind(ticksX1, ticksY, ticksZ1), // middle point with negative shift along X and Z
1412
+ cbind(ticksX3, ticksY, ticksZ3) // middle point with negative shift along X and Z for ticks
1413
+ ];
1414
+
1415
+ // here we do not need to make a matrix as the three values will be used as vectors
1416
+ const titleCoords = [
1417
+ [limX[0] - 2 * dX], [limY[1] + 3 * dY], [limZ[0] - 2 * dZ]
1418
+ ];
1419
+
1420
+ return {grid1, grid2, axisLine, tickCoords, titleCoords, tickLabels};
1421
+ }
1422
+
1423
+
1424
+ /**
1425
+ * Computes coordinates of all elements (lines, labels, etc) for z-axis ticks.
1426
+ * @param {Object} yaxis - JSON with z-axis paramaters.
1427
+ * @param {Array} limX - array with x-axis limits.
1428
+ * @param {Array} limY - array with y-axis limits.
1429
+ * @param {Array} limZ - array with z-axis limits.
1430
+ * @param {string} scale - current scale value.
1431
+ * @returns {Object} JSON with coordinates.
1432
+ */
1433
+ export function getZAxisCoords3D(zaxis, limX, limY, limZ, scale) {
1434
+
1435
+ const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1436
+ const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1437
+ const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1438
+ const ticksZ = getAxisTicks(zaxis.ticks, limZ, TICK_NUM[scale], true);
1439
+ const tickNum = ticksZ.length;
1440
+
1441
+ const [ticksY, ticksY1, ticksY2, ticksY3] = getLatentTicks(limY, tickNum);
1442
+ const [ticksX, ticksX1, ticksX2, ticksX3] = getLatentTicks(limX, tickNum);
1443
+
1444
+ // coordinates for the ends of grid
1445
+ const gridYEnd = Vector.fill(limY[1], tickNum);
1446
+ const gridXEnd = Vector.fill(limX[1], tickNum);
1447
+
1448
+ // tick labels
1449
+ const tickLabels = (!zaxis.ticks || !zaxis.tickLabels) ? ticksZ.v : zaxis.tickLabels;
1450
+ if (tickLabels.length !== ticksX.length) {
1451
+ console.error('XAxis (3D): "tickLabels" must be a array of the same size as ticks.');
1452
+ }
1453
+
1454
+ // combine all coordinates together
1455
+ const grid1 = [
1456
+ cbind(ticksX, ticksY, ticksZ),
1457
+ cbind(ticksX, gridYEnd, ticksZ)
1458
+ ];
1459
+
1460
+ const grid2 = [
1461
+ cbind(ticksX, ticksY, ticksZ),
1462
+ cbind(gridXEnd, ticksY, ticksZ)
1463
+ ];
1464
+
1465
+ const axisLine = [
1466
+ matrix([limX[0], limY[0], limZ[0]], 1, 3),
1467
+ matrix([limX[0], limY[0], limZ[1]], 1, 3)
1468
+ ]
1469
+
1470
+ const tickCoords = [
1471
+ cbind(ticksX, ticksY, ticksZ), // middle point
1472
+ cbind(ticksX, ticksY2, ticksZ), // middle point with positive shift along Y
1473
+ cbind(ticksX2, ticksY, ticksZ), // middle point with positive shift along X
1474
+ cbind(ticksX1, ticksY1, ticksZ), // middle point with negative shift along Y and X
1475
+ cbind(ticksX3, ticksY3, ticksZ) // middle point with negative shift along Y and X for ticks
1476
+ ];
1477
+
1478
+ // here we do not need to make a matrix as the three values will be used as vectors
1479
+ const titleCoords = [
1480
+ [limX[0] - 2 * dX], [limY[0] - 2 * dY], [limZ[1] + 3 * dZ]
1481
+ ];
1482
+
1483
+ return {grid1, grid2, axisLine, tickCoords, titleCoords, tickLabels};
1484
+ }