Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions draftlogs/7934_fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Fix incorrect positioning of tick labels when using ticklabelindex on period axes [[#7934](https://github.com/plotly/plotly.js/pull/7934)]
180 changes: 108 additions & 72 deletions src/plots/cartesian/axes.js
Original file line number Diff line number Diff line change
Expand Up @@ -882,25 +882,41 @@ function adjustPeriodDelta(ax) { // adjusts ax.dtick and sets ax._definedDelta
ax._definedDelta = definedDelta;
}

function positionPeriodTicks(tickVals, ax, definedDelta) {
/**
* Calculates the period label position for each tick in tickVals.
* @param {array} tickVals: the list of ticks for which to position the period labels
* @param {object} ax: the axis of the ticks
* @param {number} definedDelta: the defined distance between two ticks
* @param {array (optional)} periodEndTicks: the optional list of neighboring ticks
* for each tick in tickVals. If not provided, the function will use the next
* tick in tickVals as the neighbor. Useful if labeled periods have unlabeled
* periods between each other which can happen when using ticklabelindex.
*/
function positionPeriodTicks(tickVals, ax, definedDelta, periodEndTicks) {
for(var i = 0; i < tickVals.length; i++) {
var v = tickVals[i].value;

var a = i;
var b = i + 1;
if(i < tickVals.length - 1) {
a = i;
b = i + 1;
} else if(i > 0) {
a = i - 1;
b = i;
var A, B;
if (periodEndTicks != null) {
A = tickVals[i].value;
B = periodEndTicks[i].value;
} else {
a = i;
b = i;
var a = i;
var b = i + 1;
if(i < tickVals.length - 1) {
a = i;
b = i + 1;
} else if(i > 0) {
a = i - 1;
b = i;
} else {
a = i;
b = i;
}

A = tickVals[a].value;
B = tickVals[b].value;
}

var A = tickVals[a].value;
var B = tickVals[b].value;
var actualDelta = Math.abs(B - A);
var delta = definedDelta || actualDelta;
var periodLength = 0;
Expand Down Expand Up @@ -978,6 +994,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
var isReversed = ax.range[0] > ax.range[1];
var ticklabelIndex = (!ax.ticklabelindex || Lib.isArrayOrTypedArray(ax.ticklabelindex)) ?
ax.ticklabelindex : [ax.ticklabelindex];
ax._useTicklabelIndex = ticklabelIndex != null && ticklabelIndex !== 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This is only used inside calcTicks, so it can just be a local variable.

Suggested change
ax._useTicklabelIndex = ticklabelIndex != null && ticklabelIndex !== 0;
let useTicklabelIndex = ticklabelIndex != null && ticklabelIndex !== 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Unless this is necessary for the stacked PRs?

var rng = Lib.simpleMap(ax.range, ax.r2l, undefined, undefined, opts);
var axrev = (rng[1] < rng[0]);
var minRange = Math.min(rng[0], rng[1]);
Expand All @@ -993,11 +1010,14 @@ axes.calcTicks = function calcTicks(ax, opts) {
// all ticks for which labels are drawn which is not necessarily the major ticks when
// `ticklabelindex` is set.
var allTicklabelVals = [];
// for period label positioning when using `ticklabelindex`:
// for each tick in `allTicklabelVals` holds the neighboring period end tick
var periodEndTicks;
Comment on lines +1013 to +1015

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Instead of saving an array of end ticks for each tick, what if we save the end tick to each tick? Then you could check for an end tick on each tick and use a different code path. You'd also be able to get rid of the new arg in positionPeriodTicks.


var hasMinor = ax.minor && (ax.minor.ticks || ax.minor.showgrid);
// minor ticks should be calculated if they are visible or if ticklabelindex is set because then
// the labels are placed at minor ticks (even if invisible) instead of major ticks.
var calcMinor = hasMinor || ticklabelIndex;
var calcMinor = hasMinor || ax._useTicklabelIndex;

// calc major first
for(var major = 1; major >= (calcMinor ? 0 : 1); major--) {
Expand Down Expand Up @@ -1099,7 +1119,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
}
}

if((major || ticklabelIndex) && isPeriod) {
if((major || ax._useTicklabelIndex) && isPeriod) {
// if major: add one item to label period before tick0
// if minor: add one item for ticklabelindex positioning. positionPeriodTicks requires
// at least 2 ticks to calculate the period length, so we add a dummy tick, ensuring
Expand Down Expand Up @@ -1153,93 +1173,109 @@ axes.calcTicks = function calcTicks(ax, opts) {
}
}

function findOverlappingTick(minorTick, majorTicks) {
const majorTick = majorTicks.find((majorTick) => majorTick.value === minorTick.value);
if (majorTick != null) {
return majorTick;
}
// add 10e6 to eliminate problematic digits
const epsilon = 10e6;
for (var i = 0; i < majorTicks.length; i++) {
if (epsilon + majorTicks[i].value === epsilon + minorTick.value) {
return majorTicks[i];
}
}
return null;
};

// check if ticklabelIndex makes sense, otherwise ignore it.
// It makes sense if in addition to the always present dummy, there are at least 2 minor ticks
// with the required distance to each other.
if(!minorTickVals || minorTickVals.length < 3) {
ticklabelIndex = false;
ax._useTicklabelIndex = false;
} else {
var diff = (minorTickVals[2].value - minorTickVals[1].value) * (isReversed ? -1 : 1);
if(!periodCompatibleWithTickformat(diff, ax.tickformat)) {
ticklabelIndex = false;
ax._useTicklabelIndex = false;
// remove previously added tick before tick0 for handling ticklabelindex positioning
minorTickVals = minorTickVals.slice(1);
}
}

// Determine for which ticks to draw labels
if(!ticklabelIndex) {
if(!ax._useTicklabelIndex) {
allTicklabelVals = tickVals;
} else {
// Collect and sort all major and minor ticks, to find the minor ticks `ticklabelIndex`
// steps away from each major tick. For those minor ticks we want to draw the label.
// For each major tick, find the minor tick `ticklabelIndex` steps away.
// This minor tick will be labeled instead of the major tick.
if (isPeriod) periodEndTicks = []; // for each minor tick at the start of a labeled period this will hold the neighboring period end tick.

const labelTickValsAscending = minorTickVals

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

What should happen if there's a major tick that doesn't coincide with a minor tick? Should that also be included?

.map((minor) => {
// if there is a major tick at the same position, prefer it over the minor tick because overlapping minor ticks are stripped away
// if both minor and major ticks are drawn on the same side.
const major = findOverlappingTick(minor, tickVals);
if (major) {
return major;
}
return minor;
})
.toSorted((a, b) => a.value - b.value);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It turns out I was wrong before about esbuild. It won't transpile this method, so you'll have to use sort instead.

Suggested change
.toSorted((a, b) => a.value - b.value);
.slice()
.sort((a, b) => a.value - b.value);


var allTickVals = tickVals.concat(minorTickVals);
if(isPeriod && tickVals.length) {
if (isPeriod && tickVals.length) {
// first major tick was just added for period handling
allTickVals = allTickVals.slice(1);
tickVals[0].skipLabel = true;
}

allTickVals =
allTickVals
.sort(function(a, b) { return a.value - b.value; })
.filter(function(tick, index, self) {
return index === 0 || tick.value !== self[index - 1].value;
});

var majorTickIndices =
allTickVals
.map(function(item, index) {
return item.minor === undefined && !item.skipLabel ? index : null;
})
.filter(function(index) { return index !== null; });

majorTickIndices.forEach(function(majorIdx) {
ticklabelIndex.map(function(nextLabelIdx) {
var minorIdx = majorIdx + nextLabelIdx;
if(minorIdx >= 0 && minorIdx < allTickVals.length) {
Lib.pushUnique(allTicklabelVals, allTickVals[minorIdx]);
}
});
});
tickVals.forEach(function(tick) {
tick.skipLabel = allTicklabelVals.indexOf(tick) === -1;
tickVals.forEach(function(majorTick) {
if (!majorTick.skipLabel) {
ticklabelIndex.forEach((labelIndex) => {
if (labelIndex < 0) {
const smallerTicks = labelTickValsAscending.filter((minorTick) => minorTick.value <= majorTick.value);
const absLabelIndex = Math.abs(labelIndex);
const labeledTickIndex = smallerTicks.length - absLabelIndex - 1;
if (absLabelIndex <= smallerTicks.length - 1) {
allTicklabelVals.push(smallerTicks[labeledTickIndex]);
if (isPeriod) periodEndTicks.push(smallerTicks[labeledTickIndex + 1]);
}
} else { // labelIndex >= 0
const largerTicks = labelTickValsAscending.filter((minorTick) => minorTick.value >= majorTick.value);
if (labelIndex < largerTicks.length - 1) {
allTicklabelVals.push(largerTicks[labelIndex]);
if (isPeriod) periodEndTicks.push(largerTicks[labelIndex + 1]);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I understand why you're using length -1, but that means that you're going to lose the last label. You could run through the entire array and check that largerTicks[labelIndex + 1] is defined before pushing it. This logic would also need to change if you start adding the end tick directly to the tick itself.

}
majorTick.skipLabel = majorTick.skipLabel !== false;
});
Comment on lines +1247 to +1248

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

majorTick.skipLabel will always be true given the if check above. Let's move this out of the inner loop and assign the value directly.

Suggested change
majorTick.skipLabel = majorTick.skipLabel !== false;
});
});
// Skip the major tick label since the label moved to a minor tick
majorTick.skipLabel = true;

}
});
allTicklabelVals.forEach((t) => t.skipLabel = false);
}

if(hasMinor) {
var canOverlap =
var allowedToOverlap =
(ax.minor.ticks === 'inside' && ax.ticks === 'outside') ||
(ax.minor.ticks === 'outside' && ax.ticks === 'inside');

if(!canOverlap) {
if(!allowedToOverlap) {
// remove duplicate minors

var majorValues = tickVals.map(function(d) { return d.value; });

var list = [];
for(var k = 0; k < minorTickVals.length; k++) {
var T = minorTickVals[k];
var v = T.value;
if(majorValues.indexOf(v) !== -1) {
continue;
}
var found = false;
for(var q = 0; !found && (q < tickVals.length); q++) {
if(
// add 10e6 to eliminate problematic digits
10e6 + tickVals[q].value ===
10e6 + v
) {
found = true;
}
if (findOverlappingTick(minorTickVals[k], tickVals) == null) {
list.push(minorTickVals[k]);
}
if(!found) list.push(T);
}
minorTickVals = list;
}
}

if(isPeriod) positionPeriodTicks(allTicklabelVals, ax, ax._definedDelta);
if(isPeriod) {
if (ax._useTicklabelIndex) {
var periodDefinedDelta = ax._useTicklabelIndex ? ax.minor._definedDelta : ax._definedDelta;
positionPeriodTicks(allTicklabelVals, ax, ax.minor._definedDelta, periodEndTicks);
} else {
positionPeriodTicks(allTicklabelVals, ax, ax._definedDelta);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

It looks like ax.minor._definedDelta isn't actually defined on the axis. In adjustPeriodDelta, _definedDelta is saved to the mock axis and never added back to the axis. You can use ax._definedDelta instead.

}
}

var i;
if(ax.rangebreaks) {
Expand Down Expand Up @@ -1323,7 +1359,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
var _value = tickVals[i].value;

if(_minor) {
if(ticklabelIndex && allTicklabelVals.indexOf(tickVals[i]) !== -1) {
if(ax._useTicklabelIndex && allTicklabelVals.indexOf(tickVals[i]) !== -1) {
t = setTickLabel(ax, tickVals[i]);
} else {
t = { x: _value };
Expand All @@ -1341,7 +1377,7 @@ axes.calcTicks = function calcTicks(ax, opts) {
}
}

if(isPeriod && ticklabelIndex && minorTicks.length) {
if(isPeriod && ax._useTicklabelIndex && minorTicks.length) {
// drop very first minor tick that we added to handle ticklabelindex
minorTicks[0].noTick = true;
}
Expand Down
Binary file modified test/image/baselines/date_axes_period2_ticklabelindex.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added test/image/baselines/ticklabelindex-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions test/image/mocks/ticklabelindex-2.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
{
"data": [
{
"mode": "markers+text",
"texttemplate": "%{x|Q%q %y}",
"textposition": "top center",
"x": [
"2019-07-01", "2019-10-01", "2020-01-01", "2020-04-01", "2020-07-01"
],
"y": [
2, 2, 2, 2, 2
]
}
],
"layout": {
"width": 600,
"height": 400,
"xaxis": {
"dtick": "M24",
"insiderange": [
"2019-03-01",
"2021-01-01"
],
"tick0": "2021-01-01",
"tickformat": "%Y",
"ticklabelindex": -1,
"ticklabelmode": "period",
"ticklen": 20,
"type": "date",
"ticks": "outside",
"minor": { "dtick": "M12" },
"title": {
"text": "Should display 1 major tick and a label 2020."
}
}
}
}
Loading