]> git.ipfire.org Git - thirdparty/Chart.js.git/commitdiff
Refine logarithmic scaling / tick generation (#9166)
authorJukka Kurkela <jukka.kurkela@gmail.com>
Mon, 22 Aug 2022 18:05:27 +0000 (21:05 +0300)
committerGitHub <noreply@github.com>
Mon, 22 Aug 2022 18:05:27 +0000 (14:05 -0400)
* Refine logarithmic scaling / tick generation

* Disable autoSkip on reverese test

* Reduce ticks, fix min

14 files changed:
src/core/core.ticks.js
src/scales/scale.logarithmic.js
test/fixtures/scale.logarithmic/large-range.js [new file with mode: 0644]
test/fixtures/scale.logarithmic/large-range.png [new file with mode: 0644]
test/fixtures/scale.logarithmic/large-values-small-range.js [new file with mode: 0644]
test/fixtures/scale.logarithmic/large-values-small-range.png [new file with mode: 0644]
test/fixtures/scale.logarithmic/med-range.js [new file with mode: 0644]
test/fixtures/scale.logarithmic/med-range.png [new file with mode: 0644]
test/fixtures/scale.logarithmic/min-max.js [new file with mode: 0644]
test/fixtures/scale.logarithmic/min-max.png [new file with mode: 0644]
test/fixtures/scale.logarithmic/small-range.js [new file with mode: 0644]
test/fixtures/scale.logarithmic/small-range.png [new file with mode: 0644]
test/specs/core.ticks.tests.js
test/specs/scale.logarithmic.tests.js

index 333a7306e8ce955e79c66127fdb0229fdd469eca..8050f574ba431e5dd85ae58365d6a3086310f2df 100644 (file)
@@ -66,8 +66,8 @@ const formatters = {
     if (tickValue === 0) {
       return '0';
     }
-    const remain = tickValue / (Math.pow(10, Math.floor(log10(tickValue))));
-    if (remain === 1 || remain === 2 || remain === 5) {
+    const remain = ticks[index].significand || (tickValue / (Math.pow(10, Math.floor(log10(tickValue)))));
+    if ([1, 2, 3, 5, 10, 15].includes(remain) || index > 0.8 * ticks.length) {
       return formatters.numeric.call(this, tickValue, index, ticks);
     }
     return '';
index cbf33c6ed5b961e5beb57b533d56c2cfc22dcf7c..8f32807e79a1830810807468b35df30bf75f2756 100644 (file)
@@ -5,41 +5,68 @@ import Scale from '../core/core.scale';
 import LinearScaleBase from './scale.linearbase';
 import Ticks from '../core/core.ticks';
 
+const log10Floor = v => Math.floor(log10(v));
+const changeExponent = (v, m) => Math.pow(10, log10Floor(v) + m);
+
 function isMajor(tickVal) {
-  const remain = tickVal / (Math.pow(10, Math.floor(log10(tickVal))));
+  const remain = tickVal / (Math.pow(10, log10Floor(tickVal)));
   return remain === 1;
 }
 
+function steps(min, max, rangeExp) {
+  const rangeStep = Math.pow(10, rangeExp);
+  const start = Math.floor(min / rangeStep);
+  const end = Math.ceil(max / rangeStep);
+  return end - start;
+}
+
+function startExp(min, max) {
+  const range = max - min;
+  let rangeExp = log10Floor(range);
+  while (steps(min, max, rangeExp) > 10) {
+    rangeExp++;
+  }
+  while (steps(min, max, rangeExp) < 10) {
+    rangeExp--;
+  }
+  return Math.min(rangeExp, log10Floor(min));
+}
+
+
 /**
  * Generate a set of logarithmic ticks
  * @param generationOptions the options used to generate the ticks
  * @param dataRange the range of the data
  * @returns {object[]} array of tick objects
  */
-function generateTicks(generationOptions, dataRange) {
-  const endExp = Math.floor(log10(dataRange.max));
-  const endSignificand = Math.ceil(dataRange.max / Math.pow(10, endExp));
+function generateTicks(generationOptions, {min, max}) {
+  min = finiteOrDefault(generationOptions.min, min);
   const ticks = [];
-  let tickVal = finiteOrDefault(generationOptions.min, Math.pow(10, Math.floor(log10(dataRange.min))));
-  let exp = Math.floor(log10(tickVal));
-  let significand = Math.floor(tickVal / Math.pow(10, exp));
+  const minExp = log10Floor(min);
+  let exp = startExp(min, max);
   let precision = exp < 0 ? Math.pow(10, Math.abs(exp)) : 1;
-
-  do {
-    ticks.push({value: tickVal, major: isMajor(tickVal)});
-
-    ++significand;
-    if (significand === 10) {
-      significand = 1;
-      ++exp;
+  const stepSize = Math.pow(10, exp);
+  const base = minExp > exp ? Math.pow(10, minExp) : 0;
+  const start = Math.round((min - base) * precision) / precision;
+  const offset = Math.floor((min - base) / stepSize / 10) * stepSize * 10;
+  let significand = Math.floor((start - offset) / Math.pow(10, exp));
+  let value = finiteOrDefault(generationOptions.min, Math.round((base + offset + significand * Math.pow(10, exp)) * precision) / precision);
+  while (value < max) {
+    ticks.push({value, major: isMajor(value), significand});
+    if (significand >= 10) {
+      significand = significand < 15 ? 15 : 20;
+    } else {
+      significand++;
+    }
+    if (significand >= 20) {
+      exp++;
+      significand = 2;
       precision = exp >= 0 ? 1 : precision;
     }
-
-    tickVal = Math.round(significand * Math.pow(10, exp) * precision) / precision;
-  } while (exp < endExp || (exp === endExp && significand < endSignificand));
-
-  const lastTick = finiteOrDefault(generationOptions.max, tickVal);
-  ticks.push({value: lastTick, major: isMajor(tickVal)});
+    value = Math.round((base + offset + significand * Math.pow(10, exp)) * precision) / precision;
+  }
+  const lastTick = finiteOrDefault(generationOptions.max, value);
+  ticks.push({value: lastTick, major: isMajor(lastTick), significand});
 
   return ticks;
 }
@@ -92,6 +119,12 @@ export default class LogarithmicScale extends Scale {
       this._zero = true;
     }
 
+    // if data has `0` in it or `beginAtZero` is true, min (non zero) value is at bottom
+    // of scale, and it does not equal suggestedMin, lower the min bound by one exp.
+    if (this._zero && this.min !== this._suggestedMin && !isFinite(this._userMin)) {
+      this.min = min === changeExponent(this.min, 0) ? changeExponent(this.min, -1) : changeExponent(this.min, 0);
+    }
+
     this.handleTickRangeOptions();
   }
 
@@ -102,28 +135,24 @@ export default class LogarithmicScale extends Scale {
 
     const setMin = v => (min = minDefined ? min : v);
     const setMax = v => (max = maxDefined ? max : v);
-    const exp = (v, m) => Math.pow(10, Math.floor(log10(v)) + m);
 
     if (min === max) {
       if (min <= 0) { // includes null
         setMin(1);
         setMax(10);
       } else {
-        setMin(exp(min, -1));
-        setMax(exp(max, +1));
+        setMin(changeExponent(min, -1));
+        setMax(changeExponent(max, +1));
       }
     }
     if (min <= 0) {
-      setMin(exp(max, -1));
+      setMin(changeExponent(max, -1));
     }
     if (max <= 0) {
-      setMax(exp(min, +1));
-    }
-    // if data has `0` in it or `beginAtZero` is true, min (non zero) value is at bottom
-    // of scale, and it does not equal suggestedMin, lower the min bound by one exp.
-    if (this._zero && this.min !== this._suggestedMin && min === exp(this.min, 0)) {
-      setMin(exp(min, -1));
+
+      setMax(changeExponent(min, +1));
     }
+
     this.min = min;
     this.max = max;
   }
diff --git a/test/fixtures/scale.logarithmic/large-range.js b/test/fixtures/scale.logarithmic/large-range.js
new file mode 100644 (file)
index 0000000..ba12383
--- /dev/null
@@ -0,0 +1,31 @@
+module.exports = {
+  config: {
+    type: 'line',
+    data: {
+      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+      datasets: [{
+        backgroundColor: 'red',
+        borderColor: 'red',
+        fill: false,
+        data: [23, 21, 34, 52, 115, 3333, 5116]
+      }]
+    },
+    options: {
+      responsive: true,
+      scales: {
+        x: {
+          display: false,
+        },
+        y: {
+          type: 'logarithmic',
+          ticks: {
+            autoSkip: false
+          }
+        }
+      }
+    }
+  },
+  options: {
+    spriteText: true
+  }
+};
diff --git a/test/fixtures/scale.logarithmic/large-range.png b/test/fixtures/scale.logarithmic/large-range.png
new file mode 100644 (file)
index 0000000..13e4538
Binary files /dev/null and b/test/fixtures/scale.logarithmic/large-range.png differ
diff --git a/test/fixtures/scale.logarithmic/large-values-small-range.js b/test/fixtures/scale.logarithmic/large-values-small-range.js
new file mode 100644 (file)
index 0000000..726d493
--- /dev/null
@@ -0,0 +1,31 @@
+module.exports = {
+  config: {
+    type: 'line',
+    data: {
+      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+      datasets: [{
+        backgroundColor: 'red',
+        borderColor: 'red',
+        fill: false,
+        data: [5000.002, 5000.012, 5000.01, 5000.03, 5000.04, 5000.004, 5000.032]
+      }]
+    },
+    options: {
+      responsive: true,
+      scales: {
+        x: {
+          display: false,
+        },
+        y: {
+          type: 'logarithmic',
+          ticks: {
+            autoSkip: false
+          }
+        }
+      }
+    }
+  },
+  options: {
+    spriteText: true
+  }
+};
diff --git a/test/fixtures/scale.logarithmic/large-values-small-range.png b/test/fixtures/scale.logarithmic/large-values-small-range.png
new file mode 100644 (file)
index 0000000..47c7039
Binary files /dev/null and b/test/fixtures/scale.logarithmic/large-values-small-range.png differ
diff --git a/test/fixtures/scale.logarithmic/med-range.js b/test/fixtures/scale.logarithmic/med-range.js
new file mode 100644 (file)
index 0000000..a6191fb
--- /dev/null
@@ -0,0 +1,31 @@
+module.exports = {
+  config: {
+    type: 'line',
+    data: {
+      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+      datasets: [{
+        backgroundColor: 'red',
+        borderColor: 'red',
+        fill: false,
+        data: [25, 24, 27, 32, 45, 30, 28]
+      }]
+    },
+    options: {
+      responsive: true,
+      scales: {
+        x: {
+          display: false,
+        },
+        y: {
+          type: 'logarithmic',
+          ticks: {
+            autoSkip: false
+          }
+        }
+      }
+    }
+  },
+  options: {
+    spriteText: true
+  }
+};
diff --git a/test/fixtures/scale.logarithmic/med-range.png b/test/fixtures/scale.logarithmic/med-range.png
new file mode 100644 (file)
index 0000000..ed9b5bf
Binary files /dev/null and b/test/fixtures/scale.logarithmic/med-range.png differ
diff --git a/test/fixtures/scale.logarithmic/min-max.js b/test/fixtures/scale.logarithmic/min-max.js
new file mode 100644 (file)
index 0000000..ff57718
--- /dev/null
@@ -0,0 +1,33 @@
+module.exports = {
+  config: {
+    type: 'line',
+    data: {
+      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+      datasets: [{
+        backgroundColor: 'red',
+        borderColor: 'red',
+        fill: false,
+        data: [250, 240, 270, 320, 450, 300, 280]
+      }]
+    },
+    options: {
+      responsive: true,
+      scales: {
+        x: {
+          display: false,
+        },
+        y: {
+          type: 'logarithmic',
+          min: 233,
+          max: 471,
+          ticks: {
+            autoSkip: false
+          }
+        }
+      }
+    }
+  },
+  options: {
+    spriteText: true
+  }
+};
diff --git a/test/fixtures/scale.logarithmic/min-max.png b/test/fixtures/scale.logarithmic/min-max.png
new file mode 100644 (file)
index 0000000..c5e582f
Binary files /dev/null and b/test/fixtures/scale.logarithmic/min-max.png differ
diff --git a/test/fixtures/scale.logarithmic/small-range.js b/test/fixtures/scale.logarithmic/small-range.js
new file mode 100644 (file)
index 0000000..d60ed2a
--- /dev/null
@@ -0,0 +1,31 @@
+module.exports = {
+  config: {
+    type: 'line',
+    data: {
+      labels: ['January', 'February', 'March', 'April', 'May', 'June', 'July'],
+      datasets: [{
+        backgroundColor: 'red',
+        borderColor: 'red',
+        fill: false,
+        data: [3, 1, 4, 2, 5, 3, 16]
+      }]
+    },
+    options: {
+      responsive: true,
+      scales: {
+        x: {
+          display: false,
+        },
+        y: {
+          type: 'logarithmic',
+          ticks: {
+            autoSkip: false
+          }
+        }
+      }
+    }
+  },
+  options: {
+    spriteText: true
+  }
+};
diff --git a/test/fixtures/scale.logarithmic/small-range.png b/test/fixtures/scale.logarithmic/small-range.png
new file mode 100644 (file)
index 0000000..8ccb0db
Binary files /dev/null and b/test/fixtures/scale.logarithmic/small-range.png differ
index 52857b649aa8bb59c1818cd2b19d3a42440c822d..01db0cdce30b6f7e4f58a36c663b2c8dc3b8ef77 100644 (file)
@@ -71,6 +71,7 @@ describe('Test tick generators', function() {
             min: 0.1,
             max: 1,
             ticks: {
+              autoSkip: false,
               callback: function(value) {
                 return value.toString();
               }
@@ -81,6 +82,7 @@ describe('Test tick generators', function() {
             min: 0.1,
             max: 1,
             ticks: {
+              autoSkip: false,
               callback: function(value) {
                 return value.toString();
               }
@@ -93,8 +95,8 @@ describe('Test tick generators', function() {
     var xLabels = getLabels(chart.scales.x);
     var yLabels = getLabels(chart.scales.y);
 
-    expect(xLabels).toEqual(['0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1']);
-    expect(yLabels).toEqual(['0.1', '0.2', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1']);
+    expect(xLabels).toEqual(['0.1', '0.11', '0.12', '0.13', '0.14', '0.15', '0.16', '0.17', '0.18', '0.19', '0.2', '0.25', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1']);
+    expect(yLabels).toEqual(['0.1', '0.11', '0.12', '0.13', '0.14', '0.15', '0.16', '0.17', '0.18', '0.19', '0.2', '0.25', '0.3', '0.4', '0.5', '0.6', '0.7', '0.8', '0.9', '1']);
   });
 
   describe('formatters.numeric', function() {
index b68ef305e6df09ef9b15da9e8eb5e12486cd63d5..27cd830d8e682907ea51e9551e37f0298236ab96 100644 (file)
@@ -131,11 +131,11 @@ describe('Logarithmic Scale tests', function() {
     });
 
     expect(chart.scales.y).not.toEqual(undefined); // must construct
-    expect(chart.scales.y.min).toBe(10);
+    expect(chart.scales.y.min).toBe(40);
     expect(chart.scales.y.max).toBe(1000);
 
     expect(chart.scales.y1).not.toEqual(undefined); // must construct
-    expect(chart.scales.y1.min).toBe(1);
+    expect(chart.scales.y1.min).toBe(5);
     expect(chart.scales.y1.max).toBe(5000);
 
     expect(chart.scales.y2).not.toEqual(undefined); // must construct
@@ -189,7 +189,7 @@ describe('Logarithmic Scale tests', function() {
     });
 
     expect(chart.scales.y1).not.toEqual(undefined); // must construct
-    expect(chart.scales.y1.min).toBe(1);
+    expect(chart.scales.y1.min).toBe(5);
     expect(chart.scales.y1.max).toBe(5000);
 
     expect(chart.scales.y2).not.toEqual(undefined); // must construct
@@ -271,11 +271,11 @@ describe('Logarithmic Scale tests', function() {
       }
     });
 
-    expect(chart.scales.x.min).toBe(1);
+    expect(chart.scales.x.min).toBe(2);
     expect(chart.scales.x.max).toBe(100);
 
-    expect(chart.scales.y.min).toBe(1);
-    expect(chart.scales.y.max).toBe(200);
+    expect(chart.scales.y.min).toBe(6);
+    expect(chart.scales.y.max).toBe(150);
   });
 
   it('should correctly determine the max & min for scatter data when 0 values are present', function() {
@@ -417,8 +417,8 @@ describe('Logarithmic Scale tests', function() {
     chart.data.datasets[0].data = [0.15, 0.15];
     chart.update();
 
-    expect(chart.scales.y.min).toBe(0.01);
-    expect(chart.scales.y.max).toBe(1);
+    expect(chart.scales.y.min).toBe(0.1);
+    expect(chart.scales.y.max).toBe(0.15);
   });
 
   it('should use the min and max options', function() {
@@ -528,6 +528,7 @@ describe('Logarithmic Scale tests', function() {
           y: {
             type: 'logarithmic',
             ticks: {
+              autoSkip: false,
               callback: function(value) {
                 return value;
               }
@@ -538,7 +539,7 @@ describe('Logarithmic Scale tests', function() {
     });
 
     var scale = chart.scales.y;
-    expect(getLabels(scale)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30, 40, 50, 60, 70, 80]);
+    expect(getLabels(scale)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 30, 40, 50, 60, 70, 80]);
     expect(scale.start).toEqual(1);
     expect(scale.end).toEqual(80);
   });
@@ -568,7 +569,7 @@ describe('Logarithmic Scale tests', function() {
 
     var scale = chart.scales.y;
     // Counts down because the lines are drawn top to bottom
-    expect(getLabels(scale)).toEqual([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 20, 30]);
+    expect(getLabels(scale)).toEqual([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1, 1.5, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 20, 30]);
     expect(scale.start).toEqual(0.1);
     expect(scale.end).toEqual(30);
   });
@@ -589,6 +590,7 @@ describe('Logarithmic Scale tests', function() {
             type: 'logarithmic',
             reverse: true,
             ticks: {
+              autoSkip: false,
               callback: function(value) {
                 return value;
               }
@@ -599,7 +601,7 @@ describe('Logarithmic Scale tests', function() {
     });
 
     var scale = chart.scales.y;
-    expect(getLabels(scale)).toEqual([80, 70, 60, 50, 40, 30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]);
+    expect(getLabels(scale)).toEqual([80, 70, 60, 50, 40, 30, 20, 15, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]);
     expect(scale.start).toEqual(80);
     expect(scale.end).toEqual(1);
   });
@@ -629,7 +631,7 @@ describe('Logarithmic Scale tests', function() {
     });
 
     var scale = chart.scales.y;
-    expect(getLabels(scale)).toEqual([30, 20, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]);
+    expect(getLabels(scale)).toEqual([30, 20, 15, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1]);
     expect(scale.start).toEqual(30);
     expect(scale.end).toEqual(1);
   });
@@ -646,13 +648,16 @@ describe('Logarithmic Scale tests', function() {
       options: {
         scales: {
           y: {
-            type: 'logarithmic'
+            type: 'logarithmic',
+            ticks: {
+              autoSkip: false
+            }
           }
         }
       }
     });
 
-    expect(getLabels(chart.scales.y)).toEqual(['1', '2', '', '', '5', '', '', '', '', '10', '20', '', '', '50', '', '', '']);
+    expect(getLabels(chart.scales.y)).toEqual(['1', '2', '3', '', '5', '', '', '', '', '10', '15', '20', '30', '', '50', '60', '70', '80']);
   });
 
   it('should build labels using the user supplied callback', function() {
@@ -679,7 +684,7 @@ describe('Logarithmic Scale tests', function() {
     });
 
     // Just the index
-    expect(getLabels(chart.scales.y)).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16']);
+    expect(getLabels(chart.scales.y)).toEqual(['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13', '14', '15', '16', '17']);
   });
 
   it('should correctly get the correct label for a data item', function() {