diff --git a/draftlogs/8065_add.md b/draftlogs/8065_add.md new file mode 100644 index 00000000000..2b41ae8617e --- /dev/null +++ b/draftlogs/8065_add.md @@ -0,0 +1 @@ +- Add *auto* to the `textposition` of `scatter`, `scatterpolar`, `scatterternary`, `scattercarpet` and `scattersmith` traces, to place text labels without overlaps and with leader lines for labels moved away from their points, and `textrank` to set the order in which those labels take their positions [[#8065](https://github.com/plotly/plotly.js/pull/8065)] diff --git a/src/components/drawing/index.js b/src/components/drawing/index.js index 707c7b2cfb6..48e9346dc86 100644 --- a/src/components/drawing/index.js +++ b/src/components/drawing/index.js @@ -1241,32 +1241,206 @@ var TEXTOFFSETSIGN = { top: -1 }; -function textPointPosition(s, textPosition, fontSize, markerRadius, dontTouchParent) { - var group = d3.select(s.node().parentNode); +// px between the end of a leader line and the label it points at +var LEADER_END_GAP = 2; +/** + * Compute the geometry of a text label relative to its point. + * + * @param textPosition - a `textposition` value other than *auto* + * @param fontSize - the font size in px + * @param markerRadius - the calculated marker radius in px, or 0 without markers + * @param numLines - the line count of the label + * @returns `anchor` for the `text-anchor` attribute, and `dx` and `dy` of the label group in px + */ +function textPointOffset(textPosition, fontSize, markerRadius, numLines) { var v = textPosition.indexOf('top') !== -1 ? 'top' : textPosition.indexOf('bottom') !== -1 ? 'bottom' : 'middle'; var h = textPosition.indexOf('left') !== -1 ? 'end' : textPosition.indexOf('right') !== -1 ? 'start' : 'middle'; + var sx = TEXTOFFSETSIGN[h]; + var sy = TEXTOFFSETSIGN[v]; // if markers are shown, offset a little more than // the nominal marker size // ie 2/1.6 * nominal, bcs some markers are a bit bigger var r = markerRadius ? markerRadius / 0.8 + 1 : 0; - var numLines = (svgTextUtils.lineCount(s) - 1) * LINE_SPACING + 1; - var dx = TEXTOFFSETSIGN[h] * r; - var dy = fontSize * 0.75 + TEXTOFFSETSIGN[v] * r + ((TEXTOFFSETSIGN[v] - 1) * numLines * fontSize) / 2; + var lineSpan = ((numLines || 1) - 1) * LINE_SPACING + 1; + return { + anchor: h, + dx: sx * r, + dy: fontSize * 0.75 + sy * r + ((sy - 1) * lineSpan * fontSize) / 2 + }; +} + +/** + * Compute the geometry of an *auto* text label from its measured box, so that + * every position keeps the same clearance between the marker and the text. + * + * @param textPosition - a `textposition` value other than *auto* + * @param markerRadius - the calculated marker radius in px, or 0 without markers + * @param markerSymbol - the marker symbol of the point, name or number + * @param bb - the label box from `drawing.bBox`, relative to the text anchor + * @param gap - extra px between the point and the label, set by the *auto* placement + * @returns the same shape as the fixed-position geometry: `anchor`, `dx`, `dy`, and `leader` + */ +drawing.textPointBoxOffset = function (textPosition, markerRadius, markerSymbol, bb, gap) { + var v = textPosition.indexOf('top') !== -1 ? 'top' : textPosition.indexOf('bottom') !== -1 ? 'bottom' : 'middle'; + var h = textPosition.indexOf('left') !== -1 ? 'end' : textPosition.indexOf('right') !== -1 ? 'start' : 'middle'; + var sx = TEXTOFFSETSIGN[h]; + var sy = TEXTOFFSETSIGN[v]; + var r = markerRadius || 0; + + // the clearance the side positions of the fixed-position geometry leave + var clearance = (r ? r / 4 + 1 : 0) + (gap || 0); + + // a corner label keeps the same clearance along the diagonal: from the + // edge of a circle, or from the corner of a square, which reaches the + // farthest of all symbols on the diagonal + var reach = r + clearance; + if (sx && sy) { + var isCircle = r && drawing.symbolNumber(markerSymbol) % 100 === 0; + reach = isCircle ? (r + clearance) / Math.SQRT2 : r + clearance / Math.SQRT2; + } + var ax = sx * reach; + var ay = sy * reach; + + var out = { + anchor: h, + dx: ax, + dy: sy < 0 ? ay - bb.bottom : sy > 0 ? ay - bb.top : -(bb.top + bb.bottom) / 2, + leader: null + }; + + if (gap > 0 && (sx || sy)) { + var norm = Math.sqrt(sx * sx + sy * sy); + var ux = sx / norm; + var uy = sy / norm; + out.leader = [ux * (r + 1), uy * (r + 1), ax - ux * LEADER_END_GAP, ay - uy * LEADER_END_GAP]; + } + + return out; +}; + +/** + * Resolve the `textposition` of one point. + * + * @returns the position, or null when the *auto* placement found no free spot for the label + */ +function getTextPosition(d, trace) { + var pos = d.tp || trace.textposition; + if (pos !== 'auto') return pos; + // the scatter *auto* placement sets `_tpAuto` after it draws all points of the subplot + return d._tpAuto === undefined ? 'middle center' : d._tpAuto; +} + +/** + * Measure a text label, from its `` node or from the MathJax render that replaces it. + * + * Caches the box without `text-anchor` in the key, so a label reuses its first measurement at every position. + * + * @param s - d3 selection of one `` element, inside its own `` + * @returns `width`, `height`, and the `top` and `bottom` of the box relative to the text anchor, in px + */ +drawing.textPointBBox = function (s) { + const node = s.node(); + const math = d3.select(node.parentNode).select('g.text-math-group'); + let bb; + if (math.size()) { + // The default key of the render has no font, so key it by its source `` node, which has the font + const hash = textPointHash(node); + bb = drawing.bBox(math.node(), false, hash && hash + '~math'); + // the MathJax svg is placed in the frame of the `` node + bb.top = +math.select('svg').attr('y') - +s.attr('y'); + bb.bottom = bb.top + bb.height; + } else { + bb = drawing.bBox(node, false, textPointHash(node)); + } + // The key has no anchor, so `left` and `right` can belong to another anchor + return { width: bb.width, height: bb.height, top: bb.top, bottom: bb.bottom }; +}; + +// Leave `text-anchor` out of the `nodeHash` key, because the anchor moves a label box sideways but keeps its size +function textPointHash(node) { + const text = node.getAttribute('data-unformatted'); + if (text === null) return; + return 'textpoint~' + text + node.getAttribute('data-math') + node.getAttribute('style'); +} + +/** + * Position a text label relative to its point, and join its leader line. + * Sets `d._tpOffset` for a label with a fixed position. + * + * @param s - d3 selection of one `` element, inside its own `` + * @param d - the calcdata point + * @param trace - the full trace + * @param markerRadius - the calculated marker radius in px, or undefined without markers + * @param dontTouchParent - true to leave the `transform` of the parent `` alone + */ +drawing.textPointPosition = function (s, d, trace, markerRadius, dontTouchParent) { + var group = d3.select(s.node().parentNode); + var isAuto = (d.tp || trace.textposition) === 'auto'; + var textPosition = getTextPosition(d, trace); + + var offset; + let leaderSeg = d._tpAutoLeader; + // Skip the box for an *auto* label that the placement did not reach yet, + // because the placement measures every label in one pass right after this draw + if (isAuto && textPosition && d._tpAuto !== undefined) { + var symbol = d.mx || (trace.marker || {}).symbol; + var bb = drawing.textPointBBox(s); + offset = drawing.textPointBoxOffset(textPosition, markerRadius, symbol, bb, d._tpAutoGap); + + // The placement set the leader for `d._tpAutoRadius`, so move each end by the change that another radius makes + if (leaderSeg && offset.leader && (markerRadius || 0) !== d._tpAutoRadius) { + const base = drawing.textPointBoxOffset(textPosition, d._tpAutoRadius, symbol, bb, d._tpAutoGap); + leaderSeg = leaderSeg.map((v, i) => v + offset.leader[i] - base.leader[i]); + } + + // a MathJax render is aligned to the anchor it had when it rendered + group.select('svg.text-math').attr('x', +s.attr('x') - (bb.width * (1 - TEXTOFFSETSIGN[offset.anchor])) / 2); + } else { + offset = textPointOffset( + textPosition || 'middle center', + drawing.textPointFontSize(d, trace), + markerRadius, + svgTextUtils.lineCount(s) + ); + // The *auto* placement reads it, so it blocks with the box this draw gives the label + if (!isAuto) d._tpOffset = offset; + } // fix the overall text group position - s.attr('text-anchor', h); + s.attr('text-anchor', offset.anchor); if (!dontTouchParent) { - group.attr('transform', strTranslate(dx, dy)); + // Write `display` on the whole group, so that a MathJax render is hidden with its source text. + // Skip the write for a label without an inline `display`, because only *auto* sets one. + if (isAuto || group.node().style.display) group.style('display', textPosition === null ? 'none' : null); + group.attr('transform', strTranslate(offset.dx, offset.dy)); } -} -function extracTextFontSize(d, trace) { + // Skip the join when no leader is due and none is left over, because only *auto* draws leaders + const first = group.node().firstChild; + const hasLeader = first && first.nodeName === 'path' && first.classList.contains('textleader'); + if (!leaderSeg && !hasLeader) return; + + const leader = group.selectAll('path.textleader').data(leaderSeg ? [leaderSeg] : []); + leader.exit().remove(); + leader.enter().insert('path', ':first-child').classed('textleader', true).style('fill', 'none'); + leader.each(function (seg) { + // the leader is set in the plot frame, and the group is translated by (dx, dy) + var dx = offset.dx; + var dy = offset.dy; + d3.select(this) + .attr('d', 'M' + (seg[0] - dx) + ',' + (seg[1] - dy) + 'L' + (seg[2] - dx) + ',' + (seg[3] - dy)) + .style('stroke-width', 1) + .call(Color.stroke, s.node().style.fill); + }); +}; + +drawing.textPointFontSize = function (d, trace) { var fontSize = d.ts || trace.textfont.size; return isNumeric(fontSize) && fontSize > 0 ? fontSize : 0; -} +}; // draw text at points drawing.textPointStyle = function (s, trace, gd) { @@ -1307,8 +1481,7 @@ drawing.textPointStyle = function (s, trace, gd) { }); } - var pos = d.tp || trace.textposition; - var fontSize = extracTextFontSize(d, trace); + var fontSize = drawing.textPointFontSize(d, trace); var fontColor = selectedTextColorFn ? selectedTextColorFn(d) : d.tc || trace.textfont.color; p.call(drawing.font, { @@ -1323,8 +1496,10 @@ drawing.textPointStyle = function (s, trace, gd) { color: fontColor }) .text(text) - .call(svgTextUtils.convertToTspans, gd) - .call(textPointPosition, pos, fontSize, d.mrc); + .call(svgTextUtils.convertToTspans, gd, function () { + // a MathJax label is positioned once it is rendered + drawing.textPointPosition(p, d, trace, d.mrc); + }); }); }; @@ -1336,12 +1511,10 @@ drawing.selectedTextStyle = function (s, trace) { s.each(function (d) { var tx = d3.select(this); var tc = fns.selectedTextColorFn(d); - var tp = d.tp || trace.textposition; - var fontSize = extracTextFontSize(d, trace); Color.fill(tx, tc); var dontTouchParent = Registry.traceIs(trace, 'bar-like'); - textPointPosition(tx, tp, fontSize, d.mrc2 || d.mrc, dontTouchParent); + drawing.textPointPosition(tx, d, trace, d.mrc2 || d.mrc, dontTouchParent); }); }; diff --git a/src/lib/geometry2d.js b/src/lib/geometry2d.js index 199ca224eda..26f3e5836cb 100644 --- a/src/lib/geometry2d.js +++ b/src/lib/geometry2d.js @@ -232,3 +232,356 @@ exports.findPointOnPath = function findPointOnPath(path, val, coord, opts) { } return pt; }; + +// candidate positions of a label, in order of preference +const POSITIONS = [ + 'top center', + 'bottom center', + 'middle right', + 'middle left', + 'top right', + 'top left', + 'bottom right', + 'bottom left' +]; + +// a label that may sit on its point tries that first +const POSITIONS_ON_POINT = ['middle center', ...POSITIONS]; + +// ring 0 touches the point, every ring after it adds LEADER_STEP +// font sizes of distance and a leader line back to the point +const LEADER_RINGS = 6; +const LEADER_STEP = 1; + +// a label without a leader line must keep CLUSTER_GAP font sizes +// from every other marker, or a reader cannot tell which point is its own +const CLUSTER_GAP = 1; + +// minimum px between a label and anything else +const PAD = 2; + +// markers within NEIGHBOR_RADIUS font sizes of a point push its label +// to the far side, so labels around a cluster point away from it +const NEIGHBOR_RADIUS = 3; + +// side of one cell of the spatial index, in px +const CELL_SIZE = 64; + +/** + * Place labels around their points, so that no label covers a marker, + * another label, or a leader line, and every label stays inside the area. + * + * Labels are placed in order of decreasing `priority`, then in the given + * order, so an earlier label wins a contested spot. Every label takes the + * first free candidate: the eight positions next to the point first, then + * the same positions farther out with a leader line back to the point. + * The positions are tried in the order that points away from the markers + * near the point, and in a fixed order when there are none. A position + * next to the point is used only when no other marker sits close to the + * label, so a leader line shows which point the label belongs to whenever + * that is in doubt. A label with no free candidate, or with its point + * outside the area, is hidden. + * + * @param opts.width - the width of the area in px + * @param opts.height - the height of the area in px + * @param opts.markers - the marker boxes, as `{x0, y0, x1, y1, owner}`; + * a label may cover the marker with its own `owner` + * @param opts.fixed - the other boxes that labels stay clear of, as `{x0, y0, x1, y1}` + * @param opts.labels - the labels to place, each with: + * `x`, `y` - the point in px; + * `owner` - the value that links the label to its marker, the label itself when absent; + * `radius` - the marker radius in px, or 0; + * `fontSize` - the font size in px, which scales the leader step and the clearance from other markers; + * `priority` - a number, higher first, 0 when absent; + * `onPoint` - true when the label may also sit on its point; + * `prefer`, `preferGap` - the position and gap the label had before: the label goes back next to + * its point at that position when it can, else it stays where it was, and only then it searches again; + * `rect(position, gap)` - the box of the label at a `textposition` value, `gap` px farther + * from the point, as `{x0, y0, x1, y1, leader}`, with `leader` as `[x0, y0, x1, y1]` + * from the marker edge to the box when `gap` is above 0, else null + * @returns one entry per label, in the given order: `{position, gap, leader}`, + * or null when the label is hidden + */ +exports.placeLabels = function placeLabels(opts) { + const index = makeIndex(opts.width, opts.height); + const markers = opts.markers || []; + const fixed = opts.fixed || []; + const labels = opts.labels; + + // A marker farther from the area than any label can reach cannot block or push a label, + // and it would only pile up in the edge cells, which every nearby query scans + const reach = labels.reduce((r, l) => Math.max(r, (l.radius || 0) + NEIGHBOR_RADIUS * l.fontSize), 0) + 3 * PAD; + + for (let i = 0; i < markers.length; i++) { + const m = markers[i]; + if (m.x1 < -reach || m.x0 > opts.width + reach || m.y1 < -reach || m.y0 > opts.height + reach) continue; + const rect = makeRect(m.x0 - PAD, m.y0 - PAD, m.x1 + PAD, m.y1 + PAD, m.owner); + rect.isMarker = true; + insert(index, rect); + } + for (let i = 0; i < fixed.length; i++) { + const f = fixed[i]; + insert(index, makeRect(f.x0, f.y0, f.x1, f.y1, f.owner)); + } + + const results = new Array(labels.length); + const order = sortLabels(labels); + for (let i = 0; i < order.length; i++) { + results[order[i]] = placeOne(index, labels[order[i]]); + } + return results; +}; + +// the order in which the labels are placed: by priority, high to low, then as given +function sortLabels(labels) { + const order = labels.map((label, i) => i); + order.sort((a, b) => (labels[b].priority || 0) - (labels[a].priority || 0) || a - b); + return order; +} + +function placeOne(index, label) { + const owner = label.owner === undefined ? label : label.owner; + const step = LEADER_STEP * label.fontSize; + const rings = step ? LEADER_RINGS : 0; + + // a point outside the area gets no label, even when a candidate box would fit inside + if (label.x < 0 || label.x > index.width || label.y < 0 || label.y > index.height) return null; + + // a label that was placed before does not step ring by ring on a redraw, so a pan does not shuffle leader lines + if (label.prefer) { + const out = + tryCandidate(index, label, owner, label.prefer, 0, rings) || + (label.preferGap && tryCandidate(index, label, owner, label.prefer, label.preferGap, rings)); + if (out) return out; + } + + const positions = orderPositions(index, label, owner); + + for (let ring = 0; ring <= rings; ring++) { + const gap = ring * step; + + for (let k = 0; k < positions.length; k++) { + const out = tryCandidate(index, label, owner, positions[k], gap, rings); + if (out) return out; + } + } + + return null; +} + +// take a candidate when its box and leader line are free, and it is not ambiguous +function tryCandidate(index, label, owner, pos, gap, rings) { + if (gap && pos === 'middle center') return null; + + const box = label.rect(pos, gap); + const rect = makeRect(box.x0, box.y0, box.x1, box.y1, owner); + if (!rectIsFree(index, rect)) return null; + if (!gap && rings && isAmbiguous(index, rect, CLUSTER_GAP * label.fontSize)) return null; + + const seg = box.leader; + const line = seg ? makeLine(seg[0], seg[1], seg[2], seg[3], owner) : null; + if (line && !lineIsFree(index, line, label.x, label.y)) return null; + + insert(index, rect); + if (line) insert(index, line); + return { position: pos, gap, leader: seg || null }; +} + +// the candidate positions of a label, sorted so that the ones that point +// away from the nearby markers come first; ties keep the default order +function orderPositions(index, label, owner) { + const positions = label.onPoint ? POSITIONS_ON_POINT : POSITIONS; + const r = ((label.radius || 0) + NEIGHBOR_RADIUS * label.fontSize) ** 2; + const b = cellBounds( + index, + label.x - Math.sqrt(r), + label.y - Math.sqrt(r), + label.x + Math.sqrt(r), + label.y + Math.sqrt(r) + ); + const cells = index.cells; + let ax = 0; + let ay = 0; + + for (let row = b[1]; row <= b[3]; row++) { + for (let col = b[0]; col <= b[2]; col++) { + const cell = cells[row * index.ncols + col]; + if (!cell) continue; + + for (let i = 0; i < cell.length; i++) { + const ob = cell[i]; + if (!ob.isMarker || ob.owner === owner) continue; + const dx = label.x - (ob.x0 + ob.x1) / 2; + const dy = label.y - (ob.y0 + ob.y1) / 2; + const d2 = dx * dx + dy * dy; + if (!d2 || d2 > r) continue; + // nearer markers push harder + ax += dx / d2; + ay += dy / d2; + } + } + } + + let out = positions; + if (ax || ay) { + const scored = positions.map((pos, k) => { + const sx = pos.indexOf('right') !== -1 ? 1 : pos.indexOf('left') !== -1 ? -1 : 0; + const sy = pos.indexOf('bottom') !== -1 ? 1 : pos.indexOf('top') !== -1 ? -1 : 0; + const norm = Math.sqrt(sx * sx + sy * sy) || 1; + return { pos, k, score: (sx * ax + sy * ay) / norm }; + }); + scored.sort((a, b) => b.score - a.score || a.k - b.k); + out = scored.map((s) => s.pos); + } + + const prefer = label.prefer; + if (prefer && out.indexOf(prefer) > 0) out = [prefer, ...out.filter((pos) => pos !== prefer)]; + return out; +} + +// true when another marker sits within `margin` px of a label box, +// measured from the nearest edge or corner of the box +function isAmbiguous(index, rect, margin) { + const b = cellBounds(index, rect.x0 - margin, rect.y0 - margin, rect.x1 + margin, rect.y1 + margin); + const cells = index.cells; + + for (let row = b[1]; row <= b[3]; row++) { + for (let col = b[0]; col <= b[2]; col++) { + const cell = cells[row * index.ncols + col]; + if (!cell) continue; + + for (let i = 0; i < cell.length; i++) { + const ob = cell[i]; + if (!ob.isMarker || ob.owner === rect.owner) continue; + const dx = Math.max(0, ob.x0 - rect.x1, rect.x0 - ob.x1); + const dy = Math.max(0, ob.y0 - rect.y1, rect.y0 - ob.y1); + if (dx * dx + dy * dy < margin * margin) return true; + } + } + } + + return false; +} + +function makeRect(x0, y0, x1, y1, owner) { + return { x0, y0, x1, y1, owner, isLine: false, isMarker: false }; +} + +function makeLine(x0, y0, x1, y1, owner) { + return { x0, y0, x1, y1, owner, isLine: true, isMarker: false }; +} + +function makeIndex(width, height) { + return { + width, + height, + ncols: Math.max(1, Math.ceil(width / CELL_SIZE)), + nrows: Math.max(1, Math.ceil(height / CELL_SIZE)), + cells: [] + }; +} + +// the cells that cover a box, clamped to the grid, as [col0, row0, col1, row1] +function cellBounds(index, x0, y0, x1, y1) { + const maxCol = index.ncols - 1; + const maxRow = index.nrows - 1; + return [ + constrain(Math.floor(Math.min(x0, x1) / CELL_SIZE), 0, maxCol), + constrain(Math.floor(Math.min(y0, y1) / CELL_SIZE), 0, maxRow), + constrain(Math.floor(Math.max(x0, x1) / CELL_SIZE), 0, maxCol), + constrain(Math.floor(Math.max(y0, y1) / CELL_SIZE), 0, maxRow) + ]; +} + +function insert(index, ob) { + const b = cellBounds(index, ob.x0, ob.y0, ob.x1, ob.y1); + const cells = index.cells; + + for (let row = b[1]; row <= b[3]; row++) { + for (let col = b[0]; col <= b[2]; col++) { + const id = row * index.ncols + col; + if (!cells[id]) cells[id] = []; + cells[id].push(ob); + } + } +} + +// a label box is free when it stays inside the area +// and hits nothing but the marker of its own point +function rectIsFree(index, rect) { + const x0 = rect.x0 - PAD; + const y0 = rect.y0 - PAD; + const x1 = rect.x1 + PAD; + const y1 = rect.y1 + PAD; + if (x0 < 0 || y0 < 0 || x1 > index.width || y1 > index.height) return false; + + const b = cellBounds(index, x0, y0, x1, y1); + const cells = index.cells; + + for (let row = b[1]; row <= b[3]; row++) { + for (let col = b[0]; col <= b[2]; col++) { + const cell = cells[row * index.ncols + col]; + if (!cell) continue; + + for (let i = 0; i < cell.length; i++) { + const ob = cell[i]; + if (ob.owner === rect.owner) continue; + const hit = ob.isLine + ? lineHitsRect(ob, x0, y0, x1, y1) + : ob.x0 < x1 && ob.x1 > x0 && ob.y0 < y1 && ob.y1 > y0; + if (hit) return false; + } + } + } + + return true; +} + +// a leader line is free when it crosses nothing but the marker of its own point +// and the obstacles that already cover the point (px, py), which no candidate can escape +function lineIsFree(index, line, px, py) { + const b = cellBounds(index, line.x0, line.y0, line.x1, line.y1); + const cells = index.cells; + + for (let row = b[1]; row <= b[3]; row++) { + for (let col = b[0]; col <= b[2]; col++) { + const cell = cells[row * index.ncols + col]; + if (!cell) continue; + + for (let i = 0; i < cell.length; i++) { + const ob = cell[i]; + if (ob.owner === line.owner) continue; + let hit; + if (ob.isLine) { + hit = segmentsIntersect(line.x0, line.y0, line.x1, line.y1, ob.x0, ob.y0, ob.x1, ob.y1); + } else if (!pointInRect(px, py, ob.x0, ob.y0, ob.x1, ob.y1)) { + hit = lineHitsRect(line, ob.x0, ob.y0, ob.x1, ob.y1); + } + if (hit) return false; + } + } + } + + return true; +} + +function lineHitsRect(line, x0, y0, x1, y1) { + if (pointInRect(line.x0, line.y0, x0, y0, x1, y1)) return true; + if (pointInRect(line.x1, line.y1, x0, y0, x1, y1)) return true; + + const { x0: ax, y0: ay, x1: bx, y1: by } = line; + return !!( + segmentsIntersect(ax, ay, bx, by, x0, y0, x1, y0) || + segmentsIntersect(ax, ay, bx, by, x1, y0, x1, y1) || + segmentsIntersect(ax, ay, bx, by, x1, y1, x0, y1) || + segmentsIntersect(ax, ay, bx, by, x0, y1, x0, y0) + ); +} + +function pointInRect(x, y, x0, y0, x1, y1) { + return x > x0 && x < x1 && y > y0 && y < y1; +} + +function constrain(v, v0, v1) { + return Math.max(v0, Math.min(v1, v)); +} diff --git a/src/lib/index.js b/src/lib/index.js index d2526b1dbb4..529427af16f 100644 --- a/src/lib/index.js +++ b/src/lib/index.js @@ -165,6 +165,7 @@ lib.segmentsIntersect = geom2dModule.segmentsIntersect; lib.segmentDistance = geom2dModule.segmentDistance; lib.getTextLocation = geom2dModule.getTextLocation; lib.clearLocationCache = geom2dModule.clearLocationCache; +lib.placeLabels = geom2dModule.placeLabels; lib.getVisibleSegment = geom2dModule.getVisibleSegment; lib.findPointOnPath = geom2dModule.findPointOnPath; diff --git a/src/lib/svg_text_utils.js b/src/lib/svg_text_utils.js index 21a0aa17beb..78bc32cf3a3 100644 --- a/src/lib/svg_text_utils.js +++ b/src/lib/svg_text_utils.js @@ -149,6 +149,8 @@ exports.convertToTspans = function(_context, gd, _callback) { svgClass = _context.attr('class') + '-math'; parent.select('svg.' + svgClass).remove(); } + // A failed render left the source text hidden, so show it again + _context.style('display', null); _context.text('') .style('white-space', 'pre'); diff --git a/src/plot_api/helpers.js b/src/plot_api/helpers.js index 1dc17062f52..d0c4d38794b 100644 --- a/src/plot_api/helpers.js +++ b/src/plot_api/helpers.js @@ -205,11 +205,12 @@ exports.cleanData = function (data) { if (!traceIs(trace, 'pie-like') && !traceIs(trace, 'bar-like')) { if (Array.isArray(trace.textposition)) { + const keepAuto = acceptsAutoTextPosition(trace); for (i = 0; i < trace.textposition.length; i++) { - trace.textposition[i] = cleanTextPosition(trace.textposition[i]); + trace.textposition[i] = cleanTextPosition(trace.textposition[i], keepAuto); } } else if (trace.textposition) { - trace.textposition = cleanTextPosition(trace.textposition); + trace.textposition = cleanTextPosition(trace.textposition, acceptsAutoTextPosition(trace)); } } @@ -321,9 +322,17 @@ function commonPrefix(name1, name2, show1, show2) { return out.trim(); } +// Only some trace types accept *auto*, so the others clean it like any other incorrect value +function acceptsAutoTextPosition(trace) { + const values = Registry.getModule(trace.type || 'scatter')?.attributes?.textposition?.values; + return Array.isArray(values) && values.includes('auto'); +} + // textposition - support partial attributes (ie just 'top') // and incorrect use of middle / center etc. -function cleanTextPosition(textposition) { +function cleanTextPosition(textposition, keepAuto) { + if (keepAuto && textposition === 'auto') return textposition; + var posY = 'middle'; var posX = 'center'; diff --git a/src/plots/map/layout_attributes.js b/src/plots/map/layout_attributes.js index 0c55c82f2e9..4ba96b648b2 100644 --- a/src/plots/map/layout_attributes.js +++ b/src/plots/map/layout_attributes.js @@ -319,7 +319,12 @@ var attrs = (module.exports = overrideAll( ].join(' ') }, textfont: fontAttr, - textposition: Lib.extendFlat({}, textposition, { arrayOk: false }) + textposition: Lib.extendFlat({}, textposition, { + // *auto* placement runs in the svg scatter plot code only + values: textposition.values.filter((v) => v !== 'auto'), + arrayOk: false, + description: 'Sets the positions of the `text` elements with respects to the (x,y) coordinates.' + }) } }) }, diff --git a/src/traces/quiver/attributes.js b/src/traces/quiver/attributes.js index d6fcdda809d..a618eed12d7 100644 --- a/src/traces/quiver/attributes.js +++ b/src/traces/quiver/attributes.js @@ -99,7 +99,11 @@ var attrs = { // Text and labels (shared with scatter) text: scatterAttrs.text, - textposition: scatterAttrs.textposition, + textposition: extendFlat({}, scatterAttrs.textposition, { + // *auto* placement runs in the svg scatter plot code only + values: scatterAttrs.textposition.values.filter((v) => v !== 'auto'), + description: 'Sets the positions of the `text` elements with respects to the (x,y) coordinates.' + }), textfont: scatterAttrs.textfont, // Marker: color, colorscale, arrowhead sizing, and line styling for arrows diff --git a/src/traces/scatter/arrays_to_calcdata.js b/src/traces/scatter/arrays_to_calcdata.js index 07eac057140..df3d32925b7 100644 --- a/src/traces/scatter/arrays_to_calcdata.js +++ b/src/traces/scatter/arrays_to_calcdata.js @@ -13,6 +13,7 @@ module.exports = function arraysToCalcdata(cd, trace) { Lib.mergeArray(trace.hovertext, cd, 'htx'); Lib.mergeArray(trace.customdata, cd, 'data'); Lib.mergeArray(trace.textposition, cd, 'tp'); + Lib.mergeArray(trace.textrank, cd, 'tR'); if(trace.textfont) { Lib.mergeArrayCastPositive(trace.textfont.size, cd, 'ts'); Lib.mergeArray(trace.textfont.color, cd, 'tc'); diff --git a/src/traces/scatter/attributes.js b/src/traces/scatter/attributes.js index 255496750a0..be7fa3ce591 100644 --- a/src/traces/scatter/attributes.js +++ b/src/traces/scatter/attributes.js @@ -663,12 +663,45 @@ module.exports = { 'middle right', 'bottom left', 'bottom center', - 'bottom right' + 'bottom right', + 'auto' ], dflt: 'middle center', arrayOk: true, editType: 'calc', - description: ['Sets the positions of the `text` elements', 'with respects to the (x,y) coordinates.'].join(' ') + description: [ + 'Sets the positions of the `text` elements', + 'with respects to the (x,y) coordinates.', + 'With *auto*, each label takes the first position around its point', + 'that overlaps no marker and no other label on the subplot.', + 'When the positions next to the point are taken, or another point', + 'sits close to the label, the label moves farther out with a leader', + 'line back to the point. A label with no free position is hidden.', + 'In a trace without markers or lines, a label sits on its point when', + 'it can, and moves out the same way when another label is in the way.', + 'Labels are placed in the order of `textrank`.', + 'Auto placement considers markers, text labels, and the axis labels', + 'that sit inside the plot area.', + 'It does not avoid annotations, shapes, images, or the legend.' + ].join(' ') + }, + textrank: { + valType: 'number', + dflt: 1000, + arrayOk: true, + editType: 'calc', + description: [ + 'Sets the placement rank of the `text` elements', + 'with `textposition` *auto*, per point or for the whole trace.', + 'A label with a smaller rank takes its position', + 'before the labels of larger rank on the subplot,', + 'so it stays next to its point and is hidden last.', + 'The default rank is 1000, so that you can use ranks less than 1000', + 'to place certain labels before all unranked labels,', + 'and ranks greater than 1000 to place certain labels after them.', + 'A rank can be a fraction, which puts a label between two others.', + 'Labels of equal rank are placed in trace and data order.' + ].join(' ') }, textfont: fontAttrs({ editType: 'calc', diff --git a/src/traces/scatter/auto_text_position.js b/src/traces/scatter/auto_text_position.js new file mode 100644 index 00000000000..9569bf1d83a --- /dev/null +++ b/src/traces/scatter/auto_text_position.js @@ -0,0 +1,258 @@ +'use strict'; + +const d3 = require('@plotly/d3'); +const Lib = require('../../lib'); +const Drawing = require('../../components/drawing'); +const Axes = require('../../plots/cartesian/axes'); +const subTypes = require('./subtypes'); +const isNumeric = require('fast-isnumeric'); +const { textrank } = require('./attributes'); + +/** + * Resolve the *auto* text positions of all scatter traces on one subplot. + * + * Every drawn marker and every label with a fixed position blocks the *auto* + * labels, which `Lib.placeLabels` then places by `textrank`, trace order, + * and data order. The text in `plotinfo.axisLayers` blocks them too. + * + * A cartesian `plot` call draws one zorder group, one carpet, or one overlaid subplot. + * So the pass covers every scatter trace in the plot area of `plotinfo`, + * with the axes of its own subplot. The pass of the last group sees all traces, + * and `_tpAuto` keeps the labels of the earlier passes in place where it can. + * + * Sets `_tpAuto`, `_tpAutoGap`, `_tpAutoLeader`, and `_tpAutoRadius` on every calcdata point + * with an *auto* position and repositions its `` node. Does nothing + * when no trace of the plot area uses *auto*, or in the plot of a range slider, + * where labels are never placed. + * + * A label that MathJax has not rendered yet has no box, so it is skipped, + * and the whole pass runs again once every pending render is done. + * + * @param gd - the graph div + * @param plotinfo - the subplot, with `xaxis` and `yaxis`, and optional `axisLayers`, the d3 selections + * of the layers with text inside the plot area. A cartesian pass adds the tick labels of its axes. + * @param traceGroups - d3 selection of the `g.trace` groups, bound to calcdata + */ +module.exports = function autoTextPosition(gd, plotinfo, traceGroups) { + // The slider shares the calcdata with the main plot, so a pass here would replace the main plot's results + if (plotinfo.isRangePlot) return; + + const area = plotArea(gd, plotinfo, traceGroups); + const hasAuto = area.traces.some(({ cd }) => cd[0].trace.visible === true && hasAutoTextPosition(cd[0].trace)); + if (!hasAuto) return; + + const xa = plotinfo.xaxis; + const ya = plotinfo.yaxis; + const markers = []; + // Read the axis text before the label measurements, which write to the tester and force a layout each + const fixed = axisTextBoxes(area.axisLayers, traceGroups.node()); + const labels = []; + let pending = false; + + area.traces.forEach(({ cd, node, xaxis, yaxis }) => { + const trace = cd[0].trace; + if (trace.visible !== true) return; + + const hasMarkers = subTypes.hasMarkers(trace); + const tr = d3.select(node); + + if (hasMarkers) { + tr.selectAll('path.point').each((d) => { + const x = xaxis.c2p(d.x); + const y = yaxis.c2p(d.y); + const r = d.mrc || 0; + markers.push({ x0: x - r, y0: y - r, x1: x + r, y1: y + r, owner: d }); + }); + } + + if (!subTypes.hasText(trace)) return; + + // a label without a marker or a line can also sit on its point + const onPoint = !hasMarkers && !subTypes.hasLines(trace); + + tr.selectAll('g.textpoint').each(function (d) { + const g = d3.select(this); + const tx = g.select('text'); + if (!tx.size()) return; + + // `convertToTspans` hides the tex source until MathJax replaces it + if (tx.node().style.display === 'none' && !g.select('g.text-math-group').size()) { + pending = true; + return; + } + + const pos = d.tp || trace.textposition; + const rank = Lib.extractOption(d, trace, 'tR', 'textrank'); + const x = xaxis.c2p(d.x); + const y = yaxis.c2p(d.y); + const bb = Drawing.textPointBBox(tx); + const label = { + tx, + trace, + x, + y, + owner: d, + radius: d.mrc || 0, + fontSize: Drawing.textPointFontSize(d, trace), + // `Lib.placeLabels` places the largest priority first and `textrank` puts the smallest rank first, + // so the priority is the rank with its sign reversed. + // A value that is not a number, or a point past the end of a short array, gets the default rank. + priority: -(isNumeric(rank) ? +rank : textrank.dflt), + onPoint, + // `_tpAuto` survives a relayout, so a pan or zoom keeps the labels in place where it can + prefer: d._tpAuto || undefined, + preferGap: d._tpAutoGap, + rect: labelRect(d, trace, x, y, bb) + }; + + if (pos === 'auto') { + labels.push(label); + } else { + // `textPointPosition` stored the geometry that draws the fixed label + const box = boxAt(x, y, bb, d._tpOffset); + box.owner = d; + fixed.push(box); + } + }); + }); + + const results = Lib.placeLabels({ width: xa._length, height: ya._length, markers, fixed, labels }); + + for (let i = 0; i < labels.length; i++) { + const label = labels[i]; + const d = label.owner; + const out = results[i]; + d._tpAuto = out ? out.position : null; + d._tpAutoGap = out ? out.gap : 0; + // in the plot frame, so the leader does not depend on the `` node in mid-transition + d._tpAutoLeader = out ? out.leader : null; + // The marker radius of the leader, which a later draw can change with another marker size + d._tpAutoRadius = label.radius; + Drawing.textPointPosition(label.tx, d, label.trace, d.mrc); + } + + if (pending) { + gd._promises.push(Promise.all(gd._promises).then(() => autoTextPosition(gd, plotinfo, traceGroups))); + } +}; + +/** + * Collect the scatter traces that share the plot area of a subplot. + * + * A cartesian subplot shares its plot area with every trace on the same main axes. + * These are the traces of the other zorder groups, of the overlaid axes, and of the carpets. + * Any other subplot, such as polar or ternary, has only its own trace groups. + * + * @param gd - the graph div + * @param plotinfo - the subplot, or the stand-in that a carpet trace passes + * @param traceGroups - d3 selection of the `g.trace` groups of the calling `plot` call + * @returns `traces`, the calcdata, `` node, `xaxis` and `yaxis` of each trace, + * and `axisLayers`, the d3 selections of the axis text inside the plot area + */ +function plotArea(gd, plotinfo, traceGroups) { + const isFigureAxis = (ax) => ax && ax._id && Axes.getFromId(gd, ax._id) === ax; + + // The axes of polar, ternary, and smith subplots are not axes of the figure + if (!isFigureAxis(plotinfo.xaxis) || !isFigureAxis(plotinfo.yaxis)) { + const traces = []; + traceGroups.each(function (cd) { + traces.push({ cd, node: this, xaxis: plotinfo.xaxis, yaxis: plotinfo.yaxis }); + }); + return { traces, axisLayers: plotinfo.axisLayers }; + } + + const fullLayout = gd._fullLayout; + const mainAxis = (ax) => (ax.overlaying && Axes.getFromId(gd, ax.overlaying)) || ax; + const mainX = mainAxis(plotinfo.xaxis); + const mainY = mainAxis(plotinfo.yaxis); + const traces = []; + const axes = new Set([plotinfo.xaxis, plotinfo.yaxis]); + + fullLayout._cartesianlayer.selectAll('g.trace.scatter').each(function (cd) { + // A group that the next draw removes belongs to an older calculation + const trace = cd?.[0]?.trace; + if (!trace || !gd._fullData.includes(trace)) return; + + // The axis objects of `trace._xA` and `trace._yA` can be older than the last relayout + const axisOf = (letter) => { + const id = cd[0].carpet ? cd[0].carpet[letter + 'axis'] || letter : trace[letter + 'axis']; + return Axes.getFromId(gd, id); + }; + const xaxis = axisOf('x'); + const yaxis = axisOf('y'); + if (!xaxis || !yaxis || mainAxis(xaxis) !== mainX || mainAxis(yaxis) !== mainY) return; + + traces.push({ cd, node: this, xaxis, yaxis }); + axes.add(xaxis); + axes.add(yaxis); + }); + + // The tick labels that sit inside the plot area, and any other text that the caller passes + const layers = new Set(plotinfo.axisLayers); + axes.forEach((ax) => { + if ((ax.ticklabelposition || '').includes('inside')) { + layers.add(fullLayout._plots[ax._mainSubplot][ax._id.charAt(0) + 'axislayer']); + } + }); + return { traces, axisLayers: [...layers] }; +} + +/** + * Measure the text in some axis layers, in the frame of the trace groups. + * + * Reads the rendered boxes, so the call forces a layout when the page has changes to lay out. + * + * @param layers - d3 selections of the axis layers, or undefined; an entry can be undefined or empty + * @param frame - a `g.trace` node, whose user space holds the point positions + * @returns the boxes as `{x0, y0, x1, y1}`, or none when the frame is not rendered + */ +function axisTextBoxes(layers, frame) { + const boxes = []; + // Skip the matrix without layers, because reading it forces a layout + const ctm = layers?.length && frame?.getScreenCTM(); + if (!ctm || !ctm.a || !ctm.d) return boxes; + + for (const layer of layers) { + if (!layer || layer.empty()) continue; + // A MathJax render replaces its hidden `` node + layer.selectAll('text, g[class*="-math-group"]').each(function () { + const r = this.getBoundingClientRect(); + if (!r.width || !r.height) return; + boxes.push({ + x0: (r.left - ctm.e) / ctm.a, + y0: (r.top - ctm.f) / ctm.d, + x1: (r.right - ctm.e) / ctm.a, + y1: (r.bottom - ctm.f) / ctm.d + }); + }); + } + return boxes; +} + +function hasAutoTextPosition(trace) { + const tp = trace.textposition; + return tp === 'auto' || (Lib.isArrayOrTypedArray(tp) && tp.includes('auto')); +} + +// The box of a label at a given offset, plus its leader line, in the plot frame +function boxAt(x, y, bb, offset) { + let x0 = x + offset.dx; + if (offset.anchor === 'end') x0 -= bb.width; + else if (offset.anchor === 'middle') x0 -= bb.width / 2; + const y0 = y + offset.dy + bb.top; + + const seg = offset.leader; + return { + x0, + y0, + x1: x0 + bb.width, + y1: y0 + bb.height, + leader: seg ? [x + seg[0], y + seg[1], x + seg[2], y + seg[3]] : null + }; +} + +// The box of an *auto* label at a given position and gap +function labelRect(d, trace, x, y, bb) { + const symbol = d.mx || (trace.marker || {}).symbol; + return (pos, gap) => boxAt(x, y, bb, Drawing.textPointBoxOffset(pos, d.mrc, symbol, bb, gap)); +} diff --git a/src/traces/scatter/plot.js b/src/traces/scatter/plot.js index 3c9e19df936..e8121043356 100644 --- a/src/traces/scatter/plot.js +++ b/src/traces/scatter/plot.js @@ -12,6 +12,7 @@ var subTypes = require('./subtypes'); var linePoints = require('./line_points'); var linkTraces = require('./link_traces'); var polygonTester = require('../../lib/polygon').tester; +var autoTextPosition = require('./auto_text_position'); module.exports = function plot(gd, plotinfo, cdscatter, scatterLayer, transitionOpts, makeOnCompleteCallback) { var join, onComplete; @@ -61,11 +62,13 @@ module.exports = function plot(gd, plotinfo, cdscatter, scatterLayer, transition scatterLayer.selectAll('g.trace').each(function(d, i) { plotOne(gd, i, plotinfo, d, cdscatterSorted, this, transitionOpts); }); + autoTextPosition(gd, plotinfo, scatterLayer.selectAll('g.trace')); }); } else { join.each(function(d, i) { plotOne(gd, i, plotinfo, d, cdscatterSorted, this, transitionOpts); }); + autoTextPosition(gd, plotinfo, join); } if(isFullReplot) { @@ -515,6 +518,31 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition if(showText) textFilter = showFilter; } + // Keep *auto* labels out of the range slider until #8118 settles which labels the slider draws. + // Labels are never placed in the slider, so an *auto* label there would borrow the main plot's placement. + if (showText && plotinfo.isRangePlot) { + const baseTextFilter = textFilter; + textFilter = (d) => baseTextFilter(d).filter((v) => (v.tp || trace.textposition) !== 'auto'); + } + + // An *auto* label is hidden when its point is off the plot area, so draw none for those points. + // The placement would reset them to hidden, so reset them here, and a point back in view has no preferred position. + const tp = trace.textposition; + if (showText && (tp === 'auto' || (Lib.isArrayOrTypedArray(tp) && tp.includes('auto')))) { + const baseFilter = textFilter; + textFilter = (d) => + baseFilter(d).filter((v) => { + if ((v.tp || tp) !== 'auto') return true; + const x = xa.c2p(v.x); + const y = ya.c2p(v.y); + if (!(x < 0 || x > xa._length || y < 0 || y > ya._length)) return true; + v._tpAuto = null; + v._tpAutoGap = 0; + v._tpAutoLeader = null; + return false; + }); + } + // marker points selection = points.selectAll('path.point'); @@ -569,7 +597,7 @@ function plotOne(gd, idx, plotinfo, cdscatter, cdscatterAll, element, transition } // text points - selection = text.selectAll('g'); + selection = text.selectAll('g.textpoint'); join = selection.data(textFilter, keyFunc); // each text needs to go in its own 'g' in case diff --git a/src/traces/scatter/text_defaults.js b/src/traces/scatter/text_defaults.js index 36bbb9c674a..2163cf9caf2 100644 --- a/src/traces/scatter/text_defaults.js +++ b/src/traces/scatter/text_defaults.js @@ -1,18 +1,20 @@ 'use strict'; -var Lib = require('../../lib'); +const Lib = require('../../lib'); /* * opts: object of flags to control features not all text users support * noSelect: caller does not support selected/unselected attribute containers + * noAutoPosition: caller has no *auto* textposition, so no textrank */ -module.exports = function(traceIn, traceOut, layout, coerce, opts) { - opts = opts || {}; - - coerce('textposition'); +module.exports = (traceIn, traceOut, layout, coerce, opts = {}) => { + const textposition = coerce('textposition'); + // An array can hold *auto* even where `textposition` does not accept it, because its items are not checked + const hasAuto = textposition === 'auto' || (Lib.isArrayOrTypedArray(textposition) && textposition.includes('auto')); + if (hasAuto && !opts.noAutoPosition) coerce('textrank'); Lib.coerceFont(coerce, 'textfont', opts.font || layout.font, opts); - if(!opts.noSelect) { + if (!opts.noSelect) { coerce('selected.textfont.color'); coerce('unselected.textfont.color'); } diff --git a/src/traces/scatter3d/attributes.js b/src/traces/scatter3d/attributes.js index 7191d73c1b6..a56d7c6b01a 100644 --- a/src/traces/scatter3d/attributes.js +++ b/src/traces/scatter3d/attributes.js @@ -158,7 +158,12 @@ var attrs = (module.exports = overrideAll( colorAttributes('marker') ), - textposition: extendFlat({}, scatterAttrs.textposition, { dflt: 'top center' }), + textposition: extendFlat({}, scatterAttrs.textposition, { + // *auto* placement runs in the svg scatter plot code only + values: scatterAttrs.textposition.values.filter((v) => v !== 'auto'), + dflt: 'top center', + description: 'Sets the positions of the `text` elements with respects to the (x,y) coordinates.' + }), textfont: fontAttrs({ noFontShadow: true, noFontLineposition: true, diff --git a/src/traces/scatter3d/defaults.js b/src/traces/scatter3d/defaults.js index eb366090612..1d6611200bb 100644 --- a/src/traces/scatter3d/defaults.js +++ b/src/traces/scatter3d/defaults.js @@ -49,6 +49,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('texttemplatefallback'); handleTextDefaults(traceIn, traceOut, layout, coerce, { noSelect: true, + noAutoPosition: true, noFontShadow: true, noFontLineposition: true, noFontTextcase: true diff --git a/src/traces/scattercarpet/attributes.js b/src/traces/scattercarpet/attributes.js index e5ea51d82cd..0cfaf690885 100644 --- a/src/traces/scattercarpet/attributes.js +++ b/src/traces/scattercarpet/attributes.js @@ -110,6 +110,7 @@ module.exports = { textfont: scatterAttrs.textfont, textposition: scatterAttrs.textposition, + textrank: scatterAttrs.textrank, selected: scatterAttrs.selected, unselected: scatterAttrs.unselected, diff --git a/src/traces/scattercarpet/plot.js b/src/traces/scattercarpet/plot.js index 6d7de9cacd1..9ce69b1b80b 100644 --- a/src/traces/scattercarpet/plot.js +++ b/src/traces/scattercarpet/plot.js @@ -17,6 +17,8 @@ module.exports = function plot(gd, plotinfoproxy, data, layer) { xaxis: xaxis, yaxis: yaxis, plot: plotinfoproxy.plot, + // Keep *auto* text clear of the carpet labels and titles, which the carpet draws inside the plot area + axisLayers: [plotinfoproxy.plot.selectAll('.carpetlayer .labellayer')] }; for(i = 0; i < data.length; i++) { diff --git a/src/traces/scattergeo/attributes.js b/src/traces/scattergeo/attributes.js index 1aeddeb5474..60236b65f10 100644 --- a/src/traces/scattergeo/attributes.js +++ b/src/traces/scattergeo/attributes.js @@ -104,7 +104,11 @@ module.exports = overrideAll( }), textfont: scatterAttrs.textfont, - textposition: scatterAttrs.textposition, + textposition: extendFlat({}, scatterAttrs.textposition, { + // *auto* placement runs in the svg scatter plot code only + values: scatterAttrs.textposition.values.filter((v) => v !== 'auto'), + description: 'Sets the positions of the `text` elements with respects to the (x,y) coordinates.' + }), line: { color: scatterLineAttrs.color, diff --git a/src/traces/scattergeo/defaults.js b/src/traces/scattergeo/defaults.js index 32629884b8a..2b8edecef4a 100644 --- a/src/traces/scattergeo/defaults.js +++ b/src/traces/scattergeo/defaults.js @@ -63,7 +63,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout if (subTypes.hasText(traceOut)) { coerce('texttemplate'); coerce('texttemplatefallback'); - handleTextDefaults(traceIn, traceOut, layout, coerce); + handleTextDefaults(traceIn, traceOut, layout, coerce, { noAutoPosition: true }); } coerce('fill'); diff --git a/src/traces/scattergl/attributes.js b/src/traces/scattergl/attributes.js index 59188554c52..482ffde2dce 100644 --- a/src/traces/scattergl/attributes.js +++ b/src/traces/scattergl/attributes.js @@ -37,7 +37,11 @@ var attrs = (module.exports = overrideAll( text: scatterAttrs.text, hovertext: scatterAttrs.hovertext, - textposition: scatterAttrs.textposition, + textposition: extendFlat({}, scatterAttrs.textposition, { + // *auto* placement runs in the svg scatter plot code only + values: scatterAttrs.textposition.values.filter((v) => v !== 'auto'), + description: 'Sets the positions of the `text` elements with respects to the (x,y) coordinates.' + }), textfont: fontAttrs({ noFontShadow: true, noFontLineposition: true, diff --git a/src/traces/scattergl/defaults.js b/src/traces/scattergl/defaults.js index 47f1dbd7e10..e854d6d49e9 100644 --- a/src/traces/scattergl/defaults.js +++ b/src/traces/scattergl/defaults.js @@ -59,6 +59,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('texttemplate'); coerce('texttemplatefallback'); handleTextDefaults(traceIn, traceOut, layout, coerce, { + noAutoPosition: true, noFontShadow: true, noFontLineposition: true, noFontTextcase: true diff --git a/src/traces/scatterpolar/attributes.js b/src/traces/scatterpolar/attributes.js index 115cfba0377..a55ed5ac867 100644 --- a/src/traces/scatterpolar/attributes.js +++ b/src/traces/scatterpolar/attributes.js @@ -94,6 +94,7 @@ module.exports = { cliponaxis: extendFlat({}, scatterAttrs.cliponaxis, { dflt: false }), textposition: scatterAttrs.textposition, + textrank: scatterAttrs.textrank, textfont: scatterAttrs.textfont, fill: extendFlat({}, scatterAttrs.fill, { diff --git a/src/traces/scatterpolar/plot.js b/src/traces/scatterpolar/plot.js index a456701c160..35a0a42a6ce 100644 --- a/src/traces/scatterpolar/plot.js +++ b/src/traces/scatterpolar/plot.js @@ -13,7 +13,9 @@ module.exports = function plot(gd, subplot, moduleCalcData) { xaxis: xa, yaxis: ya, plot: subplot.framework, - layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null + layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null, + // Keep *auto* text clear of the tick labels and the title, which sit inside the plot area + axisLayers: ['angular-axis', 'radial-axis', 'radial-axis-title'].map((k) => subplot.layers[k]) }; var radialAxis = subplot.radialAxis; diff --git a/src/traces/scatterpolargl/attributes.js b/src/traces/scatterpolargl/attributes.js index 80f896b4dbc..60fe00cf37a 100644 --- a/src/traces/scatterpolargl/attributes.js +++ b/src/traces/scatterpolargl/attributes.js @@ -1,7 +1,7 @@ 'use strict'; -// No cliponaxis or hoveron -const { cliponaxis, hoveron, ...scatterPolarAttrs } = require('../scatterpolar/attributes'); +// No cliponaxis or hoveron, and no textrank without *auto* textposition +const { cliponaxis, hoveron, textrank, ...scatterPolarAttrs } = require('../scatterpolar/attributes'); const { connectgaps, line: { color, dash, width }, diff --git a/src/traces/scatterpolargl/defaults.js b/src/traces/scatterpolargl/defaults.js index c540adc2acd..35cc8c52984 100644 --- a/src/traces/scatterpolargl/defaults.js +++ b/src/traces/scatterpolargl/defaults.js @@ -49,6 +49,7 @@ module.exports = function supplyDefaults(traceIn, traceOut, defaultColor, layout coerce('texttemplate'); coerce('texttemplatefallback'); handleTextDefaults(traceIn, traceOut, layout, coerce, { + noAutoPosition: true, noFontShadow: true, noFontLineposition: true, noFontTextcase: true diff --git a/src/traces/scattersmith/attributes.js b/src/traces/scattersmith/attributes.js index c6e8cdbfaee..857ac67f3e3 100644 --- a/src/traces/scattersmith/attributes.js +++ b/src/traces/scattersmith/attributes.js @@ -50,6 +50,7 @@ module.exports = { cliponaxis: extendFlat({}, scatterAttrs.cliponaxis, { dflt: false }), textposition: scatterAttrs.textposition, + textrank: scatterAttrs.textrank, textfont: scatterAttrs.textfont, fill: extendFlat({}, scatterAttrs.fill, { diff --git a/src/traces/scattersmith/plot.js b/src/traces/scattersmith/plot.js index 7bd853abc98..ec22346f3cd 100644 --- a/src/traces/scattersmith/plot.js +++ b/src/traces/scattersmith/plot.js @@ -15,7 +15,9 @@ module.exports = function plot(gd, subplot, moduleCalcData) { xaxis: xa, yaxis: ya, plot: subplot.framework, - layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null + layerClipId: subplot._hasClipOnAxisFalse ? subplot.clipIds.forTraces : null, + // Keep *auto* text clear of the tick labels, which sit inside the plot area + axisLayers: ['angular-axis', 'radial-axis'].map((k) => subplot.layers[k]) }; // convert: diff --git a/src/traces/scatterternary/attributes.js b/src/traces/scatterternary/attributes.js index 315c04c4144..62ed7dcf8f4 100644 --- a/src/traces/scatterternary/attributes.js +++ b/src/traces/scatterternary/attributes.js @@ -139,6 +139,7 @@ module.exports = { textfont: scatterAttrs.textfont, textposition: scatterAttrs.textposition, + textrank: scatterAttrs.textrank, selected: scatterAttrs.selected, unselected: scatterAttrs.unselected, diff --git a/src/traces/scatterternary/plot.js b/src/traces/scatterternary/plot.js index cac38c11635..a45b2dffee2 100644 --- a/src/traces/scatterternary/plot.js +++ b/src/traces/scatterternary/plot.js @@ -16,7 +16,9 @@ module.exports = function plot(gd, ternary, moduleCalcData) { xaxis: xa, yaxis: ya, plot: plotContainer, - layerClipId: ternary._hasClipOnAxisFalse ? ternary.clipIdRelative : null + layerClipId: ternary._hasClipOnAxisFalse ? ternary.clipIdRelative : null, + // Keep *auto* text clear of the tick labels and the titles, which sit inside the plot area + axisLayers: ['aaxis', 'baxis', 'caxis', 'a-title', 'b-title', 'c-title'].map((k) => ternary.layers[k]) }; var scatterLayer = ternary.layers.frontplot.select('g.scatterlayer'); diff --git a/src/types/generated/schema.d.ts b/src/types/generated/schema.d.ts index 796fdd93ca9..7c66fd10cb1 100644 --- a/src/types/generated/schema.d.ts +++ b/src/types/generated/schema.d.ts @@ -7339,10 +7339,15 @@ export interface ScatterData { /** Sets the text font. */ textfont?: FontArray; /** - * Sets the positions of the `text` elements with respects to the (x,y) coordinates. + * Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend. * @default 'middle center' */ - textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right')[]; + textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto')[]; + /** + * Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order. + * @default 1000 + */ + textrank?: number | number[] | TypedArray; /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. */ texttemplate?: string | string[]; /** @@ -8065,10 +8070,15 @@ export interface ScattercarpetData { /** Sets the text font. */ textfont?: FontArray; /** - * Sets the positions of the `text` elements with respects to the (x,y) coordinates. + * Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend. * @default 'middle center' */ - textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right')[]; + textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto')[]; + /** + * Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order. + * @default 1000 + */ + textrank?: number | number[] | TypedArray; /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `a`, `b` and `text`. */ texttemplate?: string | string[]; /** @@ -9100,10 +9110,15 @@ export interface ScatterpolarData { /** Sets the text font. */ textfont?: FontArray; /** - * Sets the positions of the `text` elements with respects to the (x,y) coordinates. + * Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend. * @default 'middle center' */ - textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right')[]; + textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto')[]; + /** + * Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order. + * @default 1000 + */ + textrank?: number | number[] | TypedArray; /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `r`, `theta` and `text`. */ texttemplate?: string | string[]; /** @@ -9493,10 +9508,15 @@ export interface ScattersmithData { /** Sets the text font. */ textfont?: FontArray; /** - * Sets the positions of the `text` elements with respects to the (x,y) coordinates. + * Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend. * @default 'middle center' */ - textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right')[]; + textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto')[]; + /** + * Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order. + * @default 1000 + */ + textrank?: number | number[] | TypedArray; /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `real`, `imag` and `text`. */ texttemplate?: string | string[]; /** @@ -9684,10 +9704,15 @@ export interface ScatterternaryData { /** Sets the text font. */ textfont?: FontArray; /** - * Sets the positions of the `text` elements with respects to the (x,y) coordinates. + * Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend. * @default 'middle center' */ - textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right')[]; + textposition?: 'top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto' | ('top left' | 'top center' | 'top right' | 'middle left' | 'middle center' | 'middle right' | 'bottom left' | 'bottom center' | 'bottom right' | 'auto')[]; + /** + * Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order. + * @default 1000 + */ + textrank?: number | number[] | TypedArray; /** Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example "y: %{y}". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example "Price: %{y:$.2f}". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example "Day: %{2019-01-01|%A}". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of "data: %{x}, %{y}" will result in a value of "data: 1, %{y}" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `a`, `b`, `c` and `text`. */ texttemplate?: string | string[]; /** diff --git a/test/image/baselines/text_chart_autoposition.png b/test/image/baselines/text_chart_autoposition.png new file mode 100644 index 00000000000..b8ab23071ba Binary files /dev/null and b/test/image/baselines/text_chart_autoposition.png differ diff --git a/test/image/baselines/text_chart_autoposition_real.png b/test/image/baselines/text_chart_autoposition_real.png new file mode 100644 index 00000000000..0633ea59dbf Binary files /dev/null and b/test/image/baselines/text_chart_autoposition_real.png differ diff --git a/test/image/baselines/text_chart_autoposition_timeseries.png b/test/image/baselines/text_chart_autoposition_timeseries.png new file mode 100644 index 00000000000..9622125c95d Binary files /dev/null and b/test/image/baselines/text_chart_autoposition_timeseries.png differ diff --git a/test/image/baselines/treemap_with-without_values_template.png b/test/image/baselines/treemap_with-without_values_template.png index 664e8bcac37..a3cf4c9fd1f 100644 Binary files a/test/image/baselines/treemap_with-without_values_template.png and b/test/image/baselines/treemap_with-without_values_template.png differ diff --git a/test/image/mocks/text_chart_autoposition.json b/test/image/mocks/text_chart_autoposition.json new file mode 100644 index 00000000000..0238e78b139 --- /dev/null +++ b/test/image/mocks/text_chart_autoposition.json @@ -0,0 +1,271 @@ +{ + "data": [ + { + "name": "symbols and font sizes", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [2, 2.15, 2.3, 2.1, 2.25, 2.4], + "y": [7, 7.1, 7.2, 6.85, 6.95, 7.05], + "text": [ + "circle", + "square", + "diamond", + "open circle", + "star", + "big text" + ], + "marker": { + "size": 12, + "symbol": [ + "circle", + "square", + "diamond", + "circle-open", + "star", + "circle" + ] + }, + "textfont": { + "size": [11, 11, 11, 11, 11, 18] + } + }, + { + "name": "multi-line", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [5, 5.2, 5.1, 5.35], + "y": [7, 7.1, 6.8, 6.9], + "text": ["Two
lines", "one line", "plain", "Three
short
lines"], + "marker": { + "size": 12, + "color": "#d62728" + } + }, + { + "name": "bubbles", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [8, 8.4, 8.2], + "y": [7, 7.1, 6.7], + "text": ["bubble 40", "bubble 18", "bubble 8"], + "marker": { + "size": [40, 18, 8], + "color": "#2ca02c", + "opacity": 0.6 + } + }, + { + "name": "clock: natural side", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [ + 10.8, 10.875, 10.9299, 10.95, 10.9299, 10.875, 10.8, 10.725, 10.6701, + 10.65, 10.6701, 10.725 + ], + "y": [ + 7.15, 7.1299, 7.075, 7.0, 6.925, 6.8701, 6.85, 6.8701, 6.925, 7.0, + 7.075, 7.1299 + ], + "text": ["12", "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11"], + "marker": { + "size": 12, + "color": "#17becf" + } + }, + { + "name": "text only", + "type": "scatter", + "mode": "text", + "textposition": "auto", + "x": [1, 1, 1.05], + "y": [5, 5, 5], + "text": ["one", "two", "three"], + "textfont": { + "color": "#9467bd" + } + }, + { + "name": "fixed mixed with auto", + "type": "scatter", + "mode": "markers+text", + "textposition": ["top center", "auto", "auto", "bottom center"], + "x": [3.2, 3.25, 3.3, 3.35], + "y": [5, 5.05, 5.1, 5.15], + "text": ["fixed top", "auto A", "auto B", "fixed bottom"], + "marker": { + "size": 12, + "symbol": "square", + "color": "#8c564b" + } + }, + { + "name": "order trap: obstacles", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [5.7, 6.3, 6.0, 5.5, 5.8], + "y": [5, 5, 4.72, 5.28, 5.55], + "text": ["", "", "", "", ""], + "marker": { + "size": 12, + "color": "#7f7f7f" + } + }, + { + "name": "order trap", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [5.8, 6.0], + "y": [5.28, 5.0], + "text": ["A: first in data", "B: boxed in"], + "marker": { + "size": 12, + "color": "#e377c2" + } + }, + { + "name": "diagonal chain", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [9.0, 9.22, 9.44, 9.66, 9.88, 10.1, 10.32], + "y": [ + 4.4, 4.6000000000000005, 4.800000000000001, 5.0, 5.2, 5.4, + 5.6000000000000005 + ], + "text": ["d1", "d2", "d3", "d4", "d5", "d6", "d7"], + "marker": { + "size": 12, + "color": "#bcbd22" + } + }, + { + "name": "row: alternate sides", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [ + 1.0, 1.32, 1.6400000000000001, 1.96, 2.2800000000000002, 2.6, 2.92, 3.24 + ], + "y": [3, 3, 3, 3, 3, 3, 3, 3], + "text": ["r1", "r2", "r3", "r4", "r5", "r6", "r7", "r8"], + "marker": { + "size": 12, + "color": "#1f77b4" + } + }, + { + "name": "ring: inside or outside", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [5.2, 5.5889, 5.75, 5.5889, 5.2, 4.8111, 4.65, 4.8111], + "y": [3.55, 3.3889, 3.0, 2.6111, 2.45, 2.6111, 3.0, 3.3889], + "text": ["N", "NE", "E", "SE", "S", "SW", "W", "NW"], + "marker": { + "size": 12, + "color": "#ff7f0e" + } + }, + { + "name": "two clusters: crossing leaders", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [ + 8.6, 8.7141, 8.6705, 8.5295, 8.4859, 9.5, 9.6141, 9.5705, 9.4295, 9.3859 + ], + "y": [ + 3.12, 3.0371, 2.9029, 2.9029, 3.0371, 3.12, 3.0371, 2.9029, 2.9029, + 3.0371 + ], + "text": ["L1", "L2", "L3", "L4", "L5", "R1", "R2", "R3", "R4", "R5"], + "marker": { + "size": 12, + "color": "#2ca02c" + } + }, + { + "name": "blob: how many fit", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [ + 10.841, 10.686, 11.136, 10.615, 11.032, 10.879, 10.602, 11.007, 10.584, + 10.94, 10.613, 10.632, 10.932, 11.294, 10.661 + ], + "y": [ + 2.779, 3.102, 3.358, 3.062, 2.917, 3.381, 2.637, 3.287, 2.832, 2.715, + 2.694, 2.847, 3.253, 2.745, 3.065 + ], + "text": [ + "b1", + "b2", + "b3", + "b4", + "b5", + "b6", + "b7", + "b8", + "b9", + "b10", + "b11", + "b12", + "b13", + "b14", + "b15" + ], + "marker": { + "size": 12, + "color": "#d62728" + } + }, + { + "name": "edges and corners", + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "x": [0, 12, 0, 12, 6, 6, 0, 12], + "y": [8, 8, 0, 0, 8, 0, 4.2, 4.2], + "text": [ + "top-left corner", + "top-right corner", + "bottom-left corner", + "bottom-right corner", + "top edge", + "bottom edge", + "left edge", + "right edge" + ], + "marker": { + "size": 12, + "color": "#ff7f0e" + } + } + ], + "layout": { + "title": { + "text": "textposition: auto" + }, + "width": 1200, + "height": 800, + "margin": { + "l": 50, + "r": 30, + "t": 60, + "b": 50 + }, + "showlegend": false, + "xaxis": { + "range": [0, 12] + }, + "yaxis": { + "range": [0, 8] + } + } +} diff --git a/test/image/mocks/text_chart_autoposition_real.json b/test/image/mocks/text_chart_autoposition_real.json new file mode 100644 index 00000000000..6c5dbfcebd7 --- /dev/null +++ b/test/image/mocks/text_chart_autoposition_real.json @@ -0,0 +1,176 @@ +{ + "data": [ + { + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "name": "Mostly women", + "x": [ + 0.0184, 0.06007, 0.07486, 0.08976, 0.103, 0.26177, 0.26871, 0.41749, + 0.48319, 1.02648, 1.21504, 1.24624, 1.36008, 1.5286, 1.70498, 2.11077, + 2.17576, 2.64545, 2.82591, 3.71193, 4.50175, 6.02744, 6.17446, 9.76152, + 12.51926, 14.993 + ], + "y": [ + 38, 148, 177, 135, 30, 49, 112, 66, 53, 12, 37, 757, 15, 21, 9, 6, 3620, + 98, 141, 35, 118, 17, 176, 147, 43, 253 + ], + "text": [ + "Scleroderma", + "Lupus", + "Ovarian cancer", + "Cervical cancer", + "Sjögren's", + "Uterine cancer", + "Multiple sclerosis", + "Rheumatoid arthritis", + "Eating disorders", + "ME/CFS", + "Endometriosis", + "Breast cancer", + "Fibromyalgia", + "Uterine fibroids", + "PCOS", + "Vulvodynia", + "Alzheimer's", + "Long COVID", + "PTSD", + "TMJ disorders", + "Osteoporosis", + "Gallstones", + "Major depression", + "Osteoarthritis", + "Migraine", + "Anxiety disorders" + ], + "marker": { + "size": 11, + "color": "#d6307a", + "symbol": "circle" + }, + "textfont": { + "color": "#d6307a", + "size": 12 + } + }, + { + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "name": "Both sexes", + "x": [ + 0.00999, 0.0294, 0.0335, 0.037, 0.04432, 0.05773, 0.06068, 0.1946, + 0.27344, 0.29726, 0.43472, 0.45, 0.66307, 0.99967, 1.41777, 1.81145, + 2.19793, 2.27642, 2.27919, 4.17511, 6.46848, 8.17586, 8.20703, 10.8788, + 11.79027, 15.17152, 30.55351, 37.39665 + ], + "y": [ + 86, 155, 288, 7, 56, 434, 124, 439, 255, 50, 361, 262, 12, 216, 662, 22, + 72, 422, 21, 158, 73, 242, 613, 745, 1102, 810, 1172, 377 + ], + "text": [ + "Cystic fibrosis", + "Sickle cell disease", + "Pancreatic cancer", + "Myasthenia gravis", + "Stomach cancer", + "Brain cancer", + "Down syndrome", + "Lung cancer", + "Parkinson's", + "Crohn's disease", + "Colorectal cancer", + "Schizophrenia", + "Celiac disease", + "Epilepsy", + "Opioid use disorder", + "Interstitial cystitis", + "Bipolar disorder", + "Stroke", + "Psoriasis", + "COPD", + "ADHD", + "Asthma", + "Alcohol use disorder", + "Chronic kidney disease", + "Diabetes", + "Chronic pain", + "Obesity", + "Hypertension" + ], + "marker": { + "size": 11, + "color": "#8a96ab", + "symbol": "square" + }, + "textfont": { + "color": "#8a96ab", + "size": 12 + } + }, + { + "type": "scatter", + "mode": "markers+text", + "textposition": "auto", + "name": "Mostly men", + "x": [ + 0.00178, 0.00967, 0.01147, 0.01625, 0.03426, 0.05116, 0.09161, 0.09585, + 0.364, 0.70565, 1.08791, 2.42288, 3.90382 + ], + "y": [37, 252, 40, 61, 195, 9, 82, 12, 3294, 86, 314, 339, 404], + "text": [ + "Duchenne/Becker MD", + "ALS", + "Fragile X", + "Esophageal cancer", + "Liver cancer", + "Tourette", + "Spinal cord injury", + "Testicular cancer", + "HIV", + "Hepatitis C", + "Prostate cancer", + "Autism", + "Coronary heart disease" + ], + "marker": { + "size": 11, + "color": "#2f63d1", + "symbol": "diamond" + }, + "textfont": { + "color": "#2f63d1", + "size": 12 + } + } + ], + "layout": { + "width": 1000, + "height": 640, + "margin": { + "l": 70, + "r": 40, + "t": 60, + "b": 70 + }, + "xaxis": { + "type": "log", + "title": { + "text": "Share of US population (%)" + } + }, + "yaxis": { + "type": "log", + "title": { + "text": "NIH FY2025 funding ($M)" + } + }, + "title": { + "text": "NIH funding against prevalence, textposition: auto" + }, + "legend": { + "orientation": "h", + "y": 1.06 + } + } +} diff --git a/test/image/mocks/text_chart_autoposition_timeseries.json b/test/image/mocks/text_chart_autoposition_timeseries.json new file mode 100644 index 00000000000..14709338460 --- /dev/null +++ b/test/image/mocks/text_chart_autoposition_timeseries.json @@ -0,0 +1,971 @@ +{ + "data": [ + { + "type": "scatter", + "mode": "lines+markers+text", + "textposition": "auto", + "textrank": [ + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, + 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000, 1000 + ], + "texttemplate": "%{y:.1f}", + "textfont": { + "size": 9 + }, + "line": { + "width": 1 + }, + "marker": { + "size": 4 + }, + "x0": "2026-03-01 00:00", + "dx": 240000, + "y": [ + 49.91, 49.81, 49.75, 50.17, 50.09, 49.19, 49.41, 49.26, 49.15, 49.23, + 49.39, 50.1, 50.49, 50.55, 50.09, 49.48, 49.64, 50.43, 50.45, 50.38, + 50.69, 49.8, 49.62, 49.92, 50.45, 50.29, 50.51, 50.65, 51.11, 50.42, + 50.75, 49.83, 48.26, 47.93, 47.42, 48.0, 48.44, 47.74, 48.29, 47.72, + 47.72, 47.59, 47.7, 48.24, 48.66, 48.9, 49.31, 49.61, 49.24, 48.83, + 48.57, 48.89, 48.77, 50.19, 49.7, 49.04, 49.52, 50.39, 50.68, 51.17, + 52.0, 51.91, 51.01, 50.68, 51.23, 50.34, 50.36, 50.5, 50.3, 50.73, + 51.06, 52.43, 52.76, 52.34, 51.95, 51.42, 51.96, 51.58, 51.51, 51.92, + 51.45, 51.25, 50.12, 49.47, 49.14, 49.4, 50.13, 50.12, 50.27, 50.37, + 51.01, 51.53, 51.66, 51.02, 51.54, 51.74, 52.44, 52.37, 53.5, 53.21, + 54.1, 54.09, 53.7, 52.95, 52.8, 53.6, 54.02, 54.35, 52.84, 53.21, 53.47, + 53.08, 52.64, 52.58, 53.57, 52.86, 52.55, 53.31, 52.98, 52.7, 52.71, + 51.91, 52.0, 51.24, 51.74, 51.71, 53.05, 53.15, 53.91, 53.05, 52.91, + 53.05, 54.04, 52.95, 53.48, 53.77, 54.61, 54.95, 54.88, 54.47, 53.63, + 53.68, 53.49, 54.63, 54.17, 54.28, 53.25, 52.95, 53.05, 53.48, 54.28, + 54.17, 53.41, 53.62, 53.86, 54.08, 53.58, 54.18, 54.15, 54.49, 55.16, + 55.43, 55.49, 56.67, 56.68, 56.37, 56.31, 57.08, 57.01, 57.18, 57.75, + 57.29, 56.11, 56.16, 56.18, 55.69, 56.1, 56.34, 55.62, 55.82, 55.58, + 54.58, 54.76, 54.64, 54.1, 54.33, 54.54, 54.1, 54.26, 54.78, 54.26, + 54.4, 54.32, 55.59, 54.36, 54.69, 54.41, 54.26, 55.32, 55.21, 56.45, + 56.05, 56.14, 55.73, 55.2, 55.13, 54.86, 54.88, 53.52, 54.65, 54.46, + 55.42, 54.7, 54.79, 56.59, 55.93, 54.92, 54.49, 54.69, 55.01, 55.68, + 55.41, 54.35, 54.0, 54.66, 54.85, 53.58, 53.48, 54.25, 55.44, 55.67, + 55.74, 54.89, 54.28, 54.22, 54.44, 54.7, 54.32, 53.54, 53.0, 52.25, + 52.59, 51.15, 50.93, 51.18, 52.07, 52.07, 52.63, 52.34, 51.85, 51.4, + 52.29, 52.84, 53.08, 55.02, 54.9, 55.17, 55.25, 55.01, 56.3, 57.08, + 56.1, 55.74, 55.88, 56.23, 55.3, 53.84, 52.62, 52.52, 52.41, 52.57, + 52.06, 51.28, 50.04, 50.24, 50.46, 51.04, 51.5, 51.35, 52.13, 52.01, + 51.57, 51.23, 50.85, 49.54, 49.64, 49.8, 49.58, 49.16, 49.4, 50.45, + 50.46, 50.15, 49.79, 49.75, 49.0, 48.94, 49.0, 50.13, 50.69, 51.3, + 50.84, 51.23, 50.52, 50.7, 50.93, 50.46, 51.65, 51.95, 50.77, 51.09, + 50.82, 50.81, 51.07, 51.28, 50.03, 49.34, 49.82, 50.6, 51.73, 52.78, + 51.49, 51.92, 50.73, 51.69, 51.8, 51.08, 52.27, 52.61, 51.42, 51.57, + 51.1, 51.14, 50.84, 51.65, 50.76, 50.94, 52.01, 51.47, 51.57, 51.66, + 51.21, 51.57, 51.6, 51.0, 52.04, 52.47, 52.32, 51.9, 51.39, 52.1, 51.97, + 52.21, 52.05, 52.38, 52.26, 52.68, 52.51, 52.96, 53.3, 53.17, 52.59, + 51.97, 51.49, 50.87, 51.37, 51.31, 52.23, 53.55, 53.5, 52.5, 52.22, + 52.13, 51.19, 51.02, 51.17, 50.87, 50.39, 49.93, 51.0, 50.89, 50.37, + 50.14, 50.71, 50.29, 50.57, 51.0, 51.42, 52.24, 51.86, 52.5, 51.98, + 51.7, 52.38, 52.83, 52.77, 52.0, 52.3, 51.88, 51.28, 50.87, 51.0, 49.71, + 49.93, 50.04, 49.68, 50.31, 50.67, 50.3, 49.89, 50.31, 49.39, 49.2, + 48.84, 48.91, 49.06, 49.1, 49.8, 49.94, 49.75, 49.03, 49.48, 49.81, + 50.68, 50.22, 50.23, 50.19, 50.13, 50.14, 49.11, 49.62, 50.02, 50.7, + 52.03, 51.8, 51.74, 51.71, 52.84, 51.75, 52.01, 51.08, 49.51, 48.31, + 47.51, 48.21, 47.72, 47.63, 46.98, 47.41, 46.8, 47.65, 47.1, 46.7, + 46.84, 46.92, 47.84, 47.95, 47.27, 46.99, 46.7, 47.2, 47.49, 47.47, + 48.04, 47.76, 47.73, 47.88, 48.84, 48.8, 48.62, 48.02, 48.47, 48.15, + 48.39, 47.28, 47.96, 47.2, 46.79, 47.49, 46.74, 46.76, 46.87, 46.33, + 47.21, 48.17, 47.02, 46.21, 46.77, 46.05, 45.9, 46.06, 46.55, 46.49, + 46.61, 46.22, 47.07, 46.83, 47.1, 46.85, 46.97, 46.93, 46.9, 46.35, + 46.9, 47.81, 48.0, 48.48, 49.01, 49.59, 49.78, 49.91, 50.43, 50.47, + 49.87, 49.89, 48.76, 48.78, 49.05, 49.81, 49.91, 50.47, 50.46, 49.83, + 48.99, 48.43, 47.64, 48.07, 47.98, 48.36, 48.44, 49.1, 49.75, 49.37, + 49.22, 48.91, 47.96, 48.41, 49.15, 49.17, 48.89, 49.56, 48.17, 48.31, + 48.43, 48.12, 48.99, 49.59, 49.44, 49.4, 49.18, 48.89, 48.13, 48.99, + 49.63, 50.12, 49.46, 50.72, 51.89, 51.97, 51.39, 50.84, 51.07, 50.5, + 50.47, 51.12, 50.19, 51.39, 52.3, 51.51, 50.63, 50.45, 49.82, 49.67, + 48.13, 48.03, 49.15, 48.5, 48.36, 47.5, 47.93, 47.84, 49.05, 49.19, + 48.98, 49.33, 49.04, 48.26, 48.48, 48.4, 48.53, 48.79, 48.51, 48.79, + 49.86, 49.19, 50.25, 50.1, 49.64, 51.1, 51.17, 51.97, 52.42, 51.37, + 51.41, 51.07, 50.24, 49.17, 49.25, 49.62, 49.86, 49.68, 49.21, 48.63, + 48.33, 47.74, 47.66, 47.7, 47.21, 47.12, 46.72, 46.79, 47.16, 47.32, + 47.66, 46.71, 46.55, 46.79, 47.23, 46.33, 46.5, 47.29, 47.24, 47.24, + 47.56, 47.76, 48.21, 48.29, 48.42, 47.98, 47.92, 48.71, 48.61, 48.54, + 48.27, 48.78, 48.29, 48.87, 48.33, 47.73, 47.9, 48.02, 48.24, 48.91, + 48.69, 48.98, 48.45, 49.24, 49.63, 49.96, 50.08, 49.6, 49.41, 49.73, + 49.72, 49.8, 48.99, 49.12, 48.72, 48.1, 48.04, 46.97, 47.45, 48.01, + 48.02, 47.46, 46.94, 46.91, 45.52, 45.72, 46.2, 46.09, 45.87, 46.07, + 46.35, 47.08, 47.15, 46.8, 46.58, 46.76, 47.76, 47.61, 47.03, 46.57, + 45.38, 45.67, 44.94, 44.3, 43.23, 42.69, 43.18, 42.53, 41.63, 41.9, + 41.86, 42.28, 42.88, 43.02, 42.38, 42.46, 42.91, 42.7, 41.98, 42.81, + 42.7, 42.6, 42.76, 43.52, 43.81, 43.73, 44.27, 45.67, 46.59, 47.06, + 46.45, 48.77, 48.07, 47.89, 47.51, 48.54, 48.82, 47.99, 48.19, 48.55, + 48.39, 47.69, 47.3, 47.09, 47.16, 46.91, 47.41, 46.89, 45.92, 45.56, + 46.45, 46.18, 47.66, 48.0, 47.34, 46.9, 47.11, 46.46, 47.95, 47.4, + 47.86, 48.78, 48.74, 48.16, 48.04, 48.29, 48.85, 48.91, 48.09, 48.16, + 47.79, 46.52, 46.17, 45.39, 45.84, 47.09, 48.26, 47.95, 47.71, 48.17, + 48.84, 48.61, 48.56, 49.91, 49.85, 49.56, 49.64, 49.99, 49.38, 49.25, + 48.56, 48.42, 49.58, 50.03, 49.98, 49.42, 49.24, 49.89, 49.55, 50.43, + 49.6, 49.43, 50.49, 49.28, 50.0, 48.89, 48.86, 48.78, 49.3, 47.42, + 46.32, 46.06, 46.16, 45.7, 45.65, 45.03, 44.88, 44.48, 44.74, 45.19, + 46.15, 45.83, 45.72, 45.84, 45.74, 45.8, 44.94, 45.27, 44.81, 44.34, + 43.5, 44.33, 44.36, 45.16, 45.42, 44.49, 43.95, 44.53, 44.18, 43.64, + 43.66, 43.68, 43.2, 43.14, 42.99, 41.57, 42.04, 41.72, 42.67, 42.12, + 41.33, 42.22, 41.42, 41.21, 41.75, 41.71, 40.89, 41.18, 41.24, 42.19, + 42.7, 44.66, 43.78, 43.89, 43.38, 44.2, 44.81, 45.37, 44.99, 44.65, + 43.54, 44.69, 45.1, 45.04, 45.7, 46.24, 46.9, 45.99, 45.64, 45.84, + 46.14, 46.79, 46.25, 46.4, 47.8, 48.26, 48.81, 48.83, 49.31, 50.41, + 50.46, 51.19, 50.64, 50.98, 51.5, 51.02, 51.66, 51.22, 50.71, 50.7, + 51.84, 51.99, 52.51, 52.02, 52.03, 51.28, 50.92, 51.4, 51.35, 52.19, + 52.03, 51.87, 52.6, 52.15, 51.2, 51.96, 52.17, 50.82, 50.83, 50.57, + 51.61, 51.14, 50.77, 50.21, 49.57, 49.16, 48.31, 47.73, 46.81, 45.98, + 45.12, 44.91, 44.6, 43.97, 44.38, 44.46, 44.31, 44.15, 45.22, 45.46, + 45.84, 44.37, 44.09, 42.9, 42.79, 43.16, 42.89, 43.34, 42.86, 43.35, + 42.55, 42.54, 42.95, 43.41, 43.46, 44.76, 44.61, 44.45, 42.91, 44.11, + 44.01, 43.42, 44.34, 45.2, 45.36, 44.59, 44.95, 44.68, 44.08, 44.83, + 43.65, 44.12, 44.51, 45.85, 46.13, 44.97, 44.57, 44.72, 45.04, 45.73, + 44.64, 44.54, 45.24, 46.26, 47.45, 47.27, 47.98, 49.05, 49.78, 50.22, + 50.72, 50.23, 49.35, 49.96, 49.75, 49.74, 49.4, 49.17, 49.33, 48.73, + 48.79, 48.97, 48.94, 48.52, 49.02, 50.16, 49.8, 50.54, 51.06, 49.45, + 49.01, 48.64, 49.14, 49.27, 49.57, 49.96, 49.42, 48.98, 48.27, 48.2, + 48.17, 48.31, 47.91, 48.49, 49.07, 50.02, 50.44, 50.55, 50.94, 51.29, + 50.87, 51.63, 52.62, 53.76, 53.7, 53.82, 54.0, 53.92, 54.21, 54.05, + 53.86, 54.83, 54.84, 54.78, 55.21, 55.2, 55.27, 55.13, 54.29, 54.19, + 53.42, 53.48, 52.7, 53.2, 53.58, 53.68, 53.55, 53.72, 52.47, 51.76, + 51.64, 51.81, 51.73, 51.09, 51.15, 50.85, 50.2, 50.39, 50.26, 50.97, + 51.35, 51.76, 51.92, 51.38, 50.79, 49.89, 51.62, 50.94, 50.79, 50.56, + 50.38, 50.02, 48.9, 48.32, 47.52, 47.5, 47.14, 46.78, 46.98, 46.69, + 47.31, 47.13, 47.33, 46.58, 47.05, 46.86, 47.17, 47.43, 47.37, 47.56, + 47.37, 48.23, 48.83, 49.14, 48.45, 47.82, 47.47, 47.37, 47.69, 47.2, + 47.96, 48.01, 46.84, 46.62, 45.13, 44.78, 44.55, 44.3, 44.22, 44.1, + 43.86, 44.88, 45.71, 45.11, 45.96, 45.07, 44.68, 45.7, 44.45, 44.9, + 44.25, 44.28, 44.29, 45.42, 45.02, 45.08, 45.56, 45.49, 45.09, 45.15, + 45.31, 45.93, 45.45, 45.27, 45.56, 45.55, 45.52, 45.6, 45.27, 44.89, + 45.25, 44.69, 45.5, 45.72, 45.04, 45.31, 45.5, 46.05, 46.82, 46.67, + 46.31, 46.02, 45.51, 45.15, 45.42, 45.52, 45.31, 44.97, 45.27, 45.07, + 44.54, 44.56, 44.51, 43.98, 44.6, 45.21, 45.33, 45.5, 45.91, 45.97, + 46.59, 47.65, 47.75, 48.05, 48.62, 48.44, 47.8, 48.38, 47.48, 48.6, + 49.15, 49.28, 49.32, 50.39, 49.81, 49.12, 48.78, 48.65, 48.07, 48.2, + 48.32, 47.86, 48.87, 48.06, 47.33, 47.61, 47.53, 47.83, 47.5, 46.93, + 46.61, 46.26, 46.65, 46.43, 46.96, 47.38, 48.05, 48.63, 49.13, 49.61, + 49.31, 49.16, 48.08, 48.3, 47.93, 48.21, 48.56, 47.78, 47.21, 47.06, + 46.81, 46.97, 46.34, 46.44, 45.5, 44.3, 44.84, 44.16, 43.94, 43.21, + 42.5, 42.41, 42.32, 43.06, 43.47, 43.44, 44.03, 44.82, 45.17, 45.02, + 45.73, 46.88, 47.34, 48.45, 49.04, 49.57, 48.92, 48.28, 48.28, 47.31, + 47.01, 47.59, 47.24, 47.62, 46.85, 46.62, 47.3, 47.48, 47.71, 48.77, + 49.58, 49.06, 49.87, 49.5, 49.5, 49.52, 50.17, 49.49, 49.77, 50.09, + 50.58, 50.44, 51.03, 50.59, 51.26, 51.45, 50.58, 50.81, 50.9, 50.67, + 50.22, 50.07, 49.2, 50.23, 49.55, 49.16, 49.87, 50.22, 50.65, 51.55, + 51.33, 50.67, 51.17, 51.62, 51.39, 50.6, 51.34, 51.65, 50.8, 51.25, + 50.36, 50.06, 49.39, 49.02, 48.54, 48.2, 48.67, 48.87, 49.27, 49.94, + 49.72, 49.77, 50.1, 51.12, 51.64, 52.44, 53.0, 53.57, 52.43, 52.91, + 52.98, 52.95, 52.58, 52.27, 52.48, 52.93, 51.97, 52.06, 51.69, 51.08, + 51.3, 51.07, 50.66, 50.18, 50.35, 50.33, 49.67, 49.17, 49.08, 49.34, + 48.73, 49.04, 48.57, 50.23, 49.66, 49.2, 49.33, 48.8, 49.32, 50.42, + 50.18, 50.28, 49.96, 49.96, 50.82, 51.34, 50.77, 51.68, 52.35, 52.59, + 52.14, 52.14, 51.62, 51.87, 51.64, 50.27, 50.82, 50.92, 51.55, 51.43, + 51.52, 52.16, 52.35, 52.47, 54.35, 54.16, 54.18, 55.14, 54.55, 53.84, + 52.9, 53.16, 53.47, 53.85, 52.96, 53.51, 54.15, 54.01, 53.95, 54.28, + 54.41, 54.71, 53.96, 53.78, 52.98, 53.3, 53.55, 53.42, 53.53, 52.52, + 52.99, 52.65, 52.67, 53.29, 52.77, 53.43, 53.44, 53.38, 53.65, 52.83, + 53.13, 53.1, 53.66, 53.46, 53.17, 53.33, 53.23, 52.46, 52.29, 53.01, + 52.89, 52.31, 52.7, 51.53, 50.23, 50.22, 50.32, 50.45, 49.91, 49.82, + 48.98, 49.13, 48.18, 48.13, 48.32, 47.3, 47.04, 47.57, 48.55, 47.68, + 47.93, 47.23, 46.48, 47.51, 47.96, 47.52, 47.7, 48.13, 48.27, 49.66, + 50.03, 48.23, 48.09, 47.69, 47.61, 47.67, 47.32, 46.73, 47.61, 48.02, + 47.65, 48.11, 48.55, 49.34, 48.72, 49.21, 48.86, 49.58, 49.47, 48.12, + 48.18, 47.15, 47.07, 47.1, 46.91, 46.08, 45.49, 45.08, 45.02, 45.9, + 46.4, 46.19, 47.03, 46.27, 46.76, 46.72, 46.93, 47.37, 47.01, 47.56, + 47.6, 49.09, 48.93, 49.71, 50.11, 49.4, 50.23, 49.59, 48.26, 48.8, + 49.47, 49.51, 49.36, 50.33, 50.75, 50.34, 50.77, 50.01, 49.67, 49.05, + 47.75, 47.09, 47.88, 47.26, 46.99, 46.35, 46.94, 47.01, 47.36, 47.35, + 46.7, 46.18, 46.76, 46.79, 47.32, 45.47, 46.23, 46.01, 47.39, 47.14, + 47.21, 47.67, 47.19, 47.77, 47.42, 48.25, 48.76, 49.08, 48.67, 48.57, + 48.78, 47.58, 47.3, 47.91, 47.19, 48.02, 47.68, 47.92, 47.27, 47.62, + 47.94, 48.67, 49.51, 50.27, 50.39, 49.65, 50.45, 49.66, 49.4, 49.85, + 50.12, 51.08, 51.05, 50.02, 49.85, 49.96, 49.75, 50.6, 50.44, 49.57, + 49.86, 49.17, 50.21, 50.37, 49.52, 50.18, 51.59, 50.36, 50.89, 51.17, + 51.25, 51.43, 51.7, 51.25, 52.07, 51.22, 52.37, 53.24, 52.46, 52.41, + 53.21, 52.85, 52.7, 52.4, 53.06, 52.63, 52.57, 52.84, 52.66, 52.09, + 51.24, 50.31, 49.59, 48.57, 48.16, 48.38, 48.44, 47.98, 47.41, 46.86, + 46.09, 45.43, 45.95, 45.19, 45.3, 45.02, 46.57, 45.57, 45.77, 45.65, + 45.28, 45.56, 45.74, 45.19, 45.54, 45.86, 46.73, 46.43, 47.38, 47.32, + 46.19, 46.58, 46.6, 46.13, 46.77, 46.55, 47.23, 47.24, 47.74, 48.2, + 48.52, 47.93, 48.52, 48.56, 49.79, 50.08, 50.53, 50.06, 49.82, 49.88, + 49.18, 48.49, 48.46, 48.44, 48.6, 49.29, 49.07, 49.5, 49.42, 49.31, + 49.48, 48.25, 48.33, 47.98, 47.46, 46.38, 46.18, 45.13, 45.75, 45.7, + 44.77, 44.47, 45.04, 44.78, 44.71, 44.6, 44.63, 43.72, 43.82, 43.6, + 45.5, 45.47, 45.28, 45.97, 45.96, 46.19, 45.74, 46.76, 46.45, 46.75, + 47.49, 47.36, 47.41, 47.81, 48.27, 47.65, 47.8, 47.28, 46.89, 46.56, + 47.36, 48.37, 49.97, 49.93, 49.43, 48.86, 49.85, 48.93, 48.76, 48.44, + 49.52, 48.93, 49.61, 50.4, 50.3, 50.57, 49.63, 49.59, 49.47, 47.93, + 48.22, 48.47, 47.48, 46.69, 46.3, 46.06, 45.96, 45.46, 44.95, 45.23, + 45.04, 45.08, 45.04, 45.41, 45.41, 46.02, 46.16, 46.77, 47.58, 46.76, + 47.37, 48.5, 47.94, 47.97, 47.79, 48.1, 48.45, 48.08, 48.91, 48.41, + 48.36, 49.01, 50.2, 49.89, 50.36, 50.49, 49.53, 48.96, 48.26, 47.99, + 47.78, 47.84, 48.65, 48.93, 50.55, 50.39, 50.86, 51.09, 51.97, 51.7, + 52.18, 53.11, 52.62, 52.52, 52.77, 52.45, 51.88, 52.94, 52.75, 53.73, + 53.35, 53.16, 53.58, 53.38, 52.77, 52.29, 52.4, 51.96, 51.97, 50.58, + 50.23, 50.12, 49.81, 49.21, 48.7, 48.77, 49.69, 48.85, 48.71, 49.32, + 49.54, 49.49, 49.23, 49.25, 49.21, 47.91, 47.52, 46.4, 47.25, 47.8, + 47.37, 47.9, 49.42, 49.05, 49.85, 50.07, 49.93, 49.27, 48.95, 48.73, + 47.37, 47.74, 47.59, 47.91, 47.71, 49.55, 48.56, 48.03, 48.5, 49.94, + 49.35, 50.46, 49.9, 50.68, 50.71, 52.03, 53.06, 52.61, 54.32, 53.56, + 53.3, 54.32, 53.79, 53.33, 53.34, 53.55, 53.71, 53.19, 53.24, 53.56, + 54.39, 54.5, 53.66, 53.6, 53.16, 53.88, 52.8, 53.08, 52.38, 53.1, 53.5, + 53.56, 53.79, 53.51, 53.85, 53.74, 54.11, 54.06, 52.49, 52.13, 53.0, + 53.81, 53.8, 53.64, 53.37, 53.68, 53.96, 53.29, 53.62, 54.29, 53.63, + 53.44, 53.65, 54.01, 53.68, 53.29, 52.95, 53.12, 53.31, 53.24, 52.35, + 51.65, 52.0, 51.23, 51.34, 51.1, 50.71, 50.71, 51.71, 51.85, 51.63, + 50.71, 51.01, 51.67, 52.0, 52.66, 52.89, 52.05, 51.98, 52.26, 52.65, + 52.3, 52.21, 52.39, 53.1, 53.65, 53.96, 54.12, 53.42, 52.69, 52.58, + 53.16, 53.33, 53.6, 52.63, 51.7, 52.34, 52.27, 51.44, 51.94, 52.05, + 53.78, 54.46, 54.58, 54.39, 54.44, 54.29, 53.34, 53.83, 53.92, 52.19, + 51.77, 51.23, 51.8, 51.74, 52.59, 51.59, 50.91, 51.53, 51.11, 50.45, + 50.12, 49.67, 50.0, 50.67, 49.7, 49.53, 49.55, 49.61, 50.12, 50.19, + 49.84, 49.17, 48.09, 47.91, 47.96, 48.55, 48.36, 49.78, 49.74, 49.46, + 49.7, 49.12, 49.26, 48.04, 48.48, 48.55, 48.71, 48.83, 48.09, 47.59, + 48.57, 48.9, 48.56, 48.84, 49.11, 50.3, 50.96, 51.19, 50.9, 51.95, + 51.85, 51.29, 50.53, 50.0, 49.79, 50.25, 50.79, 51.47, 51.53, 52.0, + 53.31, 53.79, 54.68, 53.68, 53.16, 52.65, 50.99, 51.01, 51.83, 51.47, + 51.56, 51.01, 50.7, 50.86, 50.12, 50.82, 49.66, 49.86, 49.56, 50.47, + 50.91, 49.64, 48.2, 47.63, 47.38, 47.32, 47.39, 47.85, 47.07, 46.05, + 46.28, 46.14, 46.15, 45.84, 45.82, 46.8, 45.89, 45.24, 45.72, 46.74, + 46.68, 45.99, 44.87, 45.13, 44.48, 45.89, 47.38, 47.57, 47.91, 47.88, + 48.61, 48.63, 48.7, 49.54, 49.93, 51.21, 50.2, 49.86, 49.32, 49.84, + 49.55, 49.92, 49.22, 49.18, 50.57, 51.54, 50.39, 50.6, 51.08, 50.76, + 50.09, 50.48, 50.53, 50.85, 50.74, 52.13, 51.54, 51.21, 51.49, 51.9, + 51.04, 51.79, 50.88, 50.87, 50.67, 50.23, 50.9, 49.48, 49.05, 48.53, + 49.08, 50.03, 50.5, 51.32, 49.96, 50.06, 49.23, 49.02, 49.19, 49.26, + 50.1, 48.91, 48.43, 47.57, 46.64, 47.31, 47.47, 48.46, 49.2, 49.26, + 48.69, 49.31, 48.91, 48.21, 47.56, 47.68, 48.48, 48.55, 49.31, 49.38, + 49.14, 50.79, 51.86, 51.94, 51.41, 50.41, 50.64, 50.48, 50.75, 50.93, + 51.17, 51.83, 51.98, 51.51, 51.02, 51.35, 50.68, 51.1, 51.5, 51.45, + 50.95, 49.86, 49.19, 48.23, 48.28, 48.05, 47.54, 48.11, 48.23, 48.29, + 48.66, 49.31, 48.36, 48.82, 47.76, 47.32, 47.98, 48.44, 48.87, 49.0, + 49.87, 48.95, 48.79, 48.84, 50.08, 50.7, 51.31, 50.82, 52.16, 52.4, + 51.75, 53.12, 52.2, 51.63, 51.46, 50.84, 51.14, 51.29, 51.33, 52.44, + 51.41, 52.2, 51.66, 52.51, 53.6, 54.91, 54.7, 53.9, 54.02, 53.91, 52.77, + 53.16, 52.54, 53.36, 53.87, 53.62, 53.57, 53.36, 53.01, 54.81, 54.15, + 54.1, 54.62, 54.29, 53.83, 52.96, 53.61, 53.97, 54.17, 53.18, 53.42, + 53.52, 53.25, 52.36, 52.28, 51.71, 51.72, 51.73, 51.49, 51.99, 51.91, + 52.19, 52.4, 52.95, 52.32, 52.06, 52.57, 52.61, 53.24, 53.62, 53.28, + 53.74, 53.51, 54.02, 55.05, 55.6, 56.87, 55.8, 55.35, 55.09, 54.74, + 54.87, 54.55, 55.13, 55.61, 55.75, 55.03, 55.26, 55.56, 55.66, 55.56, + 56.29, 55.84, 55.5, 55.79, 55.99, 56.38, 56.09, 55.18, 54.33, 53.98, + 54.38, 54.77, 53.58, 53.83, 53.7, 53.0, 54.26, 54.33, 54.74, 56.19, + 55.71, 55.36, 56.15, 55.24, 55.18, 55.1, 55.58, 55.71, 55.05, 55.53, + 55.93, 55.18, 54.67, 54.26, 53.48, 54.05, 53.78, 53.53, 52.84, 52.55, + 53.39, 53.77, 52.72, 52.07, 52.41, 52.07, 51.69, 51.14, 50.32, 50.37, + 50.14, 50.82, 50.76, 50.17, 50.58, 51.38, 50.91, 49.87, 49.58, 48.78, + 49.79, 48.96, 48.79, 49.7, 50.22, 49.91, 49.44, 49.35, 49.21, 50.13, + 50.03, 49.22, 49.83, 50.45, 50.31, 50.77, 50.78, 50.04, 49.99, 49.31, + 48.94, 48.4, 48.63, 48.77, 48.0, 47.49, 47.54, 47.87, 47.95, 46.67, + 47.51, 48.62, 49.6, 49.93, 50.34, 49.94, 50.05, 49.37, 49.46, 48.61, + 48.99, 48.8, 49.28, 49.41, 49.14, 49.67, 48.97, 48.83, 48.53, 49.01, + 48.98, 49.16, 49.5, 48.1, 47.58, 47.7, 48.35, 48.73, 49.54, 49.42, + 49.49, 49.2, 48.95, 48.48, 48.18, 48.1, 47.75, 47.81, 46.76, 47.08, + 47.25, 47.57, 47.34, 47.09, 47.74, 47.69, 48.93, 48.11, 48.0, 47.36, + 46.94, 46.53, 46.31, 46.85, 46.23, 46.67, 45.87, 45.71, 46.59, 46.93, + 47.16, 47.59, 47.99, 48.41, 49.28, 48.67, 48.65, 49.06, 49.53, 49.88, + 50.66, 50.35, 49.53, 50.31, 50.3, 49.96, 49.99, 49.73, 50.34, 50.86, + 51.03, 51.34, 51.33, 51.92, 50.46, 49.75, 49.73, 49.49, 48.93, 49.32, + 48.75, 48.91, 49.0, 49.06, 48.58, 48.49, 49.26, 49.6, 50.18, 50.13, + 49.11, 48.85, 48.78, 49.58, 50.27, 50.52, 50.0, 50.08, 49.92, 49.77, + 49.04, 49.34, 49.33, 49.83, 48.95, 48.3, 50.52, 49.98, 50.15, 50.41, + 49.82, 50.86, 50.8, 50.87, 51.95, 51.43, 51.11, 50.88, 50.04, 50.21, + 50.49, 50.59, 50.97, 51.47, 51.64, 51.46, 52.17, 51.71, 52.51, 52.34, + 52.76, 53.8, 52.36, 52.2, 51.91, 52.9, 52.6, 54.18, 53.18, 53.33, 53.03, + 53.27, 52.87, 52.54, 51.39, 51.43, 50.5, 49.71, 49.77, 49.7, 50.32, + 49.5, 49.77, 49.96, 49.78, 49.3, 49.27, 49.82, 49.6, 50.11, 49.95, + 50.98, 50.98, 50.85, 49.87, 49.02, 49.26, 49.14, 49.33, 48.93, 48.86, + 48.51, 48.72, 48.61, 49.76, 49.35, 50.04, 49.4, 49.13, 48.65, 49.43, + 49.7, 49.79, 49.94, 49.29, 50.14, 50.97, 50.93, 50.92, 51.29, 51.16, + 51.02, 50.7, 50.48, 50.19, 50.62, 50.41, 50.15, 49.77, 48.87, 49.83, + 50.14, 50.66, 50.33, 51.19, 51.26, 52.1, 52.05, 51.99, 51.63, 51.64, + 51.74, 52.61, 53.48, 53.46, 54.12, 52.41, 52.08, 52.19, 52.68, 52.88, + 52.79, 52.45, 51.92, 53.03, 53.64, 54.35, 54.27, 53.87, 54.12, 54.02, + 53.87, 53.77, 53.32, 52.73, 52.53, 51.65, 51.97, 52.44, 51.93, 50.79, + 51.01, 51.13, 51.12, 50.3, 49.14, 50.19, 50.06, 50.36, 50.31, 50.55, + 49.77, 49.51, 48.7, 48.08, 47.69, 46.65, 46.44, 46.09, 45.46, 45.47, + 45.55, 45.14, 46.44, 46.94, 47.68, 47.53, 48.11, 48.61, 48.03, 47.66, + 49.45, 49.22, 49.52, 49.41, 49.92, 49.32, 48.73, 49.44, 49.57, 48.09, + 48.14, 48.07, 48.54, 48.76, 48.99, 49.06, 49.25, 48.58, 48.12, 47.64, + 46.88, 47.42, 48.12, 48.52, 48.19, 47.91, 47.68, 48.4, 48.75, 49.82, + 49.24, 49.12, 48.38, 47.56, 47.52, 47.36, 47.11, 46.25, 46.44, 46.34, + 46.96, 47.35, 46.62, 46.92, 46.63, 47.61, 47.64, 47.39, 47.2, 46.35, + 47.19, 48.52, 47.51, 47.41, 47.39, 47.09, 47.88, 48.67, 48.55, 49.82, + 49.84, 49.7, 49.88, 49.43, 50.31, 50.68, 50.45, 50.33, 49.78, 49.94, + 49.17, 49.18, 48.89, 49.25, 49.89, 50.37, 50.29, 49.86, 49.68, 49.67, + 49.97, 49.63, 49.06, 49.18, 50.02, 51.14, 51.59, 50.97, 51.79, 51.25, + 50.76, 51.0, 50.15, 50.63, 50.59, 50.81, 50.3, 50.81, 51.1, 50.86, + 51.41, 49.78, 50.01, 49.94, 49.46, 49.58, 49.89, 49.45, 49.39, 49.38, + 49.63, 50.37, 50.08, 50.87, 50.07, 50.25, 49.21, 49.27, 49.72, 50.94, + 50.95, 50.48, 51.09, 51.09, 51.13, 51.66, 52.28, 51.19, 51.2, 51.17, + 50.5, 49.52, 49.49, 49.22, 48.8, 48.74, 48.56, 48.6, 49.57, 49.56, 50.7, + 51.43, 51.98, 51.51, 51.3, 50.72, 50.96, 50.9, 51.14, 51.3, 51.38, + 51.14, 50.53, 50.25, 50.68, 50.57, 50.52, 51.03, 51.3, 50.39, 50.33, + 49.8, 49.78, 50.02, 50.32, 51.31, 50.99, 50.79, 51.12, 51.67, 51.02, + 51.41, 51.78, 51.66, 51.46, 51.87, 52.08, 52.28, 51.8, 50.73, 50.66, + 50.48, 50.75, 50.27, 50.65, 50.88, 50.42, 50.98, 50.75, 50.32, 50.46, + 50.45, 49.89, 50.27, 50.26, 49.8, 48.75, 49.03, 49.11, 49.2, 50.04, + 48.55, 49.34, 50.17, 49.58, 50.19, 49.7, 49.08, 49.04, 50.16, 50.37, + 50.39, 50.92, 50.52, 51.41, 52.33, 52.54, 52.01, 52.62, 52.74, 53.07, + 53.69, 53.88, 53.26, 54.09, 54.18, 54.29, 54.57, 54.67, 55.02, 55.04, + 54.83, 54.41, 54.01, 53.76, 54.28, 53.99, 53.84, 53.7, 53.95, 54.26, + 55.36, 54.42, 54.19, 54.45, 53.35, 54.33, 54.17, 53.61, 54.12, 52.72, + 52.8, 52.15, 52.09, 52.28, 52.71, 52.9, 54.09, 54.05, 53.76, 54.42, + 54.29, 54.46, 52.01, 52.24, 52.15, 51.85, 51.42, 51.93, 50.67, 52.54, + 53.18, 53.34, 54.41, 54.49, 53.86, 54.4, 53.33, 53.63, 54.27, 54.51, + 53.55, 53.6, 53.45, 52.94, 51.49, 50.75, 50.44, 51.1, 52.73, 53.19, + 52.66, 52.35, 52.95, 53.26, 53.43, 53.51, 54.15, 55.41, 55.31, 55.3, + 55.5, 55.11, 53.69, 53.91, 53.4, 54.12, 54.14, 54.96, 54.35, 54.94, + 53.86, 54.35, 54.12, 54.6, 54.29, 54.61, 54.82, 54.96, 55.12, 56.03, + 55.57, 56.3, 55.96, 56.57, 57.84, 57.21, 55.77, 55.75, 55.22, 54.8, + 54.51, 54.1, 54.87, 55.45, 56.04, 57.3, 56.69, 55.06, 56.18, 56.2, + 56.05, 54.84, 53.93, 54.27, 54.1, 52.73, 52.11, 52.69, 53.41, 53.47, + 55.1, 55.61, 56.59, 57.26, 57.61, 56.65, 55.56, 55.0, 55.06, 54.96, + 55.08, 54.97, 55.16, 55.65, 55.54, 55.26, 53.58, 54.03, 53.48, 52.87, + 53.33, 53.82, 54.15, 54.64, 53.89, 53.67, 53.27, 54.24, 54.68, 54.44, + 54.85, 54.98, 54.81, 54.99, 55.4, 55.21, 54.36, 54.48, 54.23, 53.46, + 52.93, 52.72, 52.69, 52.97, 52.78, 53.0, 52.71, 52.99, 53.17, 53.45, + 54.24, 53.98, 52.95, 52.84, 53.27, 53.22, 53.35, 52.25, 51.7, 51.99, + 52.42, 51.92, 52.1, 51.63, 50.91, 50.93, 51.04, 51.26, 51.67, 52.65, + 53.16, 52.88, 53.39, 52.44, 52.45, 53.04, 53.38, 53.81, 54.88, 54.34, + 54.73, 54.58, 55.16, 55.12, 55.49, 56.31, 56.56, 56.3, 55.96, 55.42, + 55.94, 56.6, 56.33, 55.11, 54.63, 54.8, 54.14, 52.88, 52.55, 51.28, + 51.45, 51.18, 51.19, 51.95, 51.36, 50.05, 50.03, 50.31, 49.44, 49.23, + 49.04, 49.08, 48.73, 50.35, 50.19, 50.29, 50.0, 48.41, 49.27, 47.39, + 48.26, 47.5, 46.73, 46.73, 48.12, 48.35, 48.27, 47.84, 48.27, 48.09, + 48.55, 47.4, 47.77, 48.01, 47.23, 46.67, 46.65, 46.42, 46.67, 45.87, + 45.42, 46.78, 46.34, 46.16, 46.13, 46.04, 46.84, 46.56, 47.15, 46.69, + 47.22, 47.15, 47.1, 47.1, 47.49, 46.83, 46.31, 47.6, 47.96, 47.78, + 48.21, 48.21, 46.89, 47.05, 47.04, 47.31, 46.9, 46.83, 47.57, 47.53, + 47.32, 47.6, 47.41, 48.1, 48.03, 48.29, 47.9, 47.2, 47.68, 47.89, 48.31, + 48.79, 47.93, 48.04, 46.88, 47.25, 46.66, 47.0, 47.5, 47.23, 47.27, + 48.06, 47.1, 47.55, 47.67, 48.28, 47.33, 47.38, 48.1, 48.95, 48.51, + 48.37, 49.51, 48.67, 48.67, 48.8, 48.93, 48.39, 49.0, 49.55, 50.65, + 50.16, 49.87, 50.53, 51.61, 51.57, 50.62, 50.48, 51.05, 51.44, 52.08, + 53.19, 52.87, 52.27, 51.5, 51.4, 51.98, 52.98, 53.05, 53.0, 52.86, + 53.53, 53.01, 52.88, 52.78, 52.71, 53.74, 53.13, 54.54, 54.06, 53.48, + 53.02, 53.34, 53.27, 53.88, 53.3, 52.23, 51.97, 52.1, 51.17, 51.08, + 51.38, 50.87, 50.36, 49.7, 49.62, 49.9, 51.55, 52.22, 51.42, 50.47, + 50.39, 51.32, 51.69, 51.34, 51.92, 52.7, 53.53, 52.82, 53.02, 52.19, + 51.67, 50.87, 50.69, 49.82, 50.29, 50.27, 49.75, 50.04, 50.96, 51.15, + 50.78, 50.59, 50.52, 50.09, 49.06, 48.85, 49.62, 49.13, 47.99, 47.97, + 48.68, 47.67, 47.83, 48.12, 48.48, 48.84, 49.23, 48.3, 48.7, 49.55, + 49.43, 49.66, 49.06, 49.86, 49.49, 50.49, 50.26, 50.66, 50.32, 51.44, + 50.23, 50.45, 49.19, 48.49, 49.65, 50.2, 48.99, 49.01, 48.66, 49.03, + 50.16, 50.55, 50.8, 51.08, 50.41, 50.82, 51.36, 50.96, 50.56, 51.42, + 51.51, 50.8, 50.53, 50.24, 50.2, 50.15, 50.0, 51.0, 51.48, 52.34, 51.28, + 51.32, 52.0, 52.4, 51.94, 51.45, 51.99, 51.83, 52.18, 52.23, 52.11, + 52.14, 51.6, 52.59, 52.87, 53.65, 53.26, 52.87, 52.62, 52.05, 52.42, + 52.8, 52.43, 52.19, 52.38, 51.88, 51.59, 52.5, 52.0, 51.65, 53.12, + 51.78, 51.04, 52.09, 51.81, 52.07, 53.3, 52.8, 52.39, 52.59, 52.49, + 52.13, 52.01, 51.17, 51.43, 51.9, 51.76, 51.59, 51.89, 51.53, 51.31, + 51.25, 52.11, 52.03, 51.93, 51.79, 51.52, 50.91, 50.34, 50.85, 50.49, + 50.54, 50.24, 49.44, 49.17, 50.03, 49.78, 49.38, 49.13, 48.95, 49.11, + 48.88, 48.84, 48.57, 48.81, 49.02, 49.17, 49.57, 49.52, 50.25, 50.53, + 51.41, 51.33, 51.47, 51.03, 51.02, 51.49, 51.24, 52.11, 52.09, 52.99, + 52.31, 52.09, 51.75, 52.32, 52.66, 52.56, 52.24, 52.18, 52.57, 53.02, + 52.92, 52.6, 52.61, 52.63, 52.73, 52.4, 53.4, 52.95, 53.8, 54.56, 55.04, + 56.06, 56.28, 55.96, 55.53, 55.01, 55.42, 54.42, 53.48, 54.33, 54.85, + 55.0, 55.45, 55.81, 55.58, 54.59, 55.27, 55.43, 54.5, 54.49, 54.12, + 53.97, 53.69, 53.95, 53.74, 52.98, 53.32, 53.55, 52.62, 52.81, 53.29, + 53.13, 53.18, 52.66, 51.56, 51.84, 51.75, 51.97, 51.79, 51.6, 51.66, + 51.59, 52.08, 51.79, 50.88, 50.95, 50.15, 48.63, 48.0, 47.77, 47.34, + 46.47, 46.41, 47.21, 47.76, 47.81, 48.41, 48.18, 47.68, 48.14, 47.52, + 47.77, 47.8, 47.63, 47.4, 48.14, 47.22, 47.44, 47.32, 46.92, 47.01, + 47.26, 47.28, 47.48, 48.38, 48.05, 48.34, 47.33, 47.66, 46.58, 47.03, + 46.81, 46.97, 46.16, 46.25, 46.82, 47.25, 47.3, 47.35, 48.09, 48.57, + 48.22, 47.91, 47.54, 47.57, 46.92, 45.69, 45.64, 45.88, 46.08, 46.81, + 46.09, 45.86, 45.08, 45.33, 45.36, 45.16, 45.28, 44.95, 44.44, 45.5, + 46.08, 46.05, 45.2, 45.17, 45.61, 45.8, 46.49, 47.29, 47.3, 47.07, + 46.83, 46.65, 47.26, 47.06, 46.11, 46.47, 46.48, 46.27, 45.16, 45.96, + 46.46, 45.85, 46.02, 45.45, 44.73, 43.57, 44.17, 44.81, 44.73, 44.8, + 45.96, 45.59, 44.96, 45.14, 45.57, 45.48, 44.71, 44.36, 44.88, 45.41, + 44.5, 43.88, 43.41, 43.53, 44.98, 44.91, 45.05, 44.67, 46.33, 46.04, + 45.81, 45.99, 45.27, 45.85, 45.93, 45.51, 45.24, 44.2, 43.98, 42.74, + 42.61, 42.46, 42.26, 42.07, 41.72, 42.62, 43.64, 43.66, 44.47, 44.4, + 44.33, 43.95, 44.49, 44.97, 44.29, 44.5, 44.3, 43.88, 43.98, 42.95, + 42.83, 44.11, 44.49, 43.92, 44.37, 44.54, 44.45, 44.21, 44.33, 44.09, + 44.1, 44.66, 45.26, 44.58, 44.4, 45.48, 46.65, 46.94, 47.97, 48.73, + 48.68, 47.51, 47.52, 46.8, 46.94, 48.05, 48.24, 47.81, 47.15, 46.03, + 44.96, 46.04, 46.63, 46.62, 46.6, 46.63, 46.69, 47.6, 47.88, 46.73, + 47.43, 46.33, 46.16, 45.4, 45.52, 44.85, 44.13, 43.62, 44.23, 44.24, + 44.75, 45.68, 45.9, 46.33, 45.82, 45.85, 45.25, 45.67, 46.3, 46.96, + 47.0, 47.6, 47.56, 47.27, 47.63, 48.11, 49.47, 49.92, 49.26, 48.69, + 47.93, 48.02, 47.4, 48.27, 48.13, 48.55, 48.67, 48.9, 48.3, 48.32, 48.1, + 48.07, 48.19, 48.63, 48.38, 48.91, 49.13, 50.13, 48.14, 47.79, 48.59, + 47.1, 48.05, 48.14, 46.95, 47.45, 46.87, 46.38, 47.33, 47.15, 46.64, + 47.91, 48.51, 48.34, 47.9, 47.82, 48.81, 48.24, 47.51, 48.33, 48.19, + 48.29, 48.64, 48.43, 47.01, 46.94, 47.74, 49.09, 49.04, 49.04, 49.88, + 50.24, 50.41, 49.22, 48.98, 49.13, 48.24, 48.06, 49.12, 48.98, 48.14, + 47.05, 47.1, 46.81, 46.35, 46.56, 46.8, 46.97, 47.24, 46.56, 46.25, + 46.12, 46.17, 46.84, 46.47, 46.07, 47.04, 47.2, 46.41, 46.3, 45.63, + 45.04, 44.33, 43.97, 45.33, 46.03, 46.94, 47.65, 47.62, 47.9, 48.01, + 47.41, 46.76, 47.37, 46.69, 46.88, 47.33, 47.83, 48.1, 47.42, 47.79, + 47.75, 47.21, 46.07, 46.42, 45.56, 44.63, 45.78, 45.15, 44.99, 45.14, + 45.28, 44.76, 44.44, 45.03, 44.96, 45.05, 43.85, 43.63, 44.27, 43.95, + 43.5, 43.86, 44.34, 44.65, 44.42, 44.98, 46.06, 46.84, 45.96, 47.8, + 47.05, 46.37, 46.63, 46.64, 46.92, 47.77, 48.38, 47.87, 47.96, 48.61, + 49.26, 49.95, 50.45, 50.86, 50.54, 49.73, 49.99, 49.72, 50.47, 49.94, + 49.71, 49.46, 50.05, 50.5, 50.51, 50.67, 51.63, 50.54, 50.97, 51.6, + 52.3, 52.74, 52.22, 51.85, 52.21, 51.81, 51.66, 50.72, 49.99, 50.2, + 49.94, 49.48, 49.38, 49.57, 49.43, 49.54, 49.6, 49.46, 49.46, 48.94, + 48.66, 47.59, 46.94, 46.29, 46.12, 46.88, 46.93, 47.08, 46.58, 46.82, + 47.9, 47.89, 47.44, 46.79, 47.01, 46.51, 47.0, 46.46, 45.53, 45.91, + 45.22, 45.68, 46.38, 46.1, 46.3, 47.21, 46.99, 48.01, 47.85, 47.33, + 47.44, 47.63, 48.36, 48.92, 49.17, 49.58, 49.69, 50.17, 49.86, 49.21, + 49.36, 50.98, 51.45, 51.0, 51.81, 50.66, 50.49, 50.72, 50.3, 49.94, + 49.84, 49.52, 49.06, 48.43, 47.52, 46.99, 46.99, 47.65, 47.3, 46.63, + 47.66, 47.02, 47.14, 46.63, 46.27, 46.58, 46.92, 47.75, 48.19, 48.32, + 48.73, 48.14, 48.9, 49.24, 49.55, 49.92, 49.03, 48.37, 48.48, 48.6, + 48.97, 49.06, 48.97, 49.74, 49.96, 50.41, 51.11, 51.85, 52.0, 52.4, + 52.51, 53.1, 54.41, 53.7, 53.17, 54.06, 54.09, 53.75, 53.26, 53.75, + 54.01, 55.14, 54.98, 55.42, 56.31, 55.32, 54.58, 54.65, 54.22, 53.88, + 53.41, 53.25, 52.64, 51.82, 52.01, 52.05, 52.6, 52.86, 53.05, 53.44, + 52.83, 52.47, 52.59, 53.1, 53.42, 53.23, 53.05, 53.14, 52.38, 52.84, + 53.25, 53.74, 54.26, 54.78, 53.55, 53.62, 52.69, 52.06, 51.94, 52.61, + 52.76, 52.84, 53.86, 53.92, 53.27, 52.37, 51.8, 51.83, 50.87, 51.12, + 51.28, 50.82, 50.87, 51.16, 50.76, 51.18, 50.73, 51.07, 51.92, 52.08, + 51.27, 51.3, 51.32, 50.78, 50.01, 49.78, 50.3, 50.79, 51.49, 51.43, + 52.58, 52.03, 52.25, 52.06, 52.91, 52.87, 52.2, 52.3, 51.58, 51.02, + 51.56, 51.52, 52.0, 51.44, 52.18, 52.04, 50.82, 50.27, 49.42, 48.56, + 48.89, 49.13, 49.5, 49.07, 49.41, 48.54, 48.28, 48.22, 47.91, 48.01, + 48.04, 48.36, 48.58, 49.18, 48.91, 49.32, 49.5, 49.66, 49.74, 48.45, + 49.64, 49.09, 48.63, 48.47, 47.71, 48.81, 48.46, 48.08, 47.62, 47.93, + 48.81, 49.0, 48.55, 49.37, 49.56, 49.13, 49.11, 49.2, 49.36, 49.37, + 49.4, 50.53, 52.22, 52.28, 51.68, 51.02, 51.49, 50.48, 50.36, 50.59, + 50.69, 50.44, 51.53, 51.98, 51.65, 52.09, 51.94, 51.59, 51.31, 52.0, + 51.62, 51.55, 52.13, 52.27, 52.37, 51.92, 53.28, 53.71, 52.21, 52.95, + 52.12, 51.25, 50.81, 51.55, 51.35, 51.45, 50.42, 51.06, 50.88, 50.09, + 50.28, 50.57, 50.61, 51.37, 51.69, 52.56, 52.48, 52.49, 52.96, 53.6, + 53.32, 54.39, 54.37, 54.29, 54.47, 53.86, 52.8, 53.98, 54.53, 53.89, + 53.89, 54.19, 53.81, 54.6, 54.21, 54.72, 54.25, 54.47, 55.31, 55.49, + 55.08, 55.11, 55.45, 55.08, 54.74, 55.71, 56.43, 55.9, 54.99, 54.12, + 53.37, 53.13, 53.03, 52.14, 52.19, 50.4, 50.48, 51.43, 50.67, 48.93, + 49.05, 49.49, 49.26, 49.41, 50.2, 51.21, 51.54, 50.77, 50.85, 50.24, + 50.22, 50.07, 49.73, 50.65, 50.5, 49.61, 49.84, 50.26, 50.28, 49.81, + 49.56, 49.21, 48.92, 48.52, 48.42, 47.64, 47.83, 47.41, 47.99, 48.26, + 49.19, 48.39, 49.27, 49.22, 49.6, 50.14, 50.72, 50.74, 51.0, 50.92, + 51.34, 50.78, 51.01, 51.91, 53.08, 53.66, 54.46, 54.68, 55.22, 56.29, + 56.13, 55.05, 54.85, 54.86, 54.27, 53.47, 53.1, 52.3, 52.8, 53.77, 54.1, + 53.18, 53.24, 52.66, 52.78, 52.23, 52.77, 53.14, 52.88, 53.13, 53.1, + 54.08, 52.86, 53.05, 53.73, 53.44, 53.07, 53.11, 52.5, 52.13, 51.47, + 51.33, 50.05, 49.69, 50.03, 49.8, 49.56, 50.11, 51.05, 50.59, 51.3, + 50.22, 49.71, 50.04, 50.19, 50.26, 50.77, 50.64, 51.08, 50.77, 50.38, + 49.24, 49.66, 49.15, 49.77, 50.38, 50.24, 50.29, 50.4, 50.27, 49.39, + 50.27, 49.92, 50.24, 50.41, 50.58, 50.7, 51.23, 50.68, 51.27, 51.34, + 51.34, 51.06, 51.01, 51.34, 50.61, 49.57, 49.41, 49.84, 49.67, 50.5, + 51.26, 52.08, 52.91, 53.01, 53.29, 52.81, 51.15, 51.37, 50.88, 50.74, + 49.23, 49.79, 49.67, 49.32, 48.64, 48.18, 47.71, 47.17, 46.38, 46.33, + 46.2, 46.79, 45.8, 45.62, 45.17, 45.47, 45.53, 46.13, 47.31, 47.37, + 47.74, 47.33, 47.31, 47.38, 48.71, 48.06, 48.01, 48.07, 47.65, 47.91, + 47.96, 48.07, 47.68, 48.17, 47.64, 47.47, 48.03, 48.91, 48.48, 48.88, + 48.85, 49.5, 49.58, 50.61, 51.51, 50.9, 50.68, 50.22, 50.17, 49.07, + 48.09, 48.74, 48.97, 49.44, 48.91, 48.3, 48.22, 49.08, 49.15, 49.27, + 50.0, 50.22, 50.33, 50.58, 50.45, 51.39, 51.3, 50.84, 51.23, 51.77, + 51.46, 51.86, 52.47, 50.41, 49.37, 49.62, 49.07, 50.1, 50.29, 50.22, + 50.26, 50.02, 49.64, 49.72, 50.33, 49.39, 49.55, 49.29, 49.35, 48.91, + 49.02, 48.4, 48.42, 48.29, 47.33, 47.63, 45.99, 45.85, 45.04, 45.29, + 45.18, 45.77, 45.73, 45.69, 46.45, 46.24, 46.3, 47.23, 47.68, 48.92, + 49.21, 48.91, 49.58, 50.14, 49.97, 50.3, 50.24, 50.54, 50.55, 50.46, + 51.46, 51.71, 50.87, 50.98, 50.78, 50.95, 51.08, 51.28, 50.23, 49.29, + 50.14, 50.12, 50.92, 51.22, 50.88, 49.85, 50.16, 50.46, 51.5, 51.35, + 51.37, 50.26, 49.89, 50.04, 49.83, 49.31, 50.17, 51.13, 50.47, 50.52, + 50.25, 51.14, 51.26, 51.59, 51.0, 51.77, 51.56, 52.64, 53.25, 52.79, + 53.05, 53.14, 52.94, 53.33, 53.25, 53.2, 52.76, 52.26, 52.26, 52.32, + 52.92, 52.05, 52.07, 52.99, 52.52, 51.85, 51.25, 51.7, 50.91, 50.9, + 50.48, 50.13, 49.94, 49.72, 49.5, 49.48, 49.65, 49.39, 49.68, 49.4, + 49.14, 49.18, 48.17, 47.71, 47.36, 46.8, 46.72, 46.35, 45.87, 46.57, + 47.88, 48.52, 48.73, 49.43, 49.63, 49.48, 49.56, 49.37, 48.06, 47.59, + 47.14, 47.49, 47.25, 46.23, 46.28, 46.53, 47.06, 47.34, 48.83, 48.33, + 49.15, 49.72, 49.35, 49.28, 48.85, 48.8, 48.56, 48.32, 48.35, 47.76, + 48.48, 49.04, 48.64, 49.18, 48.57, 47.53, 47.77, 48.14, 47.73, 48.09, + 49.31, 51.13, 50.58, 50.77, 50.01, 51.71, 51.42, 51.91, 51.92, 51.25, + 51.1, 50.27, 49.88, 49.72, 49.86, 48.5, 48.49, 50.05, 49.48, 49.69, + 49.44, 47.76, 47.2, 46.85, 47.42, 47.45, 47.37, 47.83, 47.95, 47.89, + 48.33, 47.54, 47.47, 48.27, 47.02, 46.6, 46.77, 46.97, 46.83, 47.15, + 46.67, 47.33, 47.5, 48.64, 49.17, 49.52, 50.38, 49.89, 49.72, 48.67, + 49.22, 50.23, 49.93, 49.2, 49.89, 49.45, 49.16, 50.1, 49.89, 49.78, + 50.75, 51.09, 50.43, 51.32, 50.25, 50.85, 51.13, 50.79, 50.31, 49.63, + 50.68, 50.74, 50.35, 50.68, 51.39, 51.56, 51.94, 52.48, 52.46, 52.6, + 52.48, 51.84, 51.04, 51.29, 50.84, 50.81, 50.64, 51.21, 51.36, 51.51, + 51.31, 51.2, 50.98, 51.11, 50.05, 50.04, 50.26, 50.49, 49.83, 49.58, + 49.36, 49.05, 48.34, 47.72, 47.77, 48.05, 48.17, 47.69, 47.97, 47.89, + 47.63, 47.12, 46.88, 47.37, 47.31, 47.29, 46.74, 48.48, 47.16, 46.0, + 45.76, 45.97, 47.11, 47.48, 47.71, 48.3, 48.4, 47.25, 47.46, 47.64, + 48.99, 48.58, 49.36, 48.56, 49.51, 49.6, 50.11, 50.53, 50.92, 50.96, + 50.55, 50.44, 49.98, 49.91, 48.95, 49.07, 49.67, 50.4, 51.18, 51.25, + 51.13, 50.96, 51.17, 51.58, 51.75, 50.91, 51.72, 51.97, 52.53, 52.51, + 52.3, 53.42, 52.71, 51.64, 52.8, 52.89, 52.58, 52.17, 50.93, 52.03, + 52.41, 52.67, 53.11, 52.5, 52.72, 53.32, 51.93, 52.25, 52.65, 52.66, + 52.08, 51.94, 51.62, 51.81, 51.45, 51.88, 51.53, 51.19, 51.87, 51.73, + 52.02, 51.2, 52.09, 51.18, 51.21, 51.05, 50.57, 50.4, 51.13, 52.15, + 51.97, 51.71, 50.55, 51.57, 50.9, 50.33, 50.39, 50.82, 51.72, 52.99, + 53.38, 53.03, 52.57, 52.41, 52.85, 53.36, 53.67, 52.46, 52.07, 52.9, + 52.52, 52.42, 54.01, 53.89, 54.66, 55.67, 56.02, 55.63, 54.15, 53.55, + 53.87, 54.15, 54.77, 54.01, 54.05, 52.74, 52.15, 52.43, 51.07, 51.54, + 52.61, 51.69, 51.5, 52.5, 52.75, 52.94, 52.94, 52.06, 51.94, 52.67, + 52.67, 53.05, 52.77, 52.19, 51.89, 51.22, 50.38, 50.36, 50.22, 49.65, + 50.07, 49.51, 49.78, 50.08, 51.08, 51.26, 51.71, 51.72, 50.84, 51.34, + 52.32, 52.2, 53.41, 53.9, 55.02, 54.81, 54.18, 54.13, 53.61, 53.66, + 53.44, 53.47, 53.35, 52.7, 52.33, 53.04, 52.94, 52.08, 50.83, 50.62, + 50.77, 50.05, 48.98, 49.33, 51.26, 51.76, 51.13, 51.03, 50.94, 50.16, + 49.31, 49.24, 49.73, 49.89, 49.48, 49.8, 50.11, 49.52, 48.8, 49.21, + 49.91, 50.15, 50.86, 51.72, 50.77, 51.04, 51.81, 52.45, 53.26, 52.84, + 53.2, 53.6, 53.17, 53.22, 52.36, 51.8, 51.96, 52.55, 52.5, 53.37, 54.2, + 53.52, 53.39, 53.02, 53.45, 53.82, 53.26, 53.75, 52.89, 53.53, 52.0, + 51.85, 51.39, 51.77, 51.89, 50.53, 49.71, 49.37, 50.09, 51.07, 51.46, + 52.28, 51.78, 52.0, 52.03, 51.34, 51.11, 50.87, 51.13, 51.4, 51.05, + 50.71, 49.55, 49.86, 49.8, 49.79, 50.12, 49.82, 49.25, 48.9, 48.72, + 48.87, 48.53, 48.3, 47.53, 47.34, 46.34, 46.02, 46.42, 46.6, 46.57, + 46.51, 47.12, 46.47, 46.8, 46.44, 46.68, 46.65, 46.39, 46.65, 46.33, + 45.68, 45.47, 44.81, 45.0, 44.74, 44.08, 44.42, 44.77, 44.77, 44.7, + 45.35, 45.28, 45.53, 44.94, 45.37, 45.38, 45.78, 45.7, 45.34, 44.71, + 44.69, 44.52, 43.85, 44.73, 45.45, 44.32, 43.15, 42.37, 42.62, 44.38, + 43.78, 43.71, 44.64, 44.45, 45.23, 45.89, 45.95, 46.56, 47.19, 46.41, + 46.37, 46.07, 46.16, 45.51, 45.0, 45.14, 45.23, 46.16, 46.36, 47.29, + 48.13, 48.85, 46.76, 46.36, 46.71, 45.98, 45.74, 46.23, 46.33, 46.2, + 47.31, 47.11, 47.84, 47.85, 47.95, 47.78, 47.32, 47.24, 46.48, 46.91, + 47.04, 46.84, 47.38, 46.8, 45.69, 46.03, 45.28 + ] + } + ], + "layout": { + "title": { + "text": "5,040 readings at four-minute steps, textposition: auto, textrank 1 on the extrema of each four-hour window" + }, + "width": 1200, + "height": 500, + "margin": { + "l": 50, + "r": 20, + "t": 60, + "b": 50 + }, + "xaxis": { + "title": { + "text": "time" + } + }, + "yaxis": { + "title": { + "text": "reading" + } + } + } +} diff --git a/test/jasmine/bundle_tests/mathjax_test.js b/test/jasmine/bundle_tests/mathjax_test.js index 75c1c888d32..60c295641e3 100644 --- a/test/jasmine/bundle_tests/mathjax_test.js +++ b/test/jasmine/bundle_tests/mathjax_test.js @@ -1,11 +1,13 @@ var Plotly = require('../../../lib/index'); var Fx = require('../../../src/components/fx'); var Lib = require('../../../src/lib'); +const Drawing = require('../../../src/components/drawing'); var d3Select = require('../../strict-d3').select; var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var loadScript = require('../assets/load_script'); +var click = require('../assets/click'); var delay = require('../assets/delay'); // eslint-disable-next-line no-undef @@ -626,4 +628,142 @@ describe('Test MathJax v' + mathjaxVersion + ':', function() { .then(done, done.fail); }); }); + + describe('Test scatter text labels:', function() { + var gd; + + var layout = { + width: 500, + height: 500, + margin: {l: 50, r: 50, t: 50, b: 50}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + }; + + beforeEach(function() { + gd = createGraphDiv(); + }); + + afterEach(destroyGraphDiv); + + function hiddenSourceCount() { + return d3Select(gd).selectAll('.textpoint text').filter(function() { + return this.style.display === 'none'; + }).size(); + } + + it('should keep the tex source hidden when a point is selected', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + x: [1, 2], + y: [1, 2], + text: ['$\\alpha$', '$\\beta$'], + textposition: 'top center' + }], Lib.extendFlat({clickmode: 'event+select'}, layout)) + .then(function() { + expect(d3Select(gd).selectAll('.textpoint .text-math-group').size()).toBe(2, 'math groups'); + expect(hiddenSourceCount()).toBe(2, 'before selection'); + return new Promise(function(resolve) { + gd.once('plotly_selected', resolve); + click(150, 350); + }); + }) + .then(function() { + expect(gd._fullData[0].selectedpoints).toEqual([0]); + expect(hiddenSourceCount()).toBe(2, 'after selection'); + }) + .then(done, done.fail); + }); + + it('should measure a tex label at the font size of its own trace', (done) => { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + x: [0.5], + y: [0.5], + text: ['$\\alpha$'], + textposition: 'auto', + textfont: {size: 10} + }, { + mode: 'markers+text', + x: [1, 1.3, 1.6, 1.9, 2.2, 2.5], + y: [2, 2.1, 2, 2.1, 2, 2.1], + text: ['$\\alpha$', '$\\alpha$', '$\\alpha$', '$\\alpha$', '$\\alpha$', '$\\alpha$'], + textposition: 'auto', + textfont: {size: 40} + }], Lib.extendDeep({}, layout, {showlegend: false, xaxis: {range: [0, 3]}, yaxis: {range: [0, 3]}})) + .then(() => { + const sizes = []; + + d3Select(gd).selectAll('.textpoint').each(function() { + const label = d3Select(this); + const rect = label.select('.text-math-group').node().getBoundingClientRect(); + const measured = Drawing.textPointBBox(label.select('text')); + + expect(Math.abs(measured.width - rect.width)).toBeLessThan(1, 'width of a tex label'); + expect(Math.abs(measured.height - rect.height)).toBeLessThan(1, 'height of a tex label'); + sizes.push(Math.round(rect.width)); + }); + + // the two font sizes give two sizes of the same string + expect(sizes.length).toBe(7, 'tex labels'); + expect(sizes[1]).toBeGreaterThan(sizes[0] * 2, 'large label against small label'); + }) + .then(done, done.fail); + }); + + it('should place *auto* labels by their rendered box and hide the whole label', function(done) { + var n = 12; + var x = []; + var y = []; + var text = []; + var textposition = []; + for(var i = 0; i < n; i++) { + x.push(2); + y.push(2); + text.push(i ? '$\\alpha_{' + i + '}$' : '$\\alpha + \\beta + \\gamma + \\delta$'); + textposition.push(i ? 'auto' : 'top center'); + } + // a point outside the plot area gets no label + x.push(5); + y.push(2); + text.push('$\\omega$'); + textposition.push('auto'); + + Plotly.newPlot(gd, [{ + mode: 'markers+text', + x: x, + y: y, + text: text, + textposition: textposition, + marker: {size: 10} + }], layout) + .then(function() { + var gd3 = d3Select(gd); + var cd = gd.calcdata[0]; + var boxes = [gd3.select('.point').node().getBoundingClientRect()]; + + // The point outside the plot area has no label to render + expect(gd3.selectAll('.textpoint .text-math-group').size()).toBe(n, 'math groups'); + expect(cd[n]._tpAuto).toBe(null, 'label outside the plot area'); + + gd3.selectAll('.textpoint').each(function(d) { + // Measure the group, because Firefox reports the nested svg in its viewBox units + const rect = d3Select(this).select('.text-math-group').node().getBoundingClientRect(); + if(d._tpAuto === null) expect(rect.width).toBe(0, 'hidden label ' + d.i); + else boxes.push(rect); + }); + + var overlaps = 0; + for(var i = 0; i < boxes.length; i++) { + for(var j = i + 1; j < boxes.length; j++) { + var a = boxes[i]; + var b = boxes[j]; + if(a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top) overlaps++; + } + } + expect(overlaps).toBe(0, 'overlaps among the marker and the visible labels'); + }) + .then(done, done.fail); + }); + }); }); diff --git a/test/jasmine/tests/lib_geometry2d_test.js b/test/jasmine/tests/lib_geometry2d_test.js index 6879426a9a4..77ede385682 100644 --- a/test/jasmine/tests/lib_geometry2d_test.js +++ b/test/jasmine/tests/lib_geometry2d_test.js @@ -51,6 +51,114 @@ describe('segmentsIntersect', function() { }); }); +describe('placeLabels', function() { + var W = 20; + var H = 10; + + // a W x H box next to its point, like a text label with a 5px marker + function rect(x, y) { + return function(pos, gap) { + var sx = pos.indexOf('right') !== -1 ? 1 : pos.indexOf('left') !== -1 ? -1 : 0; + var sy = pos.indexOf('bottom') !== -1 ? 1 : pos.indexOf('top') !== -1 ? -1 : 0; + var r = 6 + gap; + var x0 = x + sx * r - (sx > 0 ? 0 : sx < 0 ? W : W / 2); + var y0 = y + sy * r - (sy > 0 ? 0 : sy < 0 ? H : H / 2); + return { + x0: x0, + y0: y0, + x1: x0 + W, + y1: y0 + H, + leader: gap ? [x + sx * 5, y + sy * 5, x + sx * r, y + sy * r] : null + }; + }; + } + + function label(x, y, opts) { + return Object.assign({x: x, y: y, owner: {}, radius: 5, fontSize: 10, rect: rect(x, y)}, opts); + } + + function marker(lab) { + return {x0: lab.x - 5, y0: lab.y - 5, x1: lab.x + 5, y1: lab.y + 5, owner: lab.owner}; + } + + function place(labels, opts) { + return geom2d.placeLabels(Object.assign({width: 200, height: 200, labels: labels}, opts)); + } + + it('places an isolated label at the first position', function() { + var out = place([label(100, 100)]); + expect(out[0].position).toBe('top center'); + expect(out[0].gap).toBe(0); + expect(out[0].leader).toBe(null); + }); + + it('places labels in order of priority, then in the given order', function() { + var a = label(100, 100, {onPoint: true}); + var b = label(100, 100, {onPoint: true, priority: 1}); + var out = place([a, b]); + expect(out[1].position).toBe('middle center'); + expect(out[0].position).toBe('top center'); + }); + + it('avoids fixed boxes and the markers of other points', function() { + var a = label(100, 100); + var fixed = {x0: 80, y0: 70, x1: 120, y1: 92, owner: {}}; + var out = place([a], {fixed: [fixed], markers: [marker(a)]}); + expect(out[0].position).toBe('bottom center'); + expect(out[0].gap).toBe(0); + }); + + it('moves a label out with a leader line when another marker is close', function() { + var a = label(100, 100); + var b = label(102, 102); + var out = place([a, b], {markers: [marker(a), marker(b)]}); + expect(out[0].gap).toBeGreaterThan(0); + expect(out[1].gap).toBeGreaterThan(0); + expect(out[0].leader.length).toBe(4); + expect(out[0].position).not.toBe(out[1].position); + }); + + it('keeps the preferred position of a label while it is free', function() { + var out = place([label(100, 100, {prefer: 'bottom center'})]); + expect(out[0].position).toBe('bottom center'); + + var fixed = {x0: 80, y0: 108, x1: 120, y1: 130}; + out = place([label(100, 100, {prefer: 'bottom center'})], {fixed: [fixed]}); + expect(out[0].position).toBe('top center'); + }); + + it('keeps the preferred gap of a label unless it can go next to its point', function() { + var a = label(100, 100, {prefer: 'bottom center', preferGap: 25}); + var b = label(102, 102); + var out = place([a, b], {markers: [marker(a), marker(b)]}); + expect(out[0].position).toBe('bottom center'); + expect(out[0].gap).toBe(25); + + out = place([label(100, 100, {prefer: 'bottom center', preferGap: 25})]); + expect(out[0].gap).toBe(0); + }); + + it('counts a marker just outside the area, and ignores one far outside', function() { + var a = label(195, 100); + var out = place([a], {markers: [marker(a)]}); + expect(out[0].position).toBe('middle left'); + expect(out[0].gap).toBe(0); + + // 10px from the point, so a leader line must show which point owns the label + out = place([a], {markers: [marker(a), {x0: 200, y0: 95, x1: 210, y1: 105, owner: {}}]}); + expect(out[0].gap).toBeGreaterThan(0); + + out = place([a], {markers: [marker(a), {x0: 995, y0: 95, x1: 1005, y1: 105, owner: {}}]}); + expect(out[0].position).toBe('middle left'); + expect(out[0].gap).toBe(0); + }); + + it('hides a label outside the area', function() { + var out = place([label(300, 100)]); + expect(out[0]).toBe(null); + }); +}); + describe('segmentDistance', function() { function check(x1, y1, x2, y2, x3, y3, x4, y4, expected) { var result1 = geom2d.segmentDistance(x1, y1, x2, y2, x3, y3, x4, y4); diff --git a/test/jasmine/tests/plot_api_test.js b/test/jasmine/tests/plot_api_test.js index 40c75212cda..291bbb5d614 100644 --- a/test/jasmine/tests/plot_api_test.js +++ b/test/jasmine/tests/plot_api_test.js @@ -2569,6 +2569,21 @@ describe('Test plot api', function () { expect(gd.data[0].scl).toBe(undefined); }); + it('should keep *auto* textposition only on trace types that accept it', () => { + const data = [ + { type: 'scatter', mode: 'text', x: [1], y: [1], text: ['a'], textposition: 'auto' }, + { type: 'scatter', mode: 'text', x: [1], y: [1], text: ['a'], textposition: ['auto', 'top'] }, + { type: 'scattergl', mode: 'text', x: [1], y: [1], text: ['a'], textposition: 'auto' }, + { type: 'scattergl', mode: 'text', x: [1], y: [1], text: ['a'], textposition: ['auto', 'top'] } + ]; + + helpers.cleanData(data); + expect(data[0].textposition).toBe('auto'); + expect(data[1].textposition).toEqual(['auto', 'top center']); + expect(data[2].textposition).toBe('middle center'); + expect(data[3].textposition).toEqual(['middle center', 'top center']); + }); + it("should not delete rename 'scl' to 'colorscale' when colorscale is defined ", function () { var data = [ { diff --git a/test/jasmine/tests/scatter_test.js b/test/jasmine/tests/scatter_test.js index 4c27d0a9bb8..d69d436fdfa 100644 --- a/test/jasmine/tests/scatter_test.js +++ b/test/jasmine/tests/scatter_test.js @@ -4,6 +4,7 @@ var Scatter = require('../../../src/traces/scatter'); var makeBubbleSizeFn = require('../../../src/traces/scatter/make_bubble_size_func'); var linePoints = require('../../../src/traces/scatter/line_points'); var Lib = require('../../../src/lib'); +const Drawing = require('../../../src/components/drawing'); var Plots = require('../../../src/plots/plots'); var Plotly = require('../../../lib/index'); @@ -11,6 +12,7 @@ var createGraphDiv = require('../assets/create_graph_div'); var destroyGraphDiv = require('../assets/destroy_graph_div'); var customAssertions = require('../assets/custom_assertions'); var negateIf = require('../assets/negate_if'); +var selectButton = require('../assets/modebar_button'); var transitions = require('../assets/transitions'); @@ -48,6 +50,67 @@ describe('Test scatter', function() { traceOut = {}; }); + it('should accept *auto* textposition', function() { + traceIn = { + x: [1, 2], + y: [1, 2], + mode: 'markers+text', + text: ['a', 'b'], + textposition: 'auto' + }; + traceOut = {visible: true}; + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.textposition).toBe('auto'); + }); + + it('should coerce textrank with *auto* textposition only', function() { + traceIn = { + x: [1, 2], + y: [1, 2], + mode: 'markers+text', + text: ['a', 'b'], + textposition: 'auto', + textrank: [2, 1] + }; + traceOut = {visible: true}; + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.textrank).toEqual([2, 1]); + + traceIn.textposition = ['top center', 'auto']; + delete traceIn.textrank; + traceOut = {visible: true}; + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.textrank).toBe(1000); + + traceIn.textposition = 'top center'; + traceIn.textrank = [2, 1]; + traceOut = {visible: true}; + supplyDefaults(traceIn, traceOut, defaultColor, layout); + expect(traceOut.textrank).toBeUndefined(); + }); + + it('should not coerce textrank in traces without *auto* textposition, even if an array holds *auto*', () => { + const positions = ['auto', 'top center']; + const common = { mode: 'text', text: ['a', 'b'], textposition: positions }; + const traces = { + scattergl: { x: [1, 2], y: [1, 2] }, + scatterpolargl: { r: [1, 2], theta: [1, 2] }, + scatter3d: { x: [1, 2], y: [1, 2], z: [1, 2] }, + scattergeo: { lon: [1, 2], lat: [1, 2] } + }; + + Object.keys(traces).forEach((type) => { + const gd = { data: [Lib.extendFlat({ type }, common, traces[type])], layout: {} }; + expect(() => supplyAllDefaults(gd)).not.toThrow(); + expect(gd._fullData[0].textrank).toBeUndefined(type + ' textrank'); + }); + + // A scatter trace with the same array keeps its textrank + const gd = { data: [{ type: 'scatter', x: [1, 2], y: [1, 2], mode: 'text', text: ['a', 'b'], textposition: positions }], layout: {} }; + supplyAllDefaults(gd); + expect(gd._fullData[0].textrank).toBe(1000, 'scatter textrank'); + }); + it('should set visible to false when x and y are empty', function() { traceIn = {}; supplyDefaults(traceIn, traceOut, defaultColor, layout); @@ -698,6 +761,789 @@ describe('end-to-end scatter tests', function() { }).then(done, done.fail); }); + describe('with textposition *auto*', function() { + var layout = { + width: 500, + height: 500, + margin: {l: 50, r: 50, t: 50, b: 50}, + xaxis: {range: [0, 4]}, + yaxis: {range: [0, 4]} + }; + + function visibleLabelBoxes() { + var boxes = []; + d3SelectAll('.textpoint').each(function() { + if(this.style.display !== 'none') boxes.push(this.querySelector('text').getBoundingClientRect()); + }); + return boxes; + } + + function hiddenLabelCount() { + return d3SelectAll('.textpoint').filter(function() { + return this.style.display === 'none'; + }).size(); + } + + // Leaders whose label sits away from the end of the leader line + function detachedLeaders() { + let n = 0; + d3SelectAll('.textpoint').each(function () { + const leader = this.querySelector('path.textleader'); + if (!leader || this.style.display === 'none') return; + const l = leader.getBoundingClientRect(); + const t = this.querySelector('text').getBoundingClientRect(); + const dx = Math.max(t.left - l.right, l.left - t.right, 0); + const dy = Math.max(t.top - l.bottom, l.top - t.bottom, 0); + if (Math.hypot(dx, dy) > 3) n++; + }); + return n; + } + + function countOverlaps(boxes) { + var n = 0; + for(var i = 0; i < boxes.length; i++) { + for(var j = i + 1; j < boxes.length; j++) { + var a = boxes[i]; + var b = boxes[j]; + if(a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top) n++; + } + } + return n; + } + + function textPointTransforms() { + var out = []; + d3SelectAll('.textpoint').each(function() { + out.push(d3Select(this).attr('transform')); + }); + return out; + } + + function makeStack(n) { + var x = []; + var y = []; + var text = []; + for(var i = 0; i < n; i++) { + x.push(2); + y.push(2); + text.push('label ' + i); + } + return {mode: 'markers+text', textposition: 'auto', x: x, y: y, text: text, marker: {size: 10}}; + } + + it('should place labels without overlaps, add leader lines and hide the rest', function(done) { + Plotly.newPlot(gd, [makeStack(40)], layout) + .then(function() { + var cd = gd.calcdata[0]; + var hidden = 0; + var leaders = 0; + for(var i = 0; i < cd.length; i++) { + if(cd[i]._tpAuto === null) hidden++; + else if(cd[i]._tpAutoGap > 0) leaders++; + } + + // the stacked markers block every position next to the point, + // so even the first label moves out with a leader line + expect(cd[0]._tpAuto).toBe('top center'); + expect(cd[0]._tpAutoGap).toBeGreaterThan(0); + expect(hidden).toBeGreaterThan(0); + expect(leaders).toBeGreaterThan(0); + + var boxes = visibleLabelBoxes(); + expect(boxes.length).toBe(40 - hidden); + expect(countOverlaps(boxes)).toBe(0); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(leaders); + }) + .then(done, done.fail); + }); + + it('should place the labels with the smallest textrank first', function(done) { + var stack = makeStack(3); + stack.textrank = [1000, 1000, 1]; + + Plotly.newPlot(gd, [stack], layout) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[2]._tpAuto).toBe('top center'); + expect(cd[0]._tpAuto).not.toBe('top center'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + + // a trace-wide rank puts a whole trace ahead of the others + return Plotly.newPlot(gd, [makeStack(1), Lib.extendFlat(makeStack(1), {textrank: 1})], layout); + }) + .then(function() { + expect(gd.calcdata[1][0]._tpAuto).toBe('top center'); + expect(gd.calcdata[0][0]._tpAuto).not.toBe('top center'); + }) + .then(done, done.fail); + }); + + it('should keep the position of a label across a zoom while it is free', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + textposition: 'auto', + x: [2], + y: [2], + text: ['auto'] + }, { + mode: 'markers+text', + textposition: 'bottom center', + x: [2], + y: [2.25], + text: ['fixed'] + }], layout) + .then(function() { + // the fixed label hangs over the point, so the *auto* label goes below it + expect(gd.calcdata[0][0]._tpAuto).toBe('bottom center'); + return Plotly.relayout(gd, {'xaxis.range': [1.5, 2.5], 'yaxis.range': [1.5, 2.5]}); + }) + .then(function() { + // the zoom frees the top, but the label stays where it was + expect(gd.calcdata[0][0]._tpAuto).toBe('bottom center'); + return Plotly.relayout(gd, {'xaxis.range': [0, 4], 'yaxis.range': [0, 4]}); + }) + .then(function() { + expect(gd.calcdata[0][0]._tpAuto).toBe('bottom center'); + }) + .then(done, done.fail); + }); + + it('should let an isolated label take the first candidate', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + textposition: 'auto', + x: [1, 3], + y: [1, 3], + text: ['a', 'b'] + }], layout) + .then(function() { + expect(gd.calcdata[0][0]._tpAuto).toBe('top center'); + expect(gd.calcdata[0][1]._tpAuto).toBe('top center'); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(0); + expect(hiddenLabelCount()).toBe(0); + }) + .then(done, done.fail); + }); + + it('should add a leader line to every label in a cluster', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + textposition: 'auto', + x: [1, 1.05, 3], + y: [1, 1.05, 3], + text: ['a', 'b', 'c'] + }], layout) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[0]._tpAutoGap).toBeGreaterThan(0); + expect(cd[1]._tpAutoGap).toBeGreaterThan(0); + expect(cd[2]._tpAutoGap).toBe(0); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(2); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + }) + .then(done, done.fail); + }); + + it('should place text-only labels on their point first', function(done) { + Plotly.newPlot(gd, [{ + mode: 'text', + textposition: 'auto', + x: [1, 1, 1], + y: [1, 1, 1], + text: ['a', 'b', 'c'] + }], layout) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[0]._tpAuto).toBe('middle center'); + expect(cd[1]._tpAutoGap).toBeGreaterThan(0); + expect(cd[2]._tpAutoGap).toBeGreaterThan(0); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(2); + }) + .then(done, done.fail); + }); + + it('should place the labels on their points after a restyle to text only, and off them after a restyle back', (done) => { + const onPoint = () => gd.calcdata[0].filter((d) => d._tpAuto === 'middle center').length; + + Plotly.newPlot(gd, [makeStack(3)], layout) + .then(() => { + expect(onPoint()).toBe(0, 'labels on their points with markers'); + return Plotly.restyle(gd, 'mode', 'text'); + }) + .then(() => { + const cd = gd.calcdata[0]; + expect(cd[0]._tpAuto).toBe('middle center', 'first label with text only'); + expect(cd[0]._tpAutoGap).toBe(0, 'gap of the first label'); + expect(onPoint()).toBe(1, 'labels on their points with text only'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'label overlaps with text only'); + // A line is an obstacle, so a trace with lines keeps its labels off the points + return Plotly.restyle(gd, 'mode', 'lines+text'); + }) + .then(() => { + expect(onPoint()).toBe(0, 'labels on their points with lines'); + return Plotly.restyle(gd, 'mode', 'markers+text'); + }) + .then(() => { + expect(onPoint()).toBe(0, 'labels on their points after markers came back'); + }) + .then(done, done.fail); + }); + + it('should respect fixed labels and per-point *auto* values', function(done) { + Plotly.newPlot(gd, [{ + mode: 'markers+text', + textposition: ['top center', 'auto'], + x: [2, 2], + y: [2, 2], + text: ['fixed', 'auto'] + }], layout) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[0]._tpAuto).toBeUndefined(); + expect(cd[1]._tpAuto).toBe('bottom center'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + + // A corner label sits farther from a large marker than a side label does. + // The second auto label has no free spot above its point, so it takes the strip over the fixed label. + return Plotly.newPlot(gd, [{ + mode: 'markers+text', + x: [1], + y: [1], + text: ['Fixed label'], + textposition: 'top right', + marker: {size: 80} + }, { + mode: 'markers+text', + x: [1.61, 1.61], + y: [1.67, 1.8], + text: ['auto Q', 'auto B'], + textposition: 'auto', + marker: {size: 8} + }], layout); + }) + .then(() => { + expect(visibleLabelBoxes().length).toBe(3, 'visible labels'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'overlaps with a fixed corner label'); + }) + .then(done, done.fail); + }); + + it('should place labels again after a zoom', function(done) { + var x = []; + var y = []; + var text = []; + for(var i = 0; i < 8; i++) { + x.push(2 + 0.02 * i); + y.push(2 + 0.02 * (i % 3)); + text.push('point ' + i); + } + + function countHidden() { + var n = 0; + gd.calcdata[0].forEach(function(d) { + if(d._tpAuto === null) n++; + }); + return n; + } + + function countArrows() { + var n = 0; + gd.calcdata[0].forEach(function(d) { + if(d._tpAutoGap > 0) n++; + }); + return n; + } + + var hiddenAtStart; + var arrowsAtStart; + + Plotly.newPlot(gd, [{ + mode: 'markers+text', + textposition: 'auto', + x: x, + y: y, + text: text + }], layout) + .then(function() { + hiddenAtStart = countHidden(); + arrowsAtStart = countArrows(); + expect(arrowsAtStart).toBeGreaterThan(0); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + return Plotly.relayout(gd, {'xaxis.range': [1.8, 2.4], 'yaxis.range': [1.8, 2.4]}); + }) + .then(function() { + expect(countHidden()).toBe(0); + expect(countArrows()).toBeLessThan(arrowsAtStart); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + // the last three points fall outside this range + return Plotly.relayout(gd, {'xaxis.range': [1.8, 2.09], 'yaxis.range': [1.8, 2.4]}); + }) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[5]._tpAuto).toBe(null); + expect(cd[6]._tpAuto).toBe(null); + expect(cd[7]._tpAuto).toBe(null); + expect(cd[0]._tpAuto).not.toBe(null); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + return Plotly.relayout(gd, {'xaxis.range': [0, 4], 'yaxis.range': [0, 4]}); + }) + .then(function() { + expect(countHidden()).toBe(hiddenAtStart); + expect(countArrows()).toBe(arrowsAtStart); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + }) + .then(done, done.fail); + }); + + it('should keep the placement across style edits and selection', function(done) { + var transforms; + + Plotly.newPlot(gd, [makeStack(12)], layout) + .then(function() { + transforms = textPointTransforms(); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + return Plotly.restyle(gd, 'textfont.color', 'red'); + }) + .then(function() { + expect(textPointTransforms()).toEqual(transforms); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + return Plotly.restyle(gd, 'selectedpoints', [[0, 1]]); + }) + .then(function() { + expect(textPointTransforms()).toEqual(transforms); + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + return Plotly.restyle(gd, 'selected.marker.size', 30); + }) + .then(function() { + // The selected points draw their labels around the larger selected marker + expect(textPointTransforms()).not.toEqual(transforms); + expect(detachedLeaders()).toBe(0, 'detached leaders after a selection'); + return Plotly.relayout(gd, 'xaxis.range', [1, 3]); + }) + .then(function() { + expect(countOverlaps(visibleLabelBoxes())).toBe(0); + // A fixed position must show the labels that *auto* hid + expect(hiddenLabelCount()).toBeGreaterThan(0, 'hidden labels before the switch'); + return Plotly.restyle(gd, 'textposition', 'top center'); + }) + .then(function() { + var cd = gd.calcdata[0]; + expect(cd[0]._tpAuto).toBeUndefined(); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(0); + expect(hiddenLabelCount()).toBe(0); + }) + .then(done, done.fail); + }); + + it('should measure each label once, whatever position it takes', (done) => { + // Give every label unique text, so that no earlier spec shares a cached box + const prefix = 'measured once ' + Math.random() + ' '; + const trace = makeStack(20); + trace.text = trace.text.map((t) => prefix + t); + + Drawing.makeTester(); + const tester = Drawing.tester.node(); + // Each cache miss appends a clone of the label to the tester, so the appends count the measurements + spyOn(tester, 'appendChild').and.callThrough(); + const measured = () => + tester.appendChild.calls.allArgs().filter((args) => { + const text = args[0].getAttribute && args[0].getAttribute('data-unformatted'); + return text && text.indexOf(prefix) === 0; + }).length; + + Plotly.newPlot(gd, [trace], layout) + .then(() => { + expect(measured()).toBe(20, 'measurements after the first draw'); + return Plotly.relayout(gd, 'xaxis.range', [1, 3]); + }) + .then(() => { + expect(measured()).toBe(20, 'measurements after a relayout'); + }) + .then(done, done.fail); + }); + + it('should read textrank from a typed array, and count other values as the default rank', (done) => { + // In a stack, the label placed first takes *top center* on the first ring, the smallest gap of all + const firstPlaced = () => { + const cd = gd.calcdata[0]; + const gaps = cd.filter((d) => d._tpAuto).map((d) => d._tpAutoGap); + return cd.findIndex((d) => d._tpAuto === 'top center' && d._tpAutoGap === Math.min(...gaps)); + }; + const stack = (textrank) => Lib.extendFlat(makeStack(12), { textrank }); + // All labels have the default rank, 1000, except the ones given by index + const ranks = (byIndex) => Array.from({ length: 12 }, (_, i) => (i in byIndex ? byIndex[i] : 1000)); + + Plotly.newPlot(gd, [stack(new Float64Array(ranks({ 5: 1 })))], layout) + .then(() => { + expect(firstPlaced()).toBe(5, 'typed array'); + return Plotly.newPlot(gd, [stack(ranks({ 0: 'high', 1: 'high', 2: 'high', 3: 7 }))], layout); + }) + .then(() => { + expect(firstPlaced()).toBe(3, 'strings get the default rank'); + return Plotly.newPlot(gd, [stack(new Float64Array([1000, 4]))], layout); + }) + .then(() => { + expect(firstPlaced()).toBe(1, 'typed array shorter than the data'); + return Plotly.newPlot(gd, [stack(ranks({ 7: 0 }))], layout); + }) + .then(() => { + expect(firstPlaced()).toBe(7, 'a rank of 0 is a rank, not a missing value'); + return Plotly.newPlot(gd, [stack(ranks({ 4: 2.5, 6: 2.4 }))], layout); + }) + .then(() => { + expect(firstPlaced()).toBe(6, 'a fraction ranks between two integers'); + }) + .then(done, done.fail); + }); + + it('should leave the *auto* labels out of the range slider and place the main plot', (done) => { + const x = []; + const y = []; + for (let i = 0; i < 60; i++) { + x.push(i); + y.push(Math.sin(i / 4) * 5); + } + const sliderLabels = () => d3SelectAll('.rangeslider-container .textpoint').size(); + + Plotly.newPlot( + gd, + [{ mode: 'lines+markers+text', textposition: 'auto', x, y, text: x.map((v) => 'pt ' + v) }], + { width: 640, height: 480, showlegend: false, xaxis: { rangeslider: { visible: true } } } + ) + .then(() => { + expect(sliderLabels()).toBe(0, 'auto labels in the slider'); + // The slider's own placement used to replace this one and hide most labels + expect(hiddenLabelCount()).toBeLessThan(20, 'hidden labels in the main plot'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'label overlaps'); + }) + .then(done, done.fail); + }); + + it('should draw no label for an *auto* point outside the plot area, and draw it again in view', (done) => { + const x = []; + const y = []; + const textposition = []; + for (let i = 0; i < 40; i++) { + x.push(i); + y.push(Math.sin(i / 3) * 3); + // A label with a fixed position is drawn wherever its point is + textposition.push(i % 7 === 0 ? 'bottom center' : 'auto'); + } + const nFixed = textposition.filter((p) => p !== 'auto').length; + const isAuto = (d) => textposition[d.i] === 'auto'; + const textPointCount = () => d3SelectAll('.textpoint').size(); + const view = (range) => Plotly.relayout(gd, 'xaxis.range', range); + + Plotly.newPlot( + gd, + [{ mode: 'markers+text', x, y, text: x.map((v) => 'pt ' + v), textposition }], + { width: 640, height: 420, showlegend: false, xaxis: { range: [0, 40] }, yaxis: { range: [-4, 4] } } + ) + .then(() => { + expect(textPointCount()).toBe(40, 'labels in the full view'); + return view([10.5, 19.5]); + }) + .then(() => { + const cd = gd.calcdata[0]; + const outside = cd.filter((d) => isAuto(d) && (d.x < 10.5 || d.x > 19.5)); + const inside = cd.filter((d) => isAuto(d) && d.x > 10.5 && d.x < 19.5); + + // The nodes of the *auto* labels in view, and of every fixed label + expect(textPointCount()).toBe(inside.length + nFixed, 'labels after a zoom'); + expect(inside.length).toBe(8, 'auto points in view'); + // A label that comes back into view has no position to keep + outside.forEach((d) => { + expect(d._tpAuto).toBe(null, 'position of point ' + d.i); + expect(d._tpAutoGap).toBe(0, 'gap of point ' + d.i); + expect(d._tpAutoLeader).toBe(null, 'leader of point ' + d.i); + }); + inside.forEach((d) => expect(typeof d._tpAuto).toBe('string', 'position of point ' + d.i)); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'label overlaps after a zoom'); + return view([0, 40]); + }) + .then(() => { + expect(textPointCount()).toBe(40, 'labels after zooming back out'); + expect(hiddenLabelCount()).toBeLessThan(5, 'hidden labels after zooming back out'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'label overlaps after zooming back out'); + }) + .then(done, done.fail); + }); + + describe('in a plot area that several traces share', () => { + const x = Array.from({ length: 12 }, (_, i) => i); + const y = x.map((i) => i % 3); + // A new object for every plot, because a relayout writes its ranges into the layout that the plot got + const size = () => ({ width: 600, height: 400, showlegend: false }); + const trace = (name, shift, extra) => + Lib.extendFlat( + { + mode: 'markers+text', + x: x.map((i) => i + shift), + y: y.map((v) => v + shift * 0.8), + text: x.map((i) => name + i), + textposition: 'auto', + marker: { size: 12 } + }, + extra + ); + + // Labels that sit on a marker or on another label, among all the traces of the plot + function collisions() { + const markers = []; + d3SelectAll('.point').each(function () { + markers.push(this.getBoundingClientRect()); + }); + const labels = visibleLabelBoxes(); + let onMarkers = 0; + labels.forEach((l) => + markers.forEach((m) => { + if (l.left < m.right && l.right > m.left && l.top < m.bottom && l.bottom > m.top) onMarkers++; + }) + ); + return { labels: labels.length, onMarkers, onLabels: countOverlaps(labels) }; + } + + function expectClear(msg) { + const c = collisions(); + expect(c.labels).toBe(24, msg + ': visible labels'); + expect(c.onMarkers).toBe(0, msg + ': labels on markers'); + expect(c.onLabels).toBe(0, msg + ': labels on labels'); + } + + it('should keep the labels of different zorder groups apart', (done) => { + Plotly.newPlot(gd, [trace('A', 0, { zorder: 0 }), trace('B', 0.15, { zorder: 1 })], size()) + .then(() => { + expectClear('zorder groups'); + return Plotly.relayout(gd, 'xaxis.range', [-1, 8]); + }) + .then(() => { + const c = collisions(); + expect(c.onMarkers).toBe(0, 'labels on markers after a zoom'); + expect(c.onLabels).toBe(0, 'labels on labels after a zoom'); + }) + .then(done, done.fail); + }); + + it('should place the labels again after a pan and a reset of the axes', (done) => { + Plotly.newPlot(gd, [trace('A', 0, { zorder: 0 }), trace('B', 0.15, { zorder: 1 })], size()) + .then(() => { + expectClear('before the pan'); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(24, 'leaders before the pan'); + return Plotly.relayout(gd, 'xaxis.range', [3, 15]); + }) + .then( + () => + new Promise((resolve) => { + gd.once('plotly_relayout', resolve); + selectButton(gd._fullLayout._modeBar, 'resetScale2d').click(); + }) + ) + .then(() => { + expectClear('after the reset'); + expect(d3SelectAll('.textpoint path.textleader').size()).toBe(24, 'leaders after the reset'); + expect(detachedLeaders()).toBe(0, 'leaders away from their labels'); + }) + .then(done, done.fail); + }); + + it('should keep the labels of overlaid axes apart', (done) => { + Plotly.newPlot(gd, [trace('A', 0), trace('B', 0.15, { yaxis: 'y2' })], { + ...size(), + yaxis2: { overlaying: 'y' } + }) + .then(() => expectClear('overlaid axes')) + .then(done, done.fail); + }); + + it('should keep the labels of a scatter trace and a scattercarpet trace apart', (done) => { + const carpet = { type: 'carpet', carpet: 'c', a: [1, 2, 3], b: [1, 2, 3], y: [[1, 2, 3], [2, 3, 4], [3, 4, 5]] }; + const onCarpet = { + type: 'scattercarpet', + carpet: 'c', + a: x.map((i) => 1 + (i % 6) / 3), + b: x.map((i) => 1 + Math.floor(i / 6)), + mode: 'markers+text', + text: x.map((i) => 'C' + i), + textposition: 'auto', + marker: { size: 12 } + }; + + Plotly.newPlot(gd, [carpet, onCarpet], size()) + .then(() => { + // Put a scatter point 7px to the right of and 5px below every carpet point + const xa = gd._fullLayout.xaxis; + const ya = gd._fullLayout.yaxis; + const cd = gd.calcdata.find((c) => c[0].trace.type === 'scattercarpet'); + const near = { + x: cd.map((d) => xa.p2c(xa.c2p(d.x) + 7)), + y: cd.map((d) => ya.p2c(ya.c2p(d.y) + 5)), + text: x.map((i) => 'S' + i) + }; + return Plotly.newPlot(gd, [carpet, onCarpet, trace('S', 0, near)], size()); + }) + .then(() => expectClear('scatter and scattercarpet')) + .then(done, done.fail); + }); + + it('should leave no label of a trace that a redraw removes', (done) => { + Plotly.newPlot(gd, [trace('A', 0, { zorder: 0 }), trace('B', 0.15, { zorder: 1 })], size()) + .then(() => Plotly.react(gd, [trace('A', 0, { zorder: 0 })], size())) + .then(() => { + expect(d3SelectAll('.textpoint').size()).toBe(12, 'label nodes'); + const c = collisions(); + expect(c.labels).toBe(12, 'visible labels'); + expect(c.onMarkers).toBe(0, 'labels on markers'); + }) + .then(done, done.fail); + }); + }); + + describe('in a subplot with axis labels inside the plot area', () => { + const size = { width: 480, height: 440, margin: { l: 50, r: 50, t: 50, b: 50 }, showlegend: false }; + const names = (n) => Array.from({ length: n }, (_, i) => 'pt ' + i); + const ternaryEdges = () => { + const out = { a: [], b: [], c: [] }; + for (let i = 0; i <= 20; i++) { + const t = i / 20; + out.a.push(t, 0.001); + out.b.push(1 - t, t); + out.c.push(0.001, 1 - t); + } + return out; + }; + const carpetEdges = () => { + const out = { a: [], b: [] }; + for (let i = 0; i < 9; i++) { + out.a.push(4 + i * 0.25, 4); + out.b.push(1, 1 + i * 0.25); + } + return out; + }; + + // Points along the edges, where the axis labels are + const figures = { + scatterpolar: () => ({ + data: [ + { + type: 'scatterpolar', + theta: Array.from({ length: 36 }, (_, i) => i * 10), + r: Array.from({ length: 36 }, (_, i) => 9.3 + (i % 3) * 0.3), + text: names(36) + } + ], + layout: { polar: { radialaxis: { range: [0, 10], title: { text: 'radius' } } } } + }), + scatterternary: () => { + const e = ternaryEdges(); + return { data: [{ type: 'scatterternary', a: e.a, b: e.b, c: e.c, text: names(e.a.length) }] }; + }, + scattersmith: () => ({ + data: [ + { + type: 'scattersmith', + real: Array.from({ length: 24 }, (_, i) => (i % 4) * 0.2), + imag: Array.from({ length: 24 }, (_, i) => Math.tan((i / 24 - 0.5) * Math.PI * 0.95) * 2), + text: names(24) + } + ] + }), + scattercarpet: () => { + const e = carpetEdges(); + return { + data: [ + { + type: 'carpet', + a: [4, 4, 4, 4.5, 4.5, 4.5, 5, 5, 5, 6, 6, 6], + b: [1, 2, 3, 1, 2, 3, 1, 2, 3, 1, 2, 3], + y: [2, 3.5, 4, 3, 4.5, 5, 5.5, 6.5, 7.5, 8, 8.5, 10], + aaxis: { tickprefix: 'a = ', smoothing: 0, title: { text: 'A axis' } }, + baxis: { tickprefix: 'b = ', smoothing: 0, title: { text: 'B axis' } } + }, + { type: 'scattercarpet', a: e.a, b: e.b, text: names(e.a.length) } + ] + }; + }, + scatter: () => { + const x = []; + const y = []; + for (let i = 0; i <= 10; i++) { + x.push(i, 0.3); + y.push(0.3, i); + } + return { + data: [{ type: 'scatter', x, y, text: names(x.length) }], + layout: { + xaxis: { ticklabelposition: 'inside', range: [0, 10] }, + yaxis: { ticklabelposition: 'inside', range: [0, 10] } + } + }; + } + }; + + // Count the visible labels that cover other text of the graph, such as a tick label or an axis title + function labelsOnOtherText() { + const others = []; + d3SelectAll('text').each(function () { + if (this.closest('.textpoint') || !this.textContent.trim()) return; + const r = this.getBoundingClientRect(); + if (r.width) others.push(r); + }); + return visibleLabelBoxes().filter((a) => + others.some((b) => a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top) + ).length; + } + + Object.keys(figures).forEach((type) => { + it('should keep the ' + type + ' labels clear of the axis labels', (done) => { + const fig = figures[type](); + const trace = fig.data[fig.data.length - 1]; + trace.mode = 'markers+text'; + trace.textposition = 'auto'; + + Plotly.newPlot(gd, fig.data, Lib.extendFlat({}, size, fig.layout)) + .then(() => { + expect(labelsOnOtherText()).toBe(0, 'labels on axis text'); + expect(visibleLabelBoxes().length).toBeGreaterThan(trace.text.length / 2, 'visible labels'); + expect(countOverlaps(visibleLabelBoxes())).toBe(0, 'label overlaps'); + }) + .then(done, done.fail); + }); + }); + }); + + it('should draw the leader lines at the new point positions after an animation', function(done) { + function leaderPaths() { + var out = []; + d3SelectAll('.textpoint path.textleader').each(function() { + out.push(d3Select(this).attr('d')); + }); + return out; + } + + var animated; + + Plotly.newPlot(gd, [makeStack(3)], layout) + .then(function() { + expect(leaderPaths().length).toBe(3); + return Plotly.animate(gd, [{data: [{x: [1, 1, 1], y: [3, 3, 3]}]}], { + frame: {redraw: false, duration: 100}, + transition: {duration: 100} + }); + }) + .then(function() { + animated = leaderPaths(); + expect(animated.length).toBe(3); + return Plotly.redraw(gd); + }) + .then(function() { + // a full redraw draws the leader lines where the animation should have left them + expect(leaderPaths()).toEqual(animated); + }) + .then(done, done.fail); + }); + }); + it('should remove all point and text nodes on blank data', function(done) { function assertNodeCnt(ptCnt, txCnt) { expect(d3SelectAll('.point').size()).toEqual(ptCnt); diff --git a/test/jasmine/tests/scattermap_test.js b/test/jasmine/tests/scattermap_test.js index 9d49db1951d..0bd94dc777e 100644 --- a/test/jasmine/tests/scattermap_test.js +++ b/test/jasmine/tests/scattermap_test.js @@ -33,7 +33,7 @@ describe('scattermap defaults', function() { function _supply(traceIn) { var traceOut = { visible: true }; var defaultColor = '#444'; - var layout = { _dataLength: 1 }; + var layout = { _dataLength: 1, font: { family: 'Arial', size: 12, color: '#444' } }; ScatterMap.supplyDefaults(traceIn, traceOut, defaultColor, layout); @@ -123,6 +123,18 @@ describe('scattermap defaults', function() { expect(fullTrace.marker).toBeDefined(); expect(fullTrace.marker.line).toBeUndefined(); }); + + it('should not accept *auto* textposition', function() { + var fullTrace = _supply({ + mode: 'markers+text', + lon: [10, 20], + lat: [10, 20], + text: ['a', 'b'], + textposition: 'auto' + }); + + expect(fullTrace.textposition).toBe('middle center'); + }); }); describe('scattermap convert', function() { diff --git a/test/jasmine/tests/svg_text_utils_test.js b/test/jasmine/tests/svg_text_utils_test.js index 5bcde86108e..3309afb3fcb 100644 --- a/test/jasmine/tests/svg_text_utils_test.js +++ b/test/jasmine/tests/svg_text_utils_test.js @@ -660,3 +660,32 @@ describe('convertToTspans with an unsupported MathJax version', function() { .then(done, done.fail); }); }); + +describe('convertToTspans with a MathJax that fails to render', () => { + let gd; + let mathJaxBefore; + + beforeEach(() => { + gd = createGraphDiv(); + mathJaxBefore = window.MathJax; + // Look like a supported MathJax v3 without the SVG output, so that every render fails + window.MathJax = { version: '3.2.2', _: {} }; + }); + + afterEach(() => { + if (mathJaxBefore === undefined) delete window.MathJax; + else window.MathJax = mathJaxBefore; + destroyGraphDiv(); + }); + + it('shows the tex left unevaluated', (done) => { + Plotly.newPlot(gd, [{ y: [1, 2, 3] }], { title: { text: '$x^2$' } }) + .then(() => { + const title = d3Select('.gtitle'); + expect(title.size()).toBe(1, 'title'); + expect(title.text()).toBe('$x^2$', 'raw tex as title'); + expect(title.node().style.display).not.toBe('none', 'title is visible'); + }) + .then(done, done.fail); + }); +}); diff --git a/test/plot-schema.json b/test/plot-schema.json index 3e04122d215..025c7ff7578 100644 --- a/test/plot-schema.json +++ b/test/plot-schema.json @@ -54922,7 +54922,7 @@ }, "textposition": { "arrayOk": true, - "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates.", + "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend.", "dflt": "middle center", "editType": "calc", "valType": "enumerated", @@ -54935,9 +54935,17 @@ "middle right", "bottom left", "bottom center", - "bottom right" + "bottom right", + "auto" ] }, + "textrank": { + "arrayOk": true, + "description": "Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order.", + "dflt": 1000, + "editType": "calc", + "valType": "number" + }, "texttemplate": { "arrayOk": true, "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. ", @@ -59599,7 +59607,7 @@ }, "textposition": { "arrayOk": true, - "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates.", + "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend.", "dflt": "middle center", "editType": "calc", "valType": "enumerated", @@ -59612,9 +59620,17 @@ "middle right", "bottom left", "bottom center", - "bottom right" + "bottom right", + "auto" ] }, + "textrank": { + "arrayOk": true, + "description": "Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order.", + "dflt": 1000, + "editType": "calc", + "valType": "number" + }, "texttemplate": { "arrayOk": true, "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `a`, `b` and `text`.", @@ -67341,7 +67357,7 @@ }, "textposition": { "arrayOk": true, - "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates.", + "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend.", "dflt": "middle center", "editType": "calc", "valType": "enumerated", @@ -67354,9 +67370,17 @@ "middle right", "bottom left", "bottom center", - "bottom right" + "bottom right", + "auto" ] }, + "textrank": { + "arrayOk": true, + "description": "Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order.", + "dflt": 1000, + "editType": "calc", + "valType": "number" + }, "texttemplate": { "arrayOk": true, "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `r`, `theta` and `text`.", @@ -71384,7 +71408,7 @@ }, "textposition": { "arrayOk": true, - "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates.", + "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend.", "dflt": "middle center", "editType": "calc", "valType": "enumerated", @@ -71397,9 +71421,17 @@ "middle right", "bottom left", "bottom center", - "bottom right" + "bottom right", + "auto" ] }, + "textrank": { + "arrayOk": true, + "description": "Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order.", + "dflt": 1000, + "editType": "calc", + "valType": "number" + }, "texttemplate": { "arrayOk": true, "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `real`, `imag` and `text`.", @@ -73461,7 +73493,7 @@ }, "textposition": { "arrayOk": true, - "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates.", + "description": "Sets the positions of the `text` elements with respects to the (x,y) coordinates. With *auto*, each label takes the first position around its point that overlaps no marker and no other label on the subplot. When the positions next to the point are taken, or another point sits close to the label, the label moves farther out with a leader line back to the point. A label with no free position is hidden. In a trace without markers or lines, a label sits on its point when it can, and moves out the same way when another label is in the way. Labels are placed in the order of `textrank`. Auto placement considers markers, text labels, and the axis labels that sit inside the plot area. It does not avoid annotations, shapes, images, or the legend.", "dflt": "middle center", "editType": "calc", "valType": "enumerated", @@ -73474,9 +73506,17 @@ "middle right", "bottom left", "bottom center", - "bottom right" + "bottom right", + "auto" ] }, + "textrank": { + "arrayOk": true, + "description": "Sets the placement rank of the `text` elements with `textposition` *auto*, per point or for the whole trace. A label with a smaller rank takes its position before the labels of larger rank on the subplot, so it stays next to its point and is hidden last. The default rank is 1000, so that you can use ranks less than 1000 to place certain labels before all unranked labels, and ranks greater than 1000 to place certain labels after them. A rank can be a fraction, which puts a label between two others. Labels of equal rank are placed in trace and data order.", + "dflt": 1000, + "editType": "calc", + "valType": "number" + }, "texttemplate": { "arrayOk": true, "description": "Template string used for rendering the information text that appears on points. Note that this will override `textinfo`. Variables are inserted using %{variable}, for example \"y: %{y}\". Numbers are formatted using d3-format's syntax %{variable:d3-format}, for example \"Price: %{y:$.2f}\". https://github.com/d3/d3-format/tree/v1.4.5#d3-format for details on the formatting syntax. Dates are formatted using d3-time-format's syntax %{variable|d3-time-format}, for example \"Day: %{2019-01-01|%A}\". https://github.com/d3/d3-time-format/tree/v2.2.3#locale_format for details on the date formatting syntax. Variables that can't be found will be replaced with the specifier. For example, a template of \"data: %{x}, %{y}\" will result in a value of \"data: 1, %{y}\" if x is 1 and y is missing. Variables with an undefined value will be replaced with the fallback value. All attributes that can be specified per-point (the ones that are `arrayOk: true`) are available. Finally, the template string has access to variables `a`, `b`, `c` and `text`.",