svelte-plots-basic 3.3.0 → 4.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  import { cbind, vector, isvector, Vector, matrix } from 'mdatools/arrays';
2
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';
3
+ import { TICK_NUM, XTICK_NUM, YTICK_NUM, TICK_SIZE, COLMAP_WIDTH_RATIO, COLMAP_EL_HEIGHTS_PX, COLMAP_TOP_MARGIN } from './constants.js';
4
4
 
5
5
 
6
6
  /**
@@ -31,6 +31,29 @@ export function checkArray(arr, len) {
31
31
  }
32
32
 
33
33
 
34
+ /**
35
+ * Validate a line type and fall back to a solid line when it is invalid.
36
+ * Numeric strings are accepted for compatibility with HTML-style component attributes.
37
+ *
38
+ * @param {number|string} lineType - line type (1 - solid, 2 - dashed, 3 - dotted, 4 - dashdot).
39
+ * @param {string} source - name of the component which called the method.
40
+ * @returns {number} normalized line type.
41
+ */
42
+ export function normalizeLineType(lineType, source = 'Plot') {
43
+ const canConvert =
44
+ typeof lineType === 'number' ||
45
+ (typeof lineType === 'string' && lineType.trim() !== '');
46
+ const normalized = canConvert ? Number(lineType) : NaN;
47
+
48
+ if (!Number.isInteger(normalized) || normalized < 1 || normalized > 4) {
49
+ console.error(`${source}: parameter "lineType" must be a whole number from 1 to 4.`);
50
+ return 1;
51
+ }
52
+
53
+ return normalized;
54
+ }
55
+
56
+
34
57
  /**
35
58
  * Generic function to transform x or y-values from plot coordinates to screen (SVG) coordinates.
36
59
  *
@@ -94,7 +117,8 @@ export function invTransformObjects(v, t) {
94
117
  */
95
118
  export function getTickFactorLabel(tf) {
96
119
  if (tf === 0) return '';
97
- return `<tspan>&times;10</tspan><tspan font-size="0.75em" dy="-0.5em">${(tf).toString()}</tspan>`;
120
+ return '<tspan dominant-baseline="inherit">&times;10</tspan>' +
121
+ `<tspan font-size="0.75em" dominant-baseline="inherit" baseline-shift="37.5%">${tf.toString()}</tspan>`;
98
122
  }
99
123
 
100
124
 
