Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: chart jumping and wobbling on window.devicePixelRatio !== 1 #136

Draft
wants to merge 6 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 0 additions & 1 deletion builder/index.html
Expand Up @@ -430,7 +430,6 @@
bindColor({target: chart.options.grid, name: 'Grid line color', propertyName: 'strokeStyle', opacity: 1});
bindRange({target: chart.options.grid, name: 'Vertical sections', propertyName: 'verticalSections', min: 0, max: 20});
bindRange({target: chart.options.grid, name: 'Time line spacing', propertyName: 'millisPerLine', min: 1000, max: 10000, step: 1000});
bindCheckBox({target: chart.options.grid, name: 'Sharp grid lines', propertyName: 'sharpLines'});
bindCheckBox({target: chart.options.grid, name: 'Draw border', propertyName: 'borderVisible'});

// Labels
Expand Down
9 changes: 7 additions & 2 deletions examples/example1.html
Expand Up @@ -11,8 +11,13 @@
}, 500);

function createTimeline() {
var chart = new SmoothieChart();
chart.addTimeSeries(random, { strokeStyle: 'rgba(0, 255, 0, 1)', fillStyle: 'rgba(0, 255, 0, 0.2)', lineWidth: 4 });
var chart = new SmoothieChart({
millisPerPixel: 1000 / (74.6 / 3),
interpolation: 'step',
maxValue: 10000,
minValue: 0,
});
chart.addTimeSeries(random, { strokeStyle: 'rgba(0, 255, 0, 1)', fillStyle: 'rgba(0, 255, 0, 0.2)', lineWidth: 2 * 1 / window.devicePixelRatio });
chart.streamTo(document.getElementById("chart"), 500);
}
</script>
Expand Down
2 changes: 0 additions & 2 deletions smoothie.d.ts
Expand Up @@ -86,8 +86,6 @@ export interface IGridOptions {
strokeStyle?: string;
/** Distance between vertical grid lines. */
millisPerLine?: number;
/** Controls whether grid lines are 1px sharp, or softened. */
sharpLines?: boolean;
/** Number of vertical sections marked out by horizontal grid lines. */
verticalSections?: number;
/** Whether the grid lines trace the border of the chart or not. */
Expand Down
113 changes: 68 additions & 45 deletions smoothie.js
Expand Up @@ -95,6 +95,8 @@
* Add title option, by @mesca
* Fix data drop stoppage by rejecting NaNs in append(), by @timdrysdale
* Allow setting interpolation per time series, by @WofWca (#123)
* Fix chart constantly jumping in 1-2 pixel steps, by @WofWca (#131)
* Fix: make all lines sharp, by @WofWca (#134)
*/

;(function(exports) {
Expand Down Expand Up @@ -136,7 +138,35 @@
low = mid + 1;
}
return low;
}
},
// So lines (especially vertical and horizontal) look a) consistent along their length and b) sharp.
pixelSnap: function(position, lineWidth) {
// TODO grid lines and bezier lines still (occasionally) wobble. But it's still better than it was.

var dpr = window.devicePixelRatio,
coordinatesPerPixel = 1 / dpr;

// return position - position % window.devicePixelRatio;
// return position - position % coordinatesPerPixel;

// if (lineWidth % (2 * dpr) === 0) {

// TODO may need to replace the strict comparison with `<= coordinatesPerPixel / 2` (or something
// like this), that will minimize smudging instead of only removing it when it's strictly divisible.
// Not only because of truncation error that comes with `dpr !== 1` but because it also makes sense for
// `dpr === 1`.
if (lineWidth % (2 * coordinatesPerPixel) === 0) {
// Closest pixel edge.
// return Math.round(position);

// TODO It's not the closest, it's round down.
return position - position % coordinatesPerPixel;
} else {
// Closest pixel center.
// return Math.floor(position) + 0.5;
return position - position % coordinatesPerPixel + coordinatesPerPixel / 2;
}
},
};

/**
Expand Down Expand Up @@ -294,7 +324,6 @@
* lineWidth: 1, // the pixel width of grid lines
* strokeStyle: '#777777', // colour of grid lines
* millisPerLine: 1000, // distance between vertical grid lines
* sharpLines: false, // controls whether grid lines are 1px sharp, or softened
* verticalSections: 2, // number of vertical sections marked out by horizontal grid lines
* borderVisible: true // whether the grid lines trace the border of the chart or not
* },
Expand Down Expand Up @@ -387,7 +416,6 @@
fillStyle: '#000000',
strokeStyle: '#777777',
lineWidth: 1,
sharpLines: false,
millisPerLine: 1000,
verticalSections: 2,
borderVisible: true
Expand Down Expand Up @@ -784,47 +812,50 @@
if (this.options.limitFPS > 0 && nowMillis - this.lastRenderTimeMillis < (1000/this.options.limitFPS))
return;

time = time || nowMillis - (this.delay || 0);

// Round time down to pixel granularity, so motion appears smoother.
// time -= time % this.options.millisPerPixel;
// time -= time % (this.options.millisPerPixel / window.devicePixelRatio);
time -= time % (this.options.millisPerPixel / window.devicePixelRatio);

if (!this.isAnimatingScale) {
// We're not animating. We can use the last render time and the scroll speed to work out whether
// we actually need to paint anything yet. If not, we can return immediately.

// Render at least every 1/6th of a second. The canvas may be resized, which there is
// no reliable way to detect.
var maxIdleMillis = Math.min(1000/6, this.options.millisPerPixel);

if (nowMillis - this.lastRenderTimeMillis < maxIdleMillis) {
return;
var sameTime = this.lastChartTimestamp === time;
if (sameTime) {
// Render at least every 1/6th of a second. The canvas may be resized, which there is
// no reliable way to detect.
var needToRenderInCaseCanvasResized = nowMillis - this.lastRenderTimeMillis > 1000/6;
if (!needToRenderInCaseCanvasResized) {
return;
}
}
}

this.resize();

this.lastRenderTimeMillis = nowMillis;

canvas = canvas || this.canvas;
time = time || nowMillis - (this.delay || 0);

// Round time down to pixel granularity, so motion appears smoother.
time -= time % this.options.millisPerPixel;

this.lastChartTimestamp = time;

this.resize();

canvas = canvas || this.canvas;
var context = canvas.getContext('2d'),
chartOptions = this.options,
dimensions = { top: 0, left: 0, width: canvas.clientWidth, height: canvas.clientHeight },
// Calculate the threshold time for the oldest data points.
oldestValidTime = time - (dimensions.width * chartOptions.millisPerPixel),
valueToYPixel = function(value) {
var offset = value - this.currentVisMinValue;
return this.currentValueRange === 0
? dimensions.height
: dimensions.height - (Math.round((offset / this.currentValueRange) * dimensions.height));
valueToYPosition = function(value, lineWidth) {
var offset = value - this.currentVisMinValue,
unsnapped = this.currentValueRange === 0
? dimensions.height
: dimensions.height * (1 - offset / this.currentValueRange);
return Util.pixelSnap(unsnapped, lineWidth);
}.bind(this),
timeToXPixel = function(t) {
if(chartOptions.scrollBackwards) {
return Math.round((time - t) / chartOptions.millisPerPixel);
}
return Math.round(dimensions.width - ((time - t) / chartOptions.millisPerPixel));
timeToXPosition = function(t, lineWidth) {
var unsnapped = chartOptions.scrollBackwards
? (time - t) / chartOptions.millisPerPixel
: dimensions.width - ((time - t) / chartOptions.millisPerPixel);
return Util.pixelSnap(unsnapped, lineWidth);
};

this.updateValueRange();
Expand Down Expand Up @@ -862,10 +893,7 @@
for (var t = time - (time % chartOptions.grid.millisPerLine);
t >= oldestValidTime;
t -= chartOptions.grid.millisPerLine) {
var gx = timeToXPixel(t);
if (chartOptions.grid.sharpLines) {
gx -= 0.5;
}
var gx = timeToXPosition(t, chartOptions.grid.lineWidth);
context.moveTo(gx, 0);
context.lineTo(gx, dimensions.height);
}
Expand All @@ -875,10 +903,7 @@

// Horizontal (value) dividers.
for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
var gy = Math.round(v * dimensions.height / chartOptions.grid.verticalSections);
if (chartOptions.grid.sharpLines) {
gy -= 0.5;
}
var gy = Util.pixelSnap(v * dimensions.height / chartOptions.grid.verticalSections, chartOptions.grid.lineWidth);
context.beginPath();
context.moveTo(0, gy);
context.lineTo(dimensions.width, gy);
Expand All @@ -897,9 +922,10 @@
if (chartOptions.horizontalLines && chartOptions.horizontalLines.length) {
for (var hl = 0; hl < chartOptions.horizontalLines.length; hl++) {
var line = chartOptions.horizontalLines[hl],
hly = Math.round(valueToYPixel(line.value)) - 0.5;
lineWidth = line.lineWidth || 1,
hly = valueToYPosition(line.value, lineWidth);
context.strokeStyle = line.color || '#ffffff';
context.lineWidth = line.lineWidth || 1;
context.lineWidth = lineWidth;
context.beginPath();
context.moveTo(0, hly);
context.lineTo(dimensions.width, hly);
Expand Down Expand Up @@ -930,8 +956,8 @@
// Retain lastX, lastY for calculating the control points of bezier curves.
var firstX = 0, firstY = 0, lastX = 0, lastY = 0;
for (var i = 0; i < dataSet.length && dataSet.length !== 1; i++) {
var x = timeToXPixel(dataSet[i][0]),
y = valueToYPixel(dataSet[i][1]);
var x = timeToXPosition(dataSet[i][0], seriesOptions.lineWidth),
y = valueToYPosition(dataSet[i][1], seriesOptions.lineWidth);

if (i === 0) {
firstX = x;
Expand Down Expand Up @@ -1033,9 +1059,6 @@
var stepPixels = dimensions.height / chartOptions.grid.verticalSections;
for (var v = 1; v < chartOptions.grid.verticalSections; v++) {
var gy = dimensions.height - Math.round(v * stepPixels);
if (chartOptions.grid.sharpLines) {
gy -= 0.5;
}
var yValue = chartOptions.yIntermediateFormatter(this.valueRange.min + (v * step), chartOptions.labels.precision);
//left of right axis?
intermediateLabelPos =
Expand All @@ -1055,7 +1078,7 @@
for (var t = time - (time % chartOptions.grid.millisPerLine);
t >= oldestValidTime;
t -= chartOptions.grid.millisPerLine) {
var gx = timeToXPixel(t);
var gx = timeToXPosition(t, 0);
// Only draw the timestamp if it won't overlap with the previously drawn one.
if ((!chartOptions.scrollBackwards && gx < textUntilX) || (chartOptions.scrollBackwards && gx > textUntilX)) {
// Formats the timestamp based on user specified formatting function
Expand Down