Skip to content

Commit b5f0316

Browse files
authoredSep 6, 2019
Merge pull request #4165 from plotly/draw-axes-no-bounding-box-final
Axes.draw w/o getBoundingClientRect + many axis automargin fixes
2 parents 1aa0b6f + bb127af commit b5f0316

29 files changed

+540
-238
lines changed
 

Diff for: ‎src/components/fx/hover.js

+22-11
Original file line numberDiff line numberDiff line change
@@ -605,15 +605,15 @@ function _hover(gd, evt, subplot, noHoverEvent) {
605605
var result = dragElement.unhoverRaw(gd, evt);
606606
if(hasCartesian && ((spikePoints.hLinePoint !== null) || (spikePoints.vLinePoint !== null))) {
607607
if(spikesChanged(oldspikepoints)) {
608-
createSpikelines(spikePoints, spikelineOpts);
608+
createSpikelines(gd, spikePoints, spikelineOpts);
609609
}
610610
}
611611
return result;
612612
}
613613

614614
if(hasCartesian) {
615615
if(spikesChanged(oldspikepoints)) {
616-
createSpikelines(spikePoints, spikelineOpts);
616+
createSpikelines(gd, spikePoints, spikelineOpts);
617617
}
618618
}
619619

@@ -1396,9 +1396,10 @@ function cleanPoint(d, hovermode) {
13961396
return d;
13971397
}
13981398

