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

Improve offset calculation for scale.offset option #4658

Closed
wants to merge 3 commits into from

Conversation

nagix
Copy link
Contributor

@nagix nagix commented Aug 15, 2017

The offsets of time scales are currently calculated based on tick intervals, but it causes too much margin or short of margin depending on scale configuration or datasets. The proposed code calculates the offsets based on timestamps of labels and data. It also fixes offset calculation in case that a chart has a single data point.

Current version: https://jsfiddle.net/q4fo65aa/
Proposed version: https://jsfiddle.net/sfhwd1by/

Fixes #5312


if (options.offset) {
[ts.labels, ts.data].forEach(function(data) {
if (!timestamps.length) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will this work as expected if ts.data and ts.labels both have items?

It looks what will happen is that the first iteration of the forEach will run and timestamps.length will be 0 so the items in ts.labels are considered. However, the items in ts.data will be skipped because on the second iteration, timestamps.length may be non-zero due to the first iteration of the loop.

If this is as intended, I think there should be a comment explaining why. I can see it accidentally getting lost/changed later

Copy link
Contributor Author

@nagix nagix Aug 15, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is as intended. First, it processes ts.labels to pick up timestamps of labels, which are primarily used for offset calculation because bars are usually on labels. But if there are no labels, it should add offsets based on ts.data because we support bars with {x, y} data points as well. That is the second path.

I will add a comment on it.

if (length) {
divisor = length > 1 ? 2 : 1;
if (!options.time.min) {
upper = length > 1 ? interpolate(table, 'time', timestamps[1], 'pos') : 1;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is timestamps guaranteed to be sorted here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is deduped and sorted at

labels = arrayUnique(labels).sort(sorter);
and
timestamps = arrayUnique(timestamps).sort(sorter);

@nagix nagix force-pushed the offset-calc branch 3 times, most recently from b05b0f8 to 7eabbf0 Compare August 17, 2017 03:32
// primarily used for offset calculation because bars are usually on labels.
// But if there are no labels, it should add offsets based on `ts.data` because
// we support bars with {x, y} data points as well. That is the second path.
[ts.labels, ts.data].forEach(function(data) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would ts.data be enough since most of the time it contains ts.labels?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@simonbrunel I was considering the case of a mixed chart with bars on labels and line with {x, y} points. If the label positions and {x, y} points are not aligned on a timeline, bar width calculation could go wrong. But, as you say, that may be an edge case, and I agree that the calculation based on ts.data works in most cases.

@nagix
Copy link
Contributor Author

nagix commented Aug 29, 2017

Removed check for label values, and now it only uses scale._timestamps.data for offset calculation.

@benmccann
Copy link
Contributor

@nagix the fiddles in the description don't seem to be working for me

@benmccann
Copy link
Contributor

benmccann commented Oct 31, 2017

@nagix could you fix the fiddles so that we can review this PR? They're giving me the error below:

Refused to execute script from 'https://raw.githubusercontent.com/chartjs/chartjs.github.io/master/dist/master/Chart.js' because its MIME type ('text/plain') is not executable, and strict MIME type checking is enabled.

@benmccann
Copy link
Contributor

@nagix just a reminder to fix the fiddles when you get a chance. I'd love to get this change merged

@benmccann
Copy link
Contributor

benmccann commented Nov 23, 2017

@nagix have you abandoned this PR?

I'd would really like to see it get checked assuming it fixes an issue, but I can't tell what the issue is since the fiddles are broken

I may close this PR due to inactivity if you're no longer interested in seeing it merged

@etimberg
Copy link
Member

Closing due to inactivity

@etimberg etimberg closed this Dec 31, 2017
@simonbrunel simonbrunel removed this from the Version 2.8 milestone Jan 13, 2018
@nagix
Copy link
Contributor Author

nagix commented Mar 2, 2018

@benmccann @etimberg @simonbrunel Sorry for not responding earlier. I fixed the fiddles and confirmed that the problems still remain. Can you reopen this if possible?

- Offset is calulated based on the intervals between the first two data points and the last two data points (barThickness: 'flex') or the minimum interval of all data (barThickness: undefined).
- Add test for both cases of barThickness: 'flex' and undefined.
@nagix
Copy link
Contributor Author

nagix commented Jun 29, 2018

As equally sized bars were introduced in 2.7.2, I made some changes to support it.

  • Offsets are calulated based on the intervals between the first two data points and the last two data points (barThickness: 'flex') or the minimum interval of all data (barThickness: undefined).
  • Add test for both cases of barThickness: 'flex' and undefined.

@benmccann @etimberg @simonbrunel Can you review this again?

@benmccann
Copy link
Contributor

Documentation for these features added in 119a86f and awaiting deployment

@@ -164,7 +164,7 @@ function computeFlexCategoryTraits(index, ruler, options) {
if (prev === null) {
// first data: its size is double based on the next point or,
// if it's also the last data, we use the scale end extremity.
prev = curr - (next === null ? ruler.end - curr : next - curr);
prev = curr - (next === null ? Math.max(curr - ruler.start, ruler.end - curr) * 2 : next - curr);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting a bit tricky to follow. I feel like we're trying to hack prev and next to get the desired results for start and size. I wonder if it would be easier if we just computed those directly like:

if (prev == null && next == null) {
  start = ruler.start * (1 - percent / 2);
  size = ruler.end - ruler.start;
} else if (prev == null) {
  start = curr - ((next - curr) / 2) * percent;
  size = ((next - curr) / 2) * percent;
} else if (next == null) {
  start = curr - ((curr - prev) / 2) * percent;
  size =  (curr - prev) * percent;
} else {
  start = curr - ((curr - prev) / 2) * percent;
  size = ((next - prev) / 2) * percent;
}

Note that these formulas probably aren't at all correct. This is just to demonstrate an alternate way of laying out the code

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benmccann your proposal introduces lot of code duplication and actually doesn't look clearer to me since it's hard to follow all the if / else. The current implementation is not a "hack" of prev / next but an adjustment when they are not defined. Comments explain pretty well what's going on so please don't reformat this part of the code.

});

if (!barThickness) {
[data, ticks].forEach(function(timestamps) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can you add a comment here:

// Calculate minInterval

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I will add this.

) / 2;

length = pos.length;
if (length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can this be combined with the enclosing if statement? I.e. if (options.offset && data.length)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Even if data is not empty, we need to use the subset of data, which only contains timestamps between min and max, so checking pos.length is needed. But, we can move this if (length) statement before the calculation of minInterval.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, you're right. Maybe we could also invert the check. E.g.:

		data.forEach(function(timestamp) {
			if (timestamp >= min && timestamp <= max) {
				pos.push(interpolate(table, 'time', timestamp, 'pos'));
			}
		});

		length = pos.length;
		if (!length) {
			return;
		}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As this function needs to return an object, I will just move the check.


length = pos.length;
if (length) {
// Calculate minInterval
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a bit nit picky, but I would actually move this comment down a line (i.e. right above [data, ticks].forEach(function(timestamps) {) because right now it's not clear if it's referring to the entire block of code inside the if (length) statement of just to the if (!barThickness) section

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, agreed.

}

if (!timeOpts.min) {
if (length === 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if length === 0 ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the case that there is only one data point within the range. length === 0 is already filtered out here.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks. I missed that it was filtered out up above

@@ -361,27 +361,55 @@ function generate(min, max, capacity, options) {
* Returns the right and left offsets from edges in the form of {left, right}.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you clarify more what this offset it. What is it that gets offset and why does it need to be offset?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this make sense?

/**
 * Returns the right and left offsets from edges in the form of {left, right}
 * where each values is a relative width to the scale and ranges between 0 and 1.
 * They add extra margins on the both sides by scaling down the original scale.
 * Offsets are typically used for bar charts. They are calculated based on intervals
 * between the data points to keep the leftmost and rightmost bars from being cut.
 * Offsets are added when the `offset` option is true.
 */

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super helpful!

To clarify:
Are these offsets for each bar or only the leftmost and rightmost bar? I.e. can you update "edges" to specify the edge of the chart or the edge of the category

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, it's "the edges of the chart".

});

length = pos.length;
if (length) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It could make the code slightly easier to read and remove a lot of indentation if we reversed this if statement:

if (!length) {
  return {left: left, right: right};
}

You could do the same just above with the options.offset check as well which would remove the need for two levels of indentation

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. Will update it.

}

if (!timeOpts.min) {
if (length === 1) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually I just thought of one last thing. can we move setting width outside to occur just before the if statement? I think that would allow removing the duplicate code here and in the other if statement just below

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what you mean. width for the left and right offset needs to be calculated separately, and the calculation depends on the length of pos and barThickness.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're right. Nevermind

Copy link
Member

@simonbrunel simonbrunel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO, we should find a better way to fix that issue without relying on the barThickness from the time scale.

var pos = [];
var minInterval = 1;
var timeOpts = options.time;
var barThickness = options.barThickness;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accessing barThickness from the time scale doesn't seem ideal because it correlates the bar controller and this scale, which is something we are trying to remove / avoid. Actually, the barThickness should not be part of the scale options.

@benmccann
Copy link
Contributor

benmccann commented Jan 1, 2019

@nagix this PR will need to be rebased. There's also an outstanding comment from Simon

@nagix
Copy link
Contributor Author

nagix commented Jan 2, 2019

Closing this for now because accessing barThickness from the time scale code is not a good design as @simonbrunel pointed out. Note that the improvement of the offset calculation for a single data point has been added in #5933.

@nagix nagix closed this Jan 2, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[BUG] Barchart: setting xaxes 'type: time' is causing bars to be drawn off canvas.
4 participants