Refine logarithmic scaling / tick generation (#9166)

* Refine logarithmic scaling / tick generation

* Disable autoSkip on reverese test

* Reduce ticks, fix min
This commit is contained in:
Jukka Kurkela
2022-08-22 21:05:27 +03:00
committed by GitHub
parent 2031cdf051
commit 3b76488373
14 changed files with 243 additions and 50 deletions

View 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 '';

View 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;
}

View File

@@ -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
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -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
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

View File

@@ -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
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -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
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View File

@@ -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
}
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

View 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() {

View 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() {