UNPKG

plotly.js

Version:

The open source javascript graphing library that powers plotly

1,366 lines (1,174 loc) 127 kB
/** * Copyright 2012-2020, Plotly, Inc. * All rights reserved. * * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ 'use strict'; var d3 = require('d3'); var isNumeric = require('fast-isnumeric'); var Plots = require('../../plots/plots'); var Registry = require('../../registry'); var Lib = require('../../lib'); var strTranslate = Lib.strTranslate; var svgTextUtils = require('../../lib/svg_text_utils'); var Titles = require('../../components/titles'); var Color = require('../../components/color'); var Drawing = require('../../components/drawing'); var axAttrs = require('./layout_attributes'); var cleanTicks = require('./clean_ticks'); var constants = require('../../constants/numerical'); var ONEMAXYEAR = constants.ONEMAXYEAR; var ONEAVGYEAR = constants.ONEAVGYEAR; var ONEMINYEAR = constants.ONEMINYEAR; var ONEMAXQUARTER = constants.ONEMAXQUARTER; var ONEAVGQUARTER = constants.ONEAVGQUARTER; var ONEMINQUARTER = constants.ONEMINQUARTER; var ONEMAXMONTH = constants.ONEMAXMONTH; var ONEAVGMONTH = constants.ONEAVGMONTH; var ONEMINMONTH = constants.ONEMINMONTH; var ONEWEEK = constants.ONEWEEK; var ONEDAY = constants.ONEDAY; var HALFDAY = ONEDAY / 2; var ONEHOUR = constants.ONEHOUR; var ONEMIN = constants.ONEMIN; var ONESEC = constants.ONESEC; var MINUS_SIGN = constants.MINUS_SIGN; var BADNUM = constants.BADNUM; var alignmentConstants = require('../../constants/alignment'); var MID_SHIFT = alignmentConstants.MID_SHIFT; var CAP_SHIFT = alignmentConstants.CAP_SHIFT; var LINE_SPACING = alignmentConstants.LINE_SPACING; var OPPOSITE_SIDE = alignmentConstants.OPPOSITE_SIDE; var TEXTPAD = 3; var axes = module.exports = {}; axes.setConvert = require('./set_convert'); var autoType = require('./axis_autotype'); var axisIds = require('./axis_ids'); var idSort = axisIds.idSort; var isLinked = axisIds.isLinked; // tight coupling to chart studio axes.id2name = axisIds.id2name; axes.name2id = axisIds.name2id; axes.cleanId = axisIds.cleanId; axes.list = axisIds.list; axes.listIds = axisIds.listIds; axes.getFromId = axisIds.getFromId; axes.getFromTrace = axisIds.getFromTrace; var autorange = require('./autorange'); axes.getAutoRange = autorange.getAutoRange; axes.findExtremes = autorange.findExtremes; var epsilon = 0.0001; function expandRange(range) { var delta = (range[1] - range[0]) * epsilon; return [ range[0] - delta, range[1] + delta ]; } /* * find the list of possible axes to reference with an xref or yref attribute * and coerce it to that list * * attr: the attribute we're generating a reference for. Should end in 'x' or 'y' * but can be prefixed, like 'ax' for annotation's arrow x * dflt: the default to coerce to, or blank to use the first axis (falling back on * extraOption if there is no axis) * extraOption: aside from existing axes with this letter, what non-axis value is allowed? * Only required if it's different from `dflt` */ axes.coerceRef = function(containerIn, containerOut, gd, attr, dflt, extraOption) { var axLetter = attr.charAt(attr.length - 1); var axlist = gd._fullLayout._subplots[axLetter + 'axis']; var refAttr = attr + 'ref'; var attrDef = {}; if(!dflt) dflt = axlist[0] || (typeof extraOption === 'string' ? extraOption : extraOption[0]); if(!extraOption) extraOption = dflt; axlist = axlist.concat(axlist.map(function(x) { return x + ' domain'; })); // data-ref annotations are not supported in gl2d yet attrDef[refAttr] = { valType: 'enumerated', values: axlist.concat(extraOption ? (typeof extraOption === 'string' ? [extraOption] : extraOption) : []), dflt: dflt }; // xref, yref return Lib.coerce(containerIn, containerOut, attrDef, refAttr); }; /* * Get the type of an axis reference. This can be 'range', 'domain', or 'paper'. * This assumes ar is a valid axis reference and returns 'range' if it doesn't * match the patterns for 'paper' or 'domain'. * * ar: the axis reference string * */ axes.getRefType = function(ar) { if(ar === undefined) { return ar; } if(ar === 'paper') { return 'paper'; } if(ar === 'pixel') { return 'pixel'; } if(/( domain)$/.test(ar)) { return 'domain'; } else { return 'range'; } }; /* * coerce position attributes (range-type) that can be either on axes or absolute * (paper or pixel) referenced. The biggest complication here is that we don't know * before looking at the axis whether the value must be a number or not (it may be * a date string), so we can't use the regular valType='number' machinery * * axRef (string): the axis this position is referenced to, or: * paper: fraction of the plot area * pixel: pixels relative to some starting position * attr (string): the attribute in containerOut we are coercing * dflt (number): the default position, as a fraction or pixels. If the attribute * is to be axis-referenced, this will be converted to an axis data value * * Also cleans the values, since the attribute definition itself has to say * valType: 'any' to handle date axes. This allows us to accept: * - for category axes: category names, and convert them here into serial numbers. * Note that this will NOT work for axis range endpoints, because we don't know * the category list yet (it's set by ax.makeCalcdata during calc) * but it works for component (note, shape, images) positions. * - for date axes: JS Dates or milliseconds, and convert to date strings * - for other types: coerce them to numbers */ axes.coercePosition = function(containerOut, gd, coerce, axRef, attr, dflt) { var cleanPos, pos; var axRefType = axes.getRefType(axRef); if(axRefType !== 'range') { cleanPos = Lib.ensureNumber; pos = coerce(attr, dflt); } else { var ax = axes.getFromId(gd, axRef); dflt = ax.fraction2r(dflt); pos = coerce(attr, dflt); cleanPos = ax.cleanPos; } containerOut[attr] = cleanPos(pos); }; axes.cleanPosition = function(pos, gd, axRef) { var cleanPos = (axRef === 'paper' || axRef === 'pixel') ? Lib.ensureNumber : axes.getFromId(gd, axRef).cleanPos; return cleanPos(pos); }; axes.redrawComponents = function(gd, axIds) { axIds = axIds ? axIds : axes.listIds(gd); var fullLayout = gd._fullLayout; function _redrawOneComp(moduleName, methodName, stashName, shortCircuit) { var method = Registry.getComponentMethod(moduleName, methodName); var stash = {}; for(var i = 0; i < axIds.length; i++) { var ax = fullLayout[axes.id2name(axIds[i])]; var indices = ax[stashName]; for(var j = 0; j < indices.length; j++) { var ind = indices[j]; if(!stash[ind]) { method(gd, ind); stash[ind] = 1; // once is enough for images (which doesn't use the `i` arg anyway) if(shortCircuit) return; } } } } // annotations and shapes 'draw' method is slow, // use the finer-grained 'drawOne' method instead _redrawOneComp('annotations', 'drawOne', '_annIndices'); _redrawOneComp('shapes', 'drawOne', '_shapeIndices'); _redrawOneComp('images', 'draw', '_imgIndices', true); }; var getDataConversions = axes.getDataConversions = function(gd, trace, target, targetArray) { var ax; // If target points to an axis, use the type we already have for that // axis to find the data type. Otherwise use the values to autotype. var d2cTarget = (target === 'x' || target === 'y' || target === 'z') ? target : targetArray; // In the case of an array target, make a mock data array // and call supplyDefaults to the data type and // setup the data-to-calc method. if(Array.isArray(d2cTarget)) { ax = { type: autoType(targetArray, undefined, { autotypenumbers: gd._fullLayout.autotypenumbers }), _categories: [] }; axes.setConvert(ax); // build up ax._categories (usually done during ax.makeCalcdata() if(ax.type === 'category') { for(var i = 0; i < targetArray.length; i++) { ax.d2c(targetArray[i]); } } // TODO what to do for transforms? } else { ax = axes.getFromTrace(gd, trace, d2cTarget); } // if 'target' has corresponding axis // -> use setConvert method if(ax) return {d2c: ax.d2c, c2d: ax.c2d}; // special case for 'ids' // -> cast to String if(d2cTarget === 'ids') return {d2c: toString, c2d: toString}; // otherwise (e.g. numeric-array of 'marker.color' or 'marker.size') // -> cast to Number return {d2c: toNum, c2d: toNum}; }; function toNum(v) { return +v; } function toString(v) { return String(v); } axes.getDataToCoordFunc = function(gd, trace, target, targetArray) { return getDataConversions(gd, trace, target, targetArray).d2c; }; // get counteraxis letter for this axis (name or id) // this can also be used as the id for default counter axis axes.counterLetter = function(id) { var axLetter = id.charAt(0); if(axLetter === 'x') return 'y'; if(axLetter === 'y') return 'x'; }; // incorporate a new minimum difference and first tick into // forced // note that _forceTick0 is linearized, so needs to be turned into // a range value for setting tick0 axes.minDtick = function(ax, newDiff, newFirst, allow) { // doesn't make sense to do forced min dTick on log or category axes, // and the plot itself may decide to cancel (ie non-grouped bars) if(['log', 'category', 'multicategory'].indexOf(ax.type) !== -1 || !allow) { ax._minDtick = 0; } else if(ax._minDtick === undefined) { // undefined means there's nothing there yet ax._minDtick = newDiff; ax._forceTick0 = newFirst; } else if(ax._minDtick) { if((ax._minDtick / newDiff + 1e-6) % 1 < 2e-6 && // existing minDtick is an integer multiple of newDiff // (within rounding err) // and forceTick0 can be shifted to newFirst (((newFirst - ax._forceTick0) / newDiff % 1) + 1.000001) % 1 < 2e-6) { ax._minDtick = newDiff; ax._forceTick0 = newFirst; } else if((newDiff / ax._minDtick + 1e-6) % 1 > 2e-6 || // if the converse is true (newDiff is a multiple of minDtick and // newFirst can be shifted to forceTick0) then do nothing - same // forcing stands. Otherwise, cancel forced minimum (((newFirst - ax._forceTick0) / ax._minDtick % 1) + 1.000001) % 1 > 2e-6) { ax._minDtick = 0; } } }; // save a copy of the initial axis ranges in fullLayout // use them in mode bar and dblclick events axes.saveRangeInitial = function(gd, overwrite) { var axList = axes.list(gd, '', true); var hasOneAxisChanged = false; for(var i = 0; i < axList.length; i++) { var ax = axList[i]; var isNew = (ax._rangeInitial === undefined); var hasChanged = isNew || !( ax.range[0] === ax._rangeInitial[0] && ax.range[1] === ax._rangeInitial[1] ); if((isNew && ax.autorange === false) || (overwrite && hasChanged)) { ax._rangeInitial = ax.range.slice(); hasOneAxisChanged = true; } } return hasOneAxisChanged; }; // save a copy of the initial spike visibility axes.saveShowSpikeInitial = function(gd, overwrite) { var axList = axes.list(gd, '', true); var hasOneAxisChanged = false; var allSpikesEnabled = 'on'; for(var i = 0; i < axList.length; i++) { var ax = axList[i]; var isNew = (ax._showSpikeInitial === undefined); var hasChanged = isNew || !(ax.showspikes === ax._showspikes); if(isNew || (overwrite && hasChanged)) { ax._showSpikeInitial = ax.showspikes; hasOneAxisChanged = true; } if(allSpikesEnabled === 'on' && !ax.showspikes) { allSpikesEnabled = 'off'; } } gd._fullLayout._cartesianSpikesEnabled = allSpikesEnabled; return hasOneAxisChanged; }; axes.autoBin = function(data, ax, nbins, is2d, calendar, size) { var dataMin = Lib.aggNums(Math.min, null, data); var dataMax = Lib.aggNums(Math.max, null, data); if(ax.type === 'category' || ax.type === 'multicategory') { return { start: dataMin - 0.5, end: dataMax + 0.5, size: Math.max(1, Math.round(size) || 1), _dataSpan: dataMax - dataMin, }; } if(!calendar) calendar = ax.calendar; // piggyback off tick code to make "nice" bin sizes and edges var dummyAx; if(ax.type === 'log') { dummyAx = { type: 'linear', range: [dataMin, dataMax] }; } else { dummyAx = { type: ax.type, range: Lib.simpleMap([dataMin, dataMax], ax.c2r, 0, calendar), calendar: calendar }; } axes.setConvert(dummyAx); size = size && cleanTicks.dtick(size, dummyAx.type); if(size) { dummyAx.dtick = size; dummyAx.tick0 = cleanTicks.tick0(undefined, dummyAx.type, calendar); } else { var size0; if(nbins) size0 = ((dataMax - dataMin) / nbins); else { // totally auto: scale off std deviation so the highest bin is // somewhat taller than the total number of bins, but don't let // the size get smaller than the 'nice' rounded down minimum // difference between values var distinctData = Lib.distinctVals(data); var msexp = Math.pow(10, Math.floor( Math.log(distinctData.minDiff) / Math.LN10)); var minSize = msexp * Lib.roundUp( distinctData.minDiff / msexp, [0.9, 1.9, 4.9, 9.9], true); size0 = Math.max(minSize, 2 * Lib.stdev(data) / Math.pow(data.length, is2d ? 0.25 : 0.4)); // fallback if ax.d2c output BADNUMs // e.g. when user try to plot categorical bins // on a layout.xaxis.type: 'linear' if(!isNumeric(size0)) size0 = 1; } axes.autoTicks(dummyAx, size0); } var finalSize = dummyAx.dtick; var binStart = axes.tickIncrement( axes.tickFirst(dummyAx), finalSize, 'reverse', calendar); var binEnd, bincount; // check for too many data points right at the edges of bins // (>50% within 1% of bin edges) or all data points integral // and offset the bins accordingly if(typeof finalSize === 'number') { binStart = autoShiftNumericBins(binStart, data, dummyAx, dataMin, dataMax); bincount = 1 + Math.floor((dataMax - binStart) / finalSize); binEnd = binStart + bincount * finalSize; } else { // month ticks - should be the only nonlinear kind we have at this point. // dtick (as supplied by axes.autoTick) only has nonlinear values on // date and log axes, but even if you display a histogram on a log axis // we bin it on a linear axis (which one could argue against, but that's // a separate issue) if(dummyAx.dtick.charAt(0) === 'M') { binStart = autoShiftMonthBins(binStart, data, finalSize, dataMin, calendar); } // calculate the endpoint for nonlinear ticks - you have to // just increment until you're done binEnd = binStart; bincount = 0; while(binEnd <= dataMax) { binEnd = axes.tickIncrement(binEnd, finalSize, false, calendar); bincount++; } } return { start: ax.c2r(binStart, 0, calendar), end: ax.c2r(binEnd, 0, calendar), size: finalSize, _dataSpan: dataMax - dataMin }; }; function autoShiftNumericBins(binStart, data, ax, dataMin, dataMax) { var edgecount = 0; var midcount = 0; var intcount = 0; var blankCount = 0; function nearEdge(v) { // is a value within 1% of a bin edge? return (1 + (v - binStart) * 100 / ax.dtick) % 100 < 2; } for(var i = 0; i < data.length; i++) { if(data[i] % 1 === 0) intcount++; else if(!isNumeric(data[i])) blankCount++; if(nearEdge(data[i])) edgecount++; if(nearEdge(data[i] + ax.dtick / 2)) midcount++; } var dataCount = data.length - blankCount; if(intcount === dataCount && ax.type !== 'date') { if(ax.dtick < 1) { // all integers: if bin size is <1, it's because // that was specifically requested (large nbins) // so respect that... but center the bins containing // integers on those integers binStart = dataMin - 0.5 * ax.dtick; } else { // otherwise start half an integer down regardless of // the bin size, just enough to clear up endpoint // ambiguity about which integers are in which bins. binStart -= 0.5; if(binStart + ax.dtick < dataMin) binStart += ax.dtick; } } else if(midcount < dataCount * 0.1) { if(edgecount > dataCount * 0.3 || nearEdge(dataMin) || nearEdge(dataMax)) { // lots of points at the edge, not many in the middle // shift half a bin var binshift = ax.dtick / 2; binStart += (binStart + binshift < dataMin) ? binshift : -binshift; } } return binStart; } function autoShiftMonthBins(binStart, data, dtick, dataMin, calendar) { var stats = Lib.findExactDates(data, calendar); // number of data points that needs to be an exact value // to shift that increment to (near) the bin center var threshold = 0.8; if(stats.exactDays > threshold) { var numMonths = Number(dtick.substr(1)); if((stats.exactYears > threshold) && (numMonths % 12 === 0)) { // The exact middle of a non-leap-year is 1.5 days into July // so if we start the bins here, all but leap years will // get hover-labeled as exact years. binStart = axes.tickIncrement(binStart, 'M6', 'reverse') + ONEDAY * 1.5; } else if(stats.exactMonths > threshold) { // Months are not as clean, but if we shift half the *longest* // month (31/2 days) then 31-day months will get labeled exactly // and shorter months will get labeled with the correct month // but shifted 12-36 hours into it. binStart = axes.tickIncrement(binStart, 'M1', 'reverse') + ONEDAY * 15.5; } else { // Shifting half a day is exact, but since these are month bins it // will always give a somewhat odd-looking label, until we do something // smarter like showing the bin boundaries (or the bounds of the actual // data in each bin) binStart -= HALFDAY; } var nextBinStart = axes.tickIncrement(binStart, dtick); if(nextBinStart <= dataMin) return nextBinStart; } return binStart; } // ---------------------------------------------------- // Ticks and grids // ---------------------------------------------------- // ensure we have tick0, dtick, and tick rounding calculated axes.prepTicks = function(ax, opts) { var rng = Lib.simpleMap(ax.range, ax.r2l, undefined, undefined, opts); ax._dtickInit = ax.dtick; ax._tick0Init = ax.tick0; // calculate max number of (auto) ticks to display based on plot size if(ax.tickmode === 'auto' || !ax.dtick) { var nt = ax.nticks; var minPx; if(!nt) { if(ax.type === 'category' || ax.type === 'multicategory') { minPx = ax.tickfont ? (ax.tickfont.size || 12) * 1.2 : 15; nt = ax._length / minPx; } else { minPx = ax._id.charAt(0) === 'y' ? 40 : 80; nt = Lib.constrain(ax._length / minPx, 4, 9) + 1; } // radial axes span half their domain, // multiply nticks value by two to get correct number of auto ticks. if(ax._name === 'radialaxis') nt *= 2; } // add a couple of extra digits for filling in ticks when we // have explicit tickvals without tick text if(ax.tickmode === 'array') nt *= 100; ax._roughDTick = Math.abs(rng[1] - rng[0]) / nt; axes.autoTicks(ax, ax._roughDTick); // check for a forced minimum dtick if(ax._minDtick > 0 && ax.dtick < ax._minDtick * 2) { ax.dtick = ax._minDtick; ax.tick0 = ax.l2r(ax._forceTick0); } } if(ax.ticklabelmode === 'period') { adjustPeriodDelta(ax); } // check for missing tick0 if(!ax.tick0) { ax.tick0 = (ax.type === 'date') ? '2000-01-01' : 0; } // ensure we don't try to make ticks below our minimum precision // see https://github.com/plotly/plotly.js/issues/2892 if(ax.type === 'date' && ax.dtick < 0.1) ax.dtick = 0.1; // now figure out rounding of tick values autoTickRound(ax); }; function nMonths(dtick) { return +(dtick.substring(1)); } function adjustPeriodDelta(ax) { // adjusts ax.dtick and sets ax._definedDelta var definedDelta; function mDate() { return !( isNumeric(ax.dtick) || ax.dtick.charAt(0) !== 'M' ); } var isMDate = mDate(); var tickformat = axes.getTickFormat(ax); if(tickformat) { var noDtick = ax._dtickInit !== ax.dtick; if( !(/%[fLQsSMX]/.test(tickformat)) // %f: microseconds as a decimal number [000000, 999999] // %L: milliseconds as a decimal number [000, 999] // %Q: milliseconds since UNIX epoch // %s: seconds since UNIX epoch // %S: second as a decimal number [00,61] // %M: minute as a decimal number [00,59] // %X: the locale’s time, such as %-I:%M:%S %p ) { if( /%[HI]/.test(tickformat) // %H: hour (24-hour clock) as a decimal number [00,23] // %I: hour (12-hour clock) as a decimal number [01,12] ) { definedDelta = ONEHOUR; if(noDtick && !isMDate && ax.dtick < ONEHOUR) ax.dtick = ONEHOUR; } else if( /%p/.test(tickformat) // %p: either AM or PM ) { definedDelta = HALFDAY; if(noDtick && !isMDate && ax.dtick < HALFDAY) ax.dtick = HALFDAY; } else if( /%[Aadejuwx]/.test(tickformat) // %A: full weekday name // %a: abbreviated weekday name // %d: zero-padded day of the month as a decimal number [01,31] // %e: space-padded day of the month as a decimal number [ 1,31] // %j: day of the year as a decimal number [001,366] // %u: Monday-based (ISO 8601) weekday as a decimal number [1,7] // %w: Sunday-based weekday as a decimal number [0,6] // %x: the locale’s date, such as %-m/%-d/%Y ) { definedDelta = ONEDAY; if(noDtick && !isMDate && ax.dtick < ONEDAY) ax.dtick = ONEDAY; } else if( /%[UVW]/.test(tickformat) // %U: Sunday-based week of the year as a decimal number [00,53] // %V: ISO 8601 week of the year as a decimal number [01, 53] // %W: Monday-based week of the year as a decimal number [00,53] ) { definedDelta = ONEWEEK; if(noDtick && !isMDate && ax.dtick < ONEWEEK) ax.dtick = ONEWEEK; } else if( /%[Bbm]/.test(tickformat) // %B: full month name // %b: abbreviated month name // %m: month as a decimal number [01,12] ) { definedDelta = ONEAVGMONTH; if(noDtick && ( isMDate ? nMonths(ax.dtick) < 1 : ax.dtick < ONEMINMONTH) ) ax.dtick = 'M1'; } else if( /%[q]/.test(tickformat) // %q: quarter of the year as a decimal number [1,4] ) { definedDelta = ONEAVGQUARTER; if(noDtick && ( isMDate ? nMonths(ax.dtick) < 3 : ax.dtick < ONEMINQUARTER) ) ax.dtick = 'M3'; } else if( /%[Yy]/.test(tickformat) // %Y: year with century as a decimal number, such as 1999 // %y: year without century as a decimal number [00,99] ) { definedDelta = ONEAVGYEAR; if(noDtick && ( isMDate ? nMonths(ax.dtick) < 12 : ax.dtick < ONEMINYEAR) ) ax.dtick = 'M12'; } } } isMDate = mDate(); if(isMDate && ax.tick0 === ax._dowTick0) { // discard Sunday/Monday tweaks ax.tick0 = ax._rawTick0; } ax._definedDelta = definedDelta; } function positionPeriodTicks(tickVals, ax, definedDelta) { for(var i = 0; i < tickVals.length; i++) { var v = tickVals[i].value; var a = i; var b = i + 1; if(i < tickVals.length - 1) { a = i; b = i + 1; } else if(i > 0) { a = i - 1; b = i; } else { a = i; b = i; } var A = tickVals[a].value; var B = tickVals[b].value; var actualDelta = Math.abs(B - A); var delta = definedDelta || actualDelta; var periodLength = 0; if(delta >= ONEMINYEAR) { if(actualDelta >= ONEMINYEAR && actualDelta <= ONEMAXYEAR) { periodLength = actualDelta; } else { periodLength = ONEAVGYEAR; } } else if(definedDelta === ONEAVGQUARTER && delta >= ONEMINQUARTER) { if(actualDelta >= ONEMINQUARTER && actualDelta <= ONEMAXQUARTER) { periodLength = actualDelta; } else { periodLength = ONEAVGQUARTER; } } else if(delta >= ONEMINMONTH) { if(actualDelta >= ONEMINMONTH && actualDelta <= ONEMAXMONTH) { periodLength = actualDelta; } else { periodLength = ONEAVGMONTH; } } else if(definedDelta === ONEWEEK && delta >= ONEWEEK) { periodLength = ONEWEEK; } else if(delta >= ONEDAY) { periodLength = ONEDAY; } else if(definedDelta === HALFDAY && delta >= HALFDAY) { periodLength = HALFDAY; } else if(definedDelta === ONEHOUR && delta >= ONEHOUR) { periodLength = ONEHOUR; } var inBetween; if(periodLength >= actualDelta) { // ensure new label positions remain between ticks periodLength = actualDelta; inBetween = true; } var endPeriod = v + periodLength; if(ax.rangebreaks && periodLength > 0) { var nAll = 84; // highly divisible 7 * 12 var n = 0; for(var c = 0; c < nAll; c++) { var r = (c + 0.5) / nAll; if(ax.maskBreaks(v * (1 - r) + r * endPeriod) !== BADNUM) n++; } periodLength *= n / nAll; if(!periodLength) { tickVals[i].drop = true; } if(inBetween && actualDelta > ONEWEEK) periodLength = actualDelta; // center monthly & longer periods } if( periodLength > 0 || // not instant i === 0 // taking care first tick added ) { tickVals[i].periodX = v + periodLength / 2; } } } // calculate the ticks: text, values, positioning // if ticks are set to automatic, determine the right values (tick0,dtick) // in any case, set tickround to # of digits to round tick labels to, // or codes to this effect for log and date scales axes.calcTicks = function calcTicks(ax, opts) { axes.prepTicks(ax, opts); var rng = Lib.simpleMap(ax.range, ax.r2l, undefined, undefined, opts); // now that we've figured out the auto values for formatting // in case we're missing some ticktext, we can break out for array ticks if(ax.tickmode === 'array') return arrayTicks(ax); // add a tiny bit so we get ticks which may have rounded out var exRng = expandRange(rng); var startTick = exRng[0]; var endTick = exRng[1]; // check for reversed axis var axrev = (rng[1] < rng[0]); var minRange = Math.min(rng[0], rng[1]); var maxRange = Math.max(rng[0], rng[1]); var isDLog = (ax.type === 'log') && !(isNumeric(ax.dtick) || ax.dtick.charAt(0) === 'L'); var isPeriod = ax.ticklabelmode === 'period'; // find the first tick ax._tmin = axes.tickFirst(ax, opts); // No visible ticks? Quit. // I've only seen this on category axes with all categories off the edge. if((ax._tmin < startTick) !== axrev) return []; // return the full set of tick vals if(ax.type === 'category' || ax.type === 'multicategory') { endTick = (axrev) ? Math.max(-0.5, endTick) : Math.min(ax._categories.length - 0.5, endTick); } var x = ax._tmin; if(ax.rangebreaks && ax._tick0Init !== ax.tick0) { // adjust tick0 x = moveOutsideBreak(x, ax); if(!axrev) { x = axes.tickIncrement(x, ax.dtick, !axrev, ax.calendar); } } if(isPeriod) { // add one item to label period before tick0 x = axes.tickIncrement(x, ax.dtick, !axrev, ax.calendar); } var maxTicks = Math.max(1000, ax._length || 0); var tickVals = []; var xPrevious = null; for(; (axrev) ? (x >= endTick) : (x <= endTick); x = axes.tickIncrement(x, ax.dtick, axrev, ax.calendar) ) { if(ax.rangebreaks) { if(!axrev) { if(x < startTick) continue; if(ax.maskBreaks(x) === BADNUM && moveOutsideBreak(x, ax) >= maxRange) break; } } // prevent infinite loops - no more than one tick per pixel, // and make sure each value is different from the previous if(tickVals.length > maxTicks || x === xPrevious) break; xPrevious = x; var minor = false; if(isDLog && (x !== (x | 0))) { minor = true; } tickVals.push({ minor: minor, value: x }); } if(isPeriod) positionPeriodTicks(tickVals, ax, ax._definedDelta); var i; if(ax.rangebreaks) { var flip = ax._id.charAt(0) === 'y'; var fontSize = 1; // one pixel minimum if(ax.tickmode === 'auto') { fontSize = ax.tickfont ? ax.tickfont.size : 12; } var prevL = NaN; for(i = tickVals.length - 1; i > -1; i--) { if(tickVals[i].drop) { tickVals.splice(i, 1); continue; } tickVals[i].value = moveOutsideBreak(tickVals[i].value, ax); // avoid overlaps var l = ax.c2p(tickVals[i].value); if(flip ? (prevL > l - fontSize) : (prevL < l + fontSize) ) { // ensure one pixel minimum tickVals.splice(axrev ? i + 1 : i, 1); } else { prevL = l; } } } // If same angle over a full circle, the last tick vals is a duplicate. // TODO must do something similar for angular date axes. if(isAngular(ax) && Math.abs(rng[1] - rng[0]) === 360) { tickVals.pop(); } // save the last tick as well as first, so we can // show the exponent only on the last one ax._tmax = (tickVals[tickVals.length - 1] || {}).value; // for showing the rest of a date when the main tick label is only the // latter part: ax._prevDateHead holds what we showed most recently. // Start with it cleared and mark that we're in calcTicks (ie calculating a // whole string of these so we should care what the previous date head was!) ax._prevDateHead = ''; ax._inCalcTicks = true; var ticksOut = []; var t, p; for(i = 0; i < tickVals.length; i++) { var _minor = tickVals[i].minor; var _value = tickVals[i].value; t = axes.tickText( ax, _value, false, // hover _minor // noSuffixPrefix ); p = tickVals[i].periodX; if(p !== undefined) { t.periodX = p; if(p > maxRange || p < minRange) { // hide label if outside the range if(p > maxRange) t.periodX = maxRange; if(p < minRange) t.periodX = minRange; t.text = ' '; // don't use an empty string here which can confuse automargin (issue 5132) ax._prevDateHead = ''; } } ticksOut.push(t); } ax._inCalcTicks = false; return ticksOut; }; function arrayTicks(ax) { var vals = ax.tickvals; var text = ax.ticktext; var ticksOut = new Array(vals.length); var rng = Lib.simpleMap(ax.range, ax.r2l); var exRng = expandRange(rng); var tickMin = Math.min(exRng[0], exRng[1]); var tickMax = Math.max(exRng[0], exRng[1]); var j = 0; // without a text array, just format the given values as any other ticks // except with more precision to the numbers if(!Array.isArray(text)) text = []; // make sure showing ticks doesn't accidentally add new categories // TODO multicategory, if we allow ticktext / tickvals var tickVal2l = ax.type === 'category' ? ax.d2l_noadd : ax.d2l; // array ticks on log axes always show the full number // (if no explicit ticktext overrides it) if(ax.type === 'log' && String(ax.dtick).charAt(0) !== 'L') { ax.dtick = 'L' + Math.pow(10, Math.floor(Math.min(ax.range[0], ax.range[1])) - 1); } for(var i = 0; i < vals.length; i++) { var vali = tickVal2l(vals[i]); if(vali > tickMin && vali < tickMax) { if(text[i] === undefined) ticksOut[j] = axes.tickText(ax, vali); else ticksOut[j] = tickTextObj(ax, vali, String(text[i])); j++; } } if(j < vals.length) ticksOut.splice(j, vals.length - j); if(ax.rangebreaks) { // remove ticks falling inside rangebreaks ticksOut = ticksOut.filter(function(d) { return ax.maskBreaks(d.x) !== BADNUM; }); } return ticksOut; } var roundBase10 = [2, 5, 10]; var roundBase24 = [1, 2, 3, 6, 12]; var roundBase60 = [1, 2, 5, 10, 15, 30]; // 2&3 day ticks are weird, but need something btwn 1&7 var roundDays = [1, 2, 3, 7, 14]; // approx. tick positions for log axes, showing all (1) and just 1, 2, 5 (2) // these don't have to be exact, just close enough to round to the right value var roundLog1 = [-0.046, 0, 0.301, 0.477, 0.602, 0.699, 0.778, 0.845, 0.903, 0.954, 1]; var roundLog2 = [-0.301, 0, 0.301, 0.699, 1]; // N.B. `thetaunit; 'radians' angular axes must be converted to degrees var roundAngles = [15, 30, 45, 90, 180]; function roundDTick(roughDTick, base, roundingSet) { return base * Lib.roundUp(roughDTick / base, roundingSet); } // autoTicks: calculate best guess at pleasant ticks for this axis // inputs: // ax - an axis object // roughDTick - rough tick spacing (to be turned into a nice round number) // outputs (into ax): // tick0: starting point for ticks (not necessarily on the graph) // usually 0 for numeric (=10^0=1 for log) or jan 1, 2000 for dates // dtick: the actual, nice round tick spacing, usually a little larger than roughDTick // if the ticks are spaced linearly (linear scale, categories, // log with only full powers, date ticks < month), // this will just be a number // months: M# // years: M# where # is 12*number of years // log with linear ticks: L# where # is the linear tick spacing // log showing powers plus some intermediates: // D1 shows all digits, D2 shows 2 and 5 axes.autoTicks = function(ax, roughDTick) { var base; function getBase(v) { return Math.pow(v, Math.floor(Math.log(roughDTick) / Math.LN10)); } if(ax.type === 'date') { ax.tick0 = Lib.dateTick0(ax.calendar, 0); // the criteria below are all based on the rough spacing we calculate // being > half of the final unit - so precalculate twice the rough val var roughX2 = 2 * roughDTick; if(roughX2 > ONEAVGYEAR) { roughDTick /= ONEAVGYEAR; base = getBase(10); ax.dtick = 'M' + (12 * roundDTick(roughDTick, base, roundBase10)); } else if(roughX2 > ONEAVGMONTH) { roughDTick /= ONEAVGMONTH; ax.dtick = 'M' + roundDTick(roughDTick, 1, roundBase24); } else if(roughX2 > ONEDAY) { ax.dtick = roundDTick(roughDTick, ONEDAY, ax._hasDayOfWeekBreaks ? [1, 2, 7, 14] : roundDays); // get week ticks on sunday // this will also move the base tick off 2000-01-01 if dtick is // 2 or 3 days... but that's a weird enough case that we'll ignore it. var tickformat = axes.getTickFormat(ax); var isPeriod = ax.ticklabelmode === 'period'; if(isPeriod) ax._rawTick0 = ax.tick0; if(/%[uVW]/.test(tickformat)) { ax.tick0 = Lib.dateTick0(ax.calendar, 2); // Monday } else { ax.tick0 = Lib.dateTick0(ax.calendar, 1); // Sunday } if(isPeriod) ax._dowTick0 = ax.tick0; } else if(roughX2 > ONEHOUR) { ax.dtick = roundDTick(roughDTick, ONEHOUR, roundBase24); } else if(roughX2 > ONEMIN) { ax.dtick = roundDTick(roughDTick, ONEMIN, roundBase60); } else if(roughX2 > ONESEC) { ax.dtick = roundDTick(roughDTick, ONESEC, roundBase60); } else { // milliseconds base = getBase(10); ax.dtick = roundDTick(roughDTick, base, roundBase10); } } else if(ax.type === 'log') { ax.tick0 = 0; var rng = Lib.simpleMap(ax.range, ax.r2l); if(roughDTick > 0.7) { // only show powers of 10 ax.dtick = Math.ceil(roughDTick); } else if(Math.abs(rng[1] - rng[0]) < 1) { // span is less than one power of 10 var nt = 1.5 * Math.abs((rng[1] - rng[0]) / roughDTick); // ticks on a linear scale, labeled fully roughDTick = Math.abs(Math.pow(10, rng[1]) - Math.pow(10, rng[0])) / nt; base = getBase(10); ax.dtick = 'L' + roundDTick(roughDTick, base, roundBase10); } else { // include intermediates between powers of 10, // labeled with small digits // ax.dtick = "D2" (show 2 and 5) or "D1" (show all digits) ax.dtick = (roughDTick > 0.3) ? 'D2' : 'D1'; } } else if(ax.type === 'category' || ax.type === 'multicategory') { ax.tick0 = 0; ax.dtick = Math.ceil(Math.max(roughDTick, 1)); } else if(isAngular(ax)) { ax.tick0 = 0; base = 1; ax.dtick = roundDTick(roughDTick, base, roundAngles); } else { // auto ticks always start at 0 ax.tick0 = 0; base = getBase(10); ax.dtick = roundDTick(roughDTick, base, roundBase10); } // prevent infinite loops if(ax.dtick === 0) ax.dtick = 1; // TODO: this is from log axis histograms with autorange off if(!isNumeric(ax.dtick) && typeof ax.dtick !== 'string') { var olddtick = ax.dtick; ax.dtick = 1; throw 'ax.dtick error: ' + String(olddtick); } }; // after dtick is already known, find tickround = precision // to display in tick labels // for numeric ticks, integer # digits after . to round to // for date ticks, the last date part to show (y,m,d,H,M,S) // or an integer # digits past seconds function autoTickRound(ax) { var dtick = ax.dtick; ax._tickexponent = 0; if(!isNumeric(dtick) && typeof dtick !== 'string') { dtick = 1; } if(ax.type === 'category' || ax.type === 'multicategory') { ax._tickround = null; } if(ax.type === 'date') { // If tick0 is unusual, give tickround a bit more information // not necessarily *all* the information in tick0 though, if it's really odd // minimal string length for tick0: 'd' is 10, 'M' is 16, 'S' is 19 // take off a leading minus (year < 0) and i (intercalary month) so length is consistent var tick0ms = ax.r2l(ax.tick0); var tick0str = ax.l2r(tick0ms).replace(/(^-|i)/g, ''); var tick0len = tick0str.length; if(String(dtick).charAt(0) === 'M') { // any tick0 more specific than a year: alway show the full date if(tick0len > 10 || tick0str.substr(5) !== '01-01') ax._tickround = 'd'; // show the month unless ticks are full multiples of a year else ax._tickround = (+(dtick.substr(1)) % 12 === 0) ? 'y' : 'm'; } else if((dtick >= ONEDAY && tick0len <= 10) || (dtick >= ONEDAY * 15)) ax._tickround = 'd'; else if((dtick >= ONEMIN && tick0len <= 16) || (dtick >= ONEHOUR)) ax._tickround = 'M'; else if((dtick >= ONESEC && tick0len <= 19) || (dtick >= ONEMIN)) ax._tickround = 'S'; else { // tickround is a number of digits of fractional seconds // of any two adjacent ticks, at least one will have the maximum fractional digits // of all possible ticks - so take the max. length of tick0 and the next one var tick1len = ax.l2r(tick0ms + dtick).replace(/^-/, '').length; ax._tickround = Math.max(tick0len, tick1len) - 20; // We shouldn't get here... but in case there's a situation I'm // not thinking of where tick0str and tick1str are identical or // something, fall back on maximum precision if(ax._tickround < 0) ax._tickround = 4; } } else if(isNumeric(dtick) || dtick.charAt(0) === 'L') { // linear or log (except D1, D2) var rng = ax.range.map(ax.r2d || Number); if(!isNumeric(dtick)) dtick = Number(dtick.substr(1)); // 2 digits past largest digit of dtick ax._tickround = 2 - Math.floor(Math.log(dtick) / Math.LN10 + 0.01); var maxend = Math.max(Math.abs(rng[0]), Math.abs(rng[1])); var rangeexp = Math.floor(Math.log(maxend) / Math.LN10 + 0.01); var minexponent = ax.minexponent === undefined ? 3 : ax.minexponent; if(Math.abs(rangeexp) > minexponent) { if(isSIFormat(ax.exponentformat) && !beyondSI(rangeexp)) { ax._tickexponent = 3 * Math.round((rangeexp - 1) / 3); } else ax._tickexponent = rangeexp; } } else { // D1 or D2 (log) ax._tickround = null; } } // months and years don't have constant millisecond values // (but a year is always 12 months so we only need months) // log-scale ticks are also not consistently spaced, except // for pure powers of 10 // numeric ticks always have constant differences, other datetime ticks // can all be calculated as constant number of milliseconds axes.tickIncrement = function(x, dtick, axrev, calendar) { var axSign = axrev ? -1 : 1; // includes linear, all dates smaller than month, and pure 10^n in log if(isNumeric(dtick)) return Lib.increment(x, axSign * dtick); // everything else is a string, one character plus a number var tType = dtick.charAt(0); var dtSigned = axSign * Number(dtick.substr(1)); // Dates: months (or years - see Lib.incrementMonth) if(tType === 'M') return Lib.incrementMonth(x, dtSigned, calendar); // Log scales: Linear, Digits if(tType === 'L') return Math.log(Math.pow(10, x) + dtSigned) / Math.LN10; // log10 of 2,5,10, or all digits (logs just have to be // close enough to round) if(tType === 'D') { var tickset = (dtick === 'D2') ? roundLog2 : roundLog1; var x2 = x + axSign * 0.01; var frac = Lib.roundUp(Lib.mod(x2, 1), tickset, axrev); return Math.floor(x2) + Math.log(d3.round(Math.pow(10, frac), 1)) / Math.LN10; } throw 'unrecognized dtick ' + String(dtick); }; // calculate the first tick on an axis axes.tickFirst = function(ax, opts) { var r2l = ax.r2l || Number; var rng = Lib.simpleMap(ax.range, r2l, undefined, undefined, opts); var axrev = rng[1] < rng[0]; var sRound = axrev ? Math.floor : Math.ceil; // add a tiny extra bit to make sure we get ticks // that may have been rounded out var r0 = expandRange(rng)[0]; var dtick = ax.dtick; var tick0 = r2l(ax.tick0); if(isNumeric(dtick)) { var tmin = sRound((r0 - tick0) / dtick) * dtick + tick0; // make sure no ticks outside the category list if(ax.type === 'category' || ax.type === 'multicategory') { tmin = Lib.constrain(tmin, 0, ax._categories.length - 1); } return tmin; } var tType = dtick.charAt(0); var dtNum = Number(dtick.substr(1)); // Dates: months (or years) if(tType === 'M') { var cnt = 0; var t0 = tick0; var t1, mult, newDTick; // This algorithm should work for *any* nonlinear (but close to linear!) // tick spacing. Limit to 10 iterations, for gregorian months it's normally <=3. while(cnt < 10) { t1 = axes.tickIncrement(t0, dtick, axrev, ax.calendar); if((t1 - r0) * (t0 - r0) <= 0) { // t1 and t0 are on opposite sides of r0! we've succeeded! if(axrev) return Math.min(t0, t1); return Math.max(t0, t1); } mult = (r0 - ((t0 + t1) / 2)) / (t1 - t0); newDTick = tType + ((Math.abs(Math.round(mult)) || 1) * dtNum); t0 = axes.tickIncrement(t0, newDTick, mult < 0 ? !axrev : axrev, ax.calendar); cnt++; } Lib.error('tickFirst did not converge', ax); return t0; } else if(tType === 'L') { // Log scales: Linear, Digits return Math.log(sRound( (Math.pow(10, r0) - tick0) / dtNum) * dtNum + tick0) / Math.LN10; } else if(tType === 'D') { var tickset = (dtick === 'D2') ? roundLog2 : roundLog1; var frac = Lib.roundUp(Lib.mod(r0, 1), tickset, axrev); return Math.floor(r0) + Math.log(d3.round(Math.pow(10, frac), 1)) / Math.LN10; } else throw 'unrecognized dtick ' + String(dtick); }; // draw the text for one tick. // px,py are the location on gd.paper // prefix is there so the x axis ticks can be dropped a line // ax is the axis layout, x is the tick value // hover is a (truthy) flag for whether to show numbers with a bit // more precision for hovertext axes.tickText = function(ax, x, hover, noSuffixPrefix) { var out = tickTextObj(ax, x); var arrayMode = ax.tickmode === 'array'; var extraPrecision = hover || arrayMode; var axType = ax.type; // TODO multicategory, if we allow ticktext / tickvals var tickVal2l = axType === 'category' ? ax.d2l_noadd : ax.d2l; var i; if(arrayMode && Array.isArray(ax.ticktext)) { var rng = Lib.simpleMap(ax.range, ax.r2l); var minDiff = (Math.abs(rng[1] - rng[0]) - (ax._lBreaks || 0)) / 10000; for(i = 0; i < ax.ticktext.length; i++) { if(Math.abs(x - tickVal2l(ax.tickvals[i])) < minDiff) break; } if(i < ax.ticktext.length) { out.text = String(ax.ticktext[i]); return out; } } function isHidden(showAttr) { if(showAttr === undefined) return true; if(hover) return showAttr === 'none'; var firstOrLast = { first: ax._tmin, last: ax._tmax }[showAttr]; return showAttr !== 'all' && x !== firstOrLast; } var hideexp = hover ? 'never' : ax.exponentformat !== 'none' && isHidden(ax.showexponent) ? 'hide' : ''; if(axType === 'date') formatDate(ax, out, hover, extraPrecision); else if(axType === 'log') formatLog(ax, out, hover, extraPrecision, hideexp); else if(axType === 'category') formatCategory(ax, out); else if(axType === 'multicategory') formatMultiCategory(ax, out, hover); else if(isAngular(ax)) formatAngle(ax, out, hover, extraPrecision, hideexp); else formatLinear(ax, out, hover, extraPrecision, hideexp); // add prefix and suffix if(!noSuffixPrefix) { if(ax.tickprefix && !isHidden(ax.showtickprefix)) out.text = ax.tickprefix + out.text; if(ax.ticksuffix && !isHidden(ax.showticksuffix)) out.text += ax.ticksuffix; } // Setup ticks and grid lines boundaries // at 1/2 a 'category' to the left/bottom if(ax.tickson === 'boundaries' || ax.showdividers) { var inbounds = function(v) { var p = ax.l2p(v); return p >= 0 && p <= ax._length ? v : null; }; out.xbnd = [ inbounds(out.x - 0.5), inbounds(out.x + ax.dtick - 0.5) ]; } return out; }; /** * create text for a hover label on this axis, with special handling of * log axes (where negative values can't be displayed but can appear in hover text) * * @param {obje