From: Jukka Kurkela Date: Mon, 22 Aug 2022 18:05:27 +0000 (+0300) Subject: Refine logarithmic scaling / tick generation (#9166) X-Git-Tag: v4.0.0~47 X-Git-Url: http://git.ipfire.org/?a=commitdiff_plain;h=3b76488373fe4d763a60d0df4c85ad400ea68aec;p=thirdparty%2FChart.js.git Refine logarithmic scaling / tick generation (#9166) * Refine logarithmic scaling / tick generation * Disable autoSkip on reverese test * Reduce ticks, fix min --- diff --git a/src/core/core.ticks.js b/src/core/core.ticks.js index 333a7306e..8050f574b 100644 --- a/src/core/core.ticks.js +++ b/src/core/core.ticks.js @@ -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 ''; diff --git a/src/scales/scale.logarithmic.js b/src/scales/scale.logarithmic.js index cbf33c6ed..8f32807e7 100644 --- a/src/scales/scale.logarithmic.js +++ b/src/scales/scale.logarithmic.js @@ -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 index 000000000..ba123837e --- /dev/null +++ b/test/fixtures/scale.logarithmic/large-range.js @@ -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 index 000000000..13e4538c1 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 index 000000000..726d49353 --- /dev/null +++ b/test/fixtures/scale.logarithmic/large-values-small-range.js @@ -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 index 000000000..47c70393e 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 index 000000000..a6191fbb1 --- /dev/null +++ b/test/fixtures/scale.logarithmic/med-range.js @@ -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 index 000000000..ed9b5bfa7 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 index 000000000..ff5771800 --- /dev/null +++ b/test/fixtures/scale.logarithmic/min-max.js @@ -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 index 000000000..c5e582f48 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 index 000000000..d60ed2a18 --- /dev/null +++ b/test/fixtures/scale.logarithmic/small-range.js @@ -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 index 000000000..8ccb0dbdb Binary files /dev/null and b/test/fixtures/scale.logarithmic/small-range.png differ diff --git a/test/specs/core.ticks.tests.js b/test/specs/core.ticks.tests.js index 52857b649..01db0cdce 100644 --- a/test/specs/core.ticks.tests.js +++ b/test/specs/core.ticks.tests.js @@ -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() { diff --git a/test/specs/scale.logarithmic.tests.js b/test/specs/scale.logarithmic.tests.js index b68ef305e..27cd830d8 100644 --- a/test/specs/scale.logarithmic.tests.js +++ b/test/specs/scale.logarithmic.tests.js @@ -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() {