1399-
function createSpikelines(closestPoints, opts) {
1399+
function createSpikelines(gd, closestPoints, opts) {
14001400
var container = opts.container;
14011401
var fullLayout = opts.fullLayout;
1402+
var gs = fullLayout._size;
14021403
var evt = opts.event;
14031404
var showY = !!closestPoints.hLinePoint;
14041405
var showX = !!closestPoints.vLinePoint;
@@ -1433,8 +1434,7 @@ function createSpikelines(closestPoints, opts) {
14331434
var yMode = ya.spikemode;
14341435
var yThickness = ya.spikethickness;
14351436
var yColor = ya.spikecolor || dfltHLineColor;
1436-
var yBB = ya._boundingBox;
1437-
var xEdge = ((yBB.left + yBB.right) / 2) < hLinePointX ? yBB.right : yBB.left;
1437+
var xEdge = Axes.getPxPosition(gd, ya);
14381438
var xBase, xEndSpike;
14391439

14401440
if(yMode.indexOf('toaxis') !== -1 || yMode.indexOf('across') !== -1) {
@@ -1443,8 +1443,14 @@ function createSpikelines(closestPoints, opts) {
14431443
xEndSpike = hLinePointX;
14441444
}
14451445
if(yMode.indexOf('across') !== -1) {
1446-
xBase = ya._counterSpan[0];
1447-
xEndSpike = ya._counterSpan[1];
1446+
var xAcross0 = ya._counterDomainMin;
1447+
var xAcross1 = ya._counterDomainMax;
1448+
if(ya.anchor === 'free') {
1449+
xAcross0 = Math.min(xAcross0, ya.position);
1450+
xAcross1 = Math.max(xAcross1, ya.position);
1451+
}
1452+
xBase = gs.l + xAcross0 * gs.w;
1453+
xEndSpike = gs.l + xAcross1 * gs.w;
14481454
}
14491455

14501456
// Foreground horizontal line (to y-axis)
@@ -1507,8 +1513,7 @@ function createSpikelines(closestPoints, opts) {
15071513
var xMode = xa.spikemode;
15081514
var xThickness = xa.spikethickness;
15091515
var xColor = xa.spikecolor || dfltVLineColor;
1510-
var xBB = xa._boundingBox;
1511-
var yEdge = ((xBB.top + xBB.bottom) / 2) < vLinePointY ? xBB.bottom : xBB.top;
1516+
var yEdge = Axes.getPxPosition(gd, xa);
15121517
var yBase, yEndSpike;
15131518

15141519
if(xMode.indexOf('toaxis') !== -1 || xMode.indexOf('across') !== -1) {
@@ -1517,8 +1522,14 @@ function createSpikelines(closestPoints, opts) {
15171522
yEndSpike = vLinePointY;
15181523
}
15191524
if(xMode.indexOf('across') !== -1) {
1520-
yBase = xa._counterSpan[0];
1521-
yEndSpike = xa._counterSpan[1];
1525+
var yAcross0 = xa._counterDomainMin;
1526+
var yAcross1 = xa._counterDomainMax;
1527+
if(xa.anchor === 'free') {
1528+
yAcross0 = Math.min(yAcross0, xa.position);
1529+
yAcross1 = Math.max(yAcross1, xa.position);
1530+
}
1531+
yBase = gs.t + (1 - yAcross1) * gs.h;
1532+
yEndSpike = gs.t + (1 - yAcross0) * gs.h;
15221533
}
15231534

15241535
// Foreground vertical line (to x-axis)

Diff for: ‎src/components/rangeslider/draw.js

+2-5
Original file line numberDiff line numberDiff line change
@@ -108,17 +108,14 @@ module.exports = function(gd) {
108108

109109
var gs = fullLayout._size;
110110
var domain = axisOpts.domain;
111-
var tickHeight = opts._tickHeight;
112-
113-
var oppBottom = opts._oppBottom;
114111

115112
opts._width = gs.w * (domain[1] - domain[0]);
116113

117114
var x = Math.round(gs.l + (gs.w * domain[0]));
118115

119116
var y = Math.round(
120-
gs.t + gs.h * (1 - oppBottom) +
121-
tickHeight +
117+
gs.t + gs.h * (1 - axisOpts._counterDomainMin) +
118+
(axisOpts.side === 'bottom' ? axisOpts._depth : 0) +
122119
opts._offsetShift + constants.extraPad
123120
);
124121

Diff for: ‎src/components/rangeslider/helpers.js

+17-12
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@
99
'use strict';
1010

1111
var axisIDs = require('../../plots/cartesian/axis_ids');
12+
var svgTextUtils = require('../../lib/svg_text_utils');
1213
var constants = require('./constants');
14+
var LINE_SPACING = require('../../constants/alignment').LINE_SPACING;
1315
var name = constants.name;
1416

1517
function isVisible(ax) {
@@ -42,27 +44,30 @@ exports.makeData = function(fullLayout) {
4244
};
4345

4446
exports.autoMarginOpts = function(gd, ax) {
47+
var fullLayout = gd._fullLayout;
4548
var opts = ax[name];
49+
var axLetter = ax._id.charAt(0);
4650

47-
var oppBottom = Infinity;
48-
var counterAxes = ax._counterAxes;
49-
for(var j = 0; j < counterAxes.length; j++) {
50-
var counterId = counterAxes[j];
51-
var oppAxis = axisIDs.getFromId(gd, counterId);
52-
oppBottom = Math.min(oppBottom, oppAxis.domain[0]);
51+
var bottomDepth = 0;
52+
var titleHeight = 0;
53+
if(ax.side === 'bottom') {
54+
bottomDepth = ax._depth;
55+
if(ax.title.text !== fullLayout._dfltTitle[axLetter]) {
56+
// as in rangeslider/draw.js
57+
titleHeight = 1.5 * ax.title.font.size + 10 + opts._offsetShift;
58+
// multi-line extra bump
59+
var extraLines = (ax.title.text.match(svgTextUtils.BR_TAG_ALL) || []).length;
60+
titleHeight += extraLines * ax.title.font.size * LINE_SPACING;
61+
}
5362
}
54-
opts._oppBottom = oppBottom;
55-
56-
var tickHeight = (ax.side === 'bottom' && ax._boundingBox.height) || 0;
57-
opts._tickHeight = tickHeight;
5863

5964
return {
6065
x: 0,
61-
y: oppBottom,
66+
y: ax._counterDomainMin,
6267
l: 0,
6368
r: 0,
6469
t: 0,
65-
b: opts._height + gd._fullLayout.margin.b + tickHeight,
70+
b: opts._height + bottomDepth + Math.max(fullLayout.margin.b, titleHeight),
6671
pad: constants.extraPad + opts._offsetShift * 2
6772
};
6873
};

Diff for: ‎src/lib/svg_text_utils.js

+1
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,7 @@ var SPLIT_TAGS = /(<[^<>]*>)/;
266266
var ONE_TAG = /<(\/?)([^ >]*)(\s+(.*))?>/i;
267267

268268
var BR_TAG = /<br(\s+.*)?>/i;
269+
exports.BR_TAG_ALL = /<br(\s+.*)?>/gi;
269270

270271
/*
271272
* style and href: pull them out of either single or double quotes. Also

Diff for: ‎src/plot_api/subroutines.js

+5-6
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ function lsInner(gd) {
7272
// can still get here because it makes some of the SVG structure
7373
// for shared features like selections.
7474
if(!fullLayout._has('cartesian')) {
75-
return gd._promises.length && Promise.all(gd._promises);
75+
return Plots.previousPromises(gd);
7676
}
7777

7878
function getLinePosition(ax, counterAx, side) {
@@ -347,7 +347,7 @@ function lsInner(gd) {
347347

348348
Axes.makeClipPaths(gd);
349349

350-
return gd._promises.length && Promise.all(gd._promises);
350+
return Plots.previousPromises(gd);
351351
}
352352

353353
function shouldShowLinesOrTicks(ax, subplot) {
@@ -599,9 +599,11 @@ exports.drawData = function(gd) {
599599
// styling separate from drawing
600600
Plots.style(gd);
601601

602-
// show annotations and shapes
602+
// draw components that can be drawn on axes,
603+
// and that do not push the margins
603604
Registry.getComponentMethod('shapes', 'draw')(gd);
604605
Registry.getComponentMethod('annotations', 'draw')(gd);
606+
Registry.getComponentMethod('images', 'draw')(gd);
605607

606608
// Mark the first render as complete
607609
fullLayout._replotting = false;
@@ -717,9 +719,6 @@ exports.doAutoRangeAndConstraints = function(gd) {
717719
// correctly sized and the whole plot re-margined. fullLayout._replotting must
718720
// be set to false before these will work properly.
719721
exports.finalDraw = function(gd) {
720-
Registry.getComponentMethod('shapes', 'draw')(gd);
721-
Registry.getComponentMethod('images', 'draw')(gd);
722-
Registry.getComponentMethod('annotations', 'draw')(gd);
723722
// TODO: rangesliders really belong in marginPushers but they need to be
724723
// drawn after data - can we at least get the margin pushing part separated
725724
// out and done earlier?

Diff for: ‎src/plots/cartesian/axes.js

+191-193
Large diffs are not rendered by default.

Diff for: ‎src/plots/plots.js

+20-1
Original file line numberDiff line numberDiff line change
@@ -920,6 +920,26 @@ plots.linkSubplots = function(newFullData, newFullLayout, oldFullData, oldFullLa
920920
ax._counterAxes.sort(axisIDs.idSort);
921921
ax._subplotsWith.sort(Lib.subplotSort);
922922
ax._mainSubplot = findMainSubplot(ax, newFullLayout);
923+
924+
// find "full" domain span of counter axes,
925+
// this loop can be costly, so only compute it when required
926+
if(ax._counterAxes.length && (
927+
(ax.spikemode && ax.spikemode.indexOf('across') !== -1) ||
928+
(ax.automargin && ax.mirror) ||
929+
Registry.getComponentMethod('rangeslider', 'isVisible')(ax)
930+
)) {
931+
var min = 1;
932+
var max = 0;
933+
for(j = 0; j < ax._counterAxes.length; j++) {
934+
var ax2 = axisIDs.getFromId(mockGd, ax._counterAxes[j]);
935+
min = Math.min(min, ax2.domain[0]);
936+
max = Math.max(max, ax2.domain[1]);
937+
}
938+
if(min < max) {
939+
ax._counterDomainMin = min;
940+
ax._counterDomainMax = max;
941+
}
942+
}
923943
}
924944
};
925945

@@ -1645,7 +1665,6 @@ plots.purge = function(gd) {
16451665
fullLayout._glcontainer.remove();
16461666
fullLayout._glcanvas = null;
16471667
}
1648-
if(fullLayout._geocontainer !== undefined) fullLayout._geocontainer.remove();
16491668

16501669
// remove modebar
16511670
if(fullLayout._modeBar) fullLayout._modeBar.destroy();

Diff for: ‎src/plots/polar/polar.js

+1-7
Original file line numberDiff line numberDiff line change
@@ -299,13 +299,7 @@ proto.updateLayout = function(fullLayout, polarLayout) {
299299
};
300300

301301
proto.mockAxis = function(fullLayout, polarLayout, axLayout, opts) {
302-
var commonOpts = {
303-
// to get _boundingBox computation right when showticklabels is false
304-
anchor: 'free',
305-
position: 0
306-
};
307-
308-
var ax = Lib.extendFlat(commonOpts, axLayout, opts);
302+
var ax = Lib.extendFlat({}, axLayout, opts);
309303
setConvertPolar(ax, polarLayout, fullLayout);
310304
return ax;
311305
};

Diff for: ‎test/image/baselines/automargin-mirror-all.png

27.1 KB
Loading

Diff for: ‎test/image/baselines/automargin-mirror-allticks.png

22.8 KB
Loading

Diff for: ‎test/image/baselines/automargin-multiline-titles.png

21.1 KB
Loading
23.5 KB
Loading

Diff for: ‎test/image/baselines/finance_multicategory.png

260 Bytes
Loading

Diff for: ‎test/image/baselines/finance_subplots_categories.png

488 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory-inside-ticks.png

64 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory-mirror.png

18 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory-sorting.png

-163 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory-y.png

-51 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory2.png

369 Bytes
Loading

Diff for: ‎test/image/baselines/multicategory_histograms.png

6 Bytes
Loading

Diff for: ‎test/image/baselines/range_slider_box.png

-275 Bytes
Loading

Diff for: ‎test/image/baselines/range_slider_multiple.png

2 Bytes
Loading
13 Bytes
Loading

Diff for: ‎test/image/mocks/automargin-mirror-all.json

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
{
2+
"data": [
3+
{
4+
"x": [1, 2, 3],
5+
"y": [4, 5, 6]
6+
},
7+
{
8+
"x": [20, 30, 40],
9+
"y": [50, 60, 70],
10+
"xaxis": "x2",
11+
"yaxis": "y2"
12+
}
13+
],
14+
"layout": {
15+
"showlegend": false,
16+
"grid": {
17+
"rows": 1,
18+
"columns": 2,
19+
"pattern": "independent"
20+
},
21+
"xaxis": {
22+
"automargin": true,
23+
"ticks": "outside",
24+
"showline": true, "linewidth": 5,
25+
"mirror": "all"
26+
},
27+
"xaxis2": {
28+
"automargin": true,
29+
"ticks": "outside",
30+
"showline": true,
31+
"mirror": "all"
32+
},
33+
"yaxis": {
34+
"automargin": true,
35+
"ticks": "outside",
36+
"showline": true,
37+
"zeroline": false,
38+
"mirror": "all"
39+
},
40+
"yaxis2": {
41+
"automargin": true,
42+
"ticks": "outside",
43+
"showline": true, "linewidth": 10,
44+
"zeroline": false,
45+
"mirror": "all"
46+
},
47+
"margin": {"l": 0, "b": 0, "t": 0, "r": 0},
48+
"width": 500,
49+
"height": 400
50+
}
51+
}

Diff for: ‎test/image/mocks/automargin-mirror-allticks.json

+60
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"data": [
3+
{
4+
"x": [1, 2, 3],
5+
"y": [4, 5, 6]
6+
},
7+
{
8+
"x": [20, 30, 40],
9+
"y": [50, 60, 70],
10+
"xaxis": "x2"
11+
},
12+
{
13+
"x": [1, 2, 3],
14+
"y": [4, 5, 6],
15+
"yaxis": "y2"
16+
},
17+
{
18+
"x": [20, 30, 40],
19+
"y": [50, 60, 70],
20+
"xaxis": "x2",
21+
"yaxis": "y2"
22+
}
23+
],
24+
"layout": {
25+
"showlegend": false,
26+
"grid": {
27+
"rows": 2,
28+
"columns": 2
29+
},
30+
"xaxis": {
31+
"automargin": true,
32+
"ticks": "outside",
33+
"showline": true,
34+
"mirror": "all"
35+
},
36+
"xaxis2": {
37+
"automargin": true,
38+
"ticks": "outside", "ticklen": 10,
39+
"showline": true,
40+
"mirror": "allticks"
41+
},
42+
"yaxis": {
43+
"automargin": true,
44+
"ticks": "outside",
45+
"showline": true,
46+
"zeroline": false,
47+
"mirror": "all"
48+
},
49+
"yaxis2": {
50+
"automargin": true,
51+
"ticks": "outside", "ticklen": 5,
52+
"showline": true, "linewidth": 5,
53+
"zeroline": false,
54+
"mirror": "allticks"
55+
},
56+
"margin": {"l": 0, "b": 0, "t": 0, "r": 0},
57+
"width": 500,
58+
"height": 400
59+
}
60+
}

Diff for: ‎test/image/mocks/automargin-multiline-titles.json

+27
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
{
2+
"data": [
3+
{
4+
"y": [1, 2, 1]
5+
}
6+
],
7+
"layout": {
8+
"xaxis": {
9+
"automargin": true,
10+
"nticks": 3,
11+
"tickfont": {"size": 30},
12+
"title": {"text": "Hello<br>Bonjour"},
13+
"zeroline": false
14+
},
15+
"yaxis": {
16+
"automargin": true,
17+
"title": {
18+
"text": "Hello<br>Bonjour<br>Hola",
19+
"font": {"size": 32}
20+
},
21+
"ticklen": 20
22+
},
23+
"width": 400,
24+
"height": 400,
25+
"margin": {"l": 0, "t": 0, "r": 0, "b": 0}
26+
}
27+
}
+24
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
{
2+
"data": [
3+
{
4+
"x": ["a", "b", "c", "d", "long category", "another even longer", "the longest one yet!!!!!!"],
5+
"y": [0, 10, 20, 30, 40, 50, 60]
6+
}
7+
],
8+
"layout": {
9+
"xaxis": {
10+
"title": {
11+
"text": "Bottom X Axis<br><i>2nd line</i>",
12+
"font": {"size": 14}
13+
},
14+
"rangeslider": { "visible": true },
15+
"automargin": true
16+
},
17+
"yaxis": {
18+
"automargin": true
19+
},
20+
"width": 400,
21+
"height": 400,
22+
"margin": {"l": 0, "t": 0, "b": 0, "r": 0}
23+
}
24+
}

Diff for: ‎test/image/mocks/multicategory2.json

+5-3
Original file line numberDiff line numberDiff line change
@@ -25,10 +25,12 @@
2525
],
2626
"layout": {
2727
"xaxis": {
28-
"title": "MULTI-CATEGORY ON TOP",
28+
"title": {"text": "MULTI-CATEGORY ON TOP"},
2929
"side": "top",
30-
"automargin": true,
31-
"tickson": "labels"
30+
"tickson": "labels",
31+
"ticks": "outside",
32+
"ticklen": 25,
33+
"automargin": true
3234
},
3335
"showlegend": false,
3436
"width": 400,

Diff for: ‎test/jasmine/tests/hover_spikeline_test.js

+114
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,120 @@ describe('spikeline hover', function() {
156156
.then(done);
157157
});
158158

159+
it('draws lines up to x-axis position', function(done) {
160+
Plotly.plot(gd, [
161+
{ y: [1, 2, 1] },
162+
{ y: [2, 1, 2], yaxis: 'y2' }
163+
], {
164+
// here the x-axis is drawn at the middle of the graph
165+
xaxis: { showspike: true, spikemode: 'toaxis' },
166+
yaxis: { domain: [0.5, 1] },
167+
yaxis2: { anchor: 'x', domain: [0, 0.5] },
168+
width: 400,
169+
height: 400
170+
})
171+
.then(function() {
172+
_hover({xval: 1, yval: 2});
173+
// from "y" of x-axis up to "y" of pt
174+
_assert([[189, 210.5, 189, 109.25]], []);
175+
})
176+
.then(function() { return Plotly.relayout(gd, 'xaxis.spikemode', 'across'); })
177+
.then(function() {
178+
_hover({xval: 1, yval: 2});
179+
// from "y" of xy subplot top, down to "y" xy2 subplot bottom
180+
_assert([[189, 100, 189, 320]], []);
181+
})
182+
.catch(failTest)
183+
.then(done);
184+
});
185+
186+
it('draws lines up to y-axis position - anchor free case', function(done) {
187+
Plotly.plot(gd, [
188+
{ y: [1, 2, 1] },
189+
{ y: [2, 1, 2], xaxis: 'x2' }
190+
], {
191+
yaxis: { domain: [0.5, 1] },
192+
xaxis2: {
193+
anchor: 'free', position: 0, overlaying: 'x',
194+
showspikes: true, spikemode: 'across'
195+
},
196+
width: 400,
197+
height: 400,
198+
showlegend: false
199+
})
200+
.then(function() {
201+
_hover({xval: 0, yval: 2}, 'x2y');
202+
// from "y" of pt, down to "y" of x2 axis
203+
_assert([[95.75, 100, 95.75, 320]], []);
204+
})
205+
.then(function() { return Plotly.relayout(gd, 'xaxis2.position', 0.6); })
206+
.then(function() {
207+
_hover({xval: 0, yval: 2}, 'x2y');
208+
// from "y" of pt, down to "y" of x axis (which is further down)
209+
_assert([[95.75, 100, 95.75, 210]], []);
210+
})
211+
.catch(failTest)
212+
.then(done);
213+
});
214+
215+
it('draws lines up to y-axis position', function(done) {
216+
Plotly.plot(gd, [
217+
{ y: [1, 2, 1] },
218+
{ y: [2, 1, 2], xaxis: 'x2' }
219+
], {
220+
// here the y-axis is drawn at the middle of the graph,
221+
// with xy subplot to the right and xy2 to the left
222+
yaxis: { showspike: true, spikemode: 'toaxis' },
223+
xaxis: { domain: [0.5, 1] },
224+
xaxis2: { anchor: 'y', domain: [0, 0.5] },
225+
width: 400,
226+
height: 400,
227+
showlegend: false
228+
})
229+
.then(function() {
230+
_hover({xval: 1, yval: 2});
231+
// from "x" of y-axis to "x" of pt
232+
_assert([[199.5, 114.75, 260, 114.75]], []);
233+
})
234+
.then(function() { return Plotly.relayout(gd, 'yaxis.spikemode', 'across'); })
235+
.then(function() {
236+
_hover({xval: 1, yval: 2});
237+
// from "x" at xy2 subplot left, to "x" at xy subplot right
238+
_assert([[80, 114.75, 320, 114.75]], []);
239+
})
240+
.catch(failTest)
241+
.then(done);
242+
});
243+
244+
it('draws lines up to y-axis position - anchor free case', function(done) {
245+
Plotly.plot(gd, [
246+
{ y: [1, 2, 1] },
247+
{ y: [2, 1, 2], yaxis: 'y2' }
248+
], {
249+
xaxis: { domain: [0.5, 1] },
250+
yaxis2: {
251+
anchor: 'free', position: 0, overlaying: 'y',
252+
showspikes: true, spikemode: 'across'
253+
},
254+
width: 400,
255+
height: 400,
256+
showlegend: false
257+
})
258+
.then(function() {
259+
_hover({xval: 0, yval: 2}, 'xy2');
260+
// from "x" of y2 axis to "x" of pt
261+
_assert([[80, 114.75, 320, 114.75]], []);
262+
})
263+
.then(function() { return Plotly.relayout(gd, 'yaxis2.position', 0.6); })
264+
.then(function() {
265+
_hover({xval: 0, yval: 2}, 'xy2');
266+
// from "x" of y axis (which is further left) to "x" of pt
267+
_assert([[200, 114.75, 320, 114.75]], []);
268+
})
269+
.catch(failTest)
270+
.then(done);
271+
});
272+
159273
it('draws lines and markers on enabled axes in the spikesnap "cursor" mode', function(done) {
160274
var _mock = makeMock('toaxis', 'x');
161275

0 commit comments

Comments
 (0)
Please sign in to comment.