Skip to content

Commit a2e9c72

Browse files
MFedMFed
MFed
authored and
MFed
committed
Adding the ability to specify the tail of an annotation arrow in absolute point in grid terms rather than relative pixel offset terms. This is useful for the specification of trendlines which will continue to show the correct trend when the chart is zoomed in or out.
1 parent ef6efd7 commit a2e9c72

File tree

5 files changed

+119
-40
lines changed

5 files changed

+119
-40
lines changed

src/components/annotations/attributes.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,31 @@ module.exports = {
131131
role: 'style',
132132
description: 'Sets the width (in px) of annotation arrow.'
133133
},
134+
absoluteArrowTail: {
135+
valType: 'boolean',
136+
dflt: false,
137+
role: 'style',
138+
description: [
139+
'Indicates if the tail of this arrow is a point in ',
140+
'the coordinate system vs a relative offset in pixels.',
141+
'This is useful for trendline annotations which should ',
142+
'continue to indicate the correct trend when zoomed.',
143+
'If *true*, `ax` is a value on the x axis and `ay` is ',
144+
'a value on the y axis.',
145+
'If *false*, `ax` and `ay` assume their normal offset ',
146+
'roles.'
147+
].join(' ')
148+
},
134149
ax: {
135150
valType: 'number',
136151
dflt: -10,
137152
role: 'info',
138153
description: [
139154
'Sets the x component of the arrow tail about the arrow head.',
140-
'A positive (negative) component corresponds to an arrow pointing',
141-
'from right to left (left to right)'
155+
'If `absoluteArrowTail` is false, a positive (negative) ',
156+
'component corresponds to an arrow pointing',
157+
'from right to left (left to right).',
158+
'If `absoluteArrowTail` is true, this is a value on the x axis.'
142159
].join(' ')
143160
},
144161
ay: {
@@ -147,8 +164,10 @@ module.exports = {
147164
role: 'info',
148165
description: [
149166
'Sets the y component of the arrow tail about the arrow head.',
150-
'A positive (negative) component corresponds to an arrow pointing',
151-
'from bottom to top (top to bottom)'
167+
'If `absoluteArrowTail` is false, a positive (negative) ',
168+
'component corresponds to an arrow pointing',
169+
'from bottom to top (top to bottom).',
170+
'If `absoluteArrowTail` is true, this is a value on the y axis.'
152171
].join(' ')
153172
},
154173
// positioning

src/components/annotations/index.js

Lines changed: 64 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ function handleAnnotationDefaults(annIn, fullLayout) {
5959
coerce('arrowwidth', ((borderOpacity && borderWidth) || 1) * 2);
6060
coerce('ax');
6161
coerce('ay');
62+
coerce('absoluteArrowTail');
6263

6364
// if you have one part of arrow length you should have both
6465
Lib.noneOrAll(annIn, annOut, ['ax', 'ay']);
@@ -89,6 +90,11 @@ function handleAnnotationDefaults(annIn, fullLayout) {
8990
if(ax.type === 'date') {
9091
newval = Lib.dateTime2ms(annIn[axLetter]);
9192
if(newval !== false) annIn[axLetter] = newval;
93+
94+
if(annIn.absoluteArrowTail) {
95+
var newvalB = Lib.dateTime2ms(annIn['a' + axLetter]);
96+
if(newvalB !== false) annIn['a' + axLetter] = newvalB;
97+
}
9298
}
9399
else if((ax._categories || []).length) {
94100
newval = ax._categories.indexOf(annIn[axLetter]);
@@ -450,13 +456,17 @@ annotations.draw = function(gd, index, opt, value) {
450456
}
451457

452458
var alignShift = 0;
453-
if(options.showarrow) {
454-
alignShift = options['a' + axLetter];
455-
}
456-
else {
457-
alignShift = annSize * shiftFraction(alignPosition, anchor);
459+
if(options.absoluteArrowTail) {
460+
annPosPx['aa' + axLetter] = ax._offset + ax.l2p(options['a' + axLetter]);
461+
} else {
462+
if(options.showarrow) {
463+
alignShift = options['a' + axLetter];
464+
}
465+
else {
466+
alignShift = annSize * shiftFraction(alignPosition, anchor);
467+
}
468+
annPosPx[axLetter] += alignShift;
458469
}
459-
annPosPx[axLetter] += alignShift;
460470

461471
// save the current axis type for later log/linear changes
462472
options['_' + axLetter + 'type'] = ax && ax.type;
@@ -473,11 +483,16 @@ annotations.draw = function(gd, index, opt, value) {
473483

474484
var arrowX, arrowY;
475485

476-
// make sure the arrowhead (if there is one)
477-
// and the annotation center are visible
478-
if(options.showarrow) {
479-
arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1);
480-
arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1);
486+
if(options.absoluteArrowTail) {
487+
arrowX = Lib.constrain(annPosPx.x, 1, fullLayout.width - 1);
488+
arrowY = Lib.constrain(annPosPx.y, 1, fullLayout.height - 1);
489+
} else {
490+
// make sure the arrowhead (if there is one)
491+
// and the annotation center are visible
492+
if(options.showarrow) {
493+
arrowX = Lib.constrain(annPosPx.x - options.ax, 1, fullLayout.width - 1);
494+
arrowY = Lib.constrain(annPosPx.y - options.ay, 1, fullLayout.height - 1);
495+
}
481496
}
482497
annPosPx.x = Lib.constrain(annPosPx.x, 1, fullLayout.width - 1);
483498
annPosPx.y = Lib.constrain(annPosPx.y, 1, fullLayout.height - 1);
@@ -534,27 +549,32 @@ annotations.draw = function(gd, index, opt, value) {
534549
[arrowX0 + xHalf, arrowY0 - yHalf, arrowX0 - xHalf, arrowY0 - yHalf]
535550
].map(applyTransform2);
536551

537-
// Remove the line if it ends inside the box. Use ray
538-
// casting for rotated boxes: see which edges intersect a
539-
// line from the arrowhead to far away and reduce with xor
540-
// to get the parity of the number of intersections.
541-
if(edges.reduce(function(a, x) {
542-
return a ^
543-
!!lineIntersect(arrowX, arrowY, arrowX + 1e6, arrowY + 1e6,
544-
x[0], x[1], x[2], x[3]);
545-
}, false)) {
546-
// no line or arrow - so quit drawArrow now
547-
return;
548-
}
549-
550-
edges.forEach(function(x) {
551-
var p = lineIntersect(arrowX0, arrowY0, arrowX, arrowY,
552-
x[0], x[1], x[2], x[3]);
553-
if(p) {
554-
arrowX0 = p.x;
555-
arrowY0 = p.y;
552+
if(options.absoluteArrowTail) {
553+
arrowX0 = annPosPx.aax;
554+
arrowY0 = annPosPx.aay;
555+
} else {
556+
// Remove the line if it ends inside the box. Use ray
557+
// casting for rotated boxes: see which edges intersect a
558+
// line from the arrowhead to far away and reduce with xor
559+
// to get the parity of the number of intersections.
560+
if(edges.reduce(function(a, x) {
561+
return a ^
562+
!!lineIntersect(arrowX, arrowY, arrowX + 1e6, arrowY + 1e6,
563+
x[0], x[1], x[2], x[3]);
564+
}, false)) {
565+
// no line or arrow - so quit drawArrow now
566+
return;
556567
}
557-
});
568+
569+
edges.forEach(function(x) {
570+
var p = lineIntersect(arrowX0, arrowY0, arrowX, arrowY,
571+
x[0], x[1], x[2], x[3]);
572+
if(p) {
573+
arrowX0 = p.x;
574+
arrowY0 = p.y;
575+
}
576+
});
577+
}
558578

559579
var strokewidth = options.arrowwidth,
560580
arrowColor = options.arrowcolor;
@@ -618,10 +638,19 @@ annotations.draw = function(gd, index, opt, value) {
618638
(options.y + dy / ya._m) :
619639
(1 - ((arrowY + dy - gs.t) / gs.h));
620640

621-
anng.attr({
622-
transform: 'rotate(' + textangle + ',' +
623-
xcenter + ',' + ycenter + ')'
624-
});
641+
if(options.absoluteArrowTail) {
642+
update[annbase + '.ax'] = xa ?
643+
(options.ax + dx / xa._m) :
644+
((arrowX + dx - gs.l) / gs.w);
645+
update[annbase + '.ay'] = ya ?
646+
(options.ay + dy / ya._m) :
647+
(1 - ((arrowY + dy - gs.t) / gs.h));
648+
} else {
649+
anng.attr({
650+
transform: 'rotate(' + textangle + ',' +
651+
xcenter + ',' + ycenter + ')'
652+
});
653+
}
625654
},
626655
doneFn: function(dragged) {
627656
if(dragged) {

test/image/baselines/annotations.png

2.4 KB
Loading

test/image/mocks/annotations.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@
4141
"bordercolor":"rgb(255, 0, 0)","borderwidth":4,"bgcolor":"rgba(255,255,0,0.5)",
4242
"font":{"color":"rgb(0, 0, 255)","size":20},
4343
"arrowcolor":"rgb(166, 28, 0)","borderpad":3,"textangle":50,"x":5,"y":1
44-
}
44+
},
45+
{"text":"","showarrow":true,"borderwidth":1.2,"arrowhead":2,"absoluteArrowTail":true,"x":5,"y":5,"ax":4,"ay":3}
4546
]
4647
}
4748
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
var Annotations = require('@src/components/annotations');
2+
var Plots = require('@src/plots/plots');
3+
4+
describe('Test annotations', function() {
5+
'use strict';
6+
7+
describe('supplyLayoutDefaults', function() {
8+
it('should default to not use absolute arrow tail', function() {
9+
var annotationDefaults = {};
10+
annotationDefaults._has = Plots._hasPlotType.bind(annotationDefaults);
11+
12+
Annotations.supplyLayoutDefaults({ annotations: [{ showarrow: true, arrowhead: 2}] }, annotationDefaults);
13+
14+
expect(annotationDefaults.annotations[0].absoluteArrowTail).toBe(false);
15+
});
16+
17+
it('should convert ax/ay date coordinates to milliseconds if absoluteArrowTail is true', function() {
18+
var annotationOut = { xaxis: { type: 'date', range: ['2000-01-01', '2016-01-01'] }};
19+
annotationOut._has = Plots._hasPlotType.bind(annotationOut);
20+
21+
var annotationIn = {
22+
annotations: [{ showarrow: true, absoluteArrowTail: true, x: '2008-07-01', ax: '2004-07-01', y: 0, ay: 50}]
23+
};
24+
25+
Annotations.supplyLayoutDefaults(annotationIn, annotationOut);
26+
27+
expect(annotationIn.annotations[0].ax).toEqual(1088654400000);
28+
});
29+
});
30+
});

0 commit comments

Comments
 (0)