@@ -167,7 +191,7 @@ export function getTickLabels(ticks) {
167
191
 
168
192
 
169
193
  /**
170
- * Check coordinates and convert them to vector if necessary.
194
+ * Check coordinates, ensure all values are finite, and convert them to vector if necessary.
171
195
  *
172
196
  * @param {Array|Vector} x - vector or array with coordinates.
173
197
  * @param {string} source - name of component which called the method (needed for error message).
@@ -196,6 +220,13 @@ export function checkCoords(x, source, len) {
196
220
  return null;
197
221
  }
198
222
 
223
+ for (let i = 0; i < x.length; i++) {
224
+ if (!Number.isFinite(x.v[i])) {
225
+ console.error(source + ': coordinates must contain only finite numeric values.');
226
+ return null;
227
+ }
228
+ }
229
+
199
230
  return x;
200
231
  }
201
232
 
@@ -304,7 +335,7 @@ export function validateTicks(ticks, lim) {
304
335
  * @param {Array} ticks - vector with ticks if alredy available (if not, new will be computed).
305
336
  * @param {Array} lim - vector with axis limits tickets must be computed for.
306
337
  * @param {number} maxTickNum - maximum number of ticks to compute.
307
- * @param {boolean} round - round or not the fractions when computing nice numbers for the ticks.
338
+ * @param {boolean} round - round or not the fractions when computing nice numbers for the ticks, default: false.
308
339
  * @param {boolean} whole - should the ticks be a whole number.
309
340
  * @param {Number} deltaFactor - percent of range to use as internal margins
310
341
  *
@@ -314,7 +345,7 @@ export function validateTicks(ticks, lim) {
314
345
  export function getAxisTicks(ticks, limIn, maxTickNum, round, whole, deltaFactor) {
315
346
 
316
347
  if (round === undefined || round === null) {
317
- round = true;
348
+ round = false;
318
349
  }
319
350
 
320
351
  if (whole === undefined || whole === null) {
@@ -325,19 +356,37 @@ export function getAxisTicks(ticks, limIn, maxTickNum, round, whole, deltaFactor
325
356
  deltaFactor = 0.0001;
326
357
  }
327
358
 
328
- // correct limits internally if they are reverse
329
- const lim = limIn[0] < limIn[1] ? limIn : [limIn[1], limIn[0]];
330
-
331
359
  // if ticks are already provided do not recompute them
332
360
  // the validation is done in corresponding component, so no need to do it again
333
361
  if (ticks) return ticks;
334
362
 
335
363
  // check if limits are ok
336
- if (typeof(lim) !== "object" || lim[0] === undefined || lim[1] === undefined) {
364
+ if (!limIn || typeof(limIn) !== "object" || limIn[0] === undefined || limIn[1] === undefined) {
365
+ console.error('getAxisTicks: provided axis limits are not valid.');
366
+ return undefined;
367
+ }
368
+
369
+ const limValues = [Number(limIn[0]), Number(limIn[1])];
370
+ if (!limValues.every(Number.isFinite) || limValues[0] === limValues[1]) {
337
371
  console.error('getAxisTicks: provided axis limits are not valid.');
338
372
  return undefined;
339
373
  }
340
374
 
375
+ // correct limits internally if they are reverse
376
+ const lim = limValues[0] < limValues[1] ? limValues : [limValues[1], limValues[0]];
377
+
378
+ maxTickNum = Number(maxTickNum);
379
+ if (!Number.isInteger(maxTickNum) || maxTickNum < 1) {
380
+ console.error('getAxisTicks: parameter "maxTickNum" must be a finite positive whole number.');
381
+ return undefined;
382
+ }
383
+
384
+ deltaFactor = Number(deltaFactor);
385
+ if (!Number.isFinite(deltaFactor) || deltaFactor < 0 || deltaFactor >= 0.5) {
386
+ console.error('getAxisTicks: parameter "deltaFactor" must be a finite number from 0 up to, but not including, 0.5.');
387
+ return undefined;
388
+ }
389
+
341
390
  // get range as a nice number and compute min, max and steps for the tick sequence
342
391
  const delta = Math.abs((lim[1] - lim[0])) * deltaFactor;
343
392
  const localRange = lim[1] - lim[0] - 2 * delta;
@@ -345,7 +394,7 @@ export function getAxisTicks(ticks, limIn, maxTickNum, round, whole, deltaFactor
345
394
  const fraction = 1 / Math.pow(10, exponent - 1);
346
395
  const range = Math.round(localRange * fraction) / fraction;
347
396
 
348
- let tickSpacing = niceNum(range / maxTickNum, false);
397
+ let tickSpacing = niceNum(range / maxTickNum, round);
349
398
  if (whole) {
350
399
  if (Math.abs(lim[1] - lim[0]) <= 1) {
351
400
  console.error('getAxisTicks: parameter "whole" will be ignored as axis limits are too narrow to fit whole ticks.');
@@ -486,13 +535,20 @@ function createPngDataTable() {
486
535
  }
487
536
 
488
537
 
538
+ let pngDataTable = null;
539
+
540
+
489
541
  /**
490
542
  * Calculate CRC for PNG image
491
543
  * @param buf image data
492
544
  */
493
545
  function calcCrc(buf) {
494
546
  let c = -1;
495
- let pngDataTable = createPngDataTable();
547
+
548
+ if (!pngDataTable) {
549
+ pngDataTable = createPngDataTable();
550
+ }
551
+
496
552
  for (let n = 0; n < buf.length; n++) {
497
553
  c = pngDataTable[(c ^ buf[n]) & 0xFF] ^ (c >>> 8);
498
554
  }
@@ -586,26 +642,31 @@ function changeDPI(base64Image, dpi) {
586
642
  * @param {number} width - desired image width in cm
587
643
  * @param {number} height - desired image height in cm
588
644
  * @param {number} res - resolution (pixels per inch)
645
+ * @param {Function} [onSerialize] - callback invoked after serialization, or if export preparation fails
589
646
  *
590
647
  */
591
648
  export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
592
649
 
593
- if (!width) width = 10;
594
- if (!height) height = 10;
595
- if (!res) res = 300;
650
+ if (width === undefined || width === null || width === '') width = 10;
651
+ if (height === undefined || height === null || height === '') height = 10;
652
+ if (res === undefined || res === null || res === '') res = 300;
653
+
654
+ width = Number(width);
655
+ height = Number(height);
656
+ res = Number(res);
596
657
 
597
- if (width < 1 || width > 30) {
598
- console.error('Parameter "width" must be between 1 and 30 (cm).');
658
+ if (!Number.isFinite(width) || width < 1 || width > 30) {
659
+ console.error('Parameter "width" must be a finite number between 1 and 30 (cm).');
599
660
  return null;
600
661
  }
601
662
 
602
- if (height < 1 || height > 30) {
603
- console.error('Parameter "height" must be between 1 and 30 (cm).');
663
+ if (!Number.isFinite(height) || height < 1 || height > 30) {
664
+ console.error('Parameter "height" must be a finite number between 1 and 30 (cm).');
604
665
  return null;
605
666
  }
606
667
 
607
- if (res < 50 || res > 1200) {
608
- console.error('Parameter "res" must be between 50 and 1200 (ppi).');
668
+ if (!Number.isFinite(res) || res < 50 || res > 1200) {
669
+ console.error('Parameter "res" must be a finite number between 50 and 1200 (ppi).');
609
670
  return null;
610
671
  }
611
672
 
@@ -614,7 +675,7 @@ export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
614
675
  return null;
615
676
  }
616
677
 
617
- if (!fileName || fileName.trim() === '') {
678
+ if (typeof fileName !== 'string' || fileName.trim() === '') {
618
679
  fileName = 'plot';
619
680
  }
620
681
 
@@ -624,75 +685,105 @@ export function downloadPNG (svg, fileName, width, height, res, onSerialize) {
624
685
  const pxHeight = height / 2.54 * res;
625
686
 
626
687
  setTimeout(() => {
688
+ let url = null;
689
+ let callbackInvoked = false;
627
690
 
628
- // compute canvas size based on SVG size and desired PNG WIDTH in pixels
629
- const svgHeight = svg.clientHeight;
630
- const svgWidth = svg.clientWidth;
631
- let scaleFactor = Math.max(pxWidth / svgWidth, pxHeight / svgHeight);
632
-
633
- // this is needed if SVG image is larger than the future PNG image
634
- let specialScale = 1;
635
- if (scaleFactor < 1) {
636
- specialScale = scaleFactor;
637
- scaleFactor = 1;
691
+ function notifyCaller() {
692
+ if (callbackInvoked) return;
693
+ callbackInvoked = true;
694
+ if (typeof onSerialize === 'function') onSerialize();
638
695
  }
639
696
 
640
- // create a new canvas element
641
- const canvas = document.createElement('canvas');
642
- const context = canvas.getContext('2d');
643
-
644
- // set the canvas size to match the SVG element
645
- canvas.width = svgWidth * (specialScale < 1 ? specialScale : scaleFactor);
646
- canvas.height = svgHeight * (specialScale < 1 ? specialScale : scaleFactor);
647
-
648
- // save and set corresponding attributes for SVG element
649
- const origWidth = svg.getAttribute('width');
650
- const origHeight = svg.getAttribute('height');
651
- svg.setAttribute('width', svgWidth * scaleFactor);
652
- svg.setAttribute('height', svgHeight * scaleFactor);
697
+ try {
698
+ // compute canvas size based on SVG size and desired PNG WIDTH in pixels
699
+ const svgHeight = svg.clientHeight;
700
+ const svgWidth = svg.clientWidth;
653
701
 
654
- // draw a white background
655
- context.fillStyle = 'white';
656
- context.fillRect(0, 0, canvas.width, canvas.height);
657
- context.scale(scaleFactor, scaleFactor)
658
-
659
- // draw the SVG onto the canvas
660
- const svgXml = new XMLSerializer().serializeToString(svg);
661
- const blob = new Blob([svgXml], { type: 'image/svg+xml;charset=utf-8' });
662
- const url = URL.createObjectURL(blob);
663
-
664
- // restore original attributes
665
- origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
666
- origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
667
-
668
- // notify caller that serialization is done (used by ExportDialog to restore container)
669
- if (onSerialize) onSerialize();
670
-
671
- const img = new Image();
702
+ if (!Number.isFinite(svgWidth) || svgWidth <= 0 ||
703
+ !Number.isFinite(svgHeight) || svgHeight <= 0) {
704
+ throw new Error('The SVG must have finite, positive dimensions.');
705
+ }
672
706
 
673
- img.onload = function () {
707
+ let scaleFactor = Math.max(pxWidth / svgWidth, pxHeight / svgHeight);
674
708
 
675
- // downscale image if it must be smaller than the current SVG element
676
- if (specialScale < 1) {
677
- img.width = img.width * specialScale;
678
- img.height = img.height * specialScale;
709
+ // this is needed if SVG image is larger than the future PNG image
710
+ let specialScale = 1;
711
+ if (scaleFactor < 1) {
712
+ specialScale = scaleFactor;
713
+ scaleFactor = 1;
679
714
  }
680
715
 
681
- context.drawImage(img, 0, 0, canvas.width, canvas.height);
682
-
683
- // create a data URL for the canvas and adjust its DPI
684
- const dataURL = canvas.toDataURL('image/png');
685
- const dataURL2 = changeDPI(dataURL, res);
716
+ // create a new canvas element
717
+ const canvas = document.createElement('canvas');
718
+ const context = canvas.getContext('2d');
686
719
 
687
- // create a temporary link and trigger the download
688
- const downloadLink = document.createElement('a');
689
- downloadLink.href = dataURL2;
690
- downloadLink.download = `${fileName}.png`;
691
- downloadLink.click();
692
- };
720
+ if (!context) {
721
+ throw new Error('A 2D canvas context is not available.');
722
+ }
693
723
 
694
- // set the image source
695
- img.src = url;
724
+ // set the canvas size to match the SVG element
725
+ canvas.width = svgWidth * (specialScale < 1 ? specialScale : scaleFactor);
726
+ canvas.height = svgHeight * (specialScale < 1 ? specialScale : scaleFactor);
727
+
728
+ // clone the SVG and set export dimensions without changing the live plot
729
+ const exportSvg = svg.cloneNode(true);
730
+ exportSvg.setAttribute('width', svgWidth * scaleFactor);
731
+ exportSvg.setAttribute('height', svgHeight * scaleFactor);
732
+
733
+ // draw a white background
734
+ context.fillStyle = 'white';
735
+ context.fillRect(0, 0, canvas.width, canvas.height);
736
+ context.scale(scaleFactor, scaleFactor)
737
+
738
+ // draw the SVG onto the canvas
739
+ const svgXml = new XMLSerializer().serializeToString(exportSvg);
740
+ const blob = new Blob([svgXml], { type: 'image/svg+xml;charset=utf-8' });
741
+ url = URL.createObjectURL(blob);
742
+
743
+ // notify caller that serialization is done (used by ExportDialog to restore container)
744
+ notifyCaller();
745
+
746
+ const img = new Image();
747
+
748
+ img.onload = function () {
749
+ try {
750
+
751
+ // downscale image if it must be smaller than the current SVG element
752
+ if (specialScale < 1) {
753
+ img.width = img.width * specialScale;
754
+ img.height = img.height * specialScale;
755
+ }
756
+
757
+ context.drawImage(img, 0, 0, canvas.width, canvas.height);
758
+
759
+ // create a data URL for the canvas and adjust its DPI
760
+ const dataURL = canvas.toDataURL('image/png');
761
+ const dataURL2 = changeDPI(dataURL, res);
762
+
763
+ // create a temporary link and trigger the download
764
+ const downloadLink = document.createElement('a');
765
+ downloadLink.href = dataURL2;
766
+ downloadLink.download = `${fileName}.png`;
767
+ downloadLink.click();
768
+ } finally {
769
+ URL.revokeObjectURL(url);
770
+ url = null;
771
+ }
772
+ };
773
+
774
+ img.onerror = function (error) {
775
+ URL.revokeObjectURL(url);
776
+ url = null;
777
+ console.error('Failed to load the SVG for PNG export.', error);
778
+ };
779
+
780
+ // set the image source
781
+ img.src = url;
782
+ } catch (error) {
783
+ if (url !== null) URL.revokeObjectURL(url);
784
+ notifyCaller();
785
+ console.error('Failed to prepare the SVG for PNG export.', error);
786
+ }
696
787
  }, 20);
697
788
  }
698
789
 
@@ -718,26 +809,19 @@ export function downloadSVG(svg, fileName) {
718
809
  const svgHeight = svg.clientHeight;
719
810
  const svgWidth = svg.clientWidth;
720
811
 
721
- // save original attributes
722
- const origWidth = svg.getAttribute('width');
723
- const origHeight = svg.getAttribute('height');
724
- const origViewBox = svg.getAttribute('viewBox');
725
-
726
- svg.setAttribute('width', svgWidth + 'px');
727
- svg.setAttribute('height', svgHeight + 'px');
728
- svg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
812
+ // clone the SVG and set export dimensions without changing the live plot
813
+ const exportSvg = svg.cloneNode(true);
814
+ exportSvg.setAttribute('width', svgWidth + 'px');
815
+ exportSvg.setAttribute('height', svgHeight + 'px');
816
+ exportSvg.setAttribute('viewBox', `0 0 ${svgWidth} ${svgHeight}`);
729
817
 
730
- const blob = new Blob([svg.outerHTML], { type: 'image/svg+xml' });
818
+ const blob = new Blob([exportSvg.outerHTML], { type: 'image/svg+xml' });
731
819
  const url = window.URL.createObjectURL(blob)
732
820
  const a = document.createElement('a')
733
821
  a.setAttribute('href', url)
734
822
  a.setAttribute('download', `${fileName}.svg`);
735
823
  a.click()
736
-
737
- // restore original attributes
738
- origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
739
- origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
740
- origViewBox ? svg.setAttribute('viewBox', origViewBox) : svg.removeAttribute('viewBox');
824
+ setTimeout(() => window.URL.revokeObjectURL(url), 0);
741
825
  }
742
826
 
743
827
  /**
@@ -752,36 +836,43 @@ export function copyToClipboard(btn, svg, width, height) {
752
836
  // save button conent
753
837
  const content = btn.textContent;
754
838
 
755
- navigator.clipboard.write([
756
- new ClipboardItem({ 'image/png': createPngBlob(svg, width, height) })
757
- ])
758
- .then(
759
- () => {
760
- // add class "copied" for visual confirmation
761
- btn.classList.add('copied');
762
- btn.textContent = '✓ done';
763
-
764
- // Remove the class after 0.5 seconds and revert the text
765
- setTimeout(() => {
766
- btn.classList.remove('copied');
767
- btn.textContent = content;
768
- }, 500);
769
- },
770
- (e) => {
771
- console.error(e)
772
-
773
- // add class "error" for visual confirmation
774
- btn.classList.add('error');
775
- btn.textContent = 'x copy';
776
-
777
- // Remove the class after 0.5 seconds and revert the text
778
- setTimeout(() => {
779
- btn.classList.remove('error');
780
- btn.textContent = content;
781
- }, 500);
782
- }
783
- )
784
- .catch((error) => console.error(error));
839
+ function showStatus(className, text) {
840
+ btn.classList.add(className);
841
+ btn.textContent = text;
842
+
843
+ // Remove the class after 0.5 seconds and revert the text
844
+ setTimeout(() => {
845
+ btn.classList.remove(className);
846
+ btn.textContent = content;
847
+ }, 500);
848
+ }
849
+
850
+ function showError(error) {
851
+ console.error(error);
852
+ showStatus('error', 'x copy');
853
+ }
854
+
855
+ if (typeof navigator === 'undefined' ||
856
+ !navigator.clipboard ||
857
+ typeof navigator.clipboard.write !== 'function' ||
858
+ typeof ClipboardItem === 'undefined') {
859
+ showError(new Error('Copying images to the clipboard is not supported in this environment.'));
860
+ return null;
861
+ }
862
+
863
+ try {
864
+ navigator.clipboard.write([
865
+ new ClipboardItem({ 'image/png': createPngBlob(svg, width, height) })
866
+ ])
867
+ .then(
868
+ () => showStatus('copied', '✓ done'),
869
+ showError
870
+ )
871
+ .catch(showError);
872
+ } catch (error) {
873
+ showError(error);
874
+ return null;
875
+ }
785
876
 
786
877
  }
787
878
 
@@ -813,16 +904,11 @@ export async function createPngBlob(svg, width, height) {
813
904
  // scale the context
814
905
  context.scale(scaleFactor, scaleFactor)
815
906
 
816
- // save and set corresponding attributes for SVG element
817
- const origWidth = svg.getAttribute('width');
818
- const origHeight = svg.getAttribute('height');
819
- svg.setAttribute('width', canvas.width);
820
- svg.setAttribute('height', canvas.height);
821
-
822
- // serialize SVG with export dimensions, then restore original attributes
823
- const svgData = new XMLSerializer().serializeToString(svg);
824
- origWidth ? svg.setAttribute('width', origWidth) : svg.removeAttribute('width');
825
- origHeight ? svg.setAttribute('height', origHeight) : svg.removeAttribute('height');
907
+ // clone and serialize the SVG with export dimensions without changing the live plot
908
+ const exportSvg = svg.cloneNode(true);
909
+ exportSvg.setAttribute('width', canvas.width);
910
+ exportSvg.setAttribute('height', canvas.height);
911
+ const svgData = new XMLSerializer().serializeToString(exportSvg);
826
912
 
827
913
  // create promise
828
914
  const promise = new Promise((resolve, reject) => {
@@ -852,6 +938,7 @@ export async function createPngBlob(svg, width, height) {
852
938
 
853
939
  // add error callback
854
940
  img.onerror = function (err) {
941
+ DOMURL.revokeObjectURL(url);
855
942
  reject(err);
856
943
  };
857
944
 
@@ -864,6 +951,66 @@ export async function createPngBlob(svg, width, height) {
864
951
  }
865
952
 
866
953
 
954
+ let colmaps = null;
955
+
956
+
957
+ function getColmaps() {
958
+ if (!colmaps) {
959
+ colmaps = [
960
+ ["#2679B2"],
961
+ ["#2679B2", "#D22C2F"],
962
+ ["#2679B2", "#92B42A", "#D22C2F"],
963
+ ["#2679B2", "#2E9658", "#F2B825", "#D22C2F"],
964
+ ["#2679B2", "#22988A", "#92B42A", "#F79426", "#D22C2F"],
965
+ ["#2679B2", "#1C9AA8", "#379531", "#EED524", "#FB7F28", "#D22C2F"],
966
+ ["#2679B2", "#1D94A9", "#2E9658", "#92B42A", "#F2B825", "#F47129", "#D22C2F"],
967
+ ["#2679B2", "#1E90AA", "#279775", "#519E2F", "#D3CB25", "#F5A326", "#EF672A", "#D22C2F"],
968
+ ["#2679B2", "#1F8DAB", "#22988A", "#33953F", "#92B42A", "#EFCA24", "#F79426", "#EB5F2A", "#D22C2F"],
969
+ ["#2679B2", "#208BAC", "#1E999A", "#2E9658", "#5FA32E", "#C5C626", "#F2B825", "#F98827", "#E85A2B", "#D22C2F"],
970
+ ["#2679B2", "#2089AD", "#1C9AA8", "#29976C", "#379531", "#92B42A", "#EED524", "#F4AA26", "#FB7F28", "#E6552B", "#D22C2F"],
971
+ ["#2679B2", "#2188AD", "#1C97A8", "#25987C", "#329546", "#68A62D", "#BCC327", "#F0C524", "#F69E26", "#F77728", "#E4512B", "#D22C2F"],
972
+ ["#2679B2", "#2186AD", "#1D94A9", "#22988A", "#2E9658", "#469A2F", "#92B42A", "#DECF25", "#F2B825", "#F79426", "#F47129", "#E34E2C", "#D22C2F"],
973
+ ["#2679B2", "#2285AE", "#1E92AA", "#209995", "#2A9767", "#34953A", "#6FA82D", "#B5C128", "#EFCE24", "#F3AD25", "#F98C27", "#F16B29", "#E14B2C", "#D22C2F"],
974
+ ["#2679B2", "#2284AE", "#1E90AA", "#1D999F", "#279775", "#31964A", "#519E2F", "#92B42A", "#D3CB25", "#F0C224", "#F5A326", "#FA8527", "#EF672A", "#E0492C", "#D22C2F"],
975
+ ["#2679B2", "#2283AE", "#1F8FAB", "#1C9AA8", "#249880", "#2E9658", "#379531", "#73AA2C", "#B0BF28", "#EDD424", "#F2B825", "#F69B26", "#FB7F28", "#ED632A", "#DF472C", "#D22C2F"]
976
+ ];
977
+ }
978
+
979
+ return colmaps;
980
+ }
981
+
982
+
983
+ function interpolateColor(start, end, amount) {
984
+ const channels = [1, 3, 5].map(offset => {
985
+ const from = parseInt(start.slice(offset, offset + 2), 16);
986
+ const to = parseInt(end.slice(offset, offset + 2), 16);
987
+
988
+ return Math.round(from + (to - from) * amount)
989
+ .toString(16)
990
+ .padStart(2, '0');
991
+ });
992
+
993
+ return `#${channels.join('')}`.toUpperCase();
994
+ }
995
+
996
+
997
+ function expandColmap(colors, length) {
998
+ const last = colors.length - 1;
999
+
1000
+ return Array.from({length}, (_, index) => {
1001
+ const position = index * last / (length - 1);
1002
+ const start = Math.floor(position);
1003
+ const end = Math.min(start + 1, last);
1004
+
1005
+ return interpolateColor(
1006
+ colors[start],
1007
+ colors[end],
1008
+ position - start
1009
+ );
1010
+ });
1011
+ }
1012
+
1013
+
867
1014
  /**
868
1015
  * Returns an array with 'n' colors.
869
1016
  *
@@ -874,27 +1021,14 @@ export async function createPngBlob(svg, width, height) {
874
1021
  *
875
1022
  */
876
1023
  export function getcolmap(n, alpha) {
877
- if (n > 16) n = 16;
878
- const colmap = [
879
- ["#2679B2"],
880
- ["#2679B2", "#D22C2F"],
881
- ["#2679B2", "#92B42A", "#D22C2F"],
882
- ["#2679B2", "#2E9658", "#F2B825", "#D22C2F"],
883
- ["#2679B2", "#22988A", "#92B42A", "#F79426", "#D22C2F"],
884
- ["#2679B2", "#1C9AA8", "#379531", "#EED524", "#FB7F28", "#D22C2F"],
885
- ["#2679B2", "#1D94A9", "#2E9658", "#92B42A", "#F2B825", "#F47129", "#D22C2F"],
886
- ["#2679B2", "#1E90AA", "#279775", "#519E2F", "#D3CB25", "#F5A326", "#EF672A", "#D22C2F"],
887
- ["#2679B2", "#1F8DAB", "#22988A", "#33953F", "#92B42A", "#EFCA24", "#F79426", "#EB5F2A", "#D22C2F"],
888
- ["#2679B2", "#208BAC", "#1E999A", "#2E9658", "#5FA32E", "#C5C626", "#F2B825", "#F98827", "#E85A2B", "#D22C2F"],
889
- ["#2679B2", "#2089AD", "#1C9AA8", "#29976C", "#379531", "#92B42A", "#EED524", "#F4AA26", "#FB7F28", "#E6552B", "#D22C2F"],
890
- ["#2679B2", "#2188AD", "#1C97A8", "#25987C", "#329546", "#68A62D", "#BCC327", "#F0C524", "#F69E26", "#F77728", "#E4512B", "#D22C2F"],
891
- ["#2679B2", "#2186AD", "#1D94A9", "#22988A", "#2E9658", "#469A2F", "#92B42A", "#DECF25", "#F2B825", "#F79426", "#F47129", "#E34E2C", "#D22C2F"],
892
- ["#2679B2", "#2285AE", "#1E92AA", "#209995", "#2A9767", "#34953A", "#6FA82D", "#B5C128", "#EFCE24", "#F3AD25", "#F98C27", "#F16B29", "#E14B2C", "#D22C2F"],
893
- ["#2679B2", "#2284AE", "#1E90AA", "#1D999F", "#279775", "#31964A", "#519E2F", "#92B42A", "#D3CB25", "#F0C224", "#F5A326", "#FA8527", "#EF672A", "#E0492C", "#D22C2F"],
894
- ["#2679B2", "#2283AE", "#1F8FAB", "#1C9AA8", "#249880", "#2E9658", "#379531", "#73AA2C", "#B0BF28", "#EDD424", "#F2B825", "#F69B26", "#FB7F28", "#ED632A", "#DF472C", "#D22C2F"]
895
- ]
1024
+ const palettes = getColmaps();
1025
+ const colors = n <= 16
1026
+ ? palettes[n - 1]?.slice()
1027
+ : expandColmap(palettes[15], n);
1028
+
1029
+ if (!colors) return colors;
896
1030
 
897
- return alpha ? colmap[n - 1].map(v => v + alpha) : colmap[n - 1];
1031
+ return alpha ? colors.map(v => v + alpha) : colors;
898
1032
  }
899
1033
 
900
1034
 
@@ -917,7 +1051,7 @@ export function getXAxisParams(limX, limY, scales, tY, axis) {
917
1051
  let tickFactor = 0;
918
1052
 
919
1053
  // compute x-coordinates of the ticks or take the ones manually specified by user
920
- const ticksX = getAxisTicks(ticks, limX, XTICK_NUM[scales.x], true, axis.whole);
1054
+ const ticksX = getAxisTicks(ticks, limX, XTICK_NUM[scales.x], false, axis.whole);
921
1055
  const tickNum = ticksX.length;
922
1056
 
923
1057
  // compute tick y-coordinates (up and bottom)
@@ -1001,7 +1135,7 @@ export function getYAxisParams(limX, limY, scales, tX, axis) {
1001
1135
  let tickFactor = 0;
1002
1136
 
1003
1137
  // compute x-coordinates of the ticks or take the ones manually specified by user
1004
- const ticksY = getAxisTicks(axis.ticks, limY, YTICK_NUM[scales.y], true, axis.whole);
1138
+ const ticksY = getAxisTicks(axis.ticks, limY, YTICK_NUM[scales.y], false, axis.whole);
1005
1139
  const tickNum = ticksY.length;
1006
1140
 
1007
1141
  // compute tick y-coordinates (up and bottom)
@@ -1175,11 +1309,16 @@ export function handleClick(e, tagName, onclickCallback) {
1175
1309
  *
1176
1310
  * @return {number} size of element in pixels
1177
1311
  */
1312
+ let textMeasureContext = null;
1313
+
1178
1314
  export function getTextWidth(text, font) {
1179
- const element = document.createElement('canvas');
1180
- const context = element.getContext('2d');
1181
- context.font = font;
1182
- return context.measureText(text.replace(/<(.|\n)*?>/g, '')).width;
1315
+ if (!textMeasureContext) {
1316
+ const canvas = document.createElement('canvas');
1317
+ textMeasureContext = canvas.getContext('2d');
1318
+ }
1319
+
1320
+ textMeasureContext.font = font;
1321
+ return textMeasureContext.measureText(text.replace(/<(.|\n)*?>/g, '')).width;
1183
1322
  }
1184
1323
 
1185
1324
 
@@ -1188,12 +1327,11 @@ export function getTextWidth(text, font) {
1188
1327
  *
1189
1328
  * @param {Array} items - array of legend items and their properties.
1190
1329
  * @param {number} fontSize - font size of a legend element in pixels.
1191
- * @param {string} position - position of the legend.
1330
+ * @param {string} orientation - arrangement of legend items.
1192
1331
  *
1193
- * @return {Array} array with height and width of graphical part of legend item, padding size,
1194
- * height and width of text labels (all in pixels).
1332
+ * @return {Object} coordinates and sizes of legend items (all in pixels).
1195
1333
  */
1196
- function getLegendSize(items, fontSize) {
1334
+ function getLegendSize(items, fontSize, orientation = 'vertical') {
1197
1335
 
1198
1336
  // TODO: implement dynamic font family
1199
1337
  const fontName = 'Arial';
@@ -1203,25 +1341,49 @@ function getLegendSize(items, fontSize) {
1203
1341
  const elw = fontSize * 1.5; // width of legend element without label and padding
1204
1342
 
1205
1343
  // compute size of text label elements
1206
- // TODO: implement one row legend if position is "top" or "bottom"
1344
+ const elx = Array(items.length);
1207
1345
  const elh = Array(items.length);
1208
1346
  const ely = Array(items.length);
1209
- let y = 0;
1347
+ const eliw = Array(items.length);
1210
1348
  let lgh = 0;
1349
+ let lgw = 0;
1350
+
1351
+ if (orientation === 'horizontal') {
1352
+ for (let i = 0; i < items.length; i++) {
1353
+ const labelWidth = getTextWidth(' ' + items[i].label + ' ', fontSize + 'px ' + fontName);
1354
+ const itemWidth = elw + 2 * elp + labelWidth;
1355
+ const itemHeight = fontSize * items[i].labelHeight + 2 * elp;
1356
+
1357
+ elx[i] = lgw;
1358
+ ely[i] = 0;
1359
+ eliw[i] = itemWidth;
1360
+ elh[i] = itemHeight;
1361
+ lgw = lgw + itemWidth;
1362
+ lgh = itemHeight > lgh ? itemHeight : lgh;
1363
+ }
1364
+
1365
+ // Use one common row height so all item centers are aligned.
1366
+ elh.fill(lgh);
1367
+ return {elx, ely, eliw, elh, elw, elp, lgh, lgw};
1368
+ }
1369
+
1370
+ let y = 0;
1211
1371
  let lbw = 0;
1212
1372
 
1213
1373
  for (let i = 0; i < items.length; i++) {
1214
1374
  const w = getTextWidth(' ' + items[i].label + ' ', fontSize + 'px ' + fontName);
1215
1375
  lbw = w > lbw ? w : lbw;
1216
1376
  const h = fontSize * items[i].labelHeight + 2 * elp;
1377
+ elx[i] = 0;
1217
1378
  elh[i] = h;
1218
1379
  ely[i] = y;
1219
1380
  y = y + h;
1220
1381
  lgh = lgh + h;
1221
1382
  }
1222
1383
 
1223
- const lgw = (elw + 2 * elp + lbw);
1224
- return [ely, elh, elw, elp, lgh, lgw];
1384
+ lgw = (elw + 2 * elp + lbw);
1385
+ eliw.fill(lgw);
1386
+ return {elx, ely, eliw, elh, elw, elp, lgh, lgw};
1225
1387
  }
1226
1388
 
1227
1389
 
@@ -1236,7 +1398,11 @@ function getLegendSize(items, fontSize) {
1236
1398
  export function getGroupLegendCoords(params, cpx, cpy, fontSize, ts) {
1237
1399
 
1238
1400
  // compute size of legend elements
1239
- const [ely, elh, elw, elp, lgh, lgw] = getLegendSize(params.items, fontSize * params.fontSize);
1401
+ const {elx, ely, eliw, elh, elw, elp, lgh, lgw} = getLegendSize(
1402
+ params.items,
1403
+ fontSize * params.fontSize,
1404
+ params.orientation
1405
+ );
1240
1406
 
1241
1407
  // compute coordinates of top left corner of the legend box
1242
1408
  const lgl = params.position.includes("left") ? cpx[0] + ts :
@@ -1248,7 +1414,7 @@ export function getGroupLegendCoords(params, cpx, cpy, fontSize, ts) {
1248
1414
  (cpy[1] + cpy[0] - lgh) * 0.5;
1249
1415
 
1250
1416
 
1251
- return {lgl, lgt, lgw, lgh, ely, elh, elw, elp};
1417
+ return {lgl, lgt, lgw, lgh, elx, ely, eliw, elh, elw, elp};
1252
1418
  }
1253
1419
 
1254
1420
 
@@ -1262,11 +1428,11 @@ export function text2svg(text) {
1262
1428
  if (!text || text.length < 1) return text;
1263
1429
 
1264
1430
  function toSuper(txt) {
1265
- return '<tspan font-size="0.75em" dy="-0.5em">' + txt + '</tspan><tspan dy="0.4em"> </tspan>';
1431
+ return '<tspan font-size="0.6em" dominant-baseline="inherit" baseline-shift="30%">' + txt + '</tspan>';
1266
1432
  }
1267
1433
 
1268
1434
  function toSub(txt) {
1269
- return '<tspan font-size="0.75em" dy="0.5em">' + txt + '</tspan><tspan dy="-0.4em"> </tspan>';
1435
+ return '<tspan font-size="0.6em" dominant-baseline="inherit" baseline-shift="-50%">' + txt + '</tspan>';
1270
1436
  }
1271
1437
 
1272
1438
  let i = 0;
@@ -1351,7 +1517,7 @@ export function getXAxisCoords3D(xaxis, limX, limY, limZ, scale) {
1351
1517
  const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1352
1518
  const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1353
1519
  const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1354
- const ticksX = getAxisTicks(xaxis.ticks, limX, TICK_NUM[scale], true);
1520
+ const ticksX = getAxisTicks(xaxis.ticks, limX, TICK_NUM[scale], false);
1355
1521
  const tickNum = ticksX.length;
1356
1522
 
1357
1523
  const [ticksY, ticksY1, ticksY2, ticksY3] = getLatentTicks(limY, tickNum);
@@ -1414,7 +1580,7 @@ export function getYAxisCoords3D(yaxis, limX, limY, limZ, scale) {
1414
1580
  const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1415
1581
  const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1416
1582
  const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1417
- const ticksY = getAxisTicks(yaxis.ticks, limY, TICK_NUM[scale], true);
1583
+ const ticksY = getAxisTicks(yaxis.ticks, limY, TICK_NUM[scale], false);
1418
1584
  const tickNum = ticksY.length;
1419
1585
 
1420
1586
  const [ticksX, ticksX1, ticksX2, ticksX3] = getLatentTicks(limX, tickNum);
@@ -1477,7 +1643,7 @@ export function getZAxisCoords3D(zaxis, limX, limY, limZ, scale) {
1477
1643
  const dX = (limX[1] - limX[0]) / 100; // 1% of axis size
1478
1644
  const dY = (limY[1] - limY[0]) / 100; // 1% of axis size
1479
1645
  const dZ = (limZ[1] - limZ[0]) / 100; // 1% of axis size
1480
- const ticksZ = getAxisTicks(zaxis.ticks, limZ, TICK_NUM[scale], true);
1646
+ const ticksZ = getAxisTicks(zaxis.ticks, limZ, TICK_NUM[scale], false);
1481
1647
  const tickNum = ticksZ.length;
1482
1648
 
1483
1649
  const [ticksY, ticksY1, ticksY2, ticksY3] = getLatentTicks(limY, tickNum);
@@ -1523,4 +1689,4 @@ export function getZAxisCoords3D(zaxis, limX, limY, limZ, scale) {
1523
1689
  ];
1524
1690
 
1525
1691
  return {grid1, grid2, axisLine, tickCoords, titleCoords, tickLabels};
1526
- }
1692
+